mirror of
https://github.com/arsenetar/dupeguru.git
synced 2026-01-22 06:37:17 +00:00
Convert hscommon, qtlib and cocoalib to submodules
... rather than subtrees. That also represents a small qtlib updates which needed a code adjustment.
This commit is contained in:
1
hscommon
Submodule
1
hscommon
Submodule
Submodule hscommon added at ea634cefdf
5
hscommon/.gitignore
vendored
5
hscommon/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
*.pyc
|
||||
*.mo
|
||||
*.so
|
||||
.DS_Store
|
||||
/docs_html
|
||||
@@ -1,10 +0,0 @@
|
||||
Copyright 2014, Hardcoded Software Inc., http://www.hardcoded.net
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of Hardcoded Software Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1,3 +0,0 @@
|
||||
This module is common code used in all Hardcoded Software applications. It has no stable API so
|
||||
it is not recommended to actually depend on it. But if you want to copy bits and pieces for your own
|
||||
apps, be my guest.
|
||||
@@ -1,486 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2009-03-03
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
"""This module is a collection of function to help in HS apps build process.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import os.path as op
|
||||
import shutil
|
||||
import tempfile
|
||||
import plistlib
|
||||
from subprocess import Popen
|
||||
import re
|
||||
import importlib
|
||||
from datetime import datetime
|
||||
import glob
|
||||
import sysconfig
|
||||
import modulefinder
|
||||
|
||||
from setuptools import setup, Extension
|
||||
|
||||
from .plat import ISWINDOWS
|
||||
from .util import modified_after, find_in_path, ensure_folder, delete_files_with_pattern
|
||||
|
||||
def print_and_do(cmd):
|
||||
"""Prints ``cmd`` and executes it in the shell.
|
||||
"""
|
||||
print(cmd)
|
||||
p = Popen(cmd, shell=True)
|
||||
return p.wait()
|
||||
|
||||
def _perform(src, dst, action, actionname):
|
||||
if not op.lexists(src):
|
||||
print("Copying %s failed: it doesn't exist." % src)
|
||||
return
|
||||
if op.lexists(dst):
|
||||
if op.isdir(dst):
|
||||
shutil.rmtree(dst)
|
||||
else:
|
||||
os.remove(dst)
|
||||
print('%s %s --> %s' % (actionname, src, dst))
|
||||
action(src, dst)
|
||||
|
||||
def copy_file_or_folder(src, dst):
|
||||
if op.isdir(src):
|
||||
shutil.copytree(src, dst, symlinks=True)
|
||||
else:
|
||||
shutil.copy(src, dst)
|
||||
|
||||
def move(src, dst):
|
||||
_perform(src, dst, os.rename, 'Moving')
|
||||
|
||||
def copy(src, dst):
|
||||
_perform(src, dst, copy_file_or_folder, 'Copying')
|
||||
|
||||
def symlink(src, dst):
|
||||
_perform(src, dst, os.symlink, 'Symlinking')
|
||||
|
||||
def hardlink(src, dst):
|
||||
_perform(src, dst, os.link, 'Hardlinking')
|
||||
|
||||
def _perform_on_all(pattern, dst, action):
|
||||
# pattern is a glob pattern, example "folder/foo*". The file is moved directly in dst, no folder
|
||||
# structure from src is kept.
|
||||
filenames = glob.glob(pattern)
|
||||
for fn in filenames:
|
||||
destpath = op.join(dst, op.basename(fn))
|
||||
action(fn, destpath)
|
||||
|
||||
def move_all(pattern, dst):
|
||||
_perform_on_all(pattern, dst, move)
|
||||
|
||||
def copy_all(pattern, dst):
|
||||
_perform_on_all(pattern, dst, copy)
|
||||
|
||||
def ensure_empty_folder(path):
|
||||
"""Make sure that the path exists and that it's an empty folder.
|
||||
"""
|
||||
if op.exists(path):
|
||||
shutil.rmtree(path)
|
||||
os.mkdir(path)
|
||||
|
||||
def filereplace(filename, outfilename=None, **kwargs):
|
||||
"""Reads `filename`, replaces all {variables} in kwargs, and writes the result to `outfilename`.
|
||||
"""
|
||||
if outfilename is None:
|
||||
outfilename = filename
|
||||
fp = open(filename, 'rt', encoding='utf-8')
|
||||
contents = fp.read()
|
||||
fp.close()
|
||||
# We can't use str.format() because in some files, there might be {} characters that mess with it.
|
||||
for key, item in kwargs.items():
|
||||
contents = contents.replace('{{{}}}'.format(key), item)
|
||||
fp = open(outfilename, 'wt', encoding='utf-8')
|
||||
fp.write(contents)
|
||||
fp.close()
|
||||
|
||||
def get_module_version(modulename):
|
||||
mod = importlib.import_module(modulename)
|
||||
return mod.__version__
|
||||
|
||||
def setup_package_argparser(parser):
|
||||
parser.add_argument(
|
||||
'--sign', dest='sign_identity',
|
||||
help="Sign app under specified identity before packaging (OS X only)"
|
||||
)
|
||||
parser.add_argument(
|
||||
'--nosign', action='store_true', dest='nosign',
|
||||
help="Don't sign the packaged app (OS X only)"
|
||||
)
|
||||
parser.add_argument(
|
||||
'--src-pkg', action='store_true', dest='src_pkg',
|
||||
help="Build a tar.gz of the current source."
|
||||
)
|
||||
parser.add_argument(
|
||||
'--arch-pkg', action='store_true', dest='arch_pkg',
|
||||
help="Force Arch Linux packaging type, regardless of distro name."
|
||||
)
|
||||
|
||||
# `args` come from an ArgumentParser updated with setup_package_argparser()
|
||||
def package_cocoa_app_in_dmg(app_path, destfolder, args):
|
||||
# Rather than signing our app in XCode during the build phase, we sign it during the package
|
||||
# phase because running the app before packaging can modify it and we want to be sure to have
|
||||
# a valid signature.
|
||||
if args.sign_identity:
|
||||
sign_identity = "Developer ID Application: {}".format(args.sign_identity)
|
||||
result = print_and_do('codesign --force --deep --sign "{}" "{}"'.format(sign_identity, app_path))
|
||||
if result != 0:
|
||||
print("ERROR: Signing failed. Aborting packaging.")
|
||||
return
|
||||
elif not args.nosign:
|
||||
print("ERROR: Either --nosign or --sign argument required.")
|
||||
return
|
||||
build_dmg(app_path, destfolder)
|
||||
|
||||
def build_dmg(app_path, destfolder):
|
||||
"""Builds a DMG volume with application at ``app_path`` and puts it in ``dest_path``.
|
||||
|
||||
The name of the resulting DMG volume is determined by the app's name and version.
|
||||
"""
|
||||
print(repr(op.join(app_path, 'Contents', 'Info.plist')))
|
||||
plist = plistlib.readPlist(op.join(app_path, 'Contents', 'Info.plist'))
|
||||
workpath = tempfile.mkdtemp()
|
||||
dmgpath = op.join(workpath, plist['CFBundleName'])
|
||||
os.mkdir(dmgpath)
|
||||
print_and_do('cp -R "%s" "%s"' % (app_path, dmgpath))
|
||||
print_and_do('ln -s /Applications "%s"' % op.join(dmgpath, 'Applications'))
|
||||
dmgname = '%s_osx_%s.dmg' % (plist['CFBundleName'].lower().replace(' ', '_'), plist['CFBundleVersion'].replace('.', '_'))
|
||||
print('Building %s' % dmgname)
|
||||
# UDBZ = bzip compression. UDZO (zip compression) was used before, but it compresses much less.
|
||||
print_and_do('hdiutil create "%s" -format UDBZ -nocrossdev -srcdir "%s"' % (op.join(destfolder, dmgname), dmgpath))
|
||||
print('Build Complete')
|
||||
|
||||
def copy_sysconfig_files_for_embed(destpath):
|
||||
# This normally shouldn't be needed for Python 3.3+.
|
||||
makefile = sysconfig.get_makefile_filename()
|
||||
configh = sysconfig.get_config_h_filename()
|
||||
shutil.copy(makefile, destpath)
|
||||
shutil.copy(configh, destpath)
|
||||
with open(op.join(destpath, 'site.py'), 'w') as fp:
|
||||
fp.write("""
|
||||
import os.path as op
|
||||
from distutils import sysconfig
|
||||
sysconfig.get_makefile_filename = lambda: op.join(op.dirname(__file__), 'Makefile')
|
||||
sysconfig.get_config_h_filename = lambda: op.join(op.dirname(__file__), 'pyconfig.h')
|
||||
""")
|
||||
|
||||
def add_to_pythonpath(path):
|
||||
"""Adds ``path`` to both ``PYTHONPATH`` env and ``sys.path``.
|
||||
"""
|
||||
abspath = op.abspath(path)
|
||||
pythonpath = os.environ.get('PYTHONPATH', '')
|
||||
pathsep = ';' if ISWINDOWS else ':'
|
||||
pythonpath = pathsep.join([abspath, pythonpath]) if pythonpath else abspath
|
||||
os.environ['PYTHONPATH'] = pythonpath
|
||||
sys.path.insert(1, abspath)
|
||||
|
||||
# This is a method to hack around those freakingly tricky data inclusion/exlusion rules
|
||||
# in setuptools. We copy the packages *without data* in a build folder and then build the plugin
|
||||
# from there.
|
||||
def copy_packages(packages_names, dest, create_links=False, extra_ignores=None):
|
||||
"""Copy python packages ``packages_names`` to ``dest``, spurious data.
|
||||
|
||||
Copy will happen without tests, testdata, mercurial data or C extension module source with it.
|
||||
``py2app`` include and exclude rules are **quite** funky, and doing this is the only reliable
|
||||
way to make sure we don't end up with useless stuff in our app.
|
||||
"""
|
||||
if ISWINDOWS:
|
||||
create_links = False
|
||||
if not extra_ignores:
|
||||
extra_ignores = []
|
||||
ignore = shutil.ignore_patterns('.hg*', 'tests', 'testdata', 'modules', 'docs', 'locale', *extra_ignores)
|
||||
for package_name in packages_names:
|
||||
if op.exists(package_name):
|
||||
source_path = package_name
|
||||
else:
|
||||
mod = __import__(package_name)
|
||||
source_path = mod.__file__
|
||||
if mod.__file__.endswith('__init__.py'):
|
||||
source_path = op.dirname(source_path)
|
||||
dest_name = op.basename(source_path)
|
||||
dest_path = op.join(dest, dest_name)
|
||||
if op.exists(dest_path):
|
||||
if op.islink(dest_path):
|
||||
os.unlink(dest_path)
|
||||
else:
|
||||
shutil.rmtree(dest_path)
|
||||
print("Copying package at {0} to {1}".format(source_path, dest_path))
|
||||
if create_links:
|
||||
os.symlink(op.abspath(source_path), dest_path)
|
||||
else:
|
||||
if op.isdir(source_path):
|
||||
shutil.copytree(source_path, dest_path, ignore=ignore)
|
||||
else:
|
||||
shutil.copy(source_path, dest_path)
|
||||
|
||||
def copy_qt_plugins(folder_names, dest): # This is only for Windows
|
||||
from PyQt5.QtCore import QLibraryInfo
|
||||
qt_plugin_dir = QLibraryInfo.location(QLibraryInfo.PluginsPath)
|
||||
def ignore(path, names):
|
||||
if path == qt_plugin_dir:
|
||||
return [n for n in names if n not in folder_names]
|
||||
else:
|
||||
return [n for n in names if not n.endswith('.dll')]
|
||||
shutil.copytree(qt_plugin_dir, dest, ignore=ignore)
|
||||
|
||||
def build_debian_changelog(changelogpath, destfile, pkgname, from_version=None,
|
||||
distribution='precise', fix_version=None):
|
||||
"""Builds a debian changelog out of a YAML changelog.
|
||||
|
||||
Use fix_version to patch the top changelog to that version (if, for example, there was a
|
||||
packaging error and you need to quickly fix it)
|
||||
"""
|
||||
def desc2list(desc):
|
||||
# We take each item, enumerated with the '*' character, and transform it into a list.
|
||||
desc = desc.replace('\n', ' ')
|
||||
desc = desc.replace(' ', ' ')
|
||||
result = desc.split('*')
|
||||
return [s.strip() for s in result if s.strip()]
|
||||
|
||||
ENTRY_MODEL = "{pkg} ({version}~{distribution}) {distribution}; urgency=low\n\n{changes}\n -- Virgil Dupras <hsoft@hardcoded.net> {date}\n\n"
|
||||
CHANGE_MODEL = " * {description}\n"
|
||||
changelogs = read_changelog_file(changelogpath)
|
||||
if from_version:
|
||||
# We only want logs from a particular version
|
||||
for index, log in enumerate(changelogs):
|
||||
if log['version'] == from_version:
|
||||
changelogs = changelogs[:index+1]
|
||||
break
|
||||
if fix_version:
|
||||
changelogs[0]['version'] = fix_version
|
||||
rendered_logs = []
|
||||
for log in changelogs:
|
||||
version = log['version']
|
||||
logdate = log['date']
|
||||
desc = log['description']
|
||||
rendered_date = logdate.strftime('%a, %d %b %Y 00:00:00 +0000')
|
||||
rendered_descs = [CHANGE_MODEL.format(description=d) for d in desc2list(desc)]
|
||||
changes = ''.join(rendered_descs)
|
||||
rendered_log = ENTRY_MODEL.format(pkg=pkgname, version=version, changes=changes,
|
||||
date=rendered_date, distribution=distribution)
|
||||
rendered_logs.append(rendered_log)
|
||||
result = ''.join(rendered_logs)
|
||||
fp = open(destfile, 'w')
|
||||
fp.write(result)
|
||||
fp.close()
|
||||
|
||||
re_changelog_header = re.compile(r'=== ([\d.b]*) \(([\d\-]*)\)')
|
||||
def read_changelog_file(filename):
|
||||
def iter_by_three(it):
|
||||
while True:
|
||||
version = next(it)
|
||||
date = next(it)
|
||||
description = next(it)
|
||||
yield version, date, description
|
||||
|
||||
with open(filename, 'rt', encoding='utf-8') as fp:
|
||||
contents = fp.read()
|
||||
splitted = re_changelog_header.split(contents)[1:] # the first item is empty
|
||||
# splitted = [version1, date1, desc1, version2, date2, ...]
|
||||
result = []
|
||||
for version, date_str, description in iter_by_three(iter(splitted)):
|
||||
date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
d = {'date': date, 'date_str': date_str, 'version': version, 'description': description.strip()}
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
class OSXAppStructure:
|
||||
def __init__(self, dest):
|
||||
self.dest = dest
|
||||
self.contents = op.join(dest, 'Contents')
|
||||
self.macos = op.join(self.contents, 'MacOS')
|
||||
self.resources = op.join(self.contents, 'Resources')
|
||||
self.frameworks = op.join(self.contents, 'Frameworks')
|
||||
self.infoplist = op.join(self.contents, 'Info.plist')
|
||||
|
||||
def create(self, infoplist):
|
||||
ensure_empty_folder(self.dest)
|
||||
os.makedirs(self.macos)
|
||||
os.mkdir(self.resources)
|
||||
os.mkdir(self.frameworks)
|
||||
copy(infoplist, self.infoplist)
|
||||
open(op.join(self.contents, 'PkgInfo'), 'wt').write("APPLxxxx")
|
||||
|
||||
def copy_executable(self, executable):
|
||||
info = plistlib.readPlist(self.infoplist)
|
||||
self.executablename = info['CFBundleExecutable']
|
||||
self.executablepath = op.join(self.macos, self.executablename)
|
||||
copy(executable, self.executablepath)
|
||||
|
||||
def copy_resources(self, *resources, use_symlinks=False):
|
||||
for path in resources:
|
||||
resource_dest = op.join(self.resources, op.basename(path))
|
||||
action = symlink if use_symlinks else copy
|
||||
action(op.abspath(path), resource_dest)
|
||||
|
||||
def copy_frameworks(self, *frameworks):
|
||||
for path in frameworks:
|
||||
framework_dest = op.join(self.frameworks, op.basename(path))
|
||||
copy(path, framework_dest)
|
||||
|
||||
|
||||
def create_osx_app_structure(dest, executable, infoplist, resources=None, frameworks=None,
|
||||
symlink_resources=False):
|
||||
# `dest`: A path to the destination .app folder
|
||||
# `executable`: the path of the executable file that goes in "MacOS"
|
||||
# `infoplist`: The path to your Info.plist file.
|
||||
# `resources`: A list of paths of files or folders going in the "Resources" folder.
|
||||
# `frameworks`: Same as above for "Frameworks".
|
||||
# `symlink_resources`: If True, will symlink resources into the structure instead of copying them.
|
||||
app = OSXAppStructure(dest, infoplist)
|
||||
app.create()
|
||||
app.copy_executable(executable)
|
||||
app.copy_resources(*resources, use_symlinks=symlink_resources)
|
||||
app.copy_frameworks(*frameworks)
|
||||
|
||||
class OSXFrameworkStructure:
|
||||
def __init__(self, dest):
|
||||
self.dest = dest
|
||||
self.contents = op.join(dest, 'Versions', 'A')
|
||||
self.resources = op.join(self.contents, 'Resources')
|
||||
self.headers = op.join(self.contents, 'Headers')
|
||||
self.infoplist = op.join(self.resources, 'Info.plist')
|
||||
self._update_executable_path()
|
||||
|
||||
def _update_executable_path(self):
|
||||
if not op.exists(self.infoplist):
|
||||
self.executablename = self.executablepath = None
|
||||
return
|
||||
info = plistlib.readPlist(self.infoplist)
|
||||
self.executablename = info['CFBundleExecutable']
|
||||
self.executablepath = op.join(self.contents, self.executablename)
|
||||
|
||||
def create(self, infoplist):
|
||||
ensure_empty_folder(self.dest)
|
||||
os.makedirs(self.contents)
|
||||
os.mkdir(self.resources)
|
||||
os.mkdir(self.headers)
|
||||
copy(infoplist, self.infoplist)
|
||||
self._update_executable_path()
|
||||
|
||||
def create_symlinks(self):
|
||||
# Only call this after create() and copy_executable()
|
||||
rel = lambda path: op.relpath(path, self.dest)
|
||||
os.symlink('A', op.join(self.dest, 'Versions', 'Current'))
|
||||
os.symlink(rel(self.executablepath), op.join(self.dest, self.executablename))
|
||||
os.symlink(rel(self.headers), op.join(self.dest, 'Headers'))
|
||||
os.symlink(rel(self.resources), op.join(self.dest, 'Resources'))
|
||||
|
||||
def copy_executable(self, executable):
|
||||
copy(executable, self.executablepath)
|
||||
|
||||
def copy_resources(self, *resources, use_symlinks=False):
|
||||
for path in resources:
|
||||
resource_dest = op.join(self.resources, op.basename(path))
|
||||
action = symlink if use_symlinks else copy
|
||||
action(op.abspath(path), resource_dest)
|
||||
|
||||
def copy_headers(self, *headers, use_symlinks=False):
|
||||
for path in headers:
|
||||
header_dest = op.join(self.headers, op.basename(path))
|
||||
action = symlink if use_symlinks else copy
|
||||
action(op.abspath(path), header_dest)
|
||||
|
||||
|
||||
def build_cocoalib_xibless(dest='cocoa/autogen'):
|
||||
import xibless
|
||||
ensure_folder(dest)
|
||||
FNPAIRS = [
|
||||
('progress.py', 'ProgressController_UI'),
|
||||
('error_report.py', 'HSErrorReportWindow_UI'),
|
||||
('about.py', 'HSAboutBox_UI'),
|
||||
]
|
||||
for srcname, dstname in FNPAIRS:
|
||||
srcpath = op.join('cocoalib', 'ui', srcname)
|
||||
dstpath = op.join(dest, dstname)
|
||||
if modified_after(srcpath, dstpath + '.h'):
|
||||
xibless.generate(srcpath, dstpath, localizationTable='cocoalib')
|
||||
|
||||
def copy_embeddable_python_dylib(dst):
|
||||
runtime = op.join(sysconfig.get_config_var('PYTHONFRAMEWORKPREFIX'), sysconfig.get_config_var('LDLIBRARY'))
|
||||
filedest = op.join(dst, 'Python')
|
||||
shutil.copy(runtime, filedest)
|
||||
os.chmod(filedest, 0o774) # We need write permission to use install_name_tool
|
||||
cmd = 'install_name_tool -id @rpath/Python %s' % filedest
|
||||
print_and_do(cmd)
|
||||
|
||||
def collect_stdlib_dependencies(script, dest_folder, extra_deps=None):
|
||||
sysprefix = sys.prefix # could be a virtualenv
|
||||
real_lib_prefix = sysconfig.get_config_var('LIBDEST')
|
||||
def is_stdlib_path(path):
|
||||
# A module path is only a stdlib path if it's in either sys.prefix or
|
||||
# sysconfig.get_config_var('prefix') (the 2 are different if we are in a virtualenv) and if
|
||||
# there's no "site-package in the path.
|
||||
if not path:
|
||||
return False
|
||||
if 'site-package' in path:
|
||||
return False
|
||||
if not (path.startswith(sysprefix) or path.startswith(real_lib_prefix)):
|
||||
return False
|
||||
return True
|
||||
|
||||
ensure_folder(dest_folder)
|
||||
mf = modulefinder.ModuleFinder()
|
||||
mf.run_script(script)
|
||||
modpaths = [mod.__file__ for mod in mf.modules.values()]
|
||||
modpaths = filter(is_stdlib_path, modpaths)
|
||||
for p in modpaths:
|
||||
if p.startswith(real_lib_prefix):
|
||||
relpath = op.relpath(p, real_lib_prefix)
|
||||
elif p.startswith(sysprefix):
|
||||
relpath = op.relpath(p, sysprefix)
|
||||
assert relpath.startswith('lib/python3.') # we want to get rid of that lib/python3.x part
|
||||
relpath = relpath[len('lib/python3.X/'):]
|
||||
else:
|
||||
raise AssertionError()
|
||||
if relpath.startswith('lib-dynload'): # We copy .so files in lib-dynload directly in our dest
|
||||
relpath = relpath[len('lib-dynload/'):]
|
||||
if relpath.startswith('encodings') or relpath.startswith('distutils'):
|
||||
# We force their inclusion later.
|
||||
continue
|
||||
dest_path = op.join(dest_folder, relpath)
|
||||
ensure_folder(op.dirname(dest_path))
|
||||
copy(p, dest_path)
|
||||
# stringprep is used by encodings.
|
||||
# We use real_lib_prefix with distutils because virtualenv messes with it and we need to refer
|
||||
# to the original distutils folder.
|
||||
FORCED_INCLUSION = ['encodings', 'stringprep', op.join(real_lib_prefix, 'distutils')]
|
||||
if extra_deps:
|
||||
FORCED_INCLUSION += extra_deps
|
||||
copy_packages(FORCED_INCLUSION, dest_folder)
|
||||
# There's a couple of rather big exe files in the distutils folder that we absolutely don't
|
||||
# need. Remove them.
|
||||
delete_files_with_pattern(op.join(dest_folder, 'distutils'), '*.exe')
|
||||
# And, finally, create an empty "site.py" that Python needs around on startup.
|
||||
open(op.join(dest_folder, 'site.py'), 'w').close()
|
||||
|
||||
def fix_qt_resource_file(path):
|
||||
# pyrcc5 under Windows, if the locale is non-english, can produce a source file with a date
|
||||
# containing accented characters. If it does, the encoding is wrong and it prevents the file
|
||||
# from being correctly frozen by cx_freeze. To work around that, we open the file, strip all
|
||||
# comments, and save.
|
||||
with open(path, 'rb') as fp:
|
||||
contents = fp.read()
|
||||
lines = contents.split(b'\n')
|
||||
lines = [l for l in lines if not l.startswith(b'#')]
|
||||
with open(path, 'wb') as fp:
|
||||
fp.write(b'\n'.join(lines))
|
||||
|
||||
def build_cocoa_ext(extname, dest, source_files, extra_frameworks=(), extra_includes=()):
|
||||
extra_link_args = ["-framework", "CoreFoundation", "-framework", "Foundation"]
|
||||
for extra in extra_frameworks:
|
||||
extra_link_args += ['-framework', extra]
|
||||
ext = Extension(extname, source_files, extra_link_args=extra_link_args, include_dirs=extra_includes)
|
||||
setup(script_args=['build_ext', '--inplace'], ext_modules=[ext])
|
||||
# Our problem here is to get the fully qualified filename of the resulting .so but I couldn't
|
||||
# find a documented way to do so. The only thing I could find is this below :(
|
||||
fn = ext._file_name
|
||||
assert op.exists(fn)
|
||||
move(fn, op.join(dest, fn))
|
||||
@@ -1,79 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2008-01-08
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
"""When you have to deal with names that have to be unique and can conflict together, you can use
|
||||
this module that deals with conflicts by prepending unique numbers in ``[]`` brackets to the name.
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from .path import Path, pathify
|
||||
|
||||
#This matches [123], but not [12] (3 digits being the minimum).
|
||||
#It also matches [1234] [12345] etc..
|
||||
#And only at the start of the string
|
||||
re_conflict = re.compile(r'^\[\d{3}\d*\] ')
|
||||
|
||||
def get_conflicted_name(other_names, name):
|
||||
"""Returns name with a ``[000]`` number in front of it.
|
||||
|
||||
The number between brackets depends on how many conlicted filenames
|
||||
there already are in other_names.
|
||||
"""
|
||||
name = get_unconflicted_name(name)
|
||||
if name not in other_names:
|
||||
return name
|
||||
i = 0
|
||||
while True:
|
||||
newname = '[%03d] %s' % (i, name)
|
||||
if newname not in other_names:
|
||||
return newname
|
||||
i += 1
|
||||
|
||||
def get_unconflicted_name(name):
|
||||
"""Returns ``name`` without ``[]`` brackets.
|
||||
|
||||
Brackets which, of course, might have been added by func:`get_conflicted_name`.
|
||||
"""
|
||||
return re_conflict.sub('',name,1)
|
||||
|
||||
def is_conflicted(name):
|
||||
"""Returns whether ``name`` is prepended with a bracketed number.
|
||||
"""
|
||||
return re_conflict.match(name) is not None
|
||||
|
||||
@pathify
|
||||
def _smart_move_or_copy(operation, source_path: Path, dest_path: Path):
|
||||
"""Use move() or copy() to move and copy file with the conflict management.
|
||||
"""
|
||||
if dest_path.isdir() and not source_path.isdir():
|
||||
dest_path = dest_path[source_path.name]
|
||||
if dest_path.exists():
|
||||
filename = dest_path.name
|
||||
dest_dir_path = dest_path.parent()
|
||||
newname = get_conflicted_name(os.listdir(str(dest_dir_path)), filename)
|
||||
dest_path = dest_dir_path[newname]
|
||||
operation(str(source_path), str(dest_path))
|
||||
|
||||
def smart_move(source_path, dest_path):
|
||||
"""Same as :func:`smart_copy`, but it moves files instead.
|
||||
"""
|
||||
_smart_move_or_copy(shutil.move, source_path, dest_path)
|
||||
|
||||
def smart_copy(source_path, dest_path):
|
||||
"""Copies ``source_path`` to ``dest_path``, recursively and with conflict resolution.
|
||||
"""
|
||||
try:
|
||||
_smart_move_or_copy(shutil.copy, source_path, dest_path)
|
||||
except IOError as e:
|
||||
if e.errno in {21, 13}: # it's a directory, code is 21 on OS X / Linux and 13 on Windows
|
||||
_smart_move_or_copy(shutil.copytree, source_path, dest_path)
|
||||
else:
|
||||
raise
|
||||
@@ -1,533 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2008-04-20
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
"""This module facilitates currencies management. It exposes :class:`Currency` which lets you
|
||||
easily figure out their exchange value.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, date, timedelta
|
||||
import logging
|
||||
import sqlite3 as sqlite
|
||||
import threading
|
||||
from queue import Queue, Empty
|
||||
|
||||
from .path import Path
|
||||
from .util import iterdaterange
|
||||
|
||||
class Currency:
|
||||
"""Represents a currency and allow easy exchange rate lookups.
|
||||
|
||||
A ``Currency`` instance is created with either a 3-letter ISO code or with a full name. If it's
|
||||
present in the database, an instance will be returned. If not, ``ValueError`` is raised. The
|
||||
easiest way to access a currency instance, however, if by using module-level constants. For
|
||||
example::
|
||||
|
||||
>>> from hscommon.currency import USD, EUR
|
||||
>>> from datetime import date
|
||||
>>> USD.value_in(EUR, date.today())
|
||||
0.6339119851386843
|
||||
|
||||
Unless a :class:`RatesDB` global instance is set through :meth:`Currency.set_rate_db` however,
|
||||
only fallback values will be used as exchange rates.
|
||||
"""
|
||||
all = []
|
||||
by_code = {}
|
||||
by_name = {}
|
||||
rates_db = None
|
||||
|
||||
def __new__(cls, code=None, name=None):
|
||||
"""Returns the currency with the given code."""
|
||||
assert (code is None and name is not None) or (code is not None and name is None)
|
||||
if code is not None:
|
||||
try:
|
||||
return cls.by_code[code]
|
||||
except KeyError:
|
||||
raise ValueError('Unknown currency code: %r' % code)
|
||||
else:
|
||||
try:
|
||||
return cls.by_name[name]
|
||||
except KeyError:
|
||||
raise ValueError('Unknown currency name: %r' % name)
|
||||
|
||||
def __getnewargs__(self):
|
||||
return (self.code,)
|
||||
|
||||
def __getstate__(self):
|
||||
return None
|
||||
|
||||
def __setstate__(self, state):
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return '<Currency %s>' % self.code
|
||||
|
||||
@staticmethod
|
||||
def register(code, name, exponent=2, start_date=None, start_rate=1, stop_date=None, latest_rate=1):
|
||||
"""Registers a new currency and returns it."""
|
||||
assert code not in Currency.by_code
|
||||
assert name not in Currency.by_name
|
||||
currency = object.__new__(Currency)
|
||||
currency.code = code
|
||||
currency.name = name
|
||||
currency.exponent = exponent
|
||||
currency.start_date = start_date
|
||||
currency.start_rate = start_rate
|
||||
currency.stop_date = stop_date
|
||||
currency.latest_rate = latest_rate
|
||||
Currency.by_code[code] = currency
|
||||
Currency.by_name[name] = currency
|
||||
Currency.all.append(currency)
|
||||
return currency
|
||||
|
||||
@staticmethod
|
||||
def set_rates_db(db):
|
||||
"""Sets a new currency ``RatesDB`` instance to be used with all ``Currency`` instances.
|
||||
"""
|
||||
Currency.rates_db = db
|
||||
|
||||
@staticmethod
|
||||
def get_rates_db():
|
||||
"""Returns the current ``RatesDB`` instance.
|
||||
"""
|
||||
if Currency.rates_db is None:
|
||||
Currency.rates_db = RatesDB() # Make sure we always have some db to work with
|
||||
return Currency.rates_db
|
||||
|
||||
def rates_date_range(self):
|
||||
"""Returns the range of date for which rates are available for this currency."""
|
||||
return self.get_rates_db().date_range(self.code)
|
||||
|
||||
def value_in(self, currency, date):
|
||||
"""Returns the value of this currency in terms of the other currency on the given date."""
|
||||
if self.start_date is not None and date < self.start_date:
|
||||
return self.start_rate
|
||||
elif self.stop_date is not None and date > self.stop_date:
|
||||
return self.latest_rate
|
||||
else:
|
||||
return self.get_rates_db().get_rate(date, self.code, currency.code)
|
||||
|
||||
def set_CAD_value(self, value, date):
|
||||
"""Sets the currency's value in CAD on the given date."""
|
||||
self.get_rates_db().set_CAD_value(date, self.code, value)
|
||||
|
||||
|
||||
BUILTIN_CURRENCIES = {
|
||||
# In order we want to list them
|
||||
Currency.register('USD', 'U.S. dollar',
|
||||
start_date=date(1998, 1, 2), start_rate=1.425, latest_rate=1.0128),
|
||||
Currency.register('EUR', 'European Euro',
|
||||
start_date=date(1999, 1, 4), start_rate=1.8123, latest_rate=1.3298),
|
||||
Currency.register('GBP', 'U.K. pound sterling',
|
||||
start_date=date(1998, 1, 2), start_rate=2.3397, latest_rate=1.5349),
|
||||
Currency.register('CAD', 'Canadian dollar',
|
||||
latest_rate=1),
|
||||
Currency.register('AUD', 'Australian dollar',
|
||||
start_date=date(1998, 1, 2), start_rate=0.9267, latest_rate=0.9336),
|
||||
Currency.register('JPY', 'Japanese yen',
|
||||
exponent=0, start_date=date(1998, 1, 2), start_rate=0.01076, latest_rate=0.01076),
|
||||
Currency.register('INR', 'Indian rupee',
|
||||
start_date=date(1998, 1, 2), start_rate=0.03627, latest_rate=0.02273),
|
||||
Currency.register('NZD', 'New Zealand dollar',
|
||||
start_date=date(1998, 1, 2), start_rate=0.8225, latest_rate=0.7257),
|
||||
Currency.register('CHF', 'Swiss franc',
|
||||
start_date=date(1998, 1, 2), start_rate=0.9717, latest_rate=0.9273),
|
||||
Currency.register('ZAR', 'South African rand',
|
||||
start_date=date(1998, 1, 2), start_rate=0.292, latest_rate=0.1353),
|
||||
# The rest, alphabetical
|
||||
Currency.register('AED', 'U.A.E. dirham',
|
||||
start_date=date(2007, 9, 4), start_rate=0.2858, latest_rate=0.2757),
|
||||
Currency.register('ANG', 'Neth. Antilles florin',
|
||||
start_date=date(1998, 1, 2), start_rate=0.7961, latest_rate=0.5722),
|
||||
Currency.register('ARS', 'Argentine peso',
|
||||
start_date=date(1998, 1, 2), start_rate=1.4259, latest_rate=0.2589),
|
||||
Currency.register('ATS', 'Austrian schilling',
|
||||
start_date=date(1998, 1, 2), start_rate=0.1123, stop_date=date(2001, 12, 31), latest_rate=0.10309), # obsolete (euro)
|
||||
Currency.register('BBD', 'Barbadian dollar',
|
||||
start_date=date(2010, 4, 30), start_rate=0.5003, latest_rate=0.5003),
|
||||
Currency.register('BEF', 'Belgian franc',
|
||||
start_date=date(1998, 1, 2), start_rate=0.03832, stop_date=date(2001, 12, 31), latest_rate=0.03516), # obsolete (euro)
|
||||
Currency.register('BHD', 'Bahraini dinar',
|
||||
exponent=3, start_date=date(2008, 11, 8), start_rate=3.1518, latest_rate=2.6603),
|
||||
Currency.register('BRL', 'Brazilian real',
|
||||
start_date=date(1998, 1, 2), start_rate=1.2707, latest_rate=0.5741),
|
||||
Currency.register('BSD', 'Bahamian dollar',
|
||||
start_date=date(1998, 1, 2), start_rate=1.425, latest_rate=1.0128),
|
||||
Currency.register('CLP', 'Chilean peso',
|
||||
exponent=0, start_date=date(1998, 1, 2), start_rate=0.003236, latest_rate=0.001923),
|
||||
Currency.register('CNY', 'Chinese renminbi',
|
||||
start_date=date(1998, 1, 2), start_rate=0.1721, latest_rate=0.1484),
|
||||
Currency.register('COP', 'Colombian peso',
|
||||
start_date=date(1998, 1, 2), start_rate=0.00109, latest_rate=0.000513),
|
||||
Currency.register('CZK', 'Czech Republic koruna',
|
||||
start_date=date(1998, 2, 2), start_rate=0.04154, latest_rate=0.05202),
|
||||
Currency.register('DEM', 'German deutsche mark',
|
||||
start_date=date(1998, 1, 2), start_rate=0.7904, stop_date=date(2001, 12, 31), latest_rate=0.7253), # obsolete (euro)
|
||||
Currency.register('DKK', 'Danish krone',
|
||||
start_date=date(1998, 1, 2), start_rate=0.2075, latest_rate=0.1785),
|
||||
Currency.register('EGP', 'Egyptian Pound',
|
||||
start_date=date(2008, 11, 27), start_rate=0.2232, latest_rate=0.1805),
|
||||
Currency.register('ESP', 'Spanish peseta',
|
||||
exponent=0, start_date=date(1998, 1, 2), start_rate=0.009334, stop_date=date(2001, 12, 31), latest_rate=0.008526), # obsolete (euro)
|
||||
Currency.register('FIM', 'Finnish markka',
|
||||
start_date=date(1998, 1, 2), start_rate=0.2611, stop_date=date(2001, 12, 31), latest_rate=0.2386), # obsolete (euro)
|
||||
Currency.register('FJD', 'Fiji dollar',
|
||||
start_date=date(1998, 1, 2), start_rate=0.9198, latest_rate=0.5235),
|
||||
Currency.register('FRF', 'French franc',
|
||||
start_date=date(1998, 1, 2), start_rate=0.2362, stop_date=date(2001, 12, 31), latest_rate=0.2163), # obsolete (euro)
|
||||
Currency.register('GHC', 'Ghanaian cedi (old)',
|
||||
start_date=date(1998, 1, 2), start_rate=0.00063, stop_date=date(2007, 6, 29), latest_rate=0.000115), # obsolete
|
||||
Currency.register('GHS', 'Ghanaian cedi',
|
||||
start_date=date(2007, 7, 3), start_rate=1.1397, latest_rate=0.7134),
|
||||
Currency.register('GRD', 'Greek drachma',
|
||||
start_date=date(1998, 1, 2), start_rate=0.005, stop_date=date(2001, 12, 31), latest_rate=0.004163), # obsolete (euro)
|
||||
Currency.register('GTQ', 'Guatemalan quetzal',
|
||||
start_date=date(2004, 12, 21), start_rate=0.15762, latest_rate=0.1264),
|
||||
Currency.register('HKD', 'Hong Kong dollar',
|
||||
start_date=date(1998, 1, 2), start_rate=0.1838, latest_rate=0.130385),
|
||||
Currency.register('HNL', 'Honduran lempira',
|
||||
start_date=date(1998, 1, 2), start_rate=0.108, latest_rate=0.0536),
|
||||
Currency.register('HRK', 'Croatian kuna',
|
||||
start_date=date(2002, 3, 1), start_rate=0.1863, latest_rate=0.1837),
|
||||
Currency.register('HUF', 'Hungarian forint',
|
||||
start_date=date(1998, 2, 2), start_rate=0.007003, latest_rate=0.00493),
|
||||
Currency.register('IDR', 'Indonesian rupiah',
|
||||
start_date=date(1998, 2, 2), start_rate=0.000145, latest_rate=0.000112),
|
||||
Currency.register('IEP', 'Irish pound',
|
||||
start_date=date(1998, 1, 2), start_rate=2.0235, stop_date=date(2001, 12, 31), latest_rate=1.8012), # obsolete (euro)
|
||||
Currency.register('ILS', 'Israeli new shekel',
|
||||
start_date=date(1998, 1, 2), start_rate=0.4021, latest_rate=0.2706),
|
||||
Currency.register('ISK', 'Icelandic krona',
|
||||
exponent=0, start_date=date(1998, 1, 2), start_rate=0.01962, latest_rate=0.00782),
|
||||
Currency.register('ITL', 'Italian lira',
|
||||
exponent=0, start_date=date(1998, 1, 2), start_rate=0.000804, stop_date=date(2001, 12, 31), latest_rate=0.000733), # obsolete (euro)
|
||||
Currency.register('JMD', 'Jamaican dollar',
|
||||
start_date=date(2001, 6, 25), start_rate=0.03341, latest_rate=0.01145),
|
||||
Currency.register('KRW', 'South Korean won',
|
||||
exponent=0, start_date=date(1998, 1, 2), start_rate=0.000841, latest_rate=0.000905),
|
||||
Currency.register('LKR', 'Sri Lanka rupee',
|
||||
start_date=date(1998, 1, 2), start_rate=0.02304, latest_rate=0.0089),
|
||||
Currency.register('LTL', 'Lithuanian litas',
|
||||
start_date=date(2010, 4, 29), start_rate=0.384, latest_rate=0.384),
|
||||
Currency.register('LVL', 'Latvian lats',
|
||||
start_date=date(2011, 2, 6), start_rate=1.9136, latest_rate=1.9136),
|
||||
Currency.register('MAD', 'Moroccan dirham',
|
||||
start_date=date(1998, 1, 2), start_rate=0.1461, latest_rate=0.1195),
|
||||
Currency.register('MMK', 'Myanmar (Burma) kyat',
|
||||
start_date=date(1998, 1, 2), start_rate=0.226, latest_rate=0.1793),
|
||||
Currency.register('MXN', 'Mexican peso',
|
||||
start_date=date(1998, 1, 2), start_rate=0.1769, latest_rate=0.08156),
|
||||
Currency.register('MYR', 'Malaysian ringgit',
|
||||
start_date=date(1998, 1, 2), start_rate=0.3594, latest_rate=0.3149),
|
||||
# MZN in not supported in any of my sources, so I'm just creating it with a fixed rate.
|
||||
Currency.register('MZN', 'Mozambican metical',
|
||||
start_date=date(2011, 2, 6), start_rate=0.03, stop_date=date(2011, 2, 5), latest_rate=0.03),
|
||||
Currency.register('NIO', 'Nicaraguan córdoba',
|
||||
start_date=date(2011, 10, 12), start_rate=0.0448, latest_rate=0.0448),
|
||||
Currency.register('NLG', 'Netherlands guilder',
|
||||
start_date=date(1998, 1, 2), start_rate=0.7013, stop_date=date(2001, 12, 31), latest_rate=0.6437), # obsolete (euro)
|
||||
Currency.register('NOK', 'Norwegian krone',
|
||||
start_date=date(1998, 1, 2), start_rate=0.1934, latest_rate=0.1689),
|
||||
Currency.register('PAB', 'Panamanian balboa',
|
||||
start_date=date(1998, 1, 2), start_rate=1.425, latest_rate=1.0128),
|
||||
Currency.register('PEN', 'Peruvian new sol',
|
||||
start_date=date(1998, 1, 2), start_rate=0.5234, latest_rate=0.3558),
|
||||
Currency.register('PHP', 'Philippine peso',
|
||||
start_date=date(1998, 1, 2), start_rate=0.0345, latest_rate=0.02262),
|
||||
Currency.register('PKR', 'Pakistan rupee',
|
||||
start_date=date(1998, 1, 2), start_rate=0.03238, latest_rate=0.01206),
|
||||
Currency.register('PLN', 'Polish zloty',
|
||||
start_date=date(1998, 2, 2), start_rate=0.4108, latest_rate=0.3382),
|
||||
Currency.register('PTE', 'Portuguese escudo',
|
||||
exponent=0, start_date=date(1998, 1, 2), start_rate=0.007726, stop_date=date(2001, 12, 31), latest_rate=0.007076), # obsolete (euro)
|
||||
Currency.register('RON', 'Romanian new leu',
|
||||
start_date=date(2007, 9, 4), start_rate=0.4314, latest_rate=0.3215),
|
||||
Currency.register('RSD', 'Serbian dinar',
|
||||
start_date=date(2007, 9, 4), start_rate=0.0179, latest_rate=0.01338),
|
||||
Currency.register('RUB', 'Russian rouble',
|
||||
start_date=date(1998, 1, 2), start_rate=0.2375, latest_rate=0.03443),
|
||||
Currency.register('SAR', 'Saudi riyal',
|
||||
start_date=date(2012, 9, 13), start_rate=0.26, latest_rate=0.26),
|
||||
Currency.register('SEK', 'Swedish krona',
|
||||
start_date=date(1998, 1, 2), start_rate=0.1787, latest_rate=0.1378),
|
||||
Currency.register('SGD', 'Singapore dollar',
|
||||
start_date=date(1998, 1, 2), start_rate=0.84, latest_rate=0.7358),
|
||||
Currency.register('SIT', 'Slovenian tolar',
|
||||
start_date=date(2002, 3, 1), start_rate=0.006174, stop_date=date(2006, 12, 29), latest_rate=0.006419), # obsolete (euro)
|
||||
Currency.register('SKK', 'Slovak koruna',
|
||||
start_date=date(2002, 3, 1), start_rate=0.03308, stop_date=date(2008, 12, 31), latest_rate=0.05661), # obsolete (euro)
|
||||
Currency.register('THB', 'Thai baht',
|
||||
start_date=date(1998, 1, 2), start_rate=0.0296, latest_rate=0.03134),
|
||||
Currency.register('TND', 'Tunisian dinar',
|
||||
exponent=3, start_date=date(1998, 1, 2), start_rate=1.2372, latest_rate=0.7037),
|
||||
Currency.register('TRL', 'Turkish lira',
|
||||
exponent=0, start_date=date(1998, 1, 2), start_rate=7.0e-06, stop_date=date(2004, 12, 31), latest_rate=8.925e-07), # obsolete
|
||||
Currency.register('TWD', 'Taiwanese new dollar',
|
||||
start_date=date(1998, 1, 2), start_rate=0.04338, latest_rate=0.03218),
|
||||
Currency.register('UAH', 'Ukrainian hryvnia',
|
||||
start_date=date(2010, 4, 29), start_rate=0.1266, latest_rate=0.1266),
|
||||
Currency.register('VEB', 'Venezuelan bolivar',
|
||||
exponent=0, start_date=date(1998, 1, 2), start_rate=0.002827, stop_date=date(2007, 12, 31), latest_rate=0.00046), # obsolete
|
||||
Currency.register('VEF', 'Venezuelan bolivar fuerte',
|
||||
start_date=date(2008, 1, 2), start_rate=0.4623, latest_rate=0.2358),
|
||||
Currency.register('VND', 'Vietnamese dong',
|
||||
start_date=date(2004, 1, 1), start_rate=8.2e-05, latest_rate=5.3e-05),
|
||||
Currency.register('XAF', 'CFA franc',
|
||||
exponent=0, start_date=date(1998, 1, 2), start_rate=0.002362, latest_rate=0.002027),
|
||||
Currency.register('XCD', 'East Caribbean dollar',
|
||||
start_date=date(1998, 1, 2), start_rate=0.5278, latest_rate=0.3793),
|
||||
Currency.register('XPF', 'CFP franc',
|
||||
exponent=0, start_date=date(1998, 1, 2), start_rate=0.01299, latest_rate=0.01114),
|
||||
}
|
||||
BUILTIN_CURRENCY_CODES = {c.code for c in BUILTIN_CURRENCIES}
|
||||
|
||||
# For legacy purpose, we need to maintain these global variables
|
||||
CAD = Currency(code='CAD')
|
||||
USD = Currency(code='USD')
|
||||
EUR = Currency(code='EUR')
|
||||
|
||||
class CurrencyNotSupportedException(Exception):
|
||||
"""The current exchange rate provider doesn't support the requested currency."""
|
||||
|
||||
class RateProviderUnavailable(Exception):
|
||||
"""The rate provider is temporarily unavailable."""
|
||||
|
||||
def date2str(date):
|
||||
return '%d%02d%02d' % (date.year, date.month, date.day)
|
||||
|
||||
class RatesDB:
|
||||
"""Stores exchange rates for currencies.
|
||||
|
||||
The currencies are identified with ISO 4217 code (USD, CAD, EUR, etc.).
|
||||
The rates are represented as float and represent the value of the currency in CAD.
|
||||
"""
|
||||
def __init__(self, db_or_path=':memory:', async=True):
|
||||
self._cache = {} # {(date, currency): CAD value
|
||||
self.db_or_path = db_or_path
|
||||
if isinstance(db_or_path, (str, Path)):
|
||||
self.con = sqlite.connect(str(db_or_path))
|
||||
else:
|
||||
self.con = db_or_path
|
||||
self._execute("select * from rates where 1=2")
|
||||
self._rate_providers = []
|
||||
self.async = async
|
||||
self._fetched_values = Queue()
|
||||
self._fetched_ranges = {} # a currency --> (start, end) map
|
||||
|
||||
def _execute(self, *args, **kwargs):
|
||||
def create_tables():
|
||||
# date is stored as a TEXT YYYYMMDD
|
||||
sql = "create table rates(date TEXT, currency TEXT, rate REAL NOT NULL)"
|
||||
self.con.execute(sql)
|
||||
sql = "create unique index idx_rate on rates (date, currency)"
|
||||
self.con.execute(sql)
|
||||
|
||||
try:
|
||||
return self.con.execute(*args, **kwargs)
|
||||
except sqlite.OperationalError: # new db, or other problems
|
||||
try:
|
||||
create_tables()
|
||||
except Exception:
|
||||
logging.warning("Messy problems with the currency db, starting anew with a memory db")
|
||||
self.con = sqlite.connect(':memory:')
|
||||
create_tables()
|
||||
except sqlite.DatabaseError: # corrupt db
|
||||
logging.warning("Corrupt currency database at {0}. Starting over.".format(repr(self.db_or_path)))
|
||||
if isinstance(self.db_or_path, (str, Path)):
|
||||
self.con.close()
|
||||
os.remove(str(self.db_or_path))
|
||||
self.con = sqlite.connect(str(self.db_or_path))
|
||||
else:
|
||||
logging.warning("Can't re-use the file, using a memory table")
|
||||
self.con = sqlite.connect(':memory:')
|
||||
create_tables()
|
||||
return self.con.execute(*args, **kwargs) # try again
|
||||
|
||||
def _seek_value_in_CAD(self, str_date, currency_code):
|
||||
if currency_code == 'CAD':
|
||||
return 1
|
||||
def seek(date_op, desc):
|
||||
sql = "select rate from rates where date %s ? and currency = ? order by date %s limit 1" % (date_op, desc)
|
||||
cur = self._execute(sql, [str_date, currency_code])
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
return seek('<=', 'desc') or seek('>=', '') or Currency(currency_code).latest_rate
|
||||
|
||||
def _ensure_filled(self, date_start, date_end, currency_code):
|
||||
"""Make sure that the cache contains *something* for each of the dates in the range.
|
||||
|
||||
Sometimes, our provider doesn't return us the range we sought. When it does, it usually
|
||||
means that it never will and to avoid repeatedly querying those ranges forever, we have to
|
||||
fill them. We use the closest rate for this.
|
||||
"""
|
||||
# We don't want to fill today, because we want to repeatedly fetch that one until the
|
||||
# provider gives it to us.
|
||||
if date_end >= date.today():
|
||||
date_end = date.today() - timedelta(1)
|
||||
sql = "select rate from rates where date = ? and currency = ?"
|
||||
for curdate in iterdaterange(date_start, date_end):
|
||||
cur = self._execute(sql, [date2str(curdate), currency_code])
|
||||
if cur.fetchone() is None:
|
||||
nearby_rate = self._seek_value_in_CAD(date2str(curdate), currency_code)
|
||||
self.set_CAD_value(curdate, currency_code, nearby_rate)
|
||||
logging.debug("Filled currency void for %s at %s (value: %2.2f)", currency_code, curdate, nearby_rate)
|
||||
|
||||
def _save_fetched_rates(self):
|
||||
while True:
|
||||
try:
|
||||
rates, currency, fetch_start, fetch_end = self._fetched_values.get_nowait()
|
||||
logging.debug("Saving %d rates for the currency %s", len(rates), currency)
|
||||
for rate_date, rate in rates:
|
||||
logging.debug("Saving rate %2.2f for %s", rate, rate_date)
|
||||
self.set_CAD_value(rate_date, currency, rate)
|
||||
self._ensure_filled(fetch_start, fetch_end, currency)
|
||||
logging.debug("Finished saving rates for currency %s", currency)
|
||||
except Empty:
|
||||
break
|
||||
|
||||
def clear_cache(self):
|
||||
self._cache = {}
|
||||
|
||||
def date_range(self, currency_code):
|
||||
"""Returns (start, end) of the cached rates for currency.
|
||||
|
||||
Returns a tuple ``(start_date, end_date)`` representing dates covered in the database for
|
||||
currency ``currency_code``. If there are gaps, they are not accounted for (subclasses that
|
||||
automatically update themselves are not supposed to introduce gaps in the db).
|
||||
"""
|
||||
sql = "select min(date), max(date) from rates where currency = '%s'" % currency_code
|
||||
cur = self._execute(sql)
|
||||
start, end = cur.fetchone()
|
||||
if start and end:
|
||||
convert = lambda s: datetime.strptime(s, '%Y%m%d').date()
|
||||
return convert(start), convert(end)
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_rate(self, date, currency1_code, currency2_code):
|
||||
"""Returns the exchange rate between currency1 and currency2 for date.
|
||||
|
||||
The rate returned means '1 unit of currency1 is worth X units of currency2'.
|
||||
The rate of the nearest date that is smaller than 'date' is returned. If
|
||||
there is none, a seek for a rate with a higher date will be made.
|
||||
"""
|
||||
# We want to check self._fetched_values for rates to add.
|
||||
if not self._fetched_values.empty():
|
||||
self._save_fetched_rates()
|
||||
# This method is a bottleneck and has been optimized for speed.
|
||||
value1 = None
|
||||
value2 = None
|
||||
if currency1_code == 'CAD':
|
||||
value1 = 1
|
||||
else:
|
||||
value1 = self._cache.get((date, currency1_code))
|
||||
if currency2_code == 'CAD':
|
||||
value2 = 1
|
||||
else:
|
||||
value2 = self._cache.get((date, currency2_code))
|
||||
if value1 is None or value2 is None:
|
||||
str_date = date2str(date)
|
||||
if value1 is None:
|
||||
value1 = self._seek_value_in_CAD(str_date, currency1_code)
|
||||
self._cache[(date, currency1_code)] = value1
|
||||
if value2 is None:
|
||||
value2 = self._seek_value_in_CAD(str_date, currency2_code)
|
||||
self._cache[(date, currency2_code)] = value2
|
||||
return value1 / value2
|
||||
|
||||
def set_CAD_value(self, date, currency_code, value):
|
||||
"""Sets the daily value in CAD for currency at date"""
|
||||
# we must clear the whole cache because there might be other dates affected by this change
|
||||
# (dates when the currency server has no rates).
|
||||
self.clear_cache()
|
||||
str_date = date2str(date)
|
||||
sql = "replace into rates(date, currency, rate) values(?, ?, ?)"
|
||||
self._execute(sql, [str_date, currency_code, value])
|
||||
self.con.commit()
|
||||
|
||||
def register_rate_provider(self, rate_provider):
|
||||
"""Adds `rate_provider` to the list of providers supported by this DB.
|
||||
|
||||
A provider if a function(currency, start_date, end_date) that returns a list of
|
||||
(rate_date, float_rate) as a result. This function will be called asyncronously, so it's ok
|
||||
if it takes a long time to return.
|
||||
|
||||
The rates returned must be the value of 1 `currency` in CAD (Canadian Dollars) at the
|
||||
specified date.
|
||||
|
||||
The provider can be asked for any currency. If it doesn't support it, it has to raise
|
||||
CurrencyNotSupportedException.
|
||||
|
||||
If we support the currency but that there is no rate available for the specified range,
|
||||
simply return an empty list or None.
|
||||
"""
|
||||
self._rate_providers.append(rate_provider)
|
||||
|
||||
def ensure_rates(self, start_date, currencies):
|
||||
"""Ensures that the DB has all the rates it needs for 'currencies' between 'start_date' and today
|
||||
|
||||
If there is any rate missing, a request will be made to the currency server. The requests
|
||||
are made asynchronously.
|
||||
"""
|
||||
def do():
|
||||
for currency, fetch_start, fetch_end in currencies_and_range:
|
||||
logging.debug("Fetching rates for %s for date range %s to %s", currency, fetch_start, fetch_end)
|
||||
for rate_provider in self._rate_providers:
|
||||
try:
|
||||
values = rate_provider(currency, fetch_start, fetch_end)
|
||||
except CurrencyNotSupportedException:
|
||||
continue
|
||||
except RateProviderUnavailable:
|
||||
logging.debug("Fetching failed due to temporary problems.")
|
||||
break
|
||||
else:
|
||||
if not values:
|
||||
# We didn't get any value from the server, which means that we asked for
|
||||
# rates that couldn't be delivered. Still, we report empty values so
|
||||
# that the cache can correctly remember this unavailability so that we
|
||||
# don't repeatedly fetch those ranges.
|
||||
values = []
|
||||
self._fetched_values.put((values, currency, fetch_start, fetch_end))
|
||||
logging.debug("Fetching successful!")
|
||||
break
|
||||
else:
|
||||
logging.debug("Fetching failed!")
|
||||
|
||||
currencies_and_range = []
|
||||
for currency in currencies:
|
||||
if currency == 'CAD':
|
||||
continue
|
||||
try:
|
||||
cached_range = self._fetched_ranges[currency]
|
||||
except KeyError:
|
||||
cached_range = self.date_range(currency)
|
||||
range_start = start_date
|
||||
# Don't try to fetch today's rate, it's never there and results in useless server
|
||||
# hitting.
|
||||
range_end = date.today() - timedelta(1)
|
||||
if cached_range is not None:
|
||||
cached_start, cached_end = cached_range
|
||||
if range_start >= cached_start:
|
||||
# Make a forward fetch
|
||||
range_start = cached_end + timedelta(days=1)
|
||||
else:
|
||||
# Make a backward fetch
|
||||
range_end = cached_start - timedelta(days=1)
|
||||
# We don't want to fetch ranges that are too big. It can cause various problems, such
|
||||
# as hangs. We prefer to take smaller bites.
|
||||
if (range_end - range_start).days > 30:
|
||||
range_start = range_end - timedelta(days=30)
|
||||
if range_start <= range_end:
|
||||
currencies_and_range.append((currency, range_start, range_end))
|
||||
self._fetched_ranges[currency] = (start_date, date.today())
|
||||
if self.async:
|
||||
threading.Thread(target=do).start()
|
||||
else:
|
||||
do()
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2011-04-19
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
# Taken from http://bzimmer.ziclix.com/2008/12/17/python-thread-dumps/
|
||||
def stacktraces():
|
||||
code = []
|
||||
for threadId, stack in sys._current_frames().items():
|
||||
code.append("\n# ThreadID: %s" % threadId)
|
||||
for filename, lineno, name, line in traceback.extract_stack(stack):
|
||||
code.append('File: "%s", line %d, in %s' % (filename, lineno, name))
|
||||
if line:
|
||||
code.append(" %s" % (line.strip()))
|
||||
|
||||
return "\n".join(code)
|
||||
@@ -1,93 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2013-10-12
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import os.path as op
|
||||
import logging
|
||||
|
||||
class SpecialFolder:
|
||||
AppData = 1
|
||||
Cache = 2
|
||||
|
||||
def open_url(url):
|
||||
"""Open ``url`` with the default browser.
|
||||
"""
|
||||
_open_url(url)
|
||||
|
||||
def open_path(path):
|
||||
"""Open ``path`` with its associated application.
|
||||
"""
|
||||
_open_path(str(path))
|
||||
|
||||
def reveal_path(path):
|
||||
"""Open the folder containing ``path`` with the default file browser.
|
||||
"""
|
||||
_reveal_path(str(path))
|
||||
|
||||
def special_folder_path(special_folder, appname=None):
|
||||
"""Returns the path of ``special_folder``.
|
||||
|
||||
``special_folder`` is a SpecialFolder.* const. The result is the special folder for the current
|
||||
application. The running process' application info is used to determine relevant information.
|
||||
|
||||
You can override the application name with ``appname``. This argument is ingored under Qt.
|
||||
"""
|
||||
return _special_folder_path(special_folder, appname)
|
||||
|
||||
try:
|
||||
# Normally, we would simply do "from cocoa import proxy", but due to a bug in pytest (currently
|
||||
# at v2.4.2), our test suite is broken when we do that. This below is a workaround until that
|
||||
# bug is fixed.
|
||||
import cocoa
|
||||
if not hasattr(cocoa, 'proxy'):
|
||||
raise ImportError()
|
||||
proxy = cocoa.proxy
|
||||
_open_url = proxy.openURL_
|
||||
_open_path = proxy.openPath_
|
||||
_reveal_path = proxy.revealPath_
|
||||
|
||||
def _special_folder_path(special_folder, appname=None):
|
||||
if special_folder == SpecialFolder.Cache:
|
||||
base = proxy.getCachePath()
|
||||
else:
|
||||
base = proxy.getAppdataPath()
|
||||
if not appname:
|
||||
appname = proxy.bundleInfo_('CFBundleName')
|
||||
return op.join(base, appname)
|
||||
|
||||
except ImportError:
|
||||
try:
|
||||
from PyQt5.QtCore import QUrl, QStandardPaths
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
def _open_url(url):
|
||||
QDesktopServices.openUrl(QUrl(url))
|
||||
|
||||
def _open_path(path):
|
||||
url = QUrl.fromLocalFile(str(path))
|
||||
QDesktopServices.openUrl(url)
|
||||
|
||||
def _reveal_path(path):
|
||||
_open_path(op.dirname(str(path)))
|
||||
|
||||
def _special_folder_path(special_folder, appname=None):
|
||||
if special_folder == SpecialFolder.Cache:
|
||||
qtfolder = QStandardPaths.CacheLocation
|
||||
else:
|
||||
qtfolder = QStandardPaths.DataLocation
|
||||
return QStandardPaths.standardLocations(qtfolder)[0]
|
||||
except ImportError:
|
||||
# We're either running tests, and these functions don't matter much or we're in a really
|
||||
# weird situation. Let's just have dummy fallbacks.
|
||||
logging.warning("Can't setup desktop functions!")
|
||||
def _open_path(path):
|
||||
pass
|
||||
|
||||
def _reveal_path(path):
|
||||
pass
|
||||
|
||||
def _special_folder_path(special_folder, appname=None):
|
||||
return '/tmp'
|
||||
@@ -1,218 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2011-08-05
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from sys import maxsize as INF
|
||||
from math import sqrt
|
||||
|
||||
VERY_SMALL = 0.0000001
|
||||
|
||||
class Point:
|
||||
def __init__(self, x, y):
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
def __repr__(self):
|
||||
return '<Point {:2.2f}, {:2.2f}>'.format(*self)
|
||||
|
||||
def __iter__(self):
|
||||
yield self.x
|
||||
yield self.y
|
||||
|
||||
def distance_to(self, other):
|
||||
return Line(self, other).length()
|
||||
|
||||
|
||||
class Line:
|
||||
def __init__(self, p1, p2):
|
||||
self.p1 = p1
|
||||
self.p2 = p2
|
||||
|
||||
def __repr__(self):
|
||||
return '<Line {}, {}>'.format(*self)
|
||||
|
||||
def __iter__(self):
|
||||
yield self.p1
|
||||
yield self.p2
|
||||
|
||||
def dx(self):
|
||||
return self.p2.x - self.p1.x
|
||||
|
||||
def dy(self):
|
||||
return self.p2.y - self.p1.y
|
||||
|
||||
def length(self):
|
||||
return sqrt(self.dx() ** 2 + self.dy() ** 2)
|
||||
|
||||
def slope(self):
|
||||
if self.dx() == 0:
|
||||
return INF if self.dy() > 0 else -INF
|
||||
else:
|
||||
return self.dy() / self.dx()
|
||||
|
||||
def intersection_point(self, other):
|
||||
# with help from http://paulbourke.net/geometry/lineline2d/
|
||||
if abs(self.slope() - other.slope()) < VERY_SMALL:
|
||||
# parallel. Even if coincident, we return nothing
|
||||
return None
|
||||
|
||||
A, B = self
|
||||
C, D = other
|
||||
|
||||
denom = (D.y-C.y) * (B.x-A.x) - (D.x-C.x) * (B.y-A.y)
|
||||
if denom == 0:
|
||||
return None
|
||||
numera = (D.x-C.x) * (A.y-C.y) - (D.y-C.y) * (A.x-C.x)
|
||||
numerb = (B.x-A.x) * (A.y-C.y) - (B.y-A.y) * (A.x-C.x)
|
||||
|
||||
mua = numera / denom;
|
||||
mub = numerb / denom;
|
||||
if (0 <= mua <= 1) and (0 <= mub <= 1):
|
||||
x = A.x + mua * (B.x - A.x)
|
||||
y = A.y + mua * (B.y - A.y)
|
||||
return Point(x, y)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class Rect:
|
||||
def __init__(self, x, y, w, h):
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.w = w
|
||||
self.h = h
|
||||
|
||||
def __iter__(self):
|
||||
yield self.x
|
||||
yield self.y
|
||||
yield self.w
|
||||
yield self.h
|
||||
|
||||
def __repr__(self):
|
||||
return '<Rect {:2.2f}, {:2.2f}, {:2.2f}, {:2.2f}>'.format(*self)
|
||||
|
||||
@classmethod
|
||||
def from_center(cls, center, width, height):
|
||||
x = center.x - width / 2
|
||||
y = center.y - height / 2
|
||||
return cls(x, y, width, height)
|
||||
|
||||
@classmethod
|
||||
def from_corners(cls, pt1, pt2):
|
||||
x1, y1 = pt1
|
||||
x2, y2 = pt2
|
||||
return cls(min(x1, x2), min(y1, y2), abs(x1-x2), abs(y1-y2))
|
||||
|
||||
def center(self):
|
||||
return Point(self.x + self.w/2, self.y + self.h/2)
|
||||
|
||||
def contains_point(self, point):
|
||||
x, y = point
|
||||
(x1, y1), (x2, y2) = self.corners()
|
||||
return (x1 <= x <= x2) and (y1 <= y <= y2)
|
||||
|
||||
def contains_rect(self, rect):
|
||||
pt1, pt2 = rect.corners()
|
||||
return self.contains_point(pt1) and self.contains_point(pt2)
|
||||
|
||||
def corners(self):
|
||||
return Point(self.x, self.y), Point(self.x+self.w, self.y+self.h)
|
||||
|
||||
def intersects(self, other):
|
||||
r1pt1, r1pt2 = self.corners()
|
||||
r2pt1, r2pt2 = other.corners()
|
||||
if r1pt1.x < r2pt1.x:
|
||||
xinter = r1pt2.x >= r2pt1.x
|
||||
else:
|
||||
xinter = r2pt2.x >= r1pt1.x
|
||||
if not xinter:
|
||||
return False
|
||||
if r1pt1.y < r2pt1.y:
|
||||
yinter = r1pt2.y >= r2pt1.y
|
||||
else:
|
||||
yinter = r2pt2.y >= r1pt1.y
|
||||
return yinter
|
||||
|
||||
def lines(self):
|
||||
pt1, pt4 = self.corners()
|
||||
pt2 = Point(pt4.x, pt1.y)
|
||||
pt3 = Point(pt1.x, pt4.y)
|
||||
l1 = Line(pt1, pt2)
|
||||
l2 = Line(pt2, pt4)
|
||||
l3 = Line(pt3, pt4)
|
||||
l4 = Line(pt1, pt3)
|
||||
return l1, l2, l3, l4
|
||||
|
||||
def scaled_rect(self, dx, dy):
|
||||
"""Returns a rect that has the same borders at self, but grown/shrunk by dx/dy on each side.
|
||||
"""
|
||||
x, y, w, h = self
|
||||
x -= dx
|
||||
y -= dy
|
||||
w += dx * 2
|
||||
h += dy * 2
|
||||
return Rect(x, y, w, h)
|
||||
|
||||
def united(self, other):
|
||||
"""Returns the bounding rectangle of this rectangle and `other`.
|
||||
"""
|
||||
# ul=upper left lr=lower right
|
||||
ulcorner1, lrcorner1 = self.corners()
|
||||
ulcorner2, lrcorner2 = other.corners()
|
||||
corner1 = Point(min(ulcorner1.x, ulcorner2.x), min(ulcorner1.y, ulcorner2.y))
|
||||
corner2 = Point(max(lrcorner1.x, lrcorner2.x), max(lrcorner1.y, lrcorner2.y))
|
||||
return Rect.from_corners(corner1, corner2)
|
||||
|
||||
#--- Properties
|
||||
@property
|
||||
def top(self):
|
||||
return self.y
|
||||
|
||||
@top.setter
|
||||
def top(self, value):
|
||||
self.y = value
|
||||
|
||||
@property
|
||||
def bottom(self):
|
||||
return self.y + self.h
|
||||
|
||||
@bottom.setter
|
||||
def bottom(self, value):
|
||||
self.y = value - self.h
|
||||
|
||||
@property
|
||||
def left(self):
|
||||
return self.x
|
||||
|
||||
@left.setter
|
||||
def left(self, value):
|
||||
self.x = value
|
||||
|
||||
@property
|
||||
def right(self):
|
||||
return self.x + self.w
|
||||
|
||||
@right.setter
|
||||
def right(self, value):
|
||||
self.x = value - self.w
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
return self.w
|
||||
|
||||
@width.setter
|
||||
def width(self, value):
|
||||
self.w = value
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
return self.h
|
||||
|
||||
@height.setter
|
||||
def height(self, value):
|
||||
self.h = value
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2011/09/09
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
pass
|
||||
|
||||
class NoopGUI:
|
||||
def __getattr__(self, func_name):
|
||||
return noop
|
||||
|
||||
class GUIObject:
|
||||
"""Cross-toolkit "model" representation of a GUI layer object.
|
||||
|
||||
A ``GUIObject`` is a cross-toolkit "model" representation of a GUI layer object, for example, a
|
||||
table. It acts as a cross-toolkit interface to what we call here a :attr:`view`. That
|
||||
view is a toolkit-specific controller to the actual view (an ``NSTableView``, a ``QTableView``,
|
||||
etc.). In our GUIObject, we need a reference to that toolkit-specific controller because some
|
||||
actions have effects on it (for example, prompting it to refresh its data). The ``GUIObject``
|
||||
is typically instantiated before its :attr:`view`, that is why we set it to ``None`` on init.
|
||||
However, the GUI layer is supposed to set the view as soon as its toolkit-specific controller is
|
||||
instantiated.
|
||||
|
||||
When you subclass ``GUIObject``, you will likely want to update its view on instantiation. That
|
||||
is why we call ``self.view.refresh()`` in :meth:`_view_updated`. If you need another type of
|
||||
action on view instantiation, just override the method.
|
||||
"""
|
||||
def __init__(self):
|
||||
self._view = None
|
||||
|
||||
def _view_updated(self):
|
||||
"""(Virtual) Called after :attr:`view` has been set.
|
||||
|
||||
Doing nothing by default, this method is called after :attr:`view` has been set (it isn't
|
||||
called when it's unset, however). Use this for initialization code that requires a view
|
||||
(which is often the whole of the initialization code).
|
||||
"""
|
||||
|
||||
def has_view(self):
|
||||
return (self._view is not None) and (not isinstance(self._view, NoopGUI))
|
||||
|
||||
@property
|
||||
def view(self):
|
||||
"""A reference to our toolkit-specific view controller.
|
||||
|
||||
*view answering to GUIObject sublass's view protocol*. *get/set*
|
||||
|
||||
This view starts as ``None`` and has to be set "manually". There's two times at which we set
|
||||
the view property: On initialization, where we set the view that we'll use for our lifetime,
|
||||
and just before the view is deallocated. We need to unset our view at that time to avoid
|
||||
calls to a deallocated instance (which means a crash).
|
||||
|
||||
To unset our view, we simple assign it to ``None``.
|
||||
"""
|
||||
return self._view
|
||||
|
||||
@view.setter
|
||||
def view(self, value):
|
||||
if self._view is None:
|
||||
# Initial view assignment
|
||||
if value is None:
|
||||
return
|
||||
self._view = value
|
||||
self._view_updated()
|
||||
else:
|
||||
assert value is None
|
||||
# Instead of None, we put a NoopGUI() there to avoid rogue view callback raising an
|
||||
# exception.
|
||||
self._view = NoopGUI()
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2010-07-25
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import copy
|
||||
|
||||
from .base import GUIObject
|
||||
|
||||
class Column:
|
||||
"""Holds column attributes such as its name, width, visibility, etc.
|
||||
|
||||
These attributes are then used to correctly configure the column on the "view" side.
|
||||
"""
|
||||
def __init__(self, name, display='', visible=True, optional=False):
|
||||
#: "programmatical" (not for display) name. Used as a reference in a couple of place, such
|
||||
#: as :meth:`Columns.column_by_name`.
|
||||
self.name = name
|
||||
#: Immutable index of the column. Doesn't change even when columns are re-ordered. Used in
|
||||
#: :meth:`Columns.column_by_index`.
|
||||
self.logical_index = 0
|
||||
#: Index of the column in the ordered set of columns.
|
||||
self.ordered_index = 0
|
||||
#: Width of the column.
|
||||
self.width = 0
|
||||
#: Default width of the column. This value usually depends on the platform and is set on
|
||||
#: columns initialisation. It will be used if column restoration doesn't contain any
|
||||
#: "remembered" widths.
|
||||
self.default_width = 0
|
||||
#: Display name (title) of the column.
|
||||
self.display = display
|
||||
#: Whether the column is visible.
|
||||
self.visible = visible
|
||||
#: Whether the column is visible by default. It will be used if column restoration doesn't
|
||||
#: contain any "remembered" widths.
|
||||
self.default_visible = visible
|
||||
#: Whether the column can have :attr:`visible` set to false.
|
||||
self.optional = optional
|
||||
|
||||
class ColumnsView:
|
||||
"""Expected interface for :class:`Columns`'s view.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
|
||||
Our view, the columns controller of a table or outline, is expected to properly respond to
|
||||
callbacks.
|
||||
"""
|
||||
def restore_columns(self):
|
||||
"""Update all columns according to the model.
|
||||
|
||||
When this is called, our view has to update the columns title, order and visibility of all
|
||||
columns.
|
||||
"""
|
||||
|
||||
def set_column_visible(self, colname, visible):
|
||||
"""Update visibility of column ``colname``.
|
||||
|
||||
Called when the user toggles the visibility of a column, we must update the column
|
||||
``colname``'s visibility status to ``visible``.
|
||||
"""
|
||||
|
||||
class PrefAccessInterface:
|
||||
"""Expected interface for :class:`Columns`'s prefaccess.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
"""
|
||||
def get_default(self, key, fallback_value):
|
||||
"""Retrieve the value for ``key`` in the currently running app's preference store.
|
||||
|
||||
If the key doesn't exist, return ``fallback_value``.
|
||||
"""
|
||||
|
||||
def set_default(self, key, value):
|
||||
"""Set the value ``value`` for ``key`` in the currently running app's preference store.
|
||||
"""
|
||||
|
||||
class Columns(GUIObject):
|
||||
"""Cross-toolkit GUI-enabled column set for tables or outlines.
|
||||
|
||||
Manages a column set's order, visibility and width. We also manage the persistence of these
|
||||
attributes so that we can restore them on the next run.
|
||||
|
||||
Subclasses :class:`.GUIObject`. Expected view: :class:`ColumnsView`.
|
||||
|
||||
:param table: The table the columns belong to. It's from there that we retrieve our column
|
||||
configuration and it must have a ``COLUMNS`` attribute which is a list of
|
||||
:class:`Column`. We also call :meth:`~.GUITable.save_edits` on it from time to
|
||||
time. Technically, this argument can also be a tree, but there's probably some
|
||||
sorting in the code to do to support this option cleanly.
|
||||
:param prefaccess: An object giving access to user preferences for the currently running app.
|
||||
We use this to make column attributes persistent. Must follow
|
||||
:class:`PrefAccessInterface`.
|
||||
:param str savename: The name under which column preferences will be saved. This name is in fact
|
||||
a prefix. Preferences are saved under more than one name, but they will all
|
||||
have that same prefix.
|
||||
"""
|
||||
def __init__(self, table, prefaccess=None, savename=None):
|
||||
GUIObject.__init__(self)
|
||||
self.table = table
|
||||
self.prefaccess = prefaccess
|
||||
self.savename = savename
|
||||
# We use copy here for test isolation. If we don't, changing a column affects all tests.
|
||||
self.column_list = list(map(copy.copy, table.COLUMNS))
|
||||
for i, column in enumerate(self.column_list):
|
||||
column.logical_index = i
|
||||
column.ordered_index = i
|
||||
self.coldata = {col.name: col for col in self.column_list}
|
||||
|
||||
#--- Private
|
||||
def _get_colname_attr(self, colname, attrname, default):
|
||||
try:
|
||||
return getattr(self.coldata[colname], attrname)
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def _set_colname_attr(self, colname, attrname, value):
|
||||
try:
|
||||
col = self.coldata[colname]
|
||||
setattr(col, attrname, value)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _optional_columns(self):
|
||||
return [c for c in self.column_list if c.optional]
|
||||
|
||||
#--- Override
|
||||
def _view_updated(self):
|
||||
self.restore_columns()
|
||||
|
||||
#--- Public
|
||||
def column_by_index(self, index):
|
||||
"""Return the :class:`Column` having the :attr:`~Column.logical_index` ``index``.
|
||||
"""
|
||||
return self.column_list[index]
|
||||
|
||||
def column_by_name(self, name):
|
||||
"""Return the :class:`Column` having the :attr:`~Column.name` ``name``.
|
||||
"""
|
||||
return self.coldata[name]
|
||||
|
||||
def columns_count(self):
|
||||
"""Returns the number of columns in our set.
|
||||
"""
|
||||
return len(self.column_list)
|
||||
|
||||
def column_display(self, colname):
|
||||
"""Returns display name for column named ``colname``, or ``''`` if there's none.
|
||||
"""
|
||||
return self._get_colname_attr(colname, 'display', '')
|
||||
|
||||
def column_is_visible(self, colname):
|
||||
"""Returns visibility for column named ``colname``, or ``True`` if there's none.
|
||||
"""
|
||||
return self._get_colname_attr(colname, 'visible', True)
|
||||
|
||||
def column_width(self, colname):
|
||||
"""Returns width for column named ``colname``, or ``0`` if there's none.
|
||||
"""
|
||||
return self._get_colname_attr(colname, 'width', 0)
|
||||
|
||||
def columns_to_right(self, colname):
|
||||
"""Returns the list of all columns to the right of ``colname``.
|
||||
|
||||
"right" meaning "having a higher :attr:`Column.ordered_index`" in our left-to-right
|
||||
civilization.
|
||||
"""
|
||||
column = self.coldata[colname]
|
||||
index = column.ordered_index
|
||||
return [col.name for col in self.column_list if (col.visible and col.ordered_index > index)]
|
||||
|
||||
def menu_items(self):
|
||||
"""Returns a list of items convenient for quick visibility menu generation.
|
||||
|
||||
Returns a list of ``(display_name, is_marked)`` items for each optional column in the
|
||||
current view (``is_marked`` means that it's visible).
|
||||
|
||||
You can use this to generate a menu to let the user toggle the visibility of an optional
|
||||
column. That is why we only show optional column, because the visibility of mandatory
|
||||
columns can't be toggled.
|
||||
"""
|
||||
return [(c.display, c.visible) for c in self._optional_columns()]
|
||||
|
||||
def move_column(self, colname, index):
|
||||
"""Moves column ``colname`` to ``index``.
|
||||
|
||||
The column will be placed just in front of the column currently having that index, or to the
|
||||
end of the list if there's none.
|
||||
"""
|
||||
colnames = self.colnames
|
||||
colnames.remove(colname)
|
||||
colnames.insert(index, colname)
|
||||
self.set_column_order(colnames)
|
||||
|
||||
def reset_to_defaults(self):
|
||||
"""Reset all columns' width and visibility to their default values.
|
||||
"""
|
||||
self.set_column_order([col.name for col in self.column_list])
|
||||
for col in self._optional_columns():
|
||||
col.visible = col.default_visible
|
||||
col.width = col.default_width
|
||||
self.view.restore_columns()
|
||||
|
||||
def resize_column(self, colname, newwidth):
|
||||
"""Set column ``colname``'s width to ``newwidth``.
|
||||
"""
|
||||
self._set_colname_attr(colname, 'width', newwidth)
|
||||
|
||||
def restore_columns(self):
|
||||
"""Restore's column persistent attributes from the last :meth:`save_columns`.
|
||||
"""
|
||||
if not (self.prefaccess and self.savename and self.coldata):
|
||||
if (not self.savename) and (self.coldata):
|
||||
# This is a table that will not have its coldata saved/restored. we should
|
||||
# "restore" its default column attributes.
|
||||
self.view.restore_columns()
|
||||
return
|
||||
for col in self.column_list:
|
||||
pref_name = '{}.Columns.{}'.format(self.savename, col.name)
|
||||
coldata = self.prefaccess.get_default(pref_name, fallback_value={})
|
||||
if 'index' in coldata:
|
||||
col.ordered_index = coldata['index']
|
||||
if 'width' in coldata:
|
||||
col.width = coldata['width']
|
||||
if col.optional and 'visible' in coldata:
|
||||
col.visible = coldata['visible']
|
||||
self.view.restore_columns()
|
||||
|
||||
def save_columns(self):
|
||||
"""Save column attributes in persistent storage for restoration in :meth:`restore_columns`.
|
||||
"""
|
||||
if not (self.prefaccess and self.savename and self.coldata):
|
||||
return
|
||||
for col in self.column_list:
|
||||
pref_name = '{}.Columns.{}'.format(self.savename, col.name)
|
||||
coldata = {'index': col.ordered_index, 'width': col.width}
|
||||
if col.optional:
|
||||
coldata['visible'] = col.visible
|
||||
self.prefaccess.set_default(pref_name, coldata)
|
||||
|
||||
def set_column_order(self, colnames):
|
||||
"""Change the columns order so it matches the order in ``colnames``.
|
||||
|
||||
:param colnames: A list of column names in the desired order.
|
||||
"""
|
||||
colnames = (name for name in colnames if name in self.coldata)
|
||||
for i, colname in enumerate(colnames):
|
||||
col = self.coldata[colname]
|
||||
col.ordered_index = i
|
||||
|
||||
def set_column_visible(self, colname, visible):
|
||||
"""Set the visibility of column ``colname``.
|
||||
"""
|
||||
self.table.save_edits() # the table on the GUI side will stop editing when the columns change
|
||||
self._set_colname_attr(colname, 'visible', visible)
|
||||
self.view.set_column_visible(colname, visible)
|
||||
|
||||
def set_default_width(self, colname, width):
|
||||
"""Set the default width or column ``colname``.
|
||||
"""
|
||||
self._set_colname_attr(colname, 'default_width', width)
|
||||
|
||||
def toggle_menu_item(self, index):
|
||||
"""Toggles the visibility of an optional column.
|
||||
|
||||
You know, that optional column menu you've generated in :meth:`menu_items`? Well, ``index``
|
||||
is the index of them menu item in *that* menu that the user has clicked on to toggle it.
|
||||
|
||||
Returns whether the column in question ends up being visible or not.
|
||||
"""
|
||||
col = self._optional_columns()[index]
|
||||
self.set_column_visible(col.name, not col.visible)
|
||||
return col.visible
|
||||
|
||||
#--- Properties
|
||||
@property
|
||||
def ordered_columns(self):
|
||||
"""List of :class:`Column` in visible order.
|
||||
"""
|
||||
return [col for col in sorted(self.column_list, key=lambda col: col.ordered_index)]
|
||||
|
||||
@property
|
||||
def colnames(self):
|
||||
"""List of column names in visible order.
|
||||
"""
|
||||
return [col.name for col in self.ordered_columns]
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
# Created On: 2013/07/01
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from ..jobprogress.performer import ThreadedJobPerformer
|
||||
from .base import GUIObject
|
||||
from .text_field import TextField
|
||||
|
||||
class ProgressWindowView:
|
||||
"""Expected interface for :class:`ProgressWindow`'s view.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
|
||||
Our view, some kind window with a progress bar, two labels and a cancel button, is expected
|
||||
to properly respond to its callbacks.
|
||||
|
||||
It's also expected to call :meth:`ProgressWindow.cancel` when the cancel button is clicked.
|
||||
"""
|
||||
def show(self):
|
||||
"""Show the dialog.
|
||||
"""
|
||||
|
||||
def close(self):
|
||||
"""Close the dialog.
|
||||
"""
|
||||
|
||||
def set_progress(self, progress):
|
||||
"""Set the progress of the progress bar to ``progress``.
|
||||
|
||||
Not all jobs are equally responsive on their job progress report and it is recommended that
|
||||
you put your progressbar in "indeterminate" mode as long as you haven't received the first
|
||||
``set_progress()`` call to avoid letting the user think that the app is frozen.
|
||||
|
||||
:param int progress: a value between ``0`` and ``100``.
|
||||
"""
|
||||
|
||||
class ProgressWindow(GUIObject, ThreadedJobPerformer):
|
||||
"""Cross-toolkit GUI-enabled progress window.
|
||||
|
||||
This class allows you to run a long running, job enabled function in a separate thread and
|
||||
allow the user to follow its progress with a progress dialog.
|
||||
|
||||
To use it, you start your long-running job with :meth:`run` and then have your UI layer
|
||||
regularly call :meth:`pulse` to refresh the job status in the UI. It is advised that you call
|
||||
:meth:`pulse` in the main thread because GUI toolkit usually only support calling UI-related
|
||||
functions from the main thread.
|
||||
|
||||
We subclass :class:`.GUIObject` and :class:`.ThreadedJobPerformer`.
|
||||
Expected view: :class:`ProgressWindowView`.
|
||||
|
||||
:param finishfunc: A function ``f(jobid)`` that is called when a job is completed. ``jobid`` is
|
||||
an arbitrary id passed to :meth:`run`.
|
||||
"""
|
||||
def __init__(self, finish_func):
|
||||
# finish_func(jobid) is the function that is called when a job is completed.
|
||||
GUIObject.__init__(self)
|
||||
ThreadedJobPerformer.__init__(self)
|
||||
self._finish_func = finish_func
|
||||
#: :class:`.TextField`. It contains that title you gave the job on :meth:`run`.
|
||||
self.jobdesc_textfield = TextField()
|
||||
#: :class:`.TextField`. It contains the job textual update that the function might yield
|
||||
#: during its course.
|
||||
self.progressdesc_textfield = TextField()
|
||||
self.jobid = None
|
||||
|
||||
def cancel(self):
|
||||
"""Call for a user-initiated job cancellation.
|
||||
"""
|
||||
# The UI is sometimes a bit buggy and calls cancel() on self.view.close(). We just want to
|
||||
# make sure that this doesn't lead us to think that the user acually cancelled the task, so
|
||||
# we verify that the job is still running.
|
||||
if self._job_running:
|
||||
self.job_cancelled = True
|
||||
|
||||
def pulse(self):
|
||||
"""Update progress reports in the GUI.
|
||||
|
||||
Call this regularly from the GUI main run loop. The values might change before
|
||||
:meth:`ProgressWindowView.set_progress` happens.
|
||||
|
||||
If the job is finished, ``pulse()`` will take care of closing the window and re-raising any
|
||||
exception that might have been raised during the job (in the main thread this time). If
|
||||
there was no exception, ``finish_func(jobid)`` is called to let you take appropriate action.
|
||||
"""
|
||||
last_progress = self.last_progress
|
||||
last_desc = self.last_desc
|
||||
if not self._job_running or last_progress is None:
|
||||
self.view.close()
|
||||
self.reraise_if_error()
|
||||
if not self.job_cancelled:
|
||||
self._finish_func(self.jobid)
|
||||
return
|
||||
if self.job_cancelled:
|
||||
return
|
||||
if last_desc:
|
||||
self.progressdesc_textfield.text = last_desc
|
||||
self.view.set_progress(last_progress)
|
||||
|
||||
def run(self, jobid, title, target, args=()):
|
||||
"""Starts a threaded job.
|
||||
|
||||
The ``target`` function will be sent, as its first argument, a :class:`.Job` instance which
|
||||
it can use to report on its progress.
|
||||
|
||||
:param jobid: Arbitrary identifier which will be passed to ``finish_func()`` at the end.
|
||||
:param title: A title for the task you're starting.
|
||||
:param target: The function that does your famous long running job.
|
||||
:param args: additional arguments that you want to send to ``target``.
|
||||
"""
|
||||
# target is a function with its first argument being a Job. It can then be followed by other
|
||||
# arguments which are passed as `args`.
|
||||
self.jobid = jobid
|
||||
self.progressdesc_textfield.text = ''
|
||||
j = self.create_job()
|
||||
args = tuple([j] + list(args))
|
||||
self.run_threaded(target, args)
|
||||
self.jobdesc_textfield.text = title
|
||||
self.view.show()
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2011-09-06
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from collections import Sequence, MutableSequence
|
||||
|
||||
from .base import GUIObject
|
||||
|
||||
class Selectable(Sequence):
|
||||
"""Mix-in for a ``Sequence`` that manages its selection status.
|
||||
|
||||
When mixed in with a ``Sequence``, we enable it to manage its selection status. The selection
|
||||
is held as a list of ``int`` indexes. Multiple selection is supported.
|
||||
"""
|
||||
def __init__(self):
|
||||
self._selected_indexes = []
|
||||
|
||||
#--- Private
|
||||
def _check_selection_range(self):
|
||||
if not self:
|
||||
self._selected_indexes = []
|
||||
if not self._selected_indexes:
|
||||
return
|
||||
self._selected_indexes = [index for index in self._selected_indexes if index < len(self)]
|
||||
if not self._selected_indexes:
|
||||
self._selected_indexes = [len(self) - 1]
|
||||
|
||||
#--- Virtual
|
||||
def _update_selection(self):
|
||||
"""(Virtual) Updates the model's selection appropriately.
|
||||
|
||||
Called after selection has been updated. Takes the table's selection and does appropriates
|
||||
updates on the view and/or model. Common sense would dictate that when the selection doesn't
|
||||
change, we don't update anything (and thus don't call ``_update_selection()`` at all), but
|
||||
there are cases where it's false. For example, if our list updates its items but doesn't
|
||||
change its selection, we probably want to update the model's selection.
|
||||
|
||||
By default, does nothing.
|
||||
|
||||
Important note: This is only called on :meth:`select`, not on changes to
|
||||
:attr:`selected_indexes`.
|
||||
"""
|
||||
# A redesign of how this whole thing works is probably in order, but not now, there's too
|
||||
# much breakage at once involved.
|
||||
|
||||
#--- Public
|
||||
def select(self, indexes):
|
||||
"""Update selection to ``indexes``.
|
||||
|
||||
:meth:`_update_selection` is called afterwards.
|
||||
|
||||
:param list indexes: List of ``int`` that is to become the new selection.
|
||||
"""
|
||||
if isinstance(indexes, int):
|
||||
indexes = [indexes]
|
||||
self.selected_indexes = indexes
|
||||
self._update_selection()
|
||||
|
||||
#--- Properties
|
||||
@property
|
||||
def selected_index(self):
|
||||
"""Points to the first selected index.
|
||||
|
||||
*int*. *get/set*.
|
||||
|
||||
Thin wrapper around :attr:`selected_indexes`. ``None`` if selection is empty. Using this
|
||||
property only makes sense if your selectable sequence supports single selection only.
|
||||
"""
|
||||
return self._selected_indexes[0] if self._selected_indexes else None
|
||||
|
||||
@selected_index.setter
|
||||
def selected_index(self, value):
|
||||
self.selected_indexes = [value]
|
||||
|
||||
@property
|
||||
def selected_indexes(self):
|
||||
"""List of selected indexes.
|
||||
|
||||
*list of int*. *get/set*.
|
||||
|
||||
When setting the value, automatically removes out-of-bounds indexes. The list is kept
|
||||
sorted.
|
||||
"""
|
||||
return self._selected_indexes
|
||||
|
||||
@selected_indexes.setter
|
||||
def selected_indexes(self, value):
|
||||
self._selected_indexes = value
|
||||
self._selected_indexes.sort()
|
||||
self._check_selection_range()
|
||||
|
||||
|
||||
class SelectableList(MutableSequence, Selectable):
|
||||
"""A list that can manage selection of its items.
|
||||
|
||||
Subclasses :class:`Selectable`. Behaves like a ``list``.
|
||||
"""
|
||||
def __init__(self, items=None):
|
||||
Selectable.__init__(self)
|
||||
if items:
|
||||
self._items = list(items)
|
||||
else:
|
||||
self._items = []
|
||||
|
||||
def __delitem__(self, key):
|
||||
self._items.__delitem__(key)
|
||||
self._check_selection_range()
|
||||
self._on_change()
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._items.__getitem__(key)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._items)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._items.__setitem__(key, value)
|
||||
self._on_change()
|
||||
|
||||
#--- Override
|
||||
def append(self, item):
|
||||
self._items.append(item)
|
||||
self._on_change()
|
||||
|
||||
def insert(self, index, item):
|
||||
self._items.insert(index, item)
|
||||
self._on_change()
|
||||
|
||||
def remove(self, row):
|
||||
self._items.remove(row)
|
||||
self._check_selection_range()
|
||||
self._on_change()
|
||||
|
||||
#--- Virtual
|
||||
def _on_change(self):
|
||||
"""(Virtual) Called whenever the contents of the list changes.
|
||||
|
||||
By default, does nothing.
|
||||
"""
|
||||
|
||||
#--- Public
|
||||
def search_by_prefix(self, prefix):
|
||||
# XXX Why the heck is this method here?
|
||||
prefix = prefix.lower()
|
||||
for index, s in enumerate(self):
|
||||
if s.lower().startswith(prefix):
|
||||
return index
|
||||
return -1
|
||||
|
||||
|
||||
class GUISelectableListView:
|
||||
"""Expected interface for :class:`GUISelectableList`'s view.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
|
||||
Our view, some kind of list view or combobox, is expected to sync with the list's contents by
|
||||
appropriately behave to all callbacks in this interface.
|
||||
"""
|
||||
def refresh(self):
|
||||
"""Refreshes the contents of the list widget.
|
||||
|
||||
Ensures that the contents of the list widget is synced with the model.
|
||||
"""
|
||||
|
||||
def update_selection(self):
|
||||
"""Update selection status.
|
||||
|
||||
Ensures that the list widget's selection is in sync with the model.
|
||||
"""
|
||||
|
||||
class GUISelectableList(SelectableList, GUIObject):
|
||||
"""Cross-toolkit GUI-enabled list view.
|
||||
|
||||
Represents a UI element presenting the user with a selectable list of items.
|
||||
|
||||
Subclasses :class:`SelectableList` and :class:`.GUIObject`. Expected view:
|
||||
:class:`GUISelectableListView`.
|
||||
|
||||
:param iterable items: If specified, items to fill the list with initially.
|
||||
"""
|
||||
def __init__(self, items=None):
|
||||
SelectableList.__init__(self, items)
|
||||
GUIObject.__init__(self)
|
||||
|
||||
def _view_updated(self):
|
||||
"""Refreshes the view contents with :meth:`GUISelectableListView.refresh`.
|
||||
|
||||
Overrides :meth:`~hscommon.gui.base.GUIObject._view_updated`.
|
||||
"""
|
||||
self.view.refresh()
|
||||
|
||||
def _update_selection(self):
|
||||
"""Refreshes the view selection with :meth:`GUISelectableListView.update_selection`.
|
||||
|
||||
Overrides :meth:`Selectable._update_selection`.
|
||||
"""
|
||||
self.view.update_selection()
|
||||
|
||||
def _on_change(self):
|
||||
"""Refreshes the view contents with :meth:`GUISelectableListView.refresh`.
|
||||
|
||||
Overrides :meth:`SelectableList._on_change`.
|
||||
"""
|
||||
self.view.refresh()
|
||||
@@ -1,543 +0,0 @@
|
||||
# Created By: Eric Mc Sween
|
||||
# Created On: 2008-05-29
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from collections import MutableSequence, namedtuple
|
||||
|
||||
from .base import GUIObject
|
||||
from .selectable_list import Selectable
|
||||
|
||||
# We used to directly subclass list, but it caused problems at some point with deepcopy
|
||||
class Table(MutableSequence, Selectable):
|
||||
"""Sortable and selectable sequence of :class:`Row`.
|
||||
|
||||
In fact, the Table is very similar to :class:`.SelectableList` in
|
||||
practice and differs mostly in principle. Their difference lies in the nature of their items
|
||||
they manage. With the Table, rows usually have many properties, presented in columns, and they
|
||||
have to subclass :class:`Row`.
|
||||
|
||||
Usually used with :class:`~hscommon.gui.column.Column`.
|
||||
|
||||
Subclasses :class:`.Selectable`.
|
||||
"""
|
||||
def __init__(self):
|
||||
Selectable.__init__(self)
|
||||
self._rows = []
|
||||
self._header = None
|
||||
self._footer = None
|
||||
|
||||
def __delitem__(self, key):
|
||||
self._rows.__delitem__(key)
|
||||
if self._header is not None and ((not self) or (self[0] is not self._header)):
|
||||
self._header = None
|
||||
if self._footer is not None and ((not self) or (self[-1] is not self._footer)):
|
||||
self._footer = None
|
||||
self._check_selection_range()
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._rows.__getitem__(key)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._rows)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._rows.__setitem__(key, value)
|
||||
|
||||
def append(self, item):
|
||||
"""Appends ``item`` at the end of the table.
|
||||
|
||||
If there's a footer, the item is inserted before it.
|
||||
"""
|
||||
if self._footer is not None:
|
||||
self._rows.insert(-1, item)
|
||||
else:
|
||||
self._rows.append(item)
|
||||
|
||||
def insert(self, index, item):
|
||||
"""Inserts ``item`` at ``index`` in the table.
|
||||
|
||||
If there's a header, will make sure we don't insert before it, and if there's a footer, will
|
||||
make sure that we don't insert after it.
|
||||
"""
|
||||
if (self._header is not None) and (index == 0):
|
||||
index = 1
|
||||
if (self._footer is not None) and (index >= len(self)):
|
||||
index = len(self) - 1
|
||||
self._rows.insert(index, item)
|
||||
|
||||
def remove(self, row):
|
||||
"""Removes ``row`` from table.
|
||||
|
||||
If ``row`` is a header or footer, that header or footer will be set to ``None``.
|
||||
"""
|
||||
if row is self._header:
|
||||
self._header = None
|
||||
if row is self._footer:
|
||||
self._footer = None
|
||||
self._rows.remove(row)
|
||||
self._check_selection_range()
|
||||
|
||||
def sort_by(self, column_name, desc=False):
|
||||
"""Sort table by ``column_name``.
|
||||
|
||||
Sort key for each row is computed from :meth:`Row.sort_key_for_column`.
|
||||
|
||||
If ``desc`` is ``True``, sort order is reversed.
|
||||
|
||||
If present, header and footer will always be first and last, respectively.
|
||||
"""
|
||||
if self._header is not None:
|
||||
self._rows.pop(0)
|
||||
if self._footer is not None:
|
||||
self._rows.pop()
|
||||
key = lambda row: row.sort_key_for_column(column_name)
|
||||
self._rows.sort(key=key, reverse=desc)
|
||||
if self._header is not None:
|
||||
self._rows.insert(0, self._header)
|
||||
if self._footer is not None:
|
||||
self._rows.append(self._footer)
|
||||
|
||||
#--- Properties
|
||||
@property
|
||||
def footer(self):
|
||||
"""If set, a row that always stay at the bottom of the table.
|
||||
|
||||
:class:`Row`. *get/set*.
|
||||
|
||||
When set to something else than ``None``, ``header`` and ``footer`` represent rows that will
|
||||
always be kept in first and/or last position, regardless of sorting. ``len()`` and indexing
|
||||
will include them, which means that if there's a header, ``table[0]`` returns it and if
|
||||
there's a footer, ``table[-1]`` returns it. To make things short, all list-like functions
|
||||
work with header and footer "on". But things get fuzzy for ``append()`` and ``insert()``
|
||||
because these will ensure that no "normal" row gets inserted before the header or after the
|
||||
footer.
|
||||
|
||||
Adding and removing footer here and there might seem (and is) hackish, but it's much simpler
|
||||
than the alternative (when, of course, you need such a feature), which is to override magic
|
||||
methods and adjust the results. When we do that, there the slice stuff that we have to
|
||||
implement and it gets quite complex. Moreover, the most frequent operation on a table is
|
||||
``__getitem__``, and making checks to know whether the key is a header or footer at each
|
||||
call would make that operation, which is the most used, slower.
|
||||
"""
|
||||
return self._footer
|
||||
|
||||
@footer.setter
|
||||
def footer(self, value):
|
||||
if self._footer is not None:
|
||||
self._rows.pop()
|
||||
if value is not None:
|
||||
self._rows.append(value)
|
||||
self._footer = value
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
"""If set, a row that always stay at the bottom of the table.
|
||||
|
||||
See :attr:`footer` for details.
|
||||
"""
|
||||
return self._header
|
||||
|
||||
@header.setter
|
||||
def header(self, value):
|
||||
if self._header is not None:
|
||||
self._rows.pop(0)
|
||||
if value is not None:
|
||||
self._rows.insert(0, value)
|
||||
self._header = value
|
||||
|
||||
@property
|
||||
def row_count(self):
|
||||
"""Number or rows in the table (without counting header and footer).
|
||||
|
||||
*int*. *read-only*.
|
||||
"""
|
||||
result = len(self)
|
||||
if self._footer is not None:
|
||||
result -= 1
|
||||
if self._header is not None:
|
||||
result -= 1
|
||||
return result
|
||||
|
||||
@property
|
||||
def rows(self):
|
||||
"""List of rows in the table, excluding header and footer.
|
||||
|
||||
List of :class:`Row`. *read-only*.
|
||||
"""
|
||||
start = None
|
||||
end = None
|
||||
if self._footer is not None:
|
||||
end = -1
|
||||
if self._header is not None:
|
||||
start = 1
|
||||
return self[start:end]
|
||||
|
||||
@property
|
||||
def selected_row(self):
|
||||
"""Selected row according to :attr:`.selected_index`.
|
||||
|
||||
:class:`Row`. *get/set*.
|
||||
|
||||
When setting this attribute, we look up the index of the row and set the selected index from
|
||||
there. If the row isn't in the list, selection isn't changed.
|
||||
"""
|
||||
return self[self.selected_index] if self.selected_index is not None else None
|
||||
|
||||
@selected_row.setter
|
||||
def selected_row(self, value):
|
||||
try:
|
||||
self.selected_index = self.index(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def selected_rows(self):
|
||||
"""List of selected rows based on :attr:`.selected_indexes`.
|
||||
|
||||
List of :class:`Row`. *read-only*.
|
||||
"""
|
||||
return [self[index] for index in self.selected_indexes]
|
||||
|
||||
|
||||
class GUITableView:
|
||||
"""Expected interface for :class:`GUITable`'s view.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
|
||||
Our view, some kind of table view, is expected to sync with the table's contents by
|
||||
appropriately behave to all callbacks in this interface.
|
||||
|
||||
When in edit mode, the content types by the user is expected to be sent as soon as possible
|
||||
to the :class:`Row`.
|
||||
|
||||
Whenever the user changes the selection, we expect the view to call :meth:`Table.select`.
|
||||
"""
|
||||
def refresh(self):
|
||||
"""Refreshes the contents of the table widget.
|
||||
|
||||
Ensures that the contents of the table widget is synced with the model. This includes
|
||||
selection.
|
||||
"""
|
||||
|
||||
def start_editing(self):
|
||||
"""Start editing the currently selected row.
|
||||
|
||||
Begin whatever inline editing support that the view supports.
|
||||
"""
|
||||
|
||||
def stop_editing(self):
|
||||
"""Stop editing if there's an inline editing in effect.
|
||||
|
||||
There's no "aborting" implied in this call, so it's appropriate to send whatever the user
|
||||
has typed and might not have been sent down to the :class:`Row` yet. After you've done that,
|
||||
stop the editing mechanism.
|
||||
"""
|
||||
|
||||
|
||||
SortDescriptor = namedtuple('SortDescriptor', 'column desc')
|
||||
class GUITable(Table, GUIObject):
|
||||
"""Cross-toolkit GUI-enabled table view.
|
||||
|
||||
Represents a UI element presenting the user with a sortable, selectable, possibly editable,
|
||||
table view.
|
||||
|
||||
Behaves like the :class:`Table` which it subclasses, but is more focused on being the presenter
|
||||
of some model data to its :attr:`.GUIObject.view`. There's a :meth:`refresh`
|
||||
mechanism which ensures fresh data while preserving sorting order and selection. There's also an
|
||||
editing mechanism which tracks whether (and which) row is being edited (or added) and
|
||||
save/cancel edits when appropriate.
|
||||
|
||||
Subclasses :class:`Table` and :class:`.GUIObject`. Expected view:
|
||||
:class:`GUITableView`.
|
||||
"""
|
||||
def __init__(self):
|
||||
GUIObject.__init__(self)
|
||||
Table.__init__(self)
|
||||
#: The row being currently edited by the user. ``None`` if no edit is taking place.
|
||||
self.edited = None
|
||||
self._sort_descriptor = None
|
||||
|
||||
#--- Virtual
|
||||
def _do_add(self):
|
||||
"""(Virtual) Creates a new row, adds it in the table.
|
||||
|
||||
Returns ``(row, insert_index)``.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _do_delete(self):
|
||||
"""(Virtual) Delete the selected rows.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _fill(self):
|
||||
"""(Virtual/Required) Fills the table with all the rows that this table is supposed to have.
|
||||
|
||||
Called by :meth:`refresh`. Does nothing by default.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _is_edited_new(self):
|
||||
"""(Virtual) Returns whether the currently edited row should be considered "new".
|
||||
|
||||
This is used in :meth:`cancel_edits` to know whether the cancellation of the edit means a
|
||||
revert of the row's value or the removal of the row.
|
||||
|
||||
By default, always false.
|
||||
"""
|
||||
return False
|
||||
|
||||
def _restore_selection(self, previous_selection):
|
||||
"""(Virtual) Restores row selection after a contents-changing operation.
|
||||
|
||||
Before each contents changing operation, we store our previously selected indexes because in
|
||||
many cases, such as in :meth:`refresh`, our selection will be lost. After the operation is
|
||||
over, we call this method with our previously selected indexes (in ``previous_selection``).
|
||||
|
||||
The default behavior is (if we indeed have an empty :attr:`.selected_indexes`) to re-select
|
||||
``previous_selection``. If it was empty, we select the last row of the table.
|
||||
|
||||
This behavior can, of course, be overriden.
|
||||
"""
|
||||
if not self.selected_indexes:
|
||||
if previous_selection:
|
||||
self.select(previous_selection)
|
||||
else:
|
||||
self.select([len(self) - 1])
|
||||
|
||||
#--- Public
|
||||
def add(self):
|
||||
"""Add a new row in edit mode.
|
||||
|
||||
Requires :meth:`do_add` to be implemented. The newly added row will be selected and in edit
|
||||
mode.
|
||||
"""
|
||||
self.view.stop_editing()
|
||||
if self.edited is not None:
|
||||
self.save_edits()
|
||||
row, insert_index = self._do_add()
|
||||
self.insert(insert_index, row)
|
||||
self.select([insert_index])
|
||||
self.edited = row
|
||||
self.view.refresh()
|
||||
self.view.start_editing()
|
||||
|
||||
def can_edit_cell(self, column_name, row_index):
|
||||
"""Returns whether the cell at ``row_index`` and ``column_name`` can be edited.
|
||||
|
||||
A row is, by default, editable as soon as it has an attr with the same name as `column`.
|
||||
If :meth:`Row.can_edit` returns False, the row is not editable at all. You can set
|
||||
editability of rows at the attribute level with can_edit_* properties.
|
||||
|
||||
Mostly just a shortcut to :meth:`Row.can_edit_cell`.
|
||||
"""
|
||||
row = self[row_index]
|
||||
return row.can_edit_cell(column_name)
|
||||
|
||||
def cancel_edits(self):
|
||||
"""Cancels the current edit operation.
|
||||
|
||||
If there's an :attr:`edited` row, it will be re-initialized (with :meth:`Row.load`).
|
||||
"""
|
||||
if self.edited is None:
|
||||
return
|
||||
self.view.stop_editing()
|
||||
if self._is_edited_new():
|
||||
previous_selection = self.selected_indexes
|
||||
self.remove(self.edited)
|
||||
self._restore_selection(previous_selection)
|
||||
self._update_selection()
|
||||
else:
|
||||
self.edited.load()
|
||||
self.edited = None
|
||||
self.view.refresh()
|
||||
|
||||
def delete(self):
|
||||
"""Delete the currently selected rows.
|
||||
|
||||
Requires :meth:`_do_delete` for this to have any effect on the model. Cancels editing if
|
||||
relevant.
|
||||
"""
|
||||
self.view.stop_editing()
|
||||
if self.edited is not None:
|
||||
self.cancel_edits()
|
||||
return
|
||||
if self:
|
||||
self._do_delete()
|
||||
|
||||
def refresh(self, refresh_view=True):
|
||||
"""Empty the table and re-create its rows.
|
||||
|
||||
:meth:`_fill` is called after we emptied the table to create our rows. Previous sort order
|
||||
will be preserved, regardless of the order in which the rows were filled. If there was any
|
||||
edit operation taking place, it's cancelled.
|
||||
|
||||
:param bool refresh_view: Whether we tell our view to refresh after our refill operation.
|
||||
Most of the time, it's what we want, but there's some cases where
|
||||
we don't.
|
||||
"""
|
||||
self.cancel_edits()
|
||||
previous_selection = self.selected_indexes
|
||||
del self[:]
|
||||
self._fill()
|
||||
sd = self._sort_descriptor
|
||||
if sd is not None:
|
||||
Table.sort_by(self, column_name=sd.column, desc=sd.desc)
|
||||
self._restore_selection(previous_selection)
|
||||
if refresh_view:
|
||||
self.view.refresh()
|
||||
|
||||
def save_edits(self):
|
||||
"""Commit user edits to the model.
|
||||
|
||||
This is done by calling :meth:`Row.save`.
|
||||
"""
|
||||
if self.edited is None:
|
||||
return
|
||||
row = self.edited
|
||||
self.edited = None
|
||||
row.save()
|
||||
|
||||
def sort_by(self, column_name, desc=False):
|
||||
"""Sort table by ``column_name``.
|
||||
|
||||
Overrides :meth:`Table.sort_by`. After having performed sorting, calls
|
||||
:meth:`~.Selectable._update_selection` to give you the chance,
|
||||
if appropriate, to update your selected indexes according to, maybe, the selection that you
|
||||
have in your model.
|
||||
|
||||
Then, we refresh our view.
|
||||
"""
|
||||
Table.sort_by(self, column_name=column_name, desc=desc)
|
||||
self._sort_descriptor = SortDescriptor(column_name, desc)
|
||||
self._update_selection()
|
||||
self.view.refresh()
|
||||
|
||||
|
||||
class Row:
|
||||
"""Represents a row in a :class:`Table`.
|
||||
|
||||
It holds multiple values to be represented through columns. It's its role to prepare data
|
||||
fetched from model instances into ready-to-present-in-a-table fashion. You will do this in
|
||||
:meth:`load`.
|
||||
|
||||
When you do this, you'll put the result into arbitrary attributes, which will later be fetched
|
||||
by your table for presentation to the user.
|
||||
|
||||
You can organize your attributes in whatever way you want, but there's a convention you can
|
||||
follow if you want to minimize subclassing and use default behavior:
|
||||
|
||||
1. Attribute name = column name. If your attribute is ``foobar``, whenever we refer to
|
||||
``column_name``, you refer to that attribute with the column name ``foobar``.
|
||||
2. Public attributes are for *formatted* value, that is, user readable strings.
|
||||
3. Underscore prefix is the unformatted (computable) value. For example, you could have
|
||||
``_foobar`` at ``42`` and ``foobar`` at ``"42 seconds"`` (what you present to the user).
|
||||
4. Unformatted values are used for sorting.
|
||||
5. If your column name is a python keyword, add an underscore suffix (``from_``).
|
||||
|
||||
Of course, this is only default behavior. This can be overriden.
|
||||
"""
|
||||
def __init__(self, table):
|
||||
super(Row, self).__init__()
|
||||
self.table = table
|
||||
|
||||
def _edit(self):
|
||||
if self.table.edited is self:
|
||||
return
|
||||
assert self.table.edited is None
|
||||
self.table.edited = self
|
||||
|
||||
#--- Virtual
|
||||
def can_edit(self):
|
||||
"""(Virtual) Whether the whole row can be edited.
|
||||
|
||||
By default, always returns ``True``. This is for the *whole* row. For individual cells, it's
|
||||
:meth:`can_edit_cell`.
|
||||
"""
|
||||
return True
|
||||
|
||||
def load(self):
|
||||
"""(Virtual/Required) Loads up values from the model to be presented in the table.
|
||||
|
||||
Usually, our model instances contain values that are not quite ready for display. If you
|
||||
have number formatting, display calculations and other whatnots to perform, you do it here
|
||||
and then you put the result in an arbitrary attribute of the row.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def save(self):
|
||||
"""(Virtual/Required) Saves user edits into your model.
|
||||
|
||||
If your table is editable, this is called when the user commits his changes. Usually, these
|
||||
are typed up stuff, or selected indexes. You have to do proper parsing and reference
|
||||
linking, and save that stuff into your model.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def sort_key_for_column(self, column_name):
|
||||
"""(Virtual) Return the value that is to be used to sort by column ``column_name``.
|
||||
|
||||
By default, looks for an attribute with the same name as ``column_name``, but with an
|
||||
underscore prefix ("unformatted value"). If there's none, tries without the underscore. If
|
||||
there's none, raises ``AttributeError``.
|
||||
"""
|
||||
try:
|
||||
return getattr(self, '_' + column_name)
|
||||
except AttributeError:
|
||||
return getattr(self, column_name)
|
||||
|
||||
#--- Public
|
||||
def can_edit_cell(self, column_name):
|
||||
"""Returns whether cell for column ``column_name`` can be edited.
|
||||
|
||||
By the default, the check is done in many steps:
|
||||
|
||||
1. We check whether the whole row can be edited with :meth:`can_edit`. If it can't, the cell
|
||||
can't either.
|
||||
2. If the column doesn't exist as an attribute, we can't edit.
|
||||
3. If we have an attribute ``can_edit_<column_name>``, return that.
|
||||
4. Check if our attribute is a property. If it's not, it's not editable.
|
||||
5. If our attribute is in fact a property, check whether the property is "settable" (has a
|
||||
``fset`` method). The cell is editable only if the property is "settable".
|
||||
"""
|
||||
if not self.can_edit():
|
||||
return False
|
||||
# '_' is in case column is a python keyword
|
||||
if not hasattr(self, column_name):
|
||||
if hasattr(self, column_name + '_'):
|
||||
column_name = column_name + '_'
|
||||
else:
|
||||
return False
|
||||
if hasattr(self, 'can_edit_' + column_name):
|
||||
return getattr(self, 'can_edit_' + column_name)
|
||||
# If the row has a settable property, we can edit the cell
|
||||
rowclass = self.__class__
|
||||
prop = getattr(rowclass, column_name, None)
|
||||
if prop is None:
|
||||
return False
|
||||
return bool(getattr(prop, 'fset', None))
|
||||
|
||||
def get_cell_value(self, attrname):
|
||||
"""Get cell value for ``attrname``.
|
||||
|
||||
By default, does a simple ``getattr()``, but it is used to allow subclasses to have
|
||||
alternative value storage mechanisms.
|
||||
"""
|
||||
if attrname == 'from':
|
||||
attrname = 'from_'
|
||||
return getattr(self, attrname)
|
||||
|
||||
def set_cell_value(self, attrname, value):
|
||||
"""Set cell value to ``value`` for ``attrname``.
|
||||
|
||||
By default, does a simple ``setattr()``, but it is used to allow subclasses to have
|
||||
alternative value storage mechanisms.
|
||||
"""
|
||||
if attrname == 'from':
|
||||
attrname = 'from_'
|
||||
setattr(self, attrname, value)
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# Created On: 2012/01/23
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from .base import GUIObject
|
||||
from ..util import nonone
|
||||
|
||||
class TextFieldView:
|
||||
"""Expected interface for :class:`TextField`'s view.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
|
||||
Our view is expected to sync with :attr:`TextField.text` "both ways", that is, update the
|
||||
model's text when the user types something, but also update the text field when :meth:`refresh`
|
||||
is called.
|
||||
"""
|
||||
def refresh(self):
|
||||
"""Refreshes the contents of the input widget.
|
||||
|
||||
Ensures that the contents of the input widget is actually :attr:`TextField.text`.
|
||||
"""
|
||||
|
||||
class TextField(GUIObject):
|
||||
"""Cross-toolkit text field.
|
||||
|
||||
Represents a UI element allowing the user to input a text value. Its main attribute is
|
||||
:attr:`text` which acts as the store of the said value.
|
||||
|
||||
When our model value isn't a string, we have a built-in parsing/formatting mechanism allowing
|
||||
us to directly retrieve/set our non-string value through :attr:`value`.
|
||||
|
||||
Subclasses :class:`.GUIObject`. Expected view: :class:`TextFieldView`.
|
||||
"""
|
||||
def __init__(self):
|
||||
GUIObject.__init__(self)
|
||||
self._text = ''
|
||||
self._value = None
|
||||
|
||||
#--- Virtual
|
||||
def _parse(self, text):
|
||||
"""(Virtual) Parses ``text`` to put into :attr:`value`.
|
||||
|
||||
Returns the parsed version of ``text``. Called whenever :attr:`text` changes.
|
||||
"""
|
||||
return text
|
||||
|
||||
def _format(self, value):
|
||||
"""(Virtual) Formats ``value`` to put into :attr:`text`.
|
||||
|
||||
Returns the formatted version of ``value``. Called whenever :attr:`value` changes.
|
||||
"""
|
||||
return value
|
||||
|
||||
def _update(self, newvalue):
|
||||
"""(Virtual) Called whenever we have a new value.
|
||||
|
||||
Whenever our text/value store changes to a new value (different from the old one), this
|
||||
method is called. By default, it does nothing but you can override it if you want.
|
||||
"""
|
||||
|
||||
#--- Override
|
||||
def _view_updated(self):
|
||||
self.view.refresh()
|
||||
|
||||
#--- Public
|
||||
def refresh(self):
|
||||
"""Triggers a view :meth:`~TextFieldView.refresh`.
|
||||
"""
|
||||
self.view.refresh()
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
"""The text that is currently displayed in the widget.
|
||||
|
||||
*str*. *get/set*.
|
||||
|
||||
This property can be set. When it is, :meth:`refresh` is called and the view is synced with
|
||||
our value. Always in sync with :attr:`value`.
|
||||
"""
|
||||
return self._text
|
||||
|
||||
@text.setter
|
||||
def text(self, newtext):
|
||||
self.value = self._parse(nonone(newtext, ''))
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
"""The "parsed" representation of :attr:`text`.
|
||||
|
||||
*arbitrary type*. *get/set*.
|
||||
|
||||
By default, it's a mirror of :attr:`text`, but a subclass can override :meth:`_parse` and
|
||||
:meth:`_format` to have anything else. Always in sync with :attr:`text`.
|
||||
"""
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, newvalue):
|
||||
if newvalue == self._value:
|
||||
return
|
||||
self._value = newvalue
|
||||
self._text = self._format(newvalue)
|
||||
self._update(self._value)
|
||||
self.refresh()
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from collections import MutableSequence
|
||||
|
||||
from .base import GUIObject
|
||||
|
||||
class Node(MutableSequence):
|
||||
"""Pretty bland node implementation to be used in a :class:`Tree`.
|
||||
|
||||
It has a :attr:`parent`, behaves like a list, its content being its children. Link integrity
|
||||
is somewhat enforced (adding a child to a node will set the child's :attr:`parent`, but that's
|
||||
pretty much as far as we go, integrity-wise. Nodes don't tend to move around much in a GUI
|
||||
tree). We don't even check for infinite node loops. Don't play around these grounds too much.
|
||||
|
||||
Nodes are designed to be subclassed and given meaningful attributes (those you'll want to
|
||||
display in your tree view), but they all have a :attr:`name`, which is given on initialization.
|
||||
"""
|
||||
def __init__(self, name):
|
||||
self._name = name
|
||||
self._parent = None
|
||||
self._path = None
|
||||
self._children = []
|
||||
|
||||
def __repr__(self):
|
||||
return '<Node %r>' % self.name
|
||||
|
||||
#--- MutableSequence overrides
|
||||
def __delitem__(self, key):
|
||||
self._children.__delitem__(key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._children.__getitem__(key)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._children)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._children.__setitem__(key, value)
|
||||
|
||||
def append(self, node):
|
||||
self._children.append(node)
|
||||
node._parent = self
|
||||
node._path = None
|
||||
|
||||
def insert(self, index, node):
|
||||
self._children.insert(index, node)
|
||||
node._parent = self
|
||||
node._path = None
|
||||
|
||||
#--- Public
|
||||
def clear(self):
|
||||
"""Clears the node of all its children.
|
||||
"""
|
||||
del self[:]
|
||||
|
||||
def find(self, predicate, include_self=True):
|
||||
"""Return the first child to match ``predicate``.
|
||||
|
||||
See :meth:`findall`.
|
||||
"""
|
||||
try:
|
||||
return next(self.findall(predicate, include_self=include_self))
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
def findall(self, predicate, include_self=True):
|
||||
"""Yield all children matching ``predicate``.
|
||||
|
||||
:param predicate: ``f(node) --> bool``
|
||||
:param include_self: Whether we can return ``self`` or we return only children.
|
||||
"""
|
||||
if include_self and predicate(self):
|
||||
yield self
|
||||
for child in self:
|
||||
for found in child.findall(predicate, include_self=True):
|
||||
yield found
|
||||
|
||||
def get_node(self, index_path):
|
||||
"""Returns the node at ``index_path``.
|
||||
|
||||
:param index_path: a list of int indexes leading to our node. See :attr:`path`.
|
||||
"""
|
||||
result = self
|
||||
if index_path:
|
||||
for index in index_path:
|
||||
result = result[index]
|
||||
return result
|
||||
|
||||
def get_path(self, target_node):
|
||||
"""Returns the :attr:`path` of ``target_node``.
|
||||
|
||||
If ``target_node`` is ``None``, returns ``None``.
|
||||
"""
|
||||
if target_node is None:
|
||||
return None
|
||||
return target_node.path
|
||||
|
||||
@property
|
||||
def children_count(self):
|
||||
"""Same as ``len(self)``.
|
||||
"""
|
||||
return len(self)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Name for the node, supplied on init.
|
||||
"""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
"""Parent of the node.
|
||||
|
||||
If ``None``, we have a root node.
|
||||
"""
|
||||
return self._parent
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
"""A list of node indexes leading from the root node to ``self``.
|
||||
|
||||
The path of a node is always related to its :attr:`root`. It's the sequences of index that
|
||||
we have to take to get to our node, starting from the root. For example, if
|
||||
``node.path == [1, 2, 3, 4]``, it means that ``node.root[1][2][3][4] is node``.
|
||||
"""
|
||||
if self._path is None:
|
||||
if self._parent is None:
|
||||
self._path = []
|
||||
else:
|
||||
self._path = self._parent.path + [self._parent.index(self)]
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
"""Root node of current node.
|
||||
|
||||
To get it, we recursively follow our :attr:`parent` chain until we have ``None``.
|
||||
"""
|
||||
if self._parent is None:
|
||||
return self
|
||||
else:
|
||||
return self._parent.root
|
||||
|
||||
|
||||
class Tree(Node, GUIObject):
|
||||
"""Cross-toolkit GUI-enabled tree view.
|
||||
|
||||
This class is a bit too thin to be used as a tree view controller out of the box and HS apps
|
||||
that subclasses it each add quite a bit of logic to it to make it workable. Making this more
|
||||
usable out of the box is a work in progress.
|
||||
|
||||
This class is here (in addition to being a :class:`Node`) mostly to handle selection.
|
||||
|
||||
Subclasses :class:`Node` (it is the root node of all its children) and :class:`.GUIObject`.
|
||||
"""
|
||||
def __init__(self):
|
||||
Node.__init__(self, '')
|
||||
GUIObject.__init__(self)
|
||||
#: Where we store selected nodes (as a list of :class:`Node`)
|
||||
self._selected_nodes = []
|
||||
|
||||
#--- Virtual
|
||||
def _select_nodes(self, nodes):
|
||||
"""(Virtual) Customize node selection behavior.
|
||||
|
||||
By default, simply set :attr:`_selected_nodes`.
|
||||
"""
|
||||
self._selected_nodes = nodes
|
||||
|
||||
#--- Override
|
||||
def _view_updated(self):
|
||||
self.view.refresh()
|
||||
|
||||
def clear(self):
|
||||
self._selected_nodes = []
|
||||
Node.clear(self)
|
||||
|
||||
#--- Public
|
||||
@property
|
||||
def selected_node(self):
|
||||
"""Currently selected node.
|
||||
|
||||
*:class:`Node`*. *get/set*.
|
||||
|
||||
First of :attr:`selected_nodes`. ``None`` if empty.
|
||||
"""
|
||||
return self._selected_nodes[0] if self._selected_nodes else None
|
||||
|
||||
@selected_node.setter
|
||||
def selected_node(self, node):
|
||||
if node is not None:
|
||||
self._select_nodes([node])
|
||||
else:
|
||||
self._select_nodes([])
|
||||
|
||||
@property
|
||||
def selected_nodes(self):
|
||||
"""List of selected nodes in the tree.
|
||||
|
||||
*List of :class:`Node`*. *get/set*.
|
||||
|
||||
We use nodes instead of indexes to store selection because it's simpler when it's time to
|
||||
manage selection of multiple node levels.
|
||||
"""
|
||||
return self._selected_nodes
|
||||
|
||||
@selected_nodes.setter
|
||||
def selected_nodes(self, nodes):
|
||||
self._select_nodes(nodes)
|
||||
|
||||
@property
|
||||
def selected_path(self):
|
||||
"""Currently selected path.
|
||||
|
||||
*:attr:`Node.path`*. *get/set*.
|
||||
|
||||
First of :attr:`selected_paths`. ``None`` if empty.
|
||||
"""
|
||||
return self.get_path(self.selected_node)
|
||||
|
||||
@selected_path.setter
|
||||
def selected_path(self, index_path):
|
||||
if index_path is not None:
|
||||
self.selected_paths = [index_path]
|
||||
else:
|
||||
self._select_nodes([])
|
||||
|
||||
@property
|
||||
def selected_paths(self):
|
||||
"""List of selected paths in the tree.
|
||||
|
||||
*List of :attr:`Node.path`*. *get/set*
|
||||
|
||||
Computed from :attr:`selected_nodes`.
|
||||
"""
|
||||
return list(map(self.get_path, self._selected_nodes))
|
||||
|
||||
@selected_paths.setter
|
||||
def selected_paths(self, index_paths):
|
||||
nodes = []
|
||||
for path in index_paths:
|
||||
try:
|
||||
nodes.append(self.get_node(path))
|
||||
except IndexError:
|
||||
pass
|
||||
self._select_nodes(nodes)
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2004/12/20
|
||||
# Copyright 2011 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
class JobCancelled(Exception):
|
||||
"The user has cancelled the job"
|
||||
|
||||
class JobInProgressError(Exception):
|
||||
"A job is already being performed, you can't perform more than one at the same time."
|
||||
|
||||
class JobCountError(Exception):
|
||||
"The number of jobs started have exceeded the number of jobs allowed"
|
||||
|
||||
class Job:
|
||||
"""Manages a job's progression and return it's progression through a callback.
|
||||
|
||||
Note that this class is not foolproof. For example, you could call
|
||||
start_subjob, and then call add_progress from the parent job, and nothing
|
||||
would stop you from doing it. However, it would mess your progression
|
||||
because it is the sub job that is supposed to drive the progression.
|
||||
Another example would be to start a subjob, then start another, and call
|
||||
add_progress from the old subjob. Once again, it would mess your progression.
|
||||
There are no stops because it would remove the lightweight aspect of the
|
||||
class (A Job would need to have a Parent instead of just a callback,
|
||||
and the parent could be None. A lot of checks for nothing.).
|
||||
Another one is that nothing stops you from calling add_progress right after
|
||||
SkipJob.
|
||||
"""
|
||||
#---Magic functions
|
||||
def __init__(self, job_proportions, callback):
|
||||
"""Initialize the Job with 'jobcount' jobs. Start every job with
|
||||
start_job(). Every time the job progress is updated, 'callback' is called
|
||||
'callback' takes a 'progress' int param, and a optional 'desc'
|
||||
parameter. Callback must return false if the job must be cancelled.
|
||||
"""
|
||||
if not hasattr(callback, '__call__'):
|
||||
raise TypeError("'callback' MUST be set when creating a Job")
|
||||
if isinstance(job_proportions, int):
|
||||
job_proportions = [1] * job_proportions
|
||||
self._job_proportions = list(job_proportions)
|
||||
self._jobcount = sum(job_proportions)
|
||||
self._callback = callback
|
||||
self._current_job = 0
|
||||
self._passed_jobs = 0
|
||||
self._progress = 0
|
||||
self._currmax = 1
|
||||
|
||||
#---Private
|
||||
def _subjob_callback(self, progress, desc=''):
|
||||
"""This is the callback passed to children jobs.
|
||||
"""
|
||||
self.set_progress(progress, desc)
|
||||
return True #if JobCancelled has to be raised, it will be at the highest level
|
||||
|
||||
def _do_update(self, desc):
|
||||
"""Calls the callback function with a % progress as a parameter.
|
||||
|
||||
The parameter is a int in the 0-100 range.
|
||||
"""
|
||||
if self._current_job:
|
||||
passed_progress = self._passed_jobs * self._currmax
|
||||
current_progress = self._current_job * self._progress
|
||||
total_progress = self._jobcount * self._currmax
|
||||
progress = ((passed_progress + current_progress) * 100) // total_progress
|
||||
else:
|
||||
progress = -1 # indeterminate
|
||||
# It's possible that callback doesn't support a desc arg
|
||||
result = self._callback(progress, desc) if desc else self._callback(progress)
|
||||
if not result:
|
||||
raise JobCancelled()
|
||||
|
||||
#---Public
|
||||
def add_progress(self, progress=1, desc=''):
|
||||
self.set_progress(self._progress + progress, desc)
|
||||
|
||||
def check_if_cancelled(self):
|
||||
self._do_update('')
|
||||
|
||||
def iter_with_progress(self, iterable, desc_format=None, every=1, count=None):
|
||||
"""Iterate through ``iterable`` while automatically adding progress.
|
||||
|
||||
WARNING: We need our iterable's length. If ``iterable`` is not a sequence (that is,
|
||||
something we can call ``len()`` on), you *have* to specify a count through the ``count``
|
||||
argument. If ``count`` is ``None``, ``len(iterable)`` is used.
|
||||
"""
|
||||
if count is None:
|
||||
count = len(iterable)
|
||||
desc = ''
|
||||
if desc_format:
|
||||
desc = desc_format % (0, count)
|
||||
self.start_job(count, desc)
|
||||
for i, element in enumerate(iterable, start=1):
|
||||
yield element
|
||||
if i % every == 0:
|
||||
if desc_format:
|
||||
desc = desc_format % (i, count)
|
||||
self.add_progress(progress=every, desc=desc)
|
||||
if desc_format:
|
||||
desc = desc_format % (count, count)
|
||||
self.set_progress(100, desc)
|
||||
|
||||
def start_job(self, max_progress=100, desc=''):
|
||||
"""Begin work on the next job. You must not call start_job more than
|
||||
'jobcount' (in __init__) times.
|
||||
'max' is the job units you are to perform.
|
||||
'desc' is the description of the job.
|
||||
"""
|
||||
self._passed_jobs += self._current_job
|
||||
try:
|
||||
self._current_job = self._job_proportions.pop(0)
|
||||
except IndexError:
|
||||
raise JobCountError()
|
||||
self._progress = 0
|
||||
self._currmax = max(1, max_progress)
|
||||
self._do_update(desc)
|
||||
|
||||
def start_subjob(self, job_proportions, desc=''):
|
||||
"""Starts a sub job. Use this when you want to split a job into
|
||||
multiple smaller jobs. Pretty handy when starting a process where you
|
||||
know how many subjobs you will have, but don't know the work unit count
|
||||
for every of them.
|
||||
returns the Job object
|
||||
"""
|
||||
self.start_job(100, desc)
|
||||
return Job(job_proportions, self._subjob_callback)
|
||||
|
||||
def set_progress(self, progress, desc=''):
|
||||
"""Sets the progress of the current job to 'progress', and call the
|
||||
callback
|
||||
"""
|
||||
self._progress = progress
|
||||
if self._progress > self._currmax:
|
||||
self._progress = self._currmax
|
||||
if self._progress < 0:
|
||||
self._progress = 0
|
||||
self._do_update(desc)
|
||||
|
||||
|
||||
class NullJob:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def add_progress(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def check_if_cancelled(self):
|
||||
pass
|
||||
|
||||
def iter_with_progress(self, sequence, *args, **kwargs):
|
||||
return iter(sequence)
|
||||
|
||||
def start_job(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def start_subjob(self, *args, **kwargs):
|
||||
return NullJob()
|
||||
|
||||
def set_progress(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
nulljob = NullJob()
|
||||
@@ -1,72 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2010-11-19
|
||||
# Copyright 2011 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from threading import Thread
|
||||
import sys
|
||||
|
||||
from .job import Job, JobInProgressError, JobCancelled
|
||||
|
||||
class ThreadedJobPerformer:
|
||||
"""Run threaded jobs and track progress.
|
||||
|
||||
To run a threaded job, first create a job with _create_job(), then call _run_threaded(), with
|
||||
your work function as a parameter.
|
||||
|
||||
Example:
|
||||
|
||||
j = self._create_job()
|
||||
self._run_threaded(self.some_work_func, (arg1, arg2, j))
|
||||
"""
|
||||
_job_running = False
|
||||
last_error = None
|
||||
|
||||
#--- Protected
|
||||
def create_job(self):
|
||||
if self._job_running:
|
||||
raise JobInProgressError()
|
||||
self.last_progress = -1
|
||||
self.last_desc = ''
|
||||
self.job_cancelled = False
|
||||
return Job(1, self._update_progress)
|
||||
|
||||
def _async_run(self, *args):
|
||||
target = args[0]
|
||||
args = tuple(args[1:])
|
||||
self._job_running = True
|
||||
self.last_error = None
|
||||
try:
|
||||
target(*args)
|
||||
except JobCancelled:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.last_error = e
|
||||
self.last_traceback = sys.exc_info()[2]
|
||||
finally:
|
||||
self._job_running = False
|
||||
self.last_progress = None
|
||||
|
||||
def reraise_if_error(self):
|
||||
"""Reraises the error that happened in the thread if any.
|
||||
|
||||
Call this after the caller of run_threaded detected that self._job_running returned to False
|
||||
"""
|
||||
if self.last_error is not None:
|
||||
raise self.last_error.with_traceback(self.last_traceback)
|
||||
|
||||
def _update_progress(self, newprogress, newdesc=''):
|
||||
self.last_progress = newprogress
|
||||
if newdesc:
|
||||
self.last_desc = newdesc
|
||||
return not self.job_cancelled
|
||||
|
||||
def run_threaded(self, target, args=()):
|
||||
if self._job_running:
|
||||
raise JobInProgressError()
|
||||
args = (target, ) + args
|
||||
Thread(target=self._async_run, args=args).start()
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2009-09-14
|
||||
# Copyright 2011 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, Qt, QTimer
|
||||
from PyQt5.QtWidgets import QProgressDialog
|
||||
|
||||
from . import performer
|
||||
|
||||
class Progress(QProgressDialog, performer.ThreadedJobPerformer):
|
||||
finished = pyqtSignal(['QString'])
|
||||
|
||||
def __init__(self, parent):
|
||||
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
|
||||
QProgressDialog.__init__(self, '', "Cancel", 0, 100, parent, flags)
|
||||
self.setModal(True)
|
||||
self.setAutoReset(False)
|
||||
self.setAutoClose(False)
|
||||
self._timer = QTimer()
|
||||
self._jobid = ''
|
||||
self._timer.timeout.connect(self.updateProgress)
|
||||
|
||||
def updateProgress(self):
|
||||
# the values might change before setValue happens
|
||||
last_progress = self.last_progress
|
||||
last_desc = self.last_desc
|
||||
if not self._job_running or last_progress is None:
|
||||
self._timer.stop()
|
||||
self.close()
|
||||
if not self.job_cancelled:
|
||||
self.finished.emit(self._jobid)
|
||||
return
|
||||
if self.wasCanceled():
|
||||
self.job_cancelled = True
|
||||
return
|
||||
if last_desc:
|
||||
self.setLabelText(last_desc)
|
||||
self.setValue(last_progress)
|
||||
|
||||
def run(self, jobid, title, target, args=()):
|
||||
self._jobid = jobid
|
||||
self.reset()
|
||||
self.setLabelText('')
|
||||
self.run_threaded(target, args)
|
||||
self.setWindowTitle(title)
|
||||
self.show()
|
||||
self._timer.start(500)
|
||||
|
||||
197
hscommon/loc.py
197
hscommon/loc.py
@@ -1,197 +0,0 @@
|
||||
import os
|
||||
import os.path as op
|
||||
import shutil
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import polib
|
||||
|
||||
from . import pygettext
|
||||
from .util import modified_after, dedupe, ensure_folder, ensure_file
|
||||
from .build import print_and_do, ensure_empty_folder, copy
|
||||
|
||||
LC_MESSAGES = 'LC_MESSAGES'
|
||||
|
||||
def get_langs(folder):
|
||||
return [name for name in os.listdir(folder) if op.isdir(op.join(folder, name))]
|
||||
|
||||
def files_with_ext(folder, ext):
|
||||
return [op.join(folder, fn) for fn in os.listdir(folder) if fn.endswith(ext)]
|
||||
|
||||
def generate_pot(folders, outpath, keywords, merge=False):
|
||||
if merge and not op.exists(outpath):
|
||||
merge = False
|
||||
if merge:
|
||||
_, genpath = tempfile.mkstemp()
|
||||
else:
|
||||
genpath = outpath
|
||||
pyfiles = []
|
||||
for folder in folders:
|
||||
for root, dirs, filenames in os.walk(folder):
|
||||
keep = [fn for fn in filenames if fn.endswith('.py')]
|
||||
pyfiles += [op.join(root, fn) for fn in keep]
|
||||
pygettext.main(pyfiles, outpath=genpath, keywords=keywords)
|
||||
if merge:
|
||||
merge_po_and_preserve(genpath, outpath)
|
||||
os.remove(genpath)
|
||||
|
||||
def compile_all_po(base_folder):
|
||||
langs = get_langs(base_folder)
|
||||
for lang in langs:
|
||||
pofolder = op.join(base_folder, lang, LC_MESSAGES)
|
||||
pofiles = files_with_ext(pofolder, '.po')
|
||||
for pofile in pofiles:
|
||||
p = polib.pofile(pofile)
|
||||
p.save_as_mofile(pofile[:-3] + '.mo')
|
||||
|
||||
def merge_locale_dir(target, mergeinto):
|
||||
langs = get_langs(target)
|
||||
for lang in langs:
|
||||
if not op.exists(op.join(mergeinto, lang)):
|
||||
continue
|
||||
mofolder = op.join(target, lang, LC_MESSAGES)
|
||||
mofiles = files_with_ext(mofolder, '.mo')
|
||||
for mofile in mofiles:
|
||||
shutil.copy(mofile, op.join(mergeinto, lang, LC_MESSAGES))
|
||||
|
||||
def merge_pots_into_pos(folder):
|
||||
# We're going to take all pot files in `folder` and for each lang, merge it with the po file
|
||||
# with the same name.
|
||||
potfiles = files_with_ext(folder, '.pot')
|
||||
for potfile in potfiles:
|
||||
refpot = polib.pofile(potfile)
|
||||
refname = op.splitext(op.basename(potfile))[0]
|
||||
for lang in get_langs(folder):
|
||||
po = polib.pofile(op.join(folder, lang, LC_MESSAGES, refname + '.po'))
|
||||
po.merge(refpot)
|
||||
po.save()
|
||||
|
||||
def merge_po_and_preserve(source, dest):
|
||||
# Merges source entries into dest, but keep old entries intact
|
||||
sourcepo = polib.pofile(source)
|
||||
destpo = polib.pofile(dest)
|
||||
for entry in sourcepo:
|
||||
if destpo.find(entry.msgid) is not None:
|
||||
# The entry is already there
|
||||
continue
|
||||
destpo.append(entry)
|
||||
destpo.save()
|
||||
|
||||
def normalize_all_pos(base_folder):
|
||||
"""Normalize the format of .po files in base_folder.
|
||||
|
||||
When getting POs from external sources, such as Transifex, we end up with spurious diffs because
|
||||
of a difference in the way line wrapping is handled. It wouldn't be a big deal if it happened
|
||||
once, but these spurious diffs keep overwriting each other, and it's annoying.
|
||||
|
||||
Our PO files will keep polib's format. Call this function to ensure that freshly pulled POs
|
||||
are of the right format before committing them.
|
||||
"""
|
||||
langs = get_langs(base_folder)
|
||||
for lang in langs:
|
||||
pofolder = op.join(base_folder, lang, LC_MESSAGES)
|
||||
pofiles = files_with_ext(pofolder, '.po')
|
||||
for pofile in pofiles:
|
||||
p = polib.pofile(pofile)
|
||||
p.save()
|
||||
|
||||
#--- Cocoa
|
||||
def all_lproj_paths(folder):
|
||||
return files_with_ext(folder, '.lproj')
|
||||
|
||||
def escape_cocoa_strings(s):
|
||||
return s.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
|
||||
|
||||
def unescape_cocoa_strings(s):
|
||||
return s.replace('\\\\', '\\').replace('\\"', '"').replace('\\n', '\n')
|
||||
|
||||
def strings2pot(target, dest):
|
||||
with open(target, 'rt', encoding='utf-8') as fp:
|
||||
contents = fp.read()
|
||||
# We're reading an en.lproj file. We only care about the righthand part of the translation.
|
||||
re_trans = re.compile(r'".*" = "(.*)";')
|
||||
strings = re_trans.findall(contents)
|
||||
if op.exists(dest):
|
||||
po = polib.pofile(dest)
|
||||
else:
|
||||
po = polib.POFile()
|
||||
for s in dedupe(strings):
|
||||
s = unescape_cocoa_strings(s)
|
||||
entry = po.find(s)
|
||||
if entry is None:
|
||||
entry = polib.POEntry(msgid=s)
|
||||
po.append(entry)
|
||||
# we don't know or care about a line number so we put 0
|
||||
entry.occurrences.append((target, '0'))
|
||||
entry.occurrences = dedupe(entry.occurrences)
|
||||
po.save(dest)
|
||||
|
||||
def allstrings2pot(lprojpath, dest, excludes=None):
|
||||
allstrings = files_with_ext(lprojpath, '.strings')
|
||||
if excludes:
|
||||
allstrings = [p for p in allstrings if op.splitext(op.basename(p))[0] not in excludes]
|
||||
for strings_path in allstrings:
|
||||
strings2pot(strings_path, dest)
|
||||
|
||||
def po2strings(pofile, en_strings, dest):
|
||||
# Takes en_strings and replace all righthand parts of "foo" = "bar"; entries with translations
|
||||
# in pofile, then puts the result in dest.
|
||||
po = polib.pofile(pofile)
|
||||
if not modified_after(pofile, dest):
|
||||
return
|
||||
ensure_folder(op.dirname(dest))
|
||||
print("Creating {} from {}".format(dest, pofile))
|
||||
with open(en_strings, 'rt', encoding='utf-8') as fp:
|
||||
contents = fp.read()
|
||||
re_trans = re.compile(r'(?<= = ").*(?=";\n)')
|
||||
def repl(match):
|
||||
s = match.group(0)
|
||||
unescaped = unescape_cocoa_strings(s)
|
||||
entry = po.find(unescaped)
|
||||
if entry is None:
|
||||
print("WARNING: Could not find entry '{}' in .po file".format(s))
|
||||
return s
|
||||
trans = entry.msgstr
|
||||
return escape_cocoa_strings(trans) if trans else s
|
||||
contents = re_trans.sub(repl, contents)
|
||||
with open(dest, 'wt', encoding='utf-8') as fp:
|
||||
fp.write(contents)
|
||||
|
||||
def generate_cocoa_strings_from_code(code_folder, dest_folder):
|
||||
# Uses the "genstrings" command to generate strings file from all .m files in "code_folder".
|
||||
# The strings file (their name depends on the localization table used in the source) will be
|
||||
# placed in "dest_folder".
|
||||
# genstrings produces utf-16 files with comments. After having generated the files, we convert
|
||||
# them to utf-8 and remove the comments.
|
||||
ensure_empty_folder(dest_folder)
|
||||
print_and_do('genstrings -o "{}" `find "{}" -name *.m | xargs`'.format(dest_folder, code_folder))
|
||||
for stringsfile in os.listdir(dest_folder):
|
||||
stringspath = op.join(dest_folder, stringsfile)
|
||||
with open(stringspath, 'rt', encoding='utf-16') as fp:
|
||||
content = fp.read()
|
||||
content = re.sub('/\*.*?\*/', '', content)
|
||||
content = re.sub('\n{2,}', '\n', content)
|
||||
# I have no idea why, but genstrings seems to have problems with "%" character in strings
|
||||
# and inserts (number)$ after it. Find these bogus inserts and remove them.
|
||||
content = re.sub('%\d\$', '%', content)
|
||||
with open(stringspath, 'wt', encoding='utf-8') as fp:
|
||||
fp.write(content)
|
||||
|
||||
def build_cocoa_localizations(app, en_stringsfile=op.join('cocoa', 'en.lproj', 'Localizable.strings')):
|
||||
# Creates .lproj folders with Localizable.strings and cocoalib.strings based on cocoalib.po and
|
||||
# ui.po for all available languages as well as base strings files in en.lproj. These lproj
|
||||
# folders are created in `app`'s (a OSXAppStructure) resource folder.
|
||||
print("Creating lproj folders based on .po files")
|
||||
en_cocoastringsfile = op.join('cocoalib', 'en.lproj', 'cocoalib.strings')
|
||||
for lang in get_langs('locale'):
|
||||
pofile = op.join('locale', lang, 'LC_MESSAGES', 'ui.po')
|
||||
dest_lproj = op.join(app.resources, lang + '.lproj')
|
||||
ensure_folder(dest_lproj)
|
||||
po2strings(pofile, en_stringsfile, op.join(dest_lproj, 'Localizable.strings'))
|
||||
pofile = op.join('cocoalib', 'locale', lang, 'LC_MESSAGES', 'cocoalib.po')
|
||||
po2strings(pofile, en_cocoastringsfile, op.join(dest_lproj, 'cocoalib.strings'))
|
||||
# We also have to copy the "en.lproj" strings
|
||||
en_lproj = op.join(app.resources, 'en.lproj')
|
||||
ensure_folder(en_lproj)
|
||||
copy(en_stringsfile, en_lproj)
|
||||
copy(en_cocoastringsfile, en_lproj)
|
||||
@@ -1,89 +0,0 @@
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
"""Very simple inter-object notification system.
|
||||
|
||||
This module is a brain-dead simple notification system involving a :class:`Broadcaster` and a
|
||||
:class:`Listener`. A listener can only listen to one broadcaster. A broadcaster can have multiple
|
||||
listeners. If the listener is connected, whenever the broadcaster calls :meth:`~Broadcaster.notify`,
|
||||
the method with the same name as the broadcasted message is called on the listener.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
class Broadcaster:
|
||||
"""Broadcasts messages that are received by all listeners.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.listeners = set()
|
||||
|
||||
def add_listener(self, listener):
|
||||
self.listeners.add(listener)
|
||||
|
||||
def notify(self, msg):
|
||||
"""Notify all connected listeners of ``msg``.
|
||||
|
||||
That means that each listeners will have their method with the same name as ``msg`` called.
|
||||
"""
|
||||
for listener in self.listeners.copy(): # listeners can change during iteration
|
||||
if listener in self.listeners: # disconnected during notification
|
||||
listener.dispatch(msg)
|
||||
|
||||
def remove_listener(self, listener):
|
||||
self.listeners.discard(listener)
|
||||
|
||||
|
||||
class Listener:
|
||||
"""A listener is initialized with the broadcaster it's going to listen to. Initially, it is not connected.
|
||||
"""
|
||||
def __init__(self, broadcaster):
|
||||
self.broadcaster = broadcaster
|
||||
self._bound_notifications = defaultdict(list)
|
||||
|
||||
def bind_messages(self, messages, func):
|
||||
"""Binds multiple message to the same function.
|
||||
|
||||
Often, we perform the same thing on multiple messages. Instead of having the same function
|
||||
repeated again and agin in our class, we can use this method to bind multiple messages to
|
||||
the same function.
|
||||
"""
|
||||
for message in messages:
|
||||
self._bound_notifications[message].append(func)
|
||||
|
||||
def connect(self):
|
||||
"""Connects the listener to its broadcaster.
|
||||
"""
|
||||
self.broadcaster.add_listener(self)
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnects the listener from its broadcaster.
|
||||
"""
|
||||
self.broadcaster.remove_listener(self)
|
||||
|
||||
def dispatch(self, msg):
|
||||
if msg in self._bound_notifications:
|
||||
for func in self._bound_notifications[msg]:
|
||||
func()
|
||||
if hasattr(self, msg):
|
||||
method = getattr(self, msg)
|
||||
method()
|
||||
|
||||
|
||||
class Repeater(Broadcaster, Listener):
|
||||
REPEATED_NOTIFICATIONS = None
|
||||
|
||||
def __init__(self, broadcaster):
|
||||
Broadcaster.__init__(self)
|
||||
Listener.__init__(self, broadcaster)
|
||||
|
||||
def _repeat_message(self, msg):
|
||||
if not self.REPEATED_NOTIFICATIONS or msg in self.REPEATED_NOTIFICATIONS:
|
||||
self.notify(msg)
|
||||
|
||||
def dispatch(self, msg):
|
||||
Listener.dispatch(self, msg)
|
||||
self._repeat_message(msg)
|
||||
|
||||
243
hscommon/path.py
243
hscommon/path.py
@@ -1,243 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2006/02/21
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import logging
|
||||
import os
|
||||
import os.path as op
|
||||
import shutil
|
||||
import sys
|
||||
from itertools import takewhile
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
|
||||
class Path(tuple):
|
||||
"""A handy class to work with paths.
|
||||
|
||||
We subclass ``tuple``, each element of the tuple represents an element of the path.
|
||||
|
||||
* ``Path('/foo/bar/baz')[1]`` --> ``'bar'``
|
||||
* ``Path('/foo/bar/baz')[1:2]`` --> ``Path('bar/baz')``
|
||||
* ``Path('/foo/bar')['baz']`` --> ``Path('/foo/bar/baz')``
|
||||
* ``str(Path('/foo/bar/baz'))`` --> ``'/foo/bar/baz'``
|
||||
"""
|
||||
# Saves a little bit of memory usage
|
||||
__slots__ = ()
|
||||
|
||||
def __new__(cls, value, separator=None):
|
||||
def unicode_if_needed(s):
|
||||
if isinstance(s, str):
|
||||
return s
|
||||
else:
|
||||
try:
|
||||
return str(s, sys.getfilesystemencoding())
|
||||
except UnicodeDecodeError:
|
||||
logging.warning("Could not decode %r", s)
|
||||
raise
|
||||
|
||||
if isinstance(value, Path):
|
||||
return value
|
||||
if not separator:
|
||||
separator = os.sep
|
||||
if isinstance(value, bytes):
|
||||
value = unicode_if_needed(value)
|
||||
if isinstance(value, str):
|
||||
if value:
|
||||
if (separator not in value) and ('/' in value):
|
||||
separator = '/'
|
||||
value = value.split(separator)
|
||||
else:
|
||||
value = ()
|
||||
else:
|
||||
if any(isinstance(x, bytes) for x in value):
|
||||
value = [unicode_if_needed(x) for x in value]
|
||||
#value is a tuple/list
|
||||
if any(separator in x for x in value):
|
||||
#We have a component with a separator in it. Let's rejoin it, and generate another path.
|
||||
return Path(separator.join(value), separator)
|
||||
if (len(value) > 1) and (not value[-1]):
|
||||
value = value[:-1] #We never want a path to end with a '' (because Path() can be called with a trailing slash ending path)
|
||||
return tuple.__new__(cls, value)
|
||||
|
||||
def __add__(self, other):
|
||||
other = Path(other)
|
||||
if other and (not other[0]):
|
||||
other = other[1:]
|
||||
return Path(tuple.__add__(self, other))
|
||||
|
||||
def __contains__(self, item):
|
||||
if isinstance(item, Path):
|
||||
return item[:len(self)] == self
|
||||
else:
|
||||
return tuple.__contains__(self, item)
|
||||
|
||||
def __eq__(self, other):
|
||||
return tuple.__eq__(self, Path(other))
|
||||
|
||||
def __getitem__(self, key):
|
||||
if isinstance(key, slice):
|
||||
if isinstance(key.start, Path):
|
||||
equal_elems = list(takewhile(lambda pair: pair[0] == pair[1], zip(self, key.start)))
|
||||
key = slice(len(equal_elems), key.stop, key.step)
|
||||
if isinstance(key.stop, Path):
|
||||
equal_elems = list(takewhile(lambda pair: pair[0] == pair[1], zip(reversed(self), reversed(key.stop))))
|
||||
stop = -len(equal_elems) if equal_elems else None
|
||||
key = slice(key.start, stop, key.step)
|
||||
return Path(tuple.__getitem__(self, key))
|
||||
elif isinstance(key, (str, Path)):
|
||||
return self + key
|
||||
else:
|
||||
return tuple.__getitem__(self, key)
|
||||
|
||||
def __hash__(self):
|
||||
return tuple.__hash__(self)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __radd__(self, other):
|
||||
return Path(other) + self
|
||||
|
||||
def __str__(self):
|
||||
if len(self) == 1:
|
||||
first = self[0]
|
||||
if (len(first) == 2) and (first[1] == ':'): #Windows drive letter
|
||||
return first + '\\'
|
||||
elif not len(first): #root directory
|
||||
return '/'
|
||||
return os.sep.join(self)
|
||||
|
||||
def has_drive_letter(self):
|
||||
if not self:
|
||||
return False
|
||||
first = self[0]
|
||||
return (len(first) == 2) and (first[1] == ':')
|
||||
|
||||
def is_parent_of(self, other):
|
||||
"""Whether ``other`` is a subpath of ``self``.
|
||||
|
||||
Almost the same as ``other in self``, but it's a bit more self-explicative and when
|
||||
``other == self``, returns False.
|
||||
"""
|
||||
if other == self:
|
||||
return False
|
||||
else:
|
||||
return other in self
|
||||
|
||||
def remove_drive_letter(self):
|
||||
if self.has_drive_letter():
|
||||
return self[1:]
|
||||
else:
|
||||
return self
|
||||
|
||||
def tobytes(self):
|
||||
return str(self).encode(sys.getfilesystemencoding())
|
||||
|
||||
def parent(self):
|
||||
"""Returns the parent path.
|
||||
|
||||
``Path('/foo/bar/baz').parent()`` --> ``Path('/foo/bar')``
|
||||
"""
|
||||
return self[:-1]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Last element of the path (filename), with extension.
|
||||
|
||||
``Path('/foo/bar/baz').name`` --> ``'baz'``
|
||||
"""
|
||||
return self[-1]
|
||||
|
||||
# OS method wrappers
|
||||
def exists(self):
|
||||
return op.exists(str(self))
|
||||
|
||||
def copy(self, dest_path):
|
||||
return shutil.copy(str(self), str(dest_path))
|
||||
|
||||
def copytree(self, dest_path, *args, **kwargs):
|
||||
return shutil.copytree(str(self), str(dest_path), *args, **kwargs)
|
||||
|
||||
def isdir(self):
|
||||
return op.isdir(str(self))
|
||||
|
||||
def isfile(self):
|
||||
return op.isfile(str(self))
|
||||
|
||||
def islink(self):
|
||||
return op.islink(str(self))
|
||||
|
||||
def listdir(self):
|
||||
return [self[name] for name in os.listdir(str(self))]
|
||||
|
||||
def mkdir(self, *args, **kwargs):
|
||||
return os.mkdir(str(self), *args, **kwargs)
|
||||
|
||||
def makedirs(self, *args, **kwargs):
|
||||
return os.makedirs(str(self), *args, **kwargs)
|
||||
|
||||
def move(self, dest_path):
|
||||
return shutil.move(str(self), str(dest_path))
|
||||
|
||||
def open(self, *args, **kwargs):
|
||||
return open(str(self), *args, **kwargs)
|
||||
|
||||
def remove(self):
|
||||
return os.remove(str(self))
|
||||
|
||||
def rename(self, dest_path):
|
||||
return os.rename(str(self), str(dest_path))
|
||||
|
||||
def rmdir(self):
|
||||
return os.rmdir(str(self))
|
||||
|
||||
def rmtree(self):
|
||||
return shutil.rmtree(str(self))
|
||||
|
||||
def stat(self):
|
||||
return os.stat(str(self))
|
||||
|
||||
def pathify(f):
|
||||
"""Ensure that every annotated :class:`Path` arguments are actually paths.
|
||||
|
||||
When a function is decorated with ``@pathify``, every argument with annotated as Path will be
|
||||
converted to a Path if it wasn't already. Example::
|
||||
|
||||
@pathify
|
||||
def foo(path: Path, otherarg):
|
||||
return path.listdir()
|
||||
|
||||
Calling ``foo('/bar', 0)`` will convert ``'/bar'`` to ``Path('/bar')``.
|
||||
"""
|
||||
sig = signature(f)
|
||||
pindexes = {i for i, p in enumerate(sig.parameters.values()) if p.annotation is Path}
|
||||
pkeys = {k: v for k, v in sig.parameters.items() if v.annotation is Path}
|
||||
def path_or_none(p):
|
||||
return None if p is None else Path(p)
|
||||
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
args = tuple((path_or_none(a) if i in pindexes else a) for i, a in enumerate(args))
|
||||
kwargs = {k: (path_or_none(v) if k in pkeys else v) for k, v in kwargs.items()}
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
def log_io_error(func):
|
||||
""" Catches OSError, IOError and WindowsError and log them
|
||||
"""
|
||||
@wraps(func)
|
||||
def wrapper(path, *args, **kwargs):
|
||||
try:
|
||||
return func(path, *args, **kwargs)
|
||||
except (IOError, OSError) as e:
|
||||
msg = 'Error "{0}" during operation "{1}" on "{2}": "{3}"'
|
||||
classname = e.__class__.__name__
|
||||
funcname = func.__name__
|
||||
logging.warn(msg.format(classname, funcname, str(path), str(e)))
|
||||
|
||||
return wrapper
|
||||
@@ -1,16 +0,0 @@
|
||||
# Created On: 2011/09/22
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
# Yes, I know, there's the 'platform' unit for this kind of stuff, but the thing is that I got a
|
||||
# crash on startup once simply for importing this module and since then I don't trust it. One day,
|
||||
# I'll investigate the cause of that crash further.
|
||||
|
||||
import sys
|
||||
|
||||
ISWINDOWS = sys.platform == 'win32'
|
||||
ISOSX = sys.platform == 'darwin'
|
||||
ISLINUX = sys.platform.startswith('linux')
|
||||
@@ -1,417 +0,0 @@
|
||||
# This module was taken from CPython's Tools/i18n and dirtily hacked to bypass the need for cmdline
|
||||
# invocation.
|
||||
|
||||
# Originally written by Barry Warsaw <barry@zope.com>
|
||||
#
|
||||
# Minimally patched to make it even more xgettext compatible
|
||||
# by Peter Funk <pf@artcom-gmbh.de>
|
||||
#
|
||||
# 2002-11-22 Jürgen Hermann <jh@web.de>
|
||||
# Added checks that _() only contains string literals, and
|
||||
# command line args are resolved to module lists, i.e. you
|
||||
# can now pass a filename, a module or package name, or a
|
||||
# directory (including globbing chars, important for Win32).
|
||||
# Made docstring fit in 80 chars wide displays using pydoc.
|
||||
#
|
||||
|
||||
import os
|
||||
import imp
|
||||
import sys
|
||||
import glob
|
||||
import time
|
||||
import token
|
||||
import tokenize
|
||||
import operator
|
||||
|
||||
__version__ = '1.5'
|
||||
|
||||
default_keywords = ['_']
|
||||
DEFAULTKEYWORDS = ', '.join(default_keywords)
|
||||
|
||||
EMPTYSTRING = ''
|
||||
|
||||
|
||||
|
||||
# The normal pot-file header. msgmerge and Emacs's po-mode work better if it's
|
||||
# there.
|
||||
pot_header = """
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\\n"
|
||||
"Content-Transfer-Encoding: utf-8\\n"
|
||||
"""
|
||||
|
||||
|
||||
def usage(code, msg=''):
|
||||
print(__doc__ % globals(), file=sys.stderr)
|
||||
if msg:
|
||||
print(msg, file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
|
||||
escapes = []
|
||||
|
||||
def make_escapes(pass_iso8859):
|
||||
global escapes
|
||||
if pass_iso8859:
|
||||
# Allow iso-8859 characters to pass through so that e.g. 'msgid
|
||||
# "H?he"' would result not result in 'msgid "H\366he"'. Otherwise we
|
||||
# escape any character outside the 32..126 range.
|
||||
mod = 128
|
||||
else:
|
||||
mod = 256
|
||||
for i in range(256):
|
||||
if 32 <= (i % mod) <= 126:
|
||||
escapes.append(chr(i))
|
||||
else:
|
||||
escapes.append("\\%03o" % i)
|
||||
escapes[ord('\\')] = '\\\\'
|
||||
escapes[ord('\t')] = '\\t'
|
||||
escapes[ord('\r')] = '\\r'
|
||||
escapes[ord('\n')] = '\\n'
|
||||
escapes[ord('\"')] = '\\"'
|
||||
|
||||
|
||||
def escape(s):
|
||||
global escapes
|
||||
s = list(s)
|
||||
for i in range(len(s)):
|
||||
s[i] = escapes[ord(s[i])]
|
||||
return EMPTYSTRING.join(s)
|
||||
|
||||
|
||||
def safe_eval(s):
|
||||
# unwrap quotes, safely
|
||||
return eval(s, {'__builtins__':{}}, {})
|
||||
|
||||
|
||||
def normalize(s):
|
||||
# This converts the various Python string types into a format that is
|
||||
# appropriate for .po files, namely much closer to C style.
|
||||
lines = s.split('\n')
|
||||
if len(lines) == 1:
|
||||
s = '"' + escape(s) + '"'
|
||||
else:
|
||||
if not lines[-1]:
|
||||
del lines[-1]
|
||||
lines[-1] = lines[-1] + '\n'
|
||||
for i in range(len(lines)):
|
||||
lines[i] = escape(lines[i])
|
||||
lineterm = '\\n"\n"'
|
||||
s = '""\n"' + lineterm.join(lines) + '"'
|
||||
return s
|
||||
|
||||
|
||||
def containsAny(str, set):
|
||||
"""Check whether 'str' contains ANY of the chars in 'set'"""
|
||||
return 1 in [c in str for c in set]
|
||||
|
||||
|
||||
def _visit_pyfiles(list, dirname, names):
|
||||
"""Helper for getFilesForName()."""
|
||||
# get extension for python source files
|
||||
if '_py_ext' not in globals():
|
||||
global _py_ext
|
||||
_py_ext = [triple[0] for triple in imp.get_suffixes()
|
||||
if triple[2] == imp.PY_SOURCE][0]
|
||||
|
||||
# don't recurse into CVS directories
|
||||
if 'CVS' in names:
|
||||
names.remove('CVS')
|
||||
|
||||
# add all *.py files to list
|
||||
list.extend(
|
||||
[os.path.join(dirname, file) for file in names
|
||||
if os.path.splitext(file)[1] == _py_ext]
|
||||
)
|
||||
|
||||
|
||||
def _get_modpkg_path(dotted_name, pathlist=None):
|
||||
"""Get the filesystem path for a module or a package.
|
||||
|
||||
Return the file system path to a file for a module, and to a directory for
|
||||
a package. Return None if the name is not found, or is a builtin or
|
||||
extension module.
|
||||
"""
|
||||
# split off top-most name
|
||||
parts = dotted_name.split('.', 1)
|
||||
|
||||
if len(parts) > 1:
|
||||
# we have a dotted path, import top-level package
|
||||
try:
|
||||
file, pathname, description = imp.find_module(parts[0], pathlist)
|
||||
if file: file.close()
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
# check if it's indeed a package
|
||||
if description[2] == imp.PKG_DIRECTORY:
|
||||
# recursively handle the remaining name parts
|
||||
pathname = _get_modpkg_path(parts[1], [pathname])
|
||||
else:
|
||||
pathname = None
|
||||
else:
|
||||
# plain name
|
||||
try:
|
||||
file, pathname, description = imp.find_module(
|
||||
dotted_name, pathlist)
|
||||
if file:
|
||||
file.close()
|
||||
if description[2] not in [imp.PY_SOURCE, imp.PKG_DIRECTORY]:
|
||||
pathname = None
|
||||
except ImportError:
|
||||
pathname = None
|
||||
|
||||
return pathname
|
||||
|
||||
|
||||
def getFilesForName(name):
|
||||
"""Get a list of module files for a filename, a module or package name,
|
||||
or a directory.
|
||||
"""
|
||||
if not os.path.exists(name):
|
||||
# check for glob chars
|
||||
if containsAny(name, "*?[]"):
|
||||
files = glob.glob(name)
|
||||
list = []
|
||||
for file in files:
|
||||
list.extend(getFilesForName(file))
|
||||
return list
|
||||
|
||||
# try to find module or package
|
||||
name = _get_modpkg_path(name)
|
||||
if not name:
|
||||
return []
|
||||
|
||||
if os.path.isdir(name):
|
||||
# find all python files in directory
|
||||
list = []
|
||||
os.walk(name, _visit_pyfiles, list)
|
||||
return list
|
||||
elif os.path.exists(name):
|
||||
# a single file
|
||||
return [name]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
class TokenEater:
|
||||
def __init__(self, options):
|
||||
self.__options = options
|
||||
self.__messages = {}
|
||||
self.__state = self.__waiting
|
||||
self.__data = []
|
||||
self.__lineno = -1
|
||||
self.__freshmodule = 1
|
||||
self.__curfile = None
|
||||
|
||||
def __call__(self, ttype, tstring, stup, etup, line):
|
||||
# dispatch
|
||||
## import token
|
||||
## print >> sys.stderr, 'ttype:', token.tok_name[ttype], \
|
||||
## 'tstring:', tstring
|
||||
self.__state(ttype, tstring, stup[0])
|
||||
|
||||
def __waiting(self, ttype, tstring, lineno):
|
||||
opts = self.__options
|
||||
# Do docstring extractions, if enabled
|
||||
if opts.docstrings and not opts.nodocstrings.get(self.__curfile):
|
||||
# module docstring?
|
||||
if self.__freshmodule:
|
||||
if ttype == tokenize.STRING:
|
||||
self.__addentry(safe_eval(tstring), lineno, isdocstring=1)
|
||||
self.__freshmodule = 0
|
||||
elif ttype not in (tokenize.COMMENT, tokenize.NL):
|
||||
self.__freshmodule = 0
|
||||
return
|
||||
# class docstring?
|
||||
if ttype == tokenize.NAME and tstring in ('class', 'def'):
|
||||
self.__state = self.__suiteseen
|
||||
return
|
||||
if ttype == tokenize.NAME and tstring in opts.keywords:
|
||||
self.__state = self.__keywordseen
|
||||
|
||||
def __suiteseen(self, ttype, tstring, lineno):
|
||||
# ignore anything until we see the colon
|
||||
if ttype == tokenize.OP and tstring == ':':
|
||||
self.__state = self.__suitedocstring
|
||||
|
||||
def __suitedocstring(self, ttype, tstring, lineno):
|
||||
# ignore any intervening noise
|
||||
if ttype == tokenize.STRING:
|
||||
self.__addentry(safe_eval(tstring), lineno, isdocstring=1)
|
||||
self.__state = self.__waiting
|
||||
elif ttype not in (tokenize.NEWLINE, tokenize.INDENT,
|
||||
tokenize.COMMENT):
|
||||
# there was no class docstring
|
||||
self.__state = self.__waiting
|
||||
|
||||
def __keywordseen(self, ttype, tstring, lineno):
|
||||
if ttype == tokenize.OP and tstring == '(':
|
||||
self.__data = []
|
||||
self.__lineno = lineno
|
||||
self.__state = self.__openseen
|
||||
else:
|
||||
self.__state = self.__waiting
|
||||
|
||||
def __openseen(self, ttype, tstring, lineno):
|
||||
if ttype == tokenize.OP and tstring == ')':
|
||||
# We've seen the last of the translatable strings. Record the
|
||||
# line number of the first line of the strings and update the list
|
||||
# of messages seen. Reset state for the next batch. If there
|
||||
# were no strings inside _(), then just ignore this entry.
|
||||
if self.__data:
|
||||
self.__addentry(EMPTYSTRING.join(self.__data))
|
||||
self.__state = self.__waiting
|
||||
elif ttype == tokenize.STRING:
|
||||
self.__data.append(safe_eval(tstring))
|
||||
elif ttype not in [tokenize.COMMENT, token.INDENT, token.DEDENT,
|
||||
token.NEWLINE, tokenize.NL]:
|
||||
# warn if we see anything else than STRING or whitespace
|
||||
print('*** %(file)s:%(lineno)s: Seen unexpected token "%(token)s"' % {
|
||||
'token': tstring,
|
||||
'file': self.__curfile,
|
||||
'lineno': self.__lineno
|
||||
}, file=sys.stderr)
|
||||
self.__state = self.__waiting
|
||||
|
||||
def __addentry(self, msg, lineno=None, isdocstring=0):
|
||||
if lineno is None:
|
||||
lineno = self.__lineno
|
||||
if not msg in self.__options.toexclude:
|
||||
entry = (self.__curfile, lineno)
|
||||
self.__messages.setdefault(msg, {})[entry] = isdocstring
|
||||
|
||||
def set_filename(self, filename):
|
||||
self.__curfile = filename
|
||||
self.__freshmodule = 1
|
||||
|
||||
def write(self, fp):
|
||||
options = self.__options
|
||||
timestamp = time.strftime('%Y-%m-%d %H:%M+%Z')
|
||||
# The time stamp in the header doesn't have the same format as that
|
||||
# generated by xgettext...
|
||||
print(pot_header, file=fp)
|
||||
# Sort the entries. First sort each particular entry's keys, then
|
||||
# sort all the entries by their first item.
|
||||
reverse = {}
|
||||
for k, v in self.__messages.items():
|
||||
keys = sorted(v.keys())
|
||||
reverse.setdefault(tuple(keys), []).append((k, v))
|
||||
rkeys = sorted(reverse.keys())
|
||||
for rkey in rkeys:
|
||||
rentries = reverse[rkey]
|
||||
rentries.sort()
|
||||
for k, v in rentries:
|
||||
# If the entry was gleaned out of a docstring, then add a
|
||||
# comment stating so. This is to aid translators who may wish
|
||||
# to skip translating some unimportant docstrings.
|
||||
isdocstring = any(v.values())
|
||||
# k is the message string, v is a dictionary-set of (filename,
|
||||
# lineno) tuples. We want to sort the entries in v first by
|
||||
# file name and then by line number.
|
||||
v = sorted(v.keys())
|
||||
if not options.writelocations:
|
||||
pass
|
||||
# location comments are different b/w Solaris and GNU:
|
||||
elif options.locationstyle == options.SOLARIS:
|
||||
for filename, lineno in v:
|
||||
d = {'filename': filename, 'lineno': lineno}
|
||||
print('# File: %(filename)s, line: %(lineno)d' % d, file=fp)
|
||||
elif options.locationstyle == options.GNU:
|
||||
# fit as many locations on one line, as long as the
|
||||
# resulting line length doesn't exceeds 'options.width'
|
||||
locline = '#:'
|
||||
for filename, lineno in v:
|
||||
d = {'filename': filename, 'lineno': lineno}
|
||||
s = ' %(filename)s:%(lineno)d' % d
|
||||
if len(locline) + len(s) <= options.width:
|
||||
locline = locline + s
|
||||
else:
|
||||
print(locline, file=fp)
|
||||
locline = "#:" + s
|
||||
if len(locline) > 2:
|
||||
print(locline, file=fp)
|
||||
if isdocstring:
|
||||
print('#, docstring', file=fp)
|
||||
print('msgid', normalize(k), file=fp)
|
||||
print('msgstr ""\n', file=fp)
|
||||
|
||||
|
||||
|
||||
def main(source_files, outpath, keywords=None):
|
||||
global default_keywords
|
||||
# for holding option values
|
||||
class Options:
|
||||
# constants
|
||||
GNU = 1
|
||||
SOLARIS = 2
|
||||
# defaults
|
||||
extractall = 0 # FIXME: currently this option has no effect at all.
|
||||
escape = 0
|
||||
keywords = []
|
||||
outfile = 'messages.pot'
|
||||
writelocations = 1
|
||||
locationstyle = GNU
|
||||
verbose = 0
|
||||
width = 78
|
||||
excludefilename = ''
|
||||
docstrings = 0
|
||||
nodocstrings = {}
|
||||
|
||||
options = Options()
|
||||
locations = {'gnu' : options.GNU,
|
||||
'solaris' : options.SOLARIS,
|
||||
}
|
||||
|
||||
options.outfile = outpath
|
||||
if keywords:
|
||||
options.keywords = keywords
|
||||
|
||||
# calculate escapes
|
||||
make_escapes(options.escape)
|
||||
|
||||
# calculate all keywords
|
||||
options.keywords.extend(default_keywords)
|
||||
|
||||
# initialize list of strings to exclude
|
||||
if options.excludefilename:
|
||||
try:
|
||||
fp = open(options.excludefilename, encoding='utf-8')
|
||||
options.toexclude = fp.readlines()
|
||||
fp.close()
|
||||
except IOError:
|
||||
print("Can't read --exclude-file: %s" % options.excludefilename, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
options.toexclude = []
|
||||
|
||||
# slurp through all the files
|
||||
eater = TokenEater(options)
|
||||
for filename in source_files:
|
||||
if options.verbose:
|
||||
print('Working on %s' % filename)
|
||||
fp = open(filename, encoding='utf-8')
|
||||
closep = 1
|
||||
try:
|
||||
eater.set_filename(filename)
|
||||
try:
|
||||
tokens = tokenize.generate_tokens(fp.readline)
|
||||
for _token in tokens:
|
||||
eater(*_token)
|
||||
except tokenize.TokenError as e:
|
||||
print('%s: %s, line %d, column %d' % (
|
||||
e.args[0], filename, e.args[1][0], e.args[1][1]),
|
||||
file=sys.stderr)
|
||||
finally:
|
||||
if closep:
|
||||
fp.close()
|
||||
|
||||
fp = open(options.outfile, 'w', encoding='utf-8')
|
||||
closep = 1
|
||||
try:
|
||||
eater.write(fp)
|
||||
finally:
|
||||
if closep:
|
||||
fp.close()
|
||||
@@ -1,73 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2011-01-12
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import os.path as op
|
||||
import re
|
||||
|
||||
from pkg_resources import load_entry_point
|
||||
|
||||
from .build import read_changelog_file, filereplace
|
||||
|
||||
CHANGELOG_FORMAT = """
|
||||
{version} ({date})
|
||||
----------------------
|
||||
|
||||
{description}
|
||||
"""
|
||||
|
||||
def tixgen(tixurl):
|
||||
"""This is a filter *generator*. tixurl is a url pattern for the tix with a {0} placeholder
|
||||
for the tix #
|
||||
"""
|
||||
urlpattern = tixurl.format('\\1') # will be replaced buy the content of the first group in re
|
||||
R = re.compile(r'#(\d+)')
|
||||
repl = '`#\\1 <{}>`__'.format(urlpattern)
|
||||
return lambda text: R.sub(repl, text)
|
||||
|
||||
def gen(basepath, destpath, changelogpath, tixurl, confrepl=None, confpath=None, changelogtmpl=None):
|
||||
"""Generate sphinx docs with all bells and whistles.
|
||||
|
||||
basepath: The base sphinx source path.
|
||||
destpath: The final path of html files
|
||||
changelogpath: The path to the changelog file to insert in changelog.rst.
|
||||
tixurl: The URL (with one formattable argument for the tix number) to the ticket system.
|
||||
confrepl: Dictionary containing replacements that have to be made in conf.py. {name: replacement}
|
||||
"""
|
||||
if confrepl is None:
|
||||
confrepl = {}
|
||||
if confpath is None:
|
||||
confpath = op.join(basepath, 'conf.tmpl')
|
||||
if changelogtmpl is None:
|
||||
changelogtmpl = op.join(basepath, 'changelog.tmpl')
|
||||
changelog = read_changelog_file(changelogpath)
|
||||
tix = tixgen(tixurl)
|
||||
rendered_logs = []
|
||||
for log in changelog:
|
||||
description = tix(log['description'])
|
||||
# The format of the changelog descriptions is in markdown, but since we only use bulled list
|
||||
# and links, it's not worth depending on the markdown package. A simple regexp suffice.
|
||||
description = re.sub(r'\[(.*?)\]\((.*?)\)', '`\\1 <\\2>`__', description)
|
||||
rendered = CHANGELOG_FORMAT.format(version=log['version'], date=log['date_str'],
|
||||
description=description)
|
||||
rendered_logs.append(rendered)
|
||||
confrepl['version'] = changelog[0]['version']
|
||||
changelog_out = op.join(basepath, 'changelog.rst')
|
||||
filereplace(changelogtmpl, changelog_out, changelog='\n'.join(rendered_logs))
|
||||
conf_out = op.join(basepath, 'conf.py')
|
||||
filereplace(confpath, conf_out, **confrepl)
|
||||
# We used to call sphinx-build with print_and_do(), but the problem was that the virtualenv
|
||||
# of the calling python wasn't correctly considered and caused problems with documentation
|
||||
# relying on autodoc (which tries to import the module to auto-document, but fail because of
|
||||
# missing dependencies which are in the virtualenv). Here, we do exactly what is done when
|
||||
# calling the command from bash.
|
||||
cmd = load_entry_point('Sphinx', 'console_scripts', 'sphinx-build')
|
||||
try:
|
||||
cmd(['sphinx-build', basepath, destpath])
|
||||
except SystemExit:
|
||||
print("Sphinx called sys.exit(), but we're cancelling it because we don't actually want to exit")
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2007/05/19
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import sys
|
||||
import os
|
||||
import os.path as op
|
||||
import threading
|
||||
from queue import Queue
|
||||
import time
|
||||
import sqlite3 as sqlite
|
||||
|
||||
STOP = object()
|
||||
COMMIT = object()
|
||||
ROLLBACK = object()
|
||||
|
||||
class FakeCursor(list):
|
||||
# It's not possible to use sqlite cursors on another thread than the connection. Thus,
|
||||
# we can't directly return the cursor. We have to fatch all results, and support its interface.
|
||||
def fetchall(self):
|
||||
return self
|
||||
|
||||
def fetchone(self):
|
||||
try:
|
||||
return self.pop(0)
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
|
||||
class _ActualThread(threading.Thread):
|
||||
''' We can't use this class directly because thread object are not automatically freed when
|
||||
nothing refers to it, making it hang the application if not explicitely closed.
|
||||
'''
|
||||
def __init__(self, dbname, autocommit):
|
||||
threading.Thread.__init__(self)
|
||||
self._queries = Queue()
|
||||
self._results = Queue()
|
||||
self._dbname = dbname
|
||||
self._autocommit = autocommit
|
||||
self._waiting_list = set()
|
||||
self._lock = threading.Lock()
|
||||
self._run = True
|
||||
self.lastrowid = -1
|
||||
self.setDaemon(True)
|
||||
self.start()
|
||||
|
||||
def _query(self, query):
|
||||
with self._lock:
|
||||
wait_token = object()
|
||||
self._waiting_list.add(wait_token)
|
||||
self._queries.put(query)
|
||||
self._waiting_list.remove(wait_token)
|
||||
result = self._results.get()
|
||||
return result
|
||||
|
||||
def close(self):
|
||||
if not self._run:
|
||||
return
|
||||
self._query(STOP)
|
||||
|
||||
def commit(self):
|
||||
if not self._run:
|
||||
return None # Connection closed
|
||||
self._query(COMMIT)
|
||||
|
||||
def execute(self, sql, values=()):
|
||||
if not self._run:
|
||||
return None # Connection closed
|
||||
result = self._query((sql, values))
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
def rollback(self):
|
||||
if not self._run:
|
||||
return None # Connection closed
|
||||
self._query(ROLLBACK)
|
||||
|
||||
def run(self):
|
||||
# The whole chdir thing is because sqlite doesn't handle directory names with non-asci char in the AT ALL.
|
||||
oldpath = os.getcwd()
|
||||
dbdir, dbname = op.split(self._dbname)
|
||||
if dbdir:
|
||||
os.chdir(dbdir)
|
||||
if self._autocommit:
|
||||
con = sqlite.connect(dbname, isolation_level=None)
|
||||
else:
|
||||
con = sqlite.connect(dbname)
|
||||
os.chdir(oldpath)
|
||||
while self._run or self._waiting_list:
|
||||
query = self._queries.get()
|
||||
result = None
|
||||
if query is STOP:
|
||||
self._run = False
|
||||
elif query is COMMIT:
|
||||
con.commit()
|
||||
elif query is ROLLBACK:
|
||||
con.rollback()
|
||||
else:
|
||||
sql, values = query
|
||||
try:
|
||||
cur = con.execute(sql, values)
|
||||
self.lastrowid = cur.lastrowid
|
||||
result = FakeCursor(cur.fetchall())
|
||||
result.lastrowid = cur.lastrowid
|
||||
except Exception as e:
|
||||
result = e
|
||||
self._results.put(result)
|
||||
con.close()
|
||||
|
||||
|
||||
class ThreadedConn:
|
||||
"""``sqlite`` connections can't be used across threads. ``TheadedConn`` opens a sqlite
|
||||
connection in its own thread and sends it queries through a queue, making it suitable in
|
||||
multi-threaded environment.
|
||||
"""
|
||||
def __init__(self, dbname, autocommit):
|
||||
self._t = _ActualThread(dbname, autocommit)
|
||||
self.lastrowid = -1
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
self._t.close()
|
||||
|
||||
def commit(self):
|
||||
self._t.commit()
|
||||
|
||||
def execute(self, sql, values=()):
|
||||
result = self._t.execute(sql, values)
|
||||
self.lastrowid = self._t.lastrowid
|
||||
return result
|
||||
|
||||
def rollback(self):
|
||||
self._t.rollback()
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2008-01-08
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from ..conflict import *
|
||||
from ..path import Path
|
||||
from ..testutil import eq_
|
||||
|
||||
class TestCase_GetConflictedName:
|
||||
def test_simple(self):
|
||||
name = get_conflicted_name(['bar'], 'bar')
|
||||
eq_('[000] bar', name)
|
||||
name = get_conflicted_name(['bar', '[000] bar'], 'bar')
|
||||
eq_('[001] bar', name)
|
||||
|
||||
def test_no_conflict(self):
|
||||
name = get_conflicted_name(['bar'], 'foobar')
|
||||
eq_('foobar', name)
|
||||
|
||||
def test_fourth_digit(self):
|
||||
# This test is long because every time we have to add a conflicted name,
|
||||
# a test must be made for every other conflicted name existing...
|
||||
# Anyway, this has very few chances to happen.
|
||||
names = ['bar'] + ['[%03d] bar' % i for i in range(1000)]
|
||||
name = get_conflicted_name(names, 'bar')
|
||||
eq_('[1000] bar', name)
|
||||
|
||||
def test_auto_unconflict(self):
|
||||
# Automatically unconflict the name if it's already conflicted.
|
||||
name = get_conflicted_name([], '[000] foobar')
|
||||
eq_('foobar', name)
|
||||
name = get_conflicted_name(['bar'], '[001] bar')
|
||||
eq_('[000] bar', name)
|
||||
|
||||
|
||||
class TestCase_GetUnconflictedName:
|
||||
def test_main(self):
|
||||
eq_('foobar',get_unconflicted_name('[000] foobar'))
|
||||
eq_('foobar',get_unconflicted_name('[9999] foobar'))
|
||||
eq_('[000]foobar',get_unconflicted_name('[000]foobar'))
|
||||
eq_('[000a] foobar',get_unconflicted_name('[000a] foobar'))
|
||||
eq_('foobar',get_unconflicted_name('foobar'))
|
||||
eq_('foo [000] bar',get_unconflicted_name('foo [000] bar'))
|
||||
|
||||
|
||||
class TestCase_IsConflicted:
|
||||
def test_main(self):
|
||||
assert is_conflicted('[000] foobar')
|
||||
assert is_conflicted('[9999] foobar')
|
||||
assert not is_conflicted('[000]foobar')
|
||||
assert not is_conflicted('[000a] foobar')
|
||||
assert not is_conflicted('foobar')
|
||||
assert not is_conflicted('foo [000] bar')
|
||||
|
||||
|
||||
class TestCase_move_copy:
|
||||
def pytest_funcarg__do_setup(self, request):
|
||||
tmpdir = request.getfuncargvalue('tmpdir')
|
||||
self.path = Path(str(tmpdir))
|
||||
self.path['foo'].open('w').close()
|
||||
self.path['bar'].open('w').close()
|
||||
self.path['dir'].mkdir()
|
||||
|
||||
def test_move_no_conflict(self, do_setup):
|
||||
smart_move(self.path + 'foo', self.path + 'baz')
|
||||
assert self.path['baz'].exists()
|
||||
assert not self.path['foo'].exists()
|
||||
|
||||
def test_copy_no_conflict(self, do_setup): # No need to duplicate the rest of the tests... Let's just test on move
|
||||
smart_copy(self.path + 'foo', self.path + 'baz')
|
||||
assert self.path['baz'].exists()
|
||||
assert self.path['foo'].exists()
|
||||
|
||||
def test_move_no_conflict_dest_is_dir(self, do_setup):
|
||||
smart_move(self.path + 'foo', self.path + 'dir')
|
||||
assert self.path['dir']['foo'].exists()
|
||||
assert not self.path['foo'].exists()
|
||||
|
||||
def test_move_conflict(self, do_setup):
|
||||
smart_move(self.path + 'foo', self.path + 'bar')
|
||||
assert self.path['[000] bar'].exists()
|
||||
assert not self.path['foo'].exists()
|
||||
|
||||
def test_move_conflict_dest_is_dir(self, do_setup):
|
||||
smart_move(self.path['foo'], self.path['dir'])
|
||||
smart_move(self.path['bar'], self.path['foo'])
|
||||
smart_move(self.path['foo'], self.path['dir'])
|
||||
assert self.path['dir']['foo'].exists()
|
||||
assert self.path['dir']['[000] foo'].exists()
|
||||
assert not self.path['foo'].exists()
|
||||
assert not self.path['bar'].exists()
|
||||
|
||||
def test_copy_folder(self, tmpdir):
|
||||
# smart_copy also works on folders
|
||||
path = Path(str(tmpdir))
|
||||
path['foo'].mkdir()
|
||||
path['bar'].mkdir()
|
||||
smart_copy(path['foo'], path['bar']) # no crash
|
||||
assert path['[000] bar'].exists()
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2008-04-20
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from datetime import date
|
||||
import sqlite3 as sqlite
|
||||
|
||||
from ..testutil import eq_, assert_almost_equal
|
||||
from ..currency import Currency, RatesDB, CAD, EUR, USD
|
||||
|
||||
PLN = Currency(code='PLN')
|
||||
|
||||
def setup_module(module):
|
||||
global FOO
|
||||
global BAR
|
||||
FOO = Currency.register('FOO', 'Currency with start date', start_date=date(2009, 1, 12), start_rate=2)
|
||||
BAR = Currency.register('BAR', 'Currency with stop date', stop_date=date(2010, 1, 12), latest_rate=2)
|
||||
|
||||
def teardown_module(module):
|
||||
# We must unset our test currencies or else we might mess up with other tests.
|
||||
from .. import currency
|
||||
import imp
|
||||
imp.reload(currency)
|
||||
|
||||
def teardown_function(function):
|
||||
Currency.set_rates_db(None)
|
||||
|
||||
def test_currency_creation():
|
||||
# Different ways to create a currency.
|
||||
eq_(Currency('CAD'), CAD)
|
||||
eq_(Currency(name='Canadian dollar'), CAD)
|
||||
|
||||
def test_currency_copy():
|
||||
# Currencies can be copied.
|
||||
import copy
|
||||
eq_(copy.copy(CAD), CAD)
|
||||
eq_(copy.deepcopy(CAD), CAD)
|
||||
|
||||
def test_get_rate_on_empty_db():
|
||||
# When there is no data available, use the start_rate.
|
||||
eq_(CAD.value_in(USD, date(2008, 4, 20)), 1 / USD.latest_rate)
|
||||
|
||||
def test_physical_rates_db_remember_rates(tmpdir):
|
||||
# When a rates db uses a real file, rates are remembered
|
||||
dbpath = str(tmpdir.join('foo.db'))
|
||||
db = RatesDB(dbpath)
|
||||
db.set_CAD_value(date(2008, 4, 20), 'USD', 1/0.996115)
|
||||
db = RatesDB(dbpath)
|
||||
assert_almost_equal(db.get_rate(date(2008, 4, 20), 'CAD', 'USD'), 0.996115)
|
||||
|
||||
def test_db_with_connection():
|
||||
# the supplied connection is used by the rates db.
|
||||
con = sqlite.connect(':memory:')
|
||||
db = RatesDB(con)
|
||||
try:
|
||||
con.execute("select * from rates where 1=2")
|
||||
except sqlite.OperationalError: # new db
|
||||
raise AssertionError()
|
||||
|
||||
def test_corrupt_db(tmpdir):
|
||||
dbpath = str(tmpdir.join('foo.db'))
|
||||
fh = open(dbpath, 'w')
|
||||
fh.write('corrupted')
|
||||
fh.close()
|
||||
db = RatesDB(dbpath) # no crash. deletes the old file and start a new db
|
||||
db.set_CAD_value(date(2008, 4, 20), 'USD', 42)
|
||||
db = RatesDB(dbpath)
|
||||
eq_(db.get_rate(date(2008, 4, 20), 'USD', 'CAD'), 42)
|
||||
|
||||
#--- Daily rate
|
||||
def setup_daily_rate():
|
||||
USD.set_CAD_value(1/0.996115, date(2008, 4, 20))
|
||||
|
||||
def test_get_rate_with_daily_rate():
|
||||
# Getting the rate exactly as set_rate happened returns the same rate.
|
||||
setup_daily_rate()
|
||||
assert_almost_equal(CAD.value_in(USD, date(2008, 4, 20)), 0.996115)
|
||||
|
||||
def test_get_rate_different_currency():
|
||||
# Use fallback rates when necessary.
|
||||
setup_daily_rate()
|
||||
eq_(CAD.value_in(EUR, date(2008, 4, 20)), 1 / EUR.latest_rate)
|
||||
eq_(EUR.value_in(USD, date(2008, 4, 20)), EUR.latest_rate * 0.996115)
|
||||
|
||||
def test_get_rate_reverse():
|
||||
# It's possible to get the reverse value of a rate using the same data.
|
||||
setup_daily_rate()
|
||||
assert_almost_equal(USD.value_in(CAD, date(2008, 4, 20)), 1 / 0.996115)
|
||||
|
||||
def test_set_rate_twice():
|
||||
# When setting a rate for an index that already exists, the old rate is replaced by the new.
|
||||
setup_daily_rate()
|
||||
USD.set_CAD_value(1/42, date(2008, 4, 20))
|
||||
assert_almost_equal(CAD.value_in(USD, date(2008, 4, 20)), 42)
|
||||
|
||||
def test_set_rate_after_get():
|
||||
# When setting a rate after a get of the same rate, the rate cache is correctly updated.
|
||||
setup_daily_rate()
|
||||
CAD.value_in(USD, date(2008, 4, 20)) # value will be cached
|
||||
USD.set_CAD_value(1/42, date(2008, 4, 20))
|
||||
assert_almost_equal(CAD.value_in(USD, date(2008, 4, 20)), 42)
|
||||
|
||||
def test_set_rate_after_get_the_day_after():
|
||||
# When setting a rate, the cache for the whole currency is reset, or else we get old fallback
|
||||
# values for dates where the currency server returned no value.
|
||||
setup_daily_rate()
|
||||
CAD.value_in(USD, date(2008, 4, 21)) # value will be cached
|
||||
USD.set_CAD_value(1/42, date(2008, 4, 20))
|
||||
assert_almost_equal(CAD.value_in(USD, date(2008, 4, 21)), 42)
|
||||
|
||||
#--- Two daily rates
|
||||
def setup_two_daily_rate():
|
||||
# Don't change the set order, it's important for the tests
|
||||
USD.set_CAD_value(1/0.997115, date(2008, 4, 25))
|
||||
USD.set_CAD_value(1/0.996115, date(2008, 4, 20))
|
||||
|
||||
def test_date_range_range():
|
||||
# USD.rates_date_range() returns the USD's limits.
|
||||
setup_two_daily_rate()
|
||||
eq_(USD.rates_date_range(), (date(2008, 4, 20), date(2008, 4, 25)))
|
||||
|
||||
def test_date_range_for_unfetched_currency():
|
||||
# If the curency is not in the DB, return None.
|
||||
setup_two_daily_rate()
|
||||
assert PLN.rates_date_range() is None
|
||||
|
||||
def test_seek_rate_middle():
|
||||
# A rate request with seek in the middle will return the lowest date.
|
||||
setup_two_daily_rate()
|
||||
eq_(USD.value_in(CAD, date(2008, 4, 24)), 1/0.996115)
|
||||
|
||||
def test_seek_rate_after():
|
||||
# Make sure that the *nearest* lowest rate is returned. Because the 25th have been set
|
||||
# before the 20th, an order by clause is required in the seek SQL to make this test pass.
|
||||
setup_two_daily_rate()
|
||||
eq_(USD.value_in(CAD, date(2008, 4, 26)), 1/0.997115)
|
||||
|
||||
def test_seek_rate_before():
|
||||
# If there are no rate in the past, seek for a rate in the future.
|
||||
setup_two_daily_rate()
|
||||
eq_(USD.value_in(CAD, date(2008, 4, 19)), 1/0.996115)
|
||||
|
||||
#--- Rates of multiple currencies
|
||||
def setup_rates_of_multiple_currencies():
|
||||
USD.set_CAD_value(1/0.996115, date(2008, 4, 20))
|
||||
EUR.set_CAD_value(1/0.633141, date(2008, 4, 20))
|
||||
|
||||
def test_get_rate_multiple_currencies():
|
||||
# Don't mix currency rates up.
|
||||
setup_rates_of_multiple_currencies()
|
||||
assert_almost_equal(CAD.value_in(USD, date(2008, 4, 20)), 0.996115)
|
||||
assert_almost_equal(CAD.value_in(EUR, date(2008, 4, 20)), 0.633141)
|
||||
|
||||
def test_get_rate_with_pivotal():
|
||||
# It's possible to get a rate by using 2 records.
|
||||
# if 1 CAD = 0.996115 USD and 1 CAD = 0.633141 then 0.996115 USD = 0.633141 then 1 USD = 0.633141 / 0.996115 EUR
|
||||
setup_rates_of_multiple_currencies()
|
||||
assert_almost_equal(USD.value_in(EUR, date(2008, 4, 20)), 0.633141 / 0.996115)
|
||||
|
||||
def test_get_rate_doesnt_exist():
|
||||
# Don't crash when trying to do pivotal calculation with non-existing currencies.
|
||||
setup_rates_of_multiple_currencies()
|
||||
eq_(USD.value_in(PLN, date(2008, 4, 20)), 1 / 0.996115 / PLN.latest_rate)
|
||||
|
||||
#--- Problems after connection
|
||||
def get_problematic_db():
|
||||
class MockConnection(sqlite.Connection): # can't mock sqlite3.Connection's attribute, so we subclass it
|
||||
mocking = False
|
||||
def execute(self, *args, **kwargs):
|
||||
if self.mocking:
|
||||
raise sqlite.OperationalError()
|
||||
else:
|
||||
return sqlite.Connection.execute(self, *args, **kwargs)
|
||||
|
||||
con = MockConnection(':memory:')
|
||||
db = RatesDB(con)
|
||||
con.mocking = True
|
||||
return db
|
||||
|
||||
def test_date_range_with_problematic_db():
|
||||
db = get_problematic_db()
|
||||
db.date_range('USD') # no crash
|
||||
|
||||
def test_get_rate_with_problematic_db():
|
||||
db = get_problematic_db()
|
||||
db.get_rate(date(2008, 4, 20), 'USD', 'CAD') # no crash
|
||||
|
||||
def test_set_rate_with_problematic_db():
|
||||
db = get_problematic_db()
|
||||
db.set_CAD_value(date(2008, 4, 20), 'USD', 42) # no crash
|
||||
|
||||
#--- DB that doesn't allow get_rate calls
|
||||
def setup_db_raising_error_on_getrate():
|
||||
db = RatesDB()
|
||||
def mock_get_rate(*args, **kwargs):
|
||||
raise AssertionError()
|
||||
db.get_rate = mock_get_rate
|
||||
Currency.set_rates_db(db)
|
||||
|
||||
def test_currency_with_start_date():
|
||||
setup_db_raising_error_on_getrate()
|
||||
eq_(FOO.value_in(CAD, date(2009, 1, 11)), 2)
|
||||
|
||||
def test_currency_with_stop_date():
|
||||
setup_db_raising_error_on_getrate()
|
||||
eq_(BAR.value_in(CAD, date(2010, 1, 13)), 2)
|
||||
@@ -1,140 +0,0 @@
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from ..testutil import eq_
|
||||
from ..notify import Broadcaster, Listener, Repeater
|
||||
|
||||
class HelloListener(Listener):
|
||||
def __init__(self, broadcaster):
|
||||
Listener.__init__(self, broadcaster)
|
||||
self.hello_count = 0
|
||||
|
||||
def hello(self):
|
||||
self.hello_count += 1
|
||||
|
||||
class HelloRepeater(Repeater):
|
||||
def __init__(self, broadcaster):
|
||||
Repeater.__init__(self, broadcaster)
|
||||
self.hello_count = 0
|
||||
|
||||
def hello(self):
|
||||
self.hello_count += 1
|
||||
|
||||
def create_pair():
|
||||
b = Broadcaster()
|
||||
l = HelloListener(b)
|
||||
return b, l
|
||||
|
||||
def test_disconnect_during_notification():
|
||||
# When a listener disconnects another listener the other listener will not receive a
|
||||
# notification.
|
||||
# This whole complication scheme below is because the order of the notification is not
|
||||
# guaranteed. We could disconnect everything from self.broadcaster.listeners, but this
|
||||
# member is supposed to be private. Hence, the '.other' scheme
|
||||
class Disconnecter(Listener):
|
||||
def __init__(self, broadcaster):
|
||||
Listener.__init__(self, broadcaster)
|
||||
self.hello_count = 0
|
||||
|
||||
def hello(self):
|
||||
self.hello_count += 1
|
||||
self.other.disconnect()
|
||||
|
||||
broadcaster = Broadcaster()
|
||||
first = Disconnecter(broadcaster)
|
||||
second = Disconnecter(broadcaster)
|
||||
first.other, second.other = second, first
|
||||
first.connect()
|
||||
second.connect()
|
||||
broadcaster.notify('hello')
|
||||
# only one of them was notified
|
||||
eq_(first.hello_count + second.hello_count, 1)
|
||||
|
||||
def test_disconnect():
|
||||
# After a disconnect, the listener doesn't hear anything.
|
||||
b, l = create_pair()
|
||||
l.connect()
|
||||
l.disconnect()
|
||||
b.notify('hello')
|
||||
eq_(l.hello_count, 0)
|
||||
|
||||
def test_disconnect_when_not_connected():
|
||||
# When disconnecting an already disconnected listener, nothing happens.
|
||||
b, l = create_pair()
|
||||
l.disconnect()
|
||||
|
||||
def test_not_connected_on_init():
|
||||
# A listener is not initialized connected.
|
||||
b, l = create_pair()
|
||||
b.notify('hello')
|
||||
eq_(l.hello_count, 0)
|
||||
|
||||
def test_notify():
|
||||
# The listener listens to the broadcaster.
|
||||
b, l = create_pair()
|
||||
l.connect()
|
||||
b.notify('hello')
|
||||
eq_(l.hello_count, 1)
|
||||
|
||||
def test_reconnect():
|
||||
# It's possible to reconnect a listener after disconnection.
|
||||
b, l = create_pair()
|
||||
l.connect()
|
||||
l.disconnect()
|
||||
l.connect()
|
||||
b.notify('hello')
|
||||
eq_(l.hello_count, 1)
|
||||
|
||||
def test_repeater():
|
||||
b = Broadcaster()
|
||||
r = HelloRepeater(b)
|
||||
l = HelloListener(r)
|
||||
r.connect()
|
||||
l.connect()
|
||||
b.notify('hello')
|
||||
eq_(r.hello_count, 1)
|
||||
eq_(l.hello_count, 1)
|
||||
|
||||
def test_repeater_with_repeated_notifications():
|
||||
# If REPEATED_NOTIFICATIONS is not empty, only notifs in this set are repeated (but they're
|
||||
# still dispatched locally).
|
||||
class MyRepeater(HelloRepeater):
|
||||
REPEATED_NOTIFICATIONS = set(['hello'])
|
||||
def __init__(self, broadcaster):
|
||||
HelloRepeater.__init__(self, broadcaster)
|
||||
self.foo_count = 0
|
||||
def foo(self):
|
||||
self.foo_count += 1
|
||||
|
||||
b = Broadcaster()
|
||||
r = MyRepeater(b)
|
||||
l = HelloListener(r)
|
||||
r.connect()
|
||||
l.connect()
|
||||
b.notify('hello')
|
||||
b.notify('foo') # if the repeater repeated this notif, we'd get a crash on HelloListener
|
||||
eq_(r.hello_count, 1)
|
||||
eq_(l.hello_count, 1)
|
||||
eq_(r.foo_count, 1)
|
||||
|
||||
def test_repeater_doesnt_try_to_dispatch_to_self_if_it_cant():
|
||||
# if a repeater doesn't handle a particular message, it doesn't crash and simply repeats it.
|
||||
b = Broadcaster()
|
||||
r = Repeater(b) # doesnt handle hello
|
||||
l = HelloListener(r)
|
||||
r.connect()
|
||||
l.connect()
|
||||
b.notify('hello') # no crash
|
||||
eq_(l.hello_count, 1)
|
||||
|
||||
def test_bind_messages():
|
||||
b, l = create_pair()
|
||||
l.bind_messages({'foo', 'bar'}, l.hello)
|
||||
l.connect()
|
||||
b.notify('foo')
|
||||
b.notify('bar')
|
||||
b.notify('hello') # Normal dispatching still work
|
||||
eq_(l.hello_count, 3)
|
||||
@@ -1,256 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2006/02/21
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
from pytest import raises, mark
|
||||
|
||||
from ..path import Path, pathify
|
||||
from ..testutil import eq_
|
||||
|
||||
def pytest_funcarg__force_ossep(request):
|
||||
monkeypatch = request.getfuncargvalue('monkeypatch')
|
||||
monkeypatch.setattr(os, 'sep', '/')
|
||||
|
||||
def test_empty(force_ossep):
|
||||
path = Path('')
|
||||
eq_('',str(path))
|
||||
eq_(0,len(path))
|
||||
path = Path(())
|
||||
eq_('',str(path))
|
||||
eq_(0,len(path))
|
||||
|
||||
def test_single(force_ossep):
|
||||
path = Path('foobar')
|
||||
eq_('foobar',path)
|
||||
eq_(1,len(path))
|
||||
|
||||
def test_multiple(force_ossep):
|
||||
path = Path('foo/bar')
|
||||
eq_('foo/bar',path)
|
||||
eq_(2,len(path))
|
||||
|
||||
def test_init_with_tuple_and_list(force_ossep):
|
||||
path = Path(('foo','bar'))
|
||||
eq_('foo/bar',path)
|
||||
path = Path(['foo','bar'])
|
||||
eq_('foo/bar',path)
|
||||
|
||||
def test_init_with_invalid_value(force_ossep):
|
||||
try:
|
||||
path = Path(42)
|
||||
assert False
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
def test_access(force_ossep):
|
||||
path = Path('foo/bar/bleh')
|
||||
eq_('foo',path[0])
|
||||
eq_('foo',path[-3])
|
||||
eq_('bar',path[1])
|
||||
eq_('bar',path[-2])
|
||||
eq_('bleh',path[2])
|
||||
eq_('bleh',path[-1])
|
||||
|
||||
def test_slicing(force_ossep):
|
||||
path = Path('foo/bar/bleh')
|
||||
subpath = path[:2]
|
||||
eq_('foo/bar',subpath)
|
||||
assert isinstance(subpath,Path)
|
||||
|
||||
def test_parent(force_ossep):
|
||||
path = Path('foo/bar/bleh')
|
||||
subpath = path.parent()
|
||||
eq_('foo/bar', subpath)
|
||||
assert isinstance(subpath, Path)
|
||||
|
||||
def test_filename(force_ossep):
|
||||
path = Path('foo/bar/bleh.ext')
|
||||
eq_(path.name, 'bleh.ext')
|
||||
|
||||
def test_deal_with_empty_components(force_ossep):
|
||||
"""Keep ONLY a leading space, which means we want a leading slash.
|
||||
"""
|
||||
eq_('foo//bar',str(Path(('foo','','bar'))))
|
||||
eq_('/foo/bar',str(Path(('','foo','bar'))))
|
||||
eq_('foo/bar',str(Path('foo/bar/')))
|
||||
|
||||
def test_old_compare_paths(force_ossep):
|
||||
eq_(Path('foobar'),Path('foobar'))
|
||||
eq_(Path('foobar/'),Path('foobar\\','\\'))
|
||||
eq_(Path('/foobar/'),Path('\\foobar\\','\\'))
|
||||
eq_(Path('/foo/bar'),Path('\\foo\\bar','\\'))
|
||||
eq_(Path('/foo/bar'),Path('\\foo\\bar\\','\\'))
|
||||
assert Path('/foo/bar') != Path('\\foo\\foo','\\')
|
||||
#We also have to test __ne__
|
||||
assert not (Path('foobar') != Path('foobar'))
|
||||
assert Path('/a/b/c.x') != Path('/a/b/c.y')
|
||||
|
||||
def test_old_split_path(force_ossep):
|
||||
eq_(Path('foobar'),('foobar',))
|
||||
eq_(Path('foo/bar'),('foo','bar'))
|
||||
eq_(Path('/foo/bar/'),('','foo','bar'))
|
||||
eq_(Path('\\foo\\bar','\\'),('','foo','bar'))
|
||||
|
||||
def test_representation(force_ossep):
|
||||
eq_("('foo', 'bar')",repr(Path(('foo','bar'))))
|
||||
|
||||
def test_add(force_ossep):
|
||||
eq_('foo/bar/bar/foo',Path(('foo','bar')) + Path('bar/foo'))
|
||||
eq_('foo/bar/bar/foo',Path('foo/bar') + 'bar/foo')
|
||||
eq_('foo/bar/bar/foo',Path('foo/bar') + ('bar','foo'))
|
||||
eq_('foo/bar/bar/foo',('foo','bar') + Path('bar/foo'))
|
||||
eq_('foo/bar/bar/foo','foo/bar' + Path('bar/foo'))
|
||||
#Invalid concatenation
|
||||
try:
|
||||
Path(('foo','bar')) + 1
|
||||
assert False
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
def test_path_slice(force_ossep):
|
||||
foo = Path('foo')
|
||||
bar = Path('bar')
|
||||
foobar = Path('foo/bar')
|
||||
eq_('bar',foobar[foo:])
|
||||
eq_('foo',foobar[:bar])
|
||||
eq_('foo/bar',foobar[bar:])
|
||||
eq_('foo/bar',foobar[:foo])
|
||||
eq_((),foobar[foobar:])
|
||||
eq_((),foobar[:foobar])
|
||||
abcd = Path('a/b/c/d')
|
||||
a = Path('a')
|
||||
b = Path('b')
|
||||
c = Path('c')
|
||||
d = Path('d')
|
||||
z = Path('z')
|
||||
eq_('b/c',abcd[a:d])
|
||||
eq_('b/c/d',abcd[a:d+z])
|
||||
eq_('b/c',abcd[a:z+d])
|
||||
eq_('a/b/c/d',abcd[:z])
|
||||
|
||||
def test_add_with_root_path(force_ossep):
|
||||
"""if I perform /a/b/c + /d/e/f, I want /a/b/c/d/e/f, not /a/b/c//d/e/f
|
||||
"""
|
||||
eq_('/foo/bar',str(Path('/foo') + Path('/bar')))
|
||||
|
||||
def test_create_with_tuple_that_have_slash_inside(force_ossep, monkeypatch):
|
||||
eq_(('','foo','bar'), Path(('/foo','bar')))
|
||||
monkeypatch.setattr(os, 'sep', '\\')
|
||||
eq_(('','foo','bar'), Path(('\\foo','bar')))
|
||||
|
||||
def test_auto_decode_os_sep(force_ossep, monkeypatch):
|
||||
"""Path should decode any either / or os.sep, but always encode in os.sep.
|
||||
"""
|
||||
eq_(('foo\\bar','bleh'),Path('foo\\bar/bleh'))
|
||||
monkeypatch.setattr(os, 'sep', '\\')
|
||||
eq_(('foo','bar/bleh'),Path('foo\\bar/bleh'))
|
||||
path = Path('foo/bar')
|
||||
eq_(('foo','bar'),path)
|
||||
eq_('foo\\bar',str(path))
|
||||
|
||||
def test_contains(force_ossep):
|
||||
p = Path(('foo','bar'))
|
||||
assert Path(('foo','bar','bleh')) in p
|
||||
assert Path(('foo','bar')) in p
|
||||
assert 'foo' in p
|
||||
assert 'bleh' not in p
|
||||
assert Path('foo') not in p
|
||||
|
||||
def test_is_parent_of(force_ossep):
|
||||
assert Path(('foo','bar')).is_parent_of(Path(('foo','bar','bleh')))
|
||||
assert not Path(('foo','bar')).is_parent_of(Path(('foo','baz')))
|
||||
assert not Path(('foo','bar')).is_parent_of(Path(('foo','bar')))
|
||||
|
||||
def test_windows_drive_letter(force_ossep):
|
||||
p = Path(('c:',))
|
||||
eq_('c:\\',str(p))
|
||||
|
||||
def test_root_path(force_ossep):
|
||||
p = Path('/')
|
||||
eq_('/',str(p))
|
||||
|
||||
def test_str_encodes_unicode_to_getfilesystemencoding(force_ossep):
|
||||
p = Path(('foo','bar\u00e9'))
|
||||
eq_('foo/bar\u00e9'.encode(sys.getfilesystemencoding()), p.tobytes())
|
||||
|
||||
def test_unicode(force_ossep):
|
||||
p = Path(('foo','bar\u00e9'))
|
||||
eq_('foo/bar\u00e9',str(p))
|
||||
|
||||
def test_str_repr_of_mix_between_non_ascii_str_and_unicode(force_ossep):
|
||||
u = 'foo\u00e9'
|
||||
encoded = u.encode(sys.getfilesystemencoding())
|
||||
p = Path((encoded,'bar'))
|
||||
print(repr(tuple(p)))
|
||||
eq_('foo\u00e9/bar'.encode(sys.getfilesystemencoding()), p.tobytes())
|
||||
|
||||
def test_Path_of_a_Path_returns_self(force_ossep):
|
||||
#if Path() is called with a path as value, just return value.
|
||||
p = Path('foo/bar')
|
||||
assert Path(p) is p
|
||||
|
||||
def test_getitem_str(force_ossep):
|
||||
# path['something'] returns the child path corresponding to the name
|
||||
p = Path('/foo/bar')
|
||||
eq_(p['baz'], Path('/foo/bar/baz'))
|
||||
|
||||
def test_getitem_path(force_ossep):
|
||||
# path[Path('something')] returns the child path corresponding to the name (or subpath)
|
||||
p = Path('/foo/bar')
|
||||
eq_(p[Path('baz/bleh')], Path('/foo/bar/baz/bleh'))
|
||||
|
||||
@mark.xfail(reason="pytest's capture mechanism is flaky, I have to investigate")
|
||||
def test_log_unicode_errors(force_ossep, monkeypatch, capsys):
|
||||
# When an there's a UnicodeDecodeError on path creation, log it so it can be possible
|
||||
# to debug the cause of it.
|
||||
monkeypatch.setattr(sys, 'getfilesystemencoding', lambda: 'ascii')
|
||||
with raises(UnicodeDecodeError):
|
||||
Path(['', b'foo\xe9'])
|
||||
out, err = capsys.readouterr()
|
||||
assert repr(b'foo\xe9') in err
|
||||
|
||||
def test_has_drive_letter(monkeypatch):
|
||||
monkeypatch.setattr(os, 'sep', '\\')
|
||||
p = Path('foo\\bar')
|
||||
assert not p.has_drive_letter()
|
||||
p = Path('C:\\')
|
||||
assert p.has_drive_letter()
|
||||
p = Path('z:\\foo')
|
||||
assert p.has_drive_letter()
|
||||
|
||||
def test_remove_drive_letter(monkeypatch):
|
||||
monkeypatch.setattr(os, 'sep', '\\')
|
||||
p = Path('foo\\bar')
|
||||
eq_(p.remove_drive_letter(), Path('foo\\bar'))
|
||||
p = Path('C:\\')
|
||||
eq_(p.remove_drive_letter(), Path(''))
|
||||
p = Path('z:\\foo')
|
||||
eq_(p.remove_drive_letter(), Path('foo'))
|
||||
|
||||
def test_pathify():
|
||||
@pathify
|
||||
def foo(a: Path, b, c:Path):
|
||||
return a, b, c
|
||||
|
||||
a, b, c = foo('foo', 0, c=Path('bar'))
|
||||
assert isinstance(a, Path)
|
||||
assert a == Path('foo')
|
||||
assert b == 0
|
||||
assert isinstance(c, Path)
|
||||
assert c == Path('bar')
|
||||
|
||||
def test_pathify_preserve_none():
|
||||
# @pathify preserves None value and doesn't try to return a Path
|
||||
@pathify
|
||||
def foo(a: Path):
|
||||
return a
|
||||
|
||||
a = foo(None)
|
||||
assert a is None
|
||||
@@ -1,65 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2011-09-06
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from ..testutil import eq_, callcounter, CallLogger
|
||||
from ..gui.selectable_list import SelectableList, GUISelectableList
|
||||
|
||||
def test_in():
|
||||
# When a SelectableList is in a list, doing "in list" with another instance returns false, even
|
||||
# if they're the same as lists.
|
||||
sl = SelectableList()
|
||||
some_list = [sl]
|
||||
assert SelectableList() not in some_list
|
||||
|
||||
def test_selection_range():
|
||||
# selection is correctly adjusted on deletion
|
||||
sl = SelectableList(['foo', 'bar', 'baz'])
|
||||
sl.selected_index = 3
|
||||
eq_(sl.selected_index, 2)
|
||||
del sl[2]
|
||||
eq_(sl.selected_index, 1)
|
||||
|
||||
def test_update_selection_called():
|
||||
# _update_selection_is called after a change in selection. However, we only do so on select()
|
||||
# calls. I follow the old behavior of the Table class. At the moment, I don't quite remember
|
||||
# why there was a specific select() method for triggering _update_selection(), but I think I
|
||||
# remember there was a reason, so I keep it that way.
|
||||
sl = SelectableList(['foo', 'bar'])
|
||||
sl._update_selection = callcounter()
|
||||
sl.select(1)
|
||||
eq_(sl._update_selection.callcount, 1)
|
||||
sl.selected_index = 0
|
||||
eq_(sl._update_selection.callcount, 1) # no call
|
||||
|
||||
def test_guicalls():
|
||||
# A GUISelectableList appropriately calls its view.
|
||||
sl = GUISelectableList(['foo', 'bar'])
|
||||
sl.view = CallLogger()
|
||||
sl.view.check_gui_calls(['refresh']) # Upon setting the view, we get a call to refresh()
|
||||
sl[1] = 'baz'
|
||||
sl.view.check_gui_calls(['refresh'])
|
||||
sl.append('foo')
|
||||
sl.view.check_gui_calls(['refresh'])
|
||||
del sl[2]
|
||||
sl.view.check_gui_calls(['refresh'])
|
||||
sl.remove('baz')
|
||||
sl.view.check_gui_calls(['refresh'])
|
||||
sl.insert(0, 'foo')
|
||||
sl.view.check_gui_calls(['refresh'])
|
||||
sl.select(1)
|
||||
sl.view.check_gui_calls(['update_selection'])
|
||||
# XXX We have to give up on this for now because of a breakage it causes in the tables.
|
||||
# sl.select(1) # don't update when selection stays the same
|
||||
# gui.check_gui_calls([])
|
||||
|
||||
def test_search_by_prefix():
|
||||
sl = SelectableList(['foo', 'bAr', 'baZ'])
|
||||
eq_(sl.search_by_prefix('b'), 1)
|
||||
eq_(sl.search_by_prefix('BA'), 1)
|
||||
eq_(sl.search_by_prefix('BAZ'), 2)
|
||||
eq_(sl.search_by_prefix('BAZZ'), -1)
|
||||
@@ -1,126 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2007/05/19
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import time
|
||||
import threading
|
||||
import os
|
||||
import sqlite3 as sqlite
|
||||
|
||||
from pytest import raises
|
||||
|
||||
from ..testutil import eq_
|
||||
from ..sqlite import ThreadedConn
|
||||
|
||||
# Threading is hard to test. In a lot of those tests, a failure means that the test run will
|
||||
# hang forever. Well... I don't know a better alternative.
|
||||
|
||||
def test_can_access_from_multiple_threads():
|
||||
def run():
|
||||
con.execute('insert into foo(bar) values(\'baz\')')
|
||||
|
||||
con = ThreadedConn(':memory:', True)
|
||||
con.execute('create table foo(bar TEXT)')
|
||||
t = threading.Thread(target=run)
|
||||
t.start()
|
||||
t.join()
|
||||
result = con.execute('select * from foo')
|
||||
eq_(1, len(result))
|
||||
eq_('baz', result[0][0])
|
||||
|
||||
def test_exception_during_query():
|
||||
con = ThreadedConn(':memory:', True)
|
||||
con.execute('create table foo(bar TEXT)')
|
||||
with raises(sqlite.OperationalError):
|
||||
con.execute('select * from bleh')
|
||||
|
||||
def test_not_autocommit(tmpdir):
|
||||
dbpath = str(tmpdir.join('foo.db'))
|
||||
con = ThreadedConn(dbpath, False)
|
||||
con.execute('create table foo(bar TEXT)')
|
||||
con.execute('insert into foo(bar) values(\'baz\')')
|
||||
del con
|
||||
#The data shouldn't have been inserted
|
||||
con = ThreadedConn(dbpath, False)
|
||||
result = con.execute('select * from foo')
|
||||
eq_(0, len(result))
|
||||
con.execute('insert into foo(bar) values(\'baz\')')
|
||||
con.commit()
|
||||
del con
|
||||
# Now the data should be there
|
||||
con = ThreadedConn(dbpath, False)
|
||||
result = con.execute('select * from foo')
|
||||
eq_(1, len(result))
|
||||
|
||||
def test_rollback():
|
||||
con = ThreadedConn(':memory:', False)
|
||||
con.execute('create table foo(bar TEXT)')
|
||||
con.execute('insert into foo(bar) values(\'baz\')')
|
||||
con.rollback()
|
||||
result = con.execute('select * from foo')
|
||||
eq_(0, len(result))
|
||||
|
||||
def test_query_palceholders():
|
||||
con = ThreadedConn(':memory:', True)
|
||||
con.execute('create table foo(bar TEXT)')
|
||||
con.execute('insert into foo(bar) values(?)', ['baz'])
|
||||
result = con.execute('select * from foo')
|
||||
eq_(1, len(result))
|
||||
eq_('baz', result[0][0])
|
||||
|
||||
def test_make_sure_theres_no_messup_between_queries():
|
||||
def run(expected_rowid):
|
||||
time.sleep(0.1)
|
||||
result = con.execute('select rowid from foo where rowid = ?', [expected_rowid])
|
||||
assert expected_rowid == result[0][0]
|
||||
|
||||
con = ThreadedConn(':memory:', True)
|
||||
con.execute('create table foo(bar TEXT)')
|
||||
for i in range(100):
|
||||
con.execute('insert into foo(bar) values(\'baz\')')
|
||||
threads = []
|
||||
for i in range(1, 101):
|
||||
t = threading.Thread(target=run, args=(i,))
|
||||
t.start
|
||||
threads.append(t)
|
||||
while threads:
|
||||
time.sleep(0.1)
|
||||
threads = [t for t in threads if t.isAlive()]
|
||||
|
||||
def test_query_after_close():
|
||||
con = ThreadedConn(':memory:', True)
|
||||
con.close()
|
||||
con.execute('select 1')
|
||||
|
||||
def test_lastrowid():
|
||||
# It's not possible to return a cursor because of the threading, but lastrowid should be
|
||||
# fetchable from the connection itself
|
||||
con = ThreadedConn(':memory:', True)
|
||||
con.execute('create table foo(bar TEXT)')
|
||||
con.execute('insert into foo(bar) values(\'baz\')')
|
||||
eq_(1, con.lastrowid)
|
||||
|
||||
def test_add_fetchone_fetchall_interface_to_results():
|
||||
con = ThreadedConn(':memory:', True)
|
||||
con.execute('create table foo(bar TEXT)')
|
||||
con.execute('insert into foo(bar) values(\'baz1\')')
|
||||
con.execute('insert into foo(bar) values(\'baz2\')')
|
||||
result = con.execute('select * from foo')
|
||||
ref = result[:]
|
||||
eq_(ref, result.fetchall())
|
||||
eq_(ref[0], result.fetchone())
|
||||
eq_(ref[1], result.fetchone())
|
||||
assert result.fetchone() is None
|
||||
|
||||
def test_non_ascii_dbname(tmpdir):
|
||||
ThreadedConn(str(tmpdir.join('foo\u00e9.db')), True)
|
||||
|
||||
def test_non_ascii_dbdir(tmpdir):
|
||||
# when this test fails, it doesn't fail gracefully, it brings the whole test suite with it.
|
||||
dbdir = tmpdir.join('foo\u00e9')
|
||||
os.mkdir(str(dbdir))
|
||||
ThreadedConn(str(dbdir.join('foo.db')), True)
|
||||
@@ -1,313 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2008-08-12
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from ..testutil import CallLogger, eq_
|
||||
from ..gui.table import Table, GUITable, Row
|
||||
|
||||
class TestRow(Row):
|
||||
def __init__(self, table, index, is_new=False):
|
||||
Row.__init__(self, table)
|
||||
self.is_new = is_new
|
||||
self._index = index
|
||||
|
||||
def load(self):
|
||||
pass
|
||||
|
||||
def save(self):
|
||||
self.is_new = False
|
||||
|
||||
@property
|
||||
def index(self):
|
||||
return self._index
|
||||
|
||||
|
||||
class TestGUITable(GUITable):
|
||||
def __init__(self, rowcount):
|
||||
GUITable.__init__(self)
|
||||
self.view = CallLogger()
|
||||
self.rowcount = rowcount
|
||||
self.updated_rows = None
|
||||
|
||||
def _do_add(self):
|
||||
return TestRow(self, len(self), is_new=True), len(self)
|
||||
|
||||
def _is_edited_new(self):
|
||||
return self.edited is not None and self.edited.is_new
|
||||
|
||||
def _fill(self):
|
||||
for i in range(self.rowcount):
|
||||
self.append(TestRow(self, i))
|
||||
|
||||
def _update_selection(self):
|
||||
self.updated_rows = self.selected_rows[:]
|
||||
|
||||
|
||||
def table_with_footer():
|
||||
table = Table()
|
||||
table.append(TestRow(table, 0))
|
||||
footer = TestRow(table, 1)
|
||||
table.footer = footer
|
||||
return table, footer
|
||||
|
||||
def table_with_header():
|
||||
table = Table()
|
||||
table.append(TestRow(table, 1))
|
||||
header = TestRow(table, 0)
|
||||
table.header = header
|
||||
return table, header
|
||||
|
||||
#--- Tests
|
||||
def test_allow_edit_when_attr_is_property_with_fset():
|
||||
# When a row has a property that has a fset, by default, make that cell editable.
|
||||
class TestRow(Row):
|
||||
@property
|
||||
def foo(self):
|
||||
pass
|
||||
@property
|
||||
def bar(self):
|
||||
pass
|
||||
@bar.setter
|
||||
def bar(self, value):
|
||||
pass
|
||||
|
||||
row = TestRow(Table())
|
||||
assert row.can_edit_cell('bar')
|
||||
assert not row.can_edit_cell('foo')
|
||||
assert not row.can_edit_cell('baz') # doesn't exist, can't edit
|
||||
|
||||
def test_can_edit_prop_has_priority_over_fset_checks():
|
||||
# When a row has a cen_edit_* property, it's the result of that property that is used, not the
|
||||
# result of a fset check.
|
||||
class TestRow(Row):
|
||||
@property
|
||||
def bar(self):
|
||||
pass
|
||||
@bar.setter
|
||||
def bar(self, value):
|
||||
pass
|
||||
can_edit_bar = False
|
||||
|
||||
row = TestRow(Table())
|
||||
assert not row.can_edit_cell('bar')
|
||||
|
||||
def test_in():
|
||||
# When a table is in a list, doing "in list" with another instance returns false, even if
|
||||
# they're the same as lists.
|
||||
table = Table()
|
||||
some_list = [table]
|
||||
assert Table() not in some_list
|
||||
|
||||
def test_footer_del_all():
|
||||
# Removing all rows doesn't crash when doing the footer check.
|
||||
table, footer = table_with_footer()
|
||||
del table[:]
|
||||
assert table.footer is None
|
||||
|
||||
def test_footer_del_row():
|
||||
# Removing the footer row sets it to None
|
||||
table, footer = table_with_footer()
|
||||
del table[-1]
|
||||
assert table.footer is None
|
||||
eq_(len(table), 1)
|
||||
|
||||
def test_footer_is_appened_to_table():
|
||||
# A footer is appended at the table's bottom
|
||||
table, footer = table_with_footer()
|
||||
eq_(len(table), 2)
|
||||
assert table[1] is footer
|
||||
|
||||
def test_footer_remove():
|
||||
# remove() on footer sets it to None
|
||||
table, footer = table_with_footer()
|
||||
table.remove(footer)
|
||||
assert table.footer is None
|
||||
|
||||
def test_footer_replaces_old_footer():
|
||||
table, footer = table_with_footer()
|
||||
other = Row(table)
|
||||
table.footer = other
|
||||
assert table.footer is other
|
||||
eq_(len(table), 2)
|
||||
assert table[1] is other
|
||||
|
||||
def test_footer_rows_and_row_count():
|
||||
# rows() and row_count() ignore footer.
|
||||
table, footer = table_with_footer()
|
||||
eq_(table.row_count, 1)
|
||||
eq_(table.rows, table[:-1])
|
||||
|
||||
def test_footer_setting_to_none_removes_old_one():
|
||||
table, footer = table_with_footer()
|
||||
table.footer = None
|
||||
assert table.footer is None
|
||||
eq_(len(table), 1)
|
||||
|
||||
def test_footer_stays_there_on_append():
|
||||
# Appending another row puts it above the footer
|
||||
table, footer = table_with_footer()
|
||||
table.append(Row(table))
|
||||
eq_(len(table), 3)
|
||||
assert table[2] is footer
|
||||
|
||||
def test_footer_stays_there_on_insert():
|
||||
# Inserting another row puts it above the footer
|
||||
table, footer = table_with_footer()
|
||||
table.insert(3, Row(table))
|
||||
eq_(len(table), 3)
|
||||
assert table[2] is footer
|
||||
|
||||
def test_header_del_all():
|
||||
# Removing all rows doesn't crash when doing the header check.
|
||||
table, header = table_with_header()
|
||||
del table[:]
|
||||
assert table.header is None
|
||||
|
||||
def test_header_del_row():
|
||||
# Removing the header row sets it to None
|
||||
table, header = table_with_header()
|
||||
del table[0]
|
||||
assert table.header is None
|
||||
eq_(len(table), 1)
|
||||
|
||||
def test_header_is_inserted_in_table():
|
||||
# A header is inserted at the table's top
|
||||
table, header = table_with_header()
|
||||
eq_(len(table), 2)
|
||||
assert table[0] is header
|
||||
|
||||
def test_header_remove():
|
||||
# remove() on header sets it to None
|
||||
table, header = table_with_header()
|
||||
table.remove(header)
|
||||
assert table.header is None
|
||||
|
||||
def test_header_replaces_old_header():
|
||||
table, header = table_with_header()
|
||||
other = Row(table)
|
||||
table.header = other
|
||||
assert table.header is other
|
||||
eq_(len(table), 2)
|
||||
assert table[0] is other
|
||||
|
||||
def test_header_rows_and_row_count():
|
||||
# rows() and row_count() ignore header.
|
||||
table, header = table_with_header()
|
||||
eq_(table.row_count, 1)
|
||||
eq_(table.rows, table[1:])
|
||||
|
||||
def test_header_setting_to_none_removes_old_one():
|
||||
table, header = table_with_header()
|
||||
table.header = None
|
||||
assert table.header is None
|
||||
eq_(len(table), 1)
|
||||
|
||||
def test_header_stays_there_on_insert():
|
||||
# Inserting another row at the top puts it below the header
|
||||
table, header = table_with_header()
|
||||
table.insert(0, Row(table))
|
||||
eq_(len(table), 3)
|
||||
assert table[0] is header
|
||||
|
||||
def test_refresh_view_on_refresh():
|
||||
# If refresh_view is not False, we refresh the table's view on refresh()
|
||||
table = TestGUITable(1)
|
||||
table.refresh()
|
||||
table.view.check_gui_calls(['refresh'])
|
||||
table.view.clear_calls()
|
||||
table.refresh(refresh_view=False)
|
||||
table.view.check_gui_calls([])
|
||||
|
||||
def test_restore_selection():
|
||||
# By default, after a refresh, selection goes on the last row
|
||||
table = TestGUITable(10)
|
||||
table.refresh()
|
||||
eq_(table.selected_indexes, [9])
|
||||
|
||||
def test_restore_selection_after_cancel_edits():
|
||||
# _restore_selection() is called after cancel_edits(). Previously, only _update_selection would
|
||||
# be called.
|
||||
class MyTable(TestGUITable):
|
||||
def _restore_selection(self, previous_selection):
|
||||
self.selected_indexes = [6]
|
||||
|
||||
table = MyTable(10)
|
||||
table.refresh()
|
||||
table.add()
|
||||
table.cancel_edits()
|
||||
eq_(table.selected_indexes, [6])
|
||||
|
||||
def test_restore_selection_with_previous_selection():
|
||||
# By default, we try to restore the selection that was there before a refresh
|
||||
table = TestGUITable(10)
|
||||
table.refresh()
|
||||
table.selected_indexes = [2, 4]
|
||||
table.refresh()
|
||||
eq_(table.selected_indexes, [2, 4])
|
||||
|
||||
def test_restore_selection_custom():
|
||||
# After a _fill() called, the virtual _restore_selection() is called so that it's possible for a
|
||||
# GUITable subclass to customize its post-refresh selection behavior.
|
||||
class MyTable(TestGUITable):
|
||||
def _restore_selection(self, previous_selection):
|
||||
self.selected_indexes = [6]
|
||||
|
||||
table = MyTable(10)
|
||||
table.refresh()
|
||||
eq_(table.selected_indexes, [6])
|
||||
|
||||
def test_row_cell_value():
|
||||
# *_cell_value() correctly mangles attrnames that are Python reserved words.
|
||||
row = Row(Table())
|
||||
row.from_ = 'foo'
|
||||
eq_(row.get_cell_value('from'), 'foo')
|
||||
row.set_cell_value('from', 'bar')
|
||||
eq_(row.get_cell_value('from'), 'bar')
|
||||
|
||||
def test_sort_table_also_tries_attributes_without_underscores():
|
||||
# When determining a sort key, after having unsuccessfully tried the attribute with the,
|
||||
# underscore, try the one without one.
|
||||
table = Table()
|
||||
row1 = Row(table)
|
||||
row1._foo = 'a' # underscored attr must be checked first
|
||||
row1.foo = 'b'
|
||||
row1.bar = 'c'
|
||||
row2 = Row(table)
|
||||
row2._foo = 'b'
|
||||
row2.foo = 'a'
|
||||
row2.bar = 'b'
|
||||
table.append(row1)
|
||||
table.append(row2)
|
||||
table.sort_by('foo')
|
||||
assert table[0] is row1
|
||||
assert table[1] is row2
|
||||
table.sort_by('bar')
|
||||
assert table[0] is row2
|
||||
assert table[1] is row1
|
||||
|
||||
def test_sort_table_updates_selection():
|
||||
table = TestGUITable(10)
|
||||
table.refresh()
|
||||
table.select([2, 4])
|
||||
table.sort_by('index', desc=True)
|
||||
# Now, the updated rows should be 7 and 5
|
||||
eq_(len(table.updated_rows), 2)
|
||||
r1, r2 = table.updated_rows
|
||||
eq_(r1.index, 7)
|
||||
eq_(r2.index, 5)
|
||||
|
||||
def test_sort_table_with_footer():
|
||||
# Sorting a table with a footer keeps it at the bottom
|
||||
table, footer = table_with_footer()
|
||||
table.sort_by('index', desc=True)
|
||||
assert table[-1] is footer
|
||||
|
||||
def test_sort_table_with_header():
|
||||
# Sorting a table with a header keeps it at the top
|
||||
table, header = table_with_header()
|
||||
table.sort_by('index', desc=True)
|
||||
assert table[0] is header
|
||||
@@ -1,109 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2010-02-12
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from ..testutil import eq_
|
||||
from ..gui.tree import Tree, Node
|
||||
|
||||
def tree_with_some_nodes():
|
||||
t = Tree()
|
||||
t.append(Node('foo'))
|
||||
t.append(Node('bar'))
|
||||
t.append(Node('baz'))
|
||||
t[0].append(Node('sub1'))
|
||||
t[0].append(Node('sub2'))
|
||||
return t
|
||||
|
||||
def test_selection():
|
||||
t = tree_with_some_nodes()
|
||||
assert t.selected_node is None
|
||||
eq_(t.selected_nodes, [])
|
||||
assert t.selected_path is None
|
||||
eq_(t.selected_paths, [])
|
||||
|
||||
def test_select_one_node():
|
||||
t = tree_with_some_nodes()
|
||||
t.selected_node = t[0][0]
|
||||
assert t.selected_node is t[0][0]
|
||||
eq_(t.selected_nodes, [t[0][0]])
|
||||
eq_(t.selected_path, [0, 0])
|
||||
eq_(t.selected_paths, [[0, 0]])
|
||||
|
||||
def test_select_one_path():
|
||||
t = tree_with_some_nodes()
|
||||
t.selected_path = [0, 1]
|
||||
assert t.selected_node is t[0][1]
|
||||
|
||||
def test_select_multiple_nodes():
|
||||
t = tree_with_some_nodes()
|
||||
t.selected_nodes = [t[0], t[1]]
|
||||
eq_(t.selected_paths, [[0], [1]])
|
||||
|
||||
def test_select_multiple_paths():
|
||||
t = tree_with_some_nodes()
|
||||
t.selected_paths = [[0], [1]]
|
||||
eq_(t.selected_nodes, [t[0], t[1]])
|
||||
|
||||
def test_select_none_path():
|
||||
# setting selected_path to None clears the selection
|
||||
t = Tree()
|
||||
t.selected_path = None
|
||||
assert t.selected_path is None
|
||||
|
||||
def test_select_none_node():
|
||||
# setting selected_node to None clears the selection
|
||||
t = Tree()
|
||||
t.selected_node = None
|
||||
eq_(t.selected_nodes, [])
|
||||
|
||||
def test_clear_removes_selection():
|
||||
# When clearing a tree, we want to clear the selection as well or else we end up with a crash
|
||||
# when calling selected_paths.
|
||||
t = tree_with_some_nodes()
|
||||
t.selected_path = [0]
|
||||
t.clear()
|
||||
assert t.selected_node is None
|
||||
|
||||
def test_selection_override():
|
||||
# All selection changed pass through the _select_node() method so it's easy for subclasses to
|
||||
# customize the tree's behavior.
|
||||
class MyTree(Tree):
|
||||
called = False
|
||||
def _select_nodes(self, nodes):
|
||||
self.called = True
|
||||
|
||||
|
||||
t = MyTree()
|
||||
t.selected_paths = []
|
||||
assert t.called
|
||||
t.called = False
|
||||
t.selected_node = None
|
||||
assert t.called
|
||||
|
||||
def test_findall():
|
||||
t = tree_with_some_nodes()
|
||||
r = t.findall(lambda n: n.name.startswith('sub'))
|
||||
eq_(set(r), set([t[0][0], t[0][1]]))
|
||||
|
||||
def test_findall_dont_include_self():
|
||||
# When calling findall with include_self=False, the node itself is never evaluated.
|
||||
t = tree_with_some_nodes()
|
||||
del t._name # so that if the predicate is called on `t`, we crash
|
||||
r = t.findall(lambda n: not n.name.startswith('sub'), include_self=False) # no crash
|
||||
eq_(set(r), set([t[0], t[1], t[2]]))
|
||||
|
||||
def test_find_dont_include_self():
|
||||
# When calling find with include_self=False, the node itself is never evaluated.
|
||||
t = tree_with_some_nodes()
|
||||
del t._name # so that if the predicate is called on `t`, we crash
|
||||
r = t.find(lambda n: not n.name.startswith('sub'), include_self=False) # no crash
|
||||
assert r is t[0]
|
||||
|
||||
def test_find_none():
|
||||
# when find() yields no result, return None
|
||||
t = Tree()
|
||||
assert t.find(lambda n: False) is None # no StopIteration exception
|
||||
@@ -1,325 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2011-01-11
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from io import StringIO
|
||||
|
||||
from pytest import raises
|
||||
|
||||
from ..testutil import eq_
|
||||
from ..path import Path
|
||||
from ..util import *
|
||||
|
||||
def test_nonone():
|
||||
eq_('foo', nonone('foo', 'bar'))
|
||||
eq_('bar', nonone(None, 'bar'))
|
||||
|
||||
def test_tryint():
|
||||
eq_(42,tryint('42'))
|
||||
eq_(0,tryint('abc'))
|
||||
eq_(0,tryint(None))
|
||||
eq_(42,tryint(None, 42))
|
||||
|
||||
def test_minmax():
|
||||
eq_(minmax(2, 1, 3), 2)
|
||||
eq_(minmax(0, 1, 3), 1)
|
||||
eq_(minmax(4, 1, 3), 3)
|
||||
|
||||
#--- Sequence
|
||||
|
||||
def test_first():
|
||||
eq_(first([3, 2, 1]), 3)
|
||||
eq_(first(i for i in [3, 2, 1] if i < 3), 2)
|
||||
|
||||
def test_flatten():
|
||||
eq_([1,2,3,4],flatten([[1,2],[3,4]]))
|
||||
eq_([],flatten([]))
|
||||
|
||||
def test_dedupe():
|
||||
reflist = [0,7,1,2,3,4,4,5,6,7,1,2,3]
|
||||
eq_(dedupe(reflist),[0,7,1,2,3,4,5,6])
|
||||
|
||||
def test_stripfalse():
|
||||
eq_([1, 2, 3], stripfalse([None, 0, 1, 2, 3, None]))
|
||||
|
||||
def test_extract():
|
||||
wheat, shaft = extract(lambda n: n % 2 == 0, list(range(10)))
|
||||
eq_(wheat, [0, 2, 4, 6, 8])
|
||||
eq_(shaft, [1, 3, 5, 7, 9])
|
||||
|
||||
def test_allsame():
|
||||
assert allsame([42, 42, 42])
|
||||
assert not allsame([42, 43, 42])
|
||||
assert not allsame([43, 42, 42])
|
||||
# Works on non-sequence as well
|
||||
assert allsame(iter([42, 42, 42]))
|
||||
|
||||
def test_trailiter():
|
||||
eq_(list(trailiter([])), [])
|
||||
eq_(list(trailiter(['foo'])), [(None, 'foo')])
|
||||
eq_(list(trailiter(['foo', 'bar'])), [(None, 'foo'), ('foo', 'bar')])
|
||||
eq_(list(trailiter(['foo', 'bar'], skipfirst=True)), [('foo', 'bar')])
|
||||
eq_(list(trailiter([], skipfirst=True)), []) # no crash
|
||||
|
||||
def test_iterconsume():
|
||||
# We just want to make sure that we return *all* items and that we're not mistakenly skipping
|
||||
# one.
|
||||
eq_(list(range(2500)), list(iterconsume(list(range(2500)))))
|
||||
eq_(list(reversed(range(2500))), list(iterconsume(list(range(2500)), reverse=False)))
|
||||
|
||||
#--- String
|
||||
|
||||
def test_escape():
|
||||
eq_('f\\o\\ob\\ar', escape('foobar', 'oa'))
|
||||
eq_('f*o*ob*ar', escape('foobar', 'oa', '*'))
|
||||
eq_('f*o*ob*ar', escape('foobar', set('oa'), '*'))
|
||||
|
||||
def test_get_file_ext():
|
||||
eq_(get_file_ext("foobar"), "")
|
||||
eq_(get_file_ext("foo.bar"), "bar")
|
||||
eq_(get_file_ext("foobar."), "")
|
||||
eq_(get_file_ext(".foobar"), "foobar")
|
||||
|
||||
def test_rem_file_ext():
|
||||
eq_(rem_file_ext("foobar"), "foobar")
|
||||
eq_(rem_file_ext("foo.bar"), "foo")
|
||||
eq_(rem_file_ext("foobar."), "foobar")
|
||||
eq_(rem_file_ext(".foobar"), "")
|
||||
|
||||
def test_pluralize():
|
||||
eq_('0 song', pluralize(0,'song'))
|
||||
eq_('1 song', pluralize(1,'song'))
|
||||
eq_('2 songs', pluralize(2,'song'))
|
||||
eq_('1 song', pluralize(1.1,'song'))
|
||||
eq_('2 songs', pluralize(1.5,'song'))
|
||||
eq_('1.1 songs', pluralize(1.1,'song',1))
|
||||
eq_('1.5 songs', pluralize(1.5,'song',1))
|
||||
eq_('2 entries', pluralize(2,'entry', plural_word='entries'))
|
||||
|
||||
def test_format_time():
|
||||
eq_(format_time(0),'00:00:00')
|
||||
eq_(format_time(1),'00:00:01')
|
||||
eq_(format_time(23),'00:00:23')
|
||||
eq_(format_time(60),'00:01:00')
|
||||
eq_(format_time(101),'00:01:41')
|
||||
eq_(format_time(683),'00:11:23')
|
||||
eq_(format_time(3600),'01:00:00')
|
||||
eq_(format_time(3754),'01:02:34')
|
||||
eq_(format_time(36000),'10:00:00')
|
||||
eq_(format_time(366666),'101:51:06')
|
||||
eq_(format_time(0, with_hours=False),'00:00')
|
||||
eq_(format_time(1, with_hours=False),'00:01')
|
||||
eq_(format_time(23, with_hours=False),'00:23')
|
||||
eq_(format_time(60, with_hours=False),'01:00')
|
||||
eq_(format_time(101, with_hours=False),'01:41')
|
||||
eq_(format_time(683, with_hours=False),'11:23')
|
||||
eq_(format_time(3600, with_hours=False),'60:00')
|
||||
eq_(format_time(6036, with_hours=False),'100:36')
|
||||
eq_(format_time(60360, with_hours=False),'1006:00')
|
||||
|
||||
def test_format_time_decimal():
|
||||
eq_(format_time_decimal(0), '0.0 second')
|
||||
eq_(format_time_decimal(1), '1.0 second')
|
||||
eq_(format_time_decimal(23), '23.0 seconds')
|
||||
eq_(format_time_decimal(60), '1.0 minute')
|
||||
eq_(format_time_decimal(101), '1.7 minutes')
|
||||
eq_(format_time_decimal(683), '11.4 minutes')
|
||||
eq_(format_time_decimal(3600), '1.0 hour')
|
||||
eq_(format_time_decimal(6036), '1.7 hours')
|
||||
eq_(format_time_decimal(86400), '1.0 day')
|
||||
eq_(format_time_decimal(160360), '1.9 days')
|
||||
|
||||
def test_format_size():
|
||||
eq_(format_size(1024), '1 KB')
|
||||
eq_(format_size(1024,2), '1.00 KB')
|
||||
eq_(format_size(1024,0,2), '1 MB')
|
||||
eq_(format_size(1024,2,2), '0.01 MB')
|
||||
eq_(format_size(1024,3,2), '0.001 MB')
|
||||
eq_(format_size(1024,3,2,False), '0.001')
|
||||
eq_(format_size(1023), '1023 B')
|
||||
eq_(format_size(1023,0,1), '1 KB')
|
||||
eq_(format_size(511,0,1), '1 KB')
|
||||
eq_(format_size(9), '9 B')
|
||||
eq_(format_size(99), '99 B')
|
||||
eq_(format_size(999), '999 B')
|
||||
eq_(format_size(9999), '10 KB')
|
||||
eq_(format_size(99999), '98 KB')
|
||||
eq_(format_size(999999), '977 KB')
|
||||
eq_(format_size(9999999), '10 MB')
|
||||
eq_(format_size(99999999), '96 MB')
|
||||
eq_(format_size(999999999), '954 MB')
|
||||
eq_(format_size(9999999999), '10 GB')
|
||||
eq_(format_size(99999999999), '94 GB')
|
||||
eq_(format_size(999999999999), '932 GB')
|
||||
eq_(format_size(9999999999999), '10 TB')
|
||||
eq_(format_size(99999999999999), '91 TB')
|
||||
eq_(format_size(999999999999999), '910 TB')
|
||||
eq_(format_size(9999999999999999), '9 PB')
|
||||
eq_(format_size(99999999999999999), '89 PB')
|
||||
eq_(format_size(999999999999999999), '889 PB')
|
||||
eq_(format_size(9999999999999999999), '9 EB')
|
||||
eq_(format_size(99999999999999999999), '87 EB')
|
||||
eq_(format_size(999999999999999999999), '868 EB')
|
||||
eq_(format_size(9999999999999999999999), '9 ZB')
|
||||
eq_(format_size(99999999999999999999999), '85 ZB')
|
||||
eq_(format_size(999999999999999999999999), '848 ZB')
|
||||
|
||||
def test_remove_invalid_xml():
|
||||
eq_(remove_invalid_xml('foo\0bar\x0bbaz'), 'foo bar baz')
|
||||
# surrogate blocks have to be replaced, but not the rest
|
||||
eq_(remove_invalid_xml('foo\ud800bar\udfffbaz\ue000'), 'foo bar baz\ue000')
|
||||
# replace with something else
|
||||
eq_(remove_invalid_xml('foo\0baz', replace_with='bar'), 'foobarbaz')
|
||||
|
||||
def test_multi_replace():
|
||||
eq_('136',multi_replace('123456',('2','45')))
|
||||
eq_('1 3 6',multi_replace('123456',('2','45'),' '))
|
||||
eq_('1 3 6',multi_replace('123456','245',' '))
|
||||
eq_('173896',multi_replace('123456','245','789'))
|
||||
eq_('173896',multi_replace('123456','245',('7','8','9')))
|
||||
eq_('17386',multi_replace('123456',('2','45'),'78'))
|
||||
eq_('17386',multi_replace('123456',('2','45'),('7','8')))
|
||||
with raises(ValueError):
|
||||
multi_replace('123456',('2','45'),('7','8','9'))
|
||||
eq_('17346',multi_replace('12346',('2','45'),'78'))
|
||||
|
||||
#--- Files
|
||||
|
||||
class TestCase_modified_after:
|
||||
def test_first_is_modified_after(self, monkeyplus):
|
||||
monkeyplus.patch_osstat('first', st_mtime=42)
|
||||
monkeyplus.patch_osstat('second', st_mtime=41)
|
||||
assert modified_after('first', 'second')
|
||||
|
||||
def test_second_is_modified_after(self, monkeyplus):
|
||||
monkeyplus.patch_osstat('first', st_mtime=42)
|
||||
monkeyplus.patch_osstat('second', st_mtime=43)
|
||||
assert not modified_after('first', 'second')
|
||||
|
||||
def test_same_mtime(self, monkeyplus):
|
||||
monkeyplus.patch_osstat('first', st_mtime=42)
|
||||
monkeyplus.patch_osstat('second', st_mtime=42)
|
||||
assert not modified_after('first', 'second')
|
||||
|
||||
def test_first_file_does_not_exist(self, monkeyplus):
|
||||
# when the first file doesn't exist, we return False
|
||||
monkeyplus.patch_osstat('second', st_mtime=42)
|
||||
assert not modified_after('does_not_exist', 'second') # no crash
|
||||
|
||||
def test_second_file_does_not_exist(self, monkeyplus):
|
||||
# when the second file doesn't exist, we return True
|
||||
monkeyplus.patch_osstat('first', st_mtime=42)
|
||||
assert modified_after('first', 'does_not_exist') # no crash
|
||||
|
||||
def test_first_file_is_none(self, monkeyplus):
|
||||
# when the first file is None, we return False
|
||||
monkeyplus.patch_osstat('second', st_mtime=42)
|
||||
assert not modified_after(None, 'second') # no crash
|
||||
|
||||
def test_second_file_is_none(self, monkeyplus):
|
||||
# when the second file is None, we return True
|
||||
monkeyplus.patch_osstat('first', st_mtime=42)
|
||||
assert modified_after('first', None) # no crash
|
||||
|
||||
|
||||
class TestCase_delete_if_empty:
|
||||
def test_is_empty(self, tmpdir):
|
||||
testpath = Path(str(tmpdir))
|
||||
assert delete_if_empty(testpath)
|
||||
assert not testpath.exists()
|
||||
|
||||
def test_not_empty(self, tmpdir):
|
||||
testpath = Path(str(tmpdir))
|
||||
testpath['foo'].mkdir()
|
||||
assert not delete_if_empty(testpath)
|
||||
assert testpath.exists()
|
||||
|
||||
def test_with_files_to_delete(self, tmpdir):
|
||||
testpath = Path(str(tmpdir))
|
||||
testpath['foo'].open('w')
|
||||
testpath['bar'].open('w')
|
||||
assert delete_if_empty(testpath, ['foo', 'bar'])
|
||||
assert not testpath.exists()
|
||||
|
||||
def test_directory_in_files_to_delete(self, tmpdir):
|
||||
testpath = Path(str(tmpdir))
|
||||
testpath['foo'].mkdir()
|
||||
assert not delete_if_empty(testpath, ['foo'])
|
||||
assert testpath.exists()
|
||||
|
||||
def test_delete_files_to_delete_only_if_dir_is_empty(self, tmpdir):
|
||||
testpath = Path(str(tmpdir))
|
||||
testpath['foo'].open('w')
|
||||
testpath['bar'].open('w')
|
||||
assert not delete_if_empty(testpath, ['foo'])
|
||||
assert testpath.exists()
|
||||
assert testpath['foo'].exists()
|
||||
|
||||
def test_doesnt_exist(self):
|
||||
# When the 'path' doesn't exist, just do nothing.
|
||||
delete_if_empty(Path('does_not_exist')) # no crash
|
||||
|
||||
def test_is_file(self, tmpdir):
|
||||
# When 'path' is a file, do nothing.
|
||||
p = Path(str(tmpdir)) + 'filename'
|
||||
p.open('w').close()
|
||||
delete_if_empty(p) # no crash
|
||||
|
||||
def test_ioerror(self, tmpdir, monkeypatch):
|
||||
# if an IO error happens during the operation, ignore it.
|
||||
def do_raise(*args, **kw):
|
||||
raise OSError()
|
||||
|
||||
monkeypatch.setattr(Path, 'rmdir', do_raise)
|
||||
delete_if_empty(Path(str(tmpdir))) # no crash
|
||||
|
||||
|
||||
class TestCase_open_if_filename:
|
||||
def test_file_name(self, tmpdir):
|
||||
filepath = str(tmpdir.join('test.txt'))
|
||||
open(filepath, 'wb').write(b'test_data')
|
||||
file, close = open_if_filename(filepath)
|
||||
assert close
|
||||
eq_(b'test_data', file.read())
|
||||
file.close()
|
||||
|
||||
def test_opened_file(self):
|
||||
sio = StringIO()
|
||||
sio.write('test_data')
|
||||
sio.seek(0)
|
||||
file, close = open_if_filename(sio)
|
||||
assert not close
|
||||
eq_('test_data', file.read())
|
||||
|
||||
def test_mode_is_passed_to_open(self, tmpdir):
|
||||
filepath = str(tmpdir.join('test.txt'))
|
||||
open(filepath, 'w').close()
|
||||
file, close = open_if_filename(filepath, 'a')
|
||||
eq_('a', file.mode)
|
||||
file.close()
|
||||
|
||||
|
||||
class TestCase_FileOrPath:
|
||||
def test_path(self, tmpdir):
|
||||
filepath = str(tmpdir.join('test.txt'))
|
||||
open(filepath, 'wb').write(b'test_data')
|
||||
with FileOrPath(filepath) as fp:
|
||||
eq_(b'test_data', fp.read())
|
||||
|
||||
def test_opened_file(self):
|
||||
sio = StringIO()
|
||||
sio.write('test_data')
|
||||
sio.seek(0)
|
||||
with FileOrPath(sio) as fp:
|
||||
eq_('test_data', fp.read())
|
||||
|
||||
def test_mode_is_passed_to_open(self, tmpdir):
|
||||
filepath = str(tmpdir.join('test.txt'))
|
||||
open(filepath, 'w').close()
|
||||
with FileOrPath(filepath, 'a') as fp:
|
||||
eq_('a', fp.mode)
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2010-11-14
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import threading
|
||||
import py.path
|
||||
|
||||
def eq_(a, b, msg=None):
|
||||
__tracebackhide__ = True
|
||||
assert a == b, msg or "%r != %r" % (a, b)
|
||||
|
||||
def assert_almost_equal(a, b, places=7):
|
||||
__tracebackhide__ = True
|
||||
assert round(a, ndigits=places) == round(b, ndigits=places)
|
||||
|
||||
def callcounter():
|
||||
def f(*args, **kwargs):
|
||||
f.callcount += 1
|
||||
|
||||
f.callcount = 0
|
||||
return f
|
||||
|
||||
class TestData:
|
||||
def __init__(self, datadirpath):
|
||||
self.datadirpath = py.path.local(datadirpath)
|
||||
|
||||
def filepath(self, relative_path, *args):
|
||||
"""Returns the path of a file in testdata.
|
||||
|
||||
'relative_path' can be anything that can be added to a Path
|
||||
if args is not empty, it will be joined to relative_path
|
||||
"""
|
||||
resultpath = self.datadirpath.join(relative_path)
|
||||
if args:
|
||||
resultpath = resultpath.join(*args)
|
||||
assert resultpath.check()
|
||||
return str(resultpath)
|
||||
|
||||
|
||||
class CallLogger:
|
||||
"""This is a dummy object that logs all calls made to it.
|
||||
|
||||
It is used to simulate the GUI layer.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __getattr__(self, func_name):
|
||||
def func(*args, **kw):
|
||||
self.calls.append(func_name)
|
||||
return func
|
||||
|
||||
def clear_calls(self):
|
||||
del self.calls[:]
|
||||
|
||||
def check_gui_calls(self, expected, verify_order=False):
|
||||
"""Checks that the expected calls have been made to 'self', then clears the log.
|
||||
|
||||
`expected` is an iterable of strings representing method names.
|
||||
If `verify_order` is True, the order of the calls matters.
|
||||
"""
|
||||
__tracebackhide__ = True
|
||||
if verify_order:
|
||||
eq_(self.calls, expected)
|
||||
else:
|
||||
eq_(set(self.calls), set(expected))
|
||||
self.clear_calls()
|
||||
|
||||
def check_gui_calls_partial(self, expected=None, not_expected=None, verify_order=False):
|
||||
"""Checks that the expected calls have been made to 'self', then clears the log.
|
||||
|
||||
`expected` is an iterable of strings representing method names. Order doesn't matter.
|
||||
Moreover, if calls have been made that are not in expected, no failure occur.
|
||||
`not_expected` can be used for a more explicit check (rather than calling `check_gui_calls`
|
||||
with an empty `expected`) to assert that calls have *not* been made.
|
||||
"""
|
||||
__tracebackhide__ = True
|
||||
if expected is not None:
|
||||
not_called = set(expected) - set(self.calls)
|
||||
assert not not_called, "These calls haven't been made: {0}".format(not_called)
|
||||
if verify_order:
|
||||
max_index = 0
|
||||
for call in expected:
|
||||
index = self.calls.index(call)
|
||||
if index < max_index:
|
||||
raise AssertionError("The call {0} hasn't been made in the correct order".format(call))
|
||||
max_index = index
|
||||
if not_expected is not None:
|
||||
called = set(not_expected) & set(self.calls)
|
||||
assert not called, "These calls shouldn't have been made: {0}".format(called)
|
||||
self.clear_calls()
|
||||
|
||||
|
||||
class TestApp:
|
||||
def __init__(self):
|
||||
self._call_loggers = []
|
||||
|
||||
def clear_gui_calls(self):
|
||||
for logger in self._call_loggers:
|
||||
logger.clear_calls()
|
||||
|
||||
def make_logger(self, class_=CallLogger, *initargs):
|
||||
logger = class_(*initargs)
|
||||
self._call_loggers.append(logger)
|
||||
return logger
|
||||
|
||||
def make_gui(self, name, class_, view=None, parent=None, holder=None):
|
||||
if view is None:
|
||||
view = self.make_logger()
|
||||
if parent is None:
|
||||
# The attribute "default_parent" has to be set for this to work correctly
|
||||
parent = self.default_parent
|
||||
if holder is None:
|
||||
holder = self
|
||||
setattr(holder, '{0}_gui'.format(name), view)
|
||||
gui = class_(parent)
|
||||
gui.view = view
|
||||
setattr(holder, name, gui)
|
||||
return gui
|
||||
|
||||
|
||||
# To use @with_app, you have to import pytest_funcarg__app in your conftest.py file.
|
||||
def with_app(setupfunc):
|
||||
def decorator(func):
|
||||
func.setupfunc = setupfunc
|
||||
return func
|
||||
return decorator
|
||||
|
||||
def pytest_funcarg__app(request):
|
||||
setupfunc = request.function.setupfunc
|
||||
if hasattr(setupfunc, '__code__'):
|
||||
argnames = setupfunc.__code__.co_varnames[:setupfunc.__code__.co_argcount]
|
||||
def getarg(name):
|
||||
if name == 'self':
|
||||
return request.function.__self__
|
||||
else:
|
||||
return request.getfuncargvalue(name)
|
||||
args = [getarg(argname) for argname in argnames]
|
||||
else:
|
||||
args = []
|
||||
app = setupfunc(*args)
|
||||
return app
|
||||
|
||||
def jointhreads():
|
||||
"""Join all threads to the main thread"""
|
||||
for thread in threading.enumerate():
|
||||
if hasattr(thread, 'BUGGY'):
|
||||
continue
|
||||
if thread.getName() != 'MainThread' and thread.isAlive():
|
||||
if hasattr(thread, 'close'):
|
||||
thread.close()
|
||||
thread.join(1)
|
||||
if thread.isAlive():
|
||||
print("Thread problem. Some thread doesn't want to stop.")
|
||||
thread.BUGGY = True
|
||||
|
||||
def _unify_args(func, args, kwargs, args_to_ignore=None):
|
||||
''' Unify args and kwargs in the same dictionary.
|
||||
|
||||
The result is kwargs with args added to it. func.func_code.co_varnames is used to determine
|
||||
under what key each elements of arg will be mapped in kwargs.
|
||||
|
||||
if you want some arguments not to be in the results, supply a list of arg names in
|
||||
args_to_ignore.
|
||||
|
||||
if f is a function that takes *args, func_code.co_varnames is empty, so args will be put
|
||||
under 'args' in kwargs.
|
||||
|
||||
def foo(bar, baz)
|
||||
_unifyArgs(foo, (42,), {'baz': 23}) --> {'bar': 42, 'baz': 23}
|
||||
_unifyArgs(foo, (42,), {'baz': 23}, ['bar']) --> {'baz': 23}
|
||||
'''
|
||||
result = kwargs.copy()
|
||||
if hasattr(func, '__code__'): # built-in functions don't have func_code
|
||||
args = list(args)
|
||||
if getattr(func, '__self__', None) is not None: # bound method, we have to add self to args list
|
||||
args = [func.__self__] + args
|
||||
defaults = list(func.__defaults__) if func.__defaults__ is not None else []
|
||||
arg_count = func.__code__.co_argcount
|
||||
arg_names = list(func.__code__.co_varnames)
|
||||
if len(args) < arg_count: # We have default values
|
||||
required_arg_count = arg_count - len(args)
|
||||
args = args + defaults[-required_arg_count:]
|
||||
for arg_name, arg in zip(arg_names, args):
|
||||
# setdefault is used because if the arg is already in kwargs, we don't want to use default values
|
||||
result.setdefault(arg_name, arg)
|
||||
else:
|
||||
#'func' has a *args argument
|
||||
result['args'] = args
|
||||
if args_to_ignore:
|
||||
for kw in args_to_ignore:
|
||||
del result[kw]
|
||||
return result
|
||||
|
||||
def log_calls(func):
|
||||
''' Logs all func calls' arguments under func.calls.
|
||||
|
||||
func.calls is a list of _unify_args() result (dict).
|
||||
|
||||
Mostly used for unit testing.
|
||||
'''
|
||||
def wrapper(*args, **kwargs):
|
||||
unifiedArgs = _unify_args(func, args, kwargs)
|
||||
wrapper.calls.append(unifiedArgs)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper.calls = []
|
||||
return wrapper
|
||||
@@ -1,162 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2010-06-23
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
# Doing i18n with GNU gettext for the core text gets complicated, so what I do is that I make the
|
||||
# GUI layer responsible for supplying a tr() function.
|
||||
|
||||
import locale
|
||||
import logging
|
||||
import os.path as op
|
||||
|
||||
from .plat import ISWINDOWS, ISLINUX
|
||||
|
||||
_trfunc = None
|
||||
_trget = None
|
||||
installed_lang = None
|
||||
|
||||
def tr(s, context=None):
|
||||
if _trfunc is None:
|
||||
return s
|
||||
else:
|
||||
if context:
|
||||
return _trfunc(s, context)
|
||||
else:
|
||||
return _trfunc(s)
|
||||
|
||||
def trget(domain):
|
||||
# Returns a tr() function for the specified domain.
|
||||
if _trget is None:
|
||||
return lambda s: tr(s, domain)
|
||||
else:
|
||||
return _trget(domain)
|
||||
|
||||
def set_tr(new_tr, new_trget=None):
|
||||
global _trfunc, _trget
|
||||
_trfunc = new_tr
|
||||
if new_trget is not None:
|
||||
_trget = new_trget
|
||||
|
||||
def get_locale_name(lang):
|
||||
if ISWINDOWS:
|
||||
# http://msdn.microsoft.com/en-us/library/39cwe7zf(vs.71).aspx
|
||||
LANG2LOCALENAME = {
|
||||
'cs': 'czy',
|
||||
'de': 'deu',
|
||||
'es': 'esn',
|
||||
'fr': 'fra',
|
||||
'it': 'ita',
|
||||
'ko': 'korean',
|
||||
'nl': 'nld',
|
||||
'pl_PL': 'polish_poland',
|
||||
'pt_BR': 'ptb',
|
||||
'ru': 'rus',
|
||||
'zh_CN': 'chs',
|
||||
}
|
||||
else:
|
||||
LANG2LOCALENAME = {
|
||||
'cs': 'cs_CZ',
|
||||
'de': 'de_DE',
|
||||
'es': 'es_ES',
|
||||
'fr': 'fr_FR',
|
||||
'it': 'it_IT',
|
||||
'nl': 'nl_NL',
|
||||
'hy': 'hy_AM',
|
||||
'ko': 'ko_KR',
|
||||
'pl_PL': 'pl_PL',
|
||||
'pt_BR': 'pt_BR',
|
||||
'ru': 'ru_RU',
|
||||
'uk': 'uk_UA',
|
||||
'vi': 'vi_VN',
|
||||
'zh_CN': 'zh_CN',
|
||||
}
|
||||
if lang not in LANG2LOCALENAME:
|
||||
return None
|
||||
result = LANG2LOCALENAME[lang]
|
||||
if ISLINUX:
|
||||
result += '.UTF-8'
|
||||
return result
|
||||
|
||||
#--- Qt
|
||||
def install_qt_trans(lang=None):
|
||||
from PyQt5.QtCore import QCoreApplication, QTranslator, QLocale
|
||||
if not lang:
|
||||
lang = str(QLocale.system().name())[:2]
|
||||
localename = get_locale_name(lang)
|
||||
if localename is not None:
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, localename)
|
||||
except locale.Error:
|
||||
logging.warning("Couldn't set locale %s", localename)
|
||||
else:
|
||||
lang = 'en'
|
||||
qtr1 = QTranslator(QCoreApplication.instance())
|
||||
qtr1.load(':/qt_%s' % lang)
|
||||
QCoreApplication.installTranslator(qtr1)
|
||||
qtr2 = QTranslator(QCoreApplication.instance())
|
||||
qtr2.load(':/%s' % lang)
|
||||
QCoreApplication.installTranslator(qtr2)
|
||||
def qt_tr(s, context='core'):
|
||||
return str(QCoreApplication.translate(context, s, None))
|
||||
set_tr(qt_tr)
|
||||
|
||||
#--- gettext
|
||||
def install_gettext_trans(base_folder, lang):
|
||||
import gettext
|
||||
def gettext_trget(domain):
|
||||
if not lang:
|
||||
return lambda s: s
|
||||
try:
|
||||
return gettext.translation(domain, localedir=base_folder, languages=[lang]).gettext
|
||||
except IOError:
|
||||
return lambda s: s
|
||||
|
||||
default_gettext = gettext_trget('core')
|
||||
def gettext_tr(s, context=None):
|
||||
if not context:
|
||||
return default_gettext(s)
|
||||
else:
|
||||
trfunc = gettext_trget(context)
|
||||
return trfunc(s)
|
||||
set_tr(gettext_tr, gettext_trget)
|
||||
global installed_lang
|
||||
installed_lang = lang
|
||||
|
||||
def install_gettext_trans_under_cocoa():
|
||||
from cocoa import proxy
|
||||
resFolder = proxy.getResourcePath()
|
||||
baseFolder = op.join(resFolder, 'locale')
|
||||
currentLang = proxy.systemLang()
|
||||
install_gettext_trans(baseFolder, currentLang)
|
||||
localename = get_locale_name(currentLang)
|
||||
if localename is not None:
|
||||
locale.setlocale(locale.LC_ALL, localename)
|
||||
|
||||
def install_gettext_trans_under_qt(base_folder, lang=None):
|
||||
# So, we install the gettext locale, great, but we also should try to install qt_*.qm if
|
||||
# available so that strings that are inside Qt itself over which I have no control are in the
|
||||
# right language.
|
||||
from PyQt5.QtCore import QCoreApplication, QTranslator, QLocale, QLibraryInfo
|
||||
if not lang:
|
||||
lang = str(QLocale.system().name())[:2]
|
||||
localename = get_locale_name(lang)
|
||||
if localename is not None:
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, localename)
|
||||
except locale.Error:
|
||||
logging.warning("Couldn't set locale %s", localename)
|
||||
qmname = 'qt_%s' % lang
|
||||
if ISLINUX:
|
||||
# Under linux, a full Qt installation is already available in the system, we didn't bundle
|
||||
# up the qm files in our package, so we have to load translations from the system.
|
||||
qmpath = op.join(QLibraryInfo.location(QLibraryInfo.TranslationsPath), qmname)
|
||||
else:
|
||||
qmpath = op.join(base_folder, qmname)
|
||||
qtr = QTranslator(QCoreApplication.instance())
|
||||
qtr.load(qmpath)
|
||||
QCoreApplication.installTranslator(qtr)
|
||||
install_gettext_trans(base_folder, lang)
|
||||
407
hscommon/util.py
407
hscommon/util.py
@@ -1,407 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2011-01-11
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import sys
|
||||
import os
|
||||
import os.path as op
|
||||
import re
|
||||
from math import ceil
|
||||
import glob
|
||||
import shutil
|
||||
from datetime import timedelta
|
||||
|
||||
from .path import Path, pathify, log_io_error
|
||||
|
||||
def nonone(value, replace_value):
|
||||
"""Returns ``value`` if ``value`` is not ``None``. Returns ``replace_value`` otherwise.
|
||||
"""
|
||||
if value is None:
|
||||
return replace_value
|
||||
else:
|
||||
return value
|
||||
|
||||
def tryint(value, default=0):
|
||||
"""Tries to convert ``value`` to in ``int`` and returns ``default`` if it fails.
|
||||
"""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def minmax(value, min_value, max_value):
|
||||
"""Returns `value` or one of the min/max bounds if `value` is not between them.
|
||||
"""
|
||||
return min(max(value, min_value), max_value)
|
||||
|
||||
#--- Sequence related
|
||||
|
||||
def dedupe(iterable):
|
||||
"""Returns a list of elements in ``iterable`` with all dupes removed.
|
||||
|
||||
The order of the elements is preserved.
|
||||
"""
|
||||
result = []
|
||||
seen = {}
|
||||
for item in iterable:
|
||||
if item in seen:
|
||||
continue
|
||||
seen[item] = 1
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def flatten(iterables, start_with=None):
|
||||
"""Takes a list of lists ``iterables`` and returns a list containing elements of every list.
|
||||
|
||||
If ``start_with`` is not ``None``, the result will start with ``start_with`` items, exactly as
|
||||
if ``start_with`` would be the first item of lists.
|
||||
"""
|
||||
result = []
|
||||
if start_with:
|
||||
result.extend(start_with)
|
||||
for iterable in iterables:
|
||||
result.extend(iterable)
|
||||
return result
|
||||
|
||||
def first(iterable):
|
||||
"""Returns the first item of ``iterable``.
|
||||
"""
|
||||
try:
|
||||
return next(iter(iterable))
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
def stripfalse(seq):
|
||||
"""Returns a sequence with all false elements stripped out of seq.
|
||||
"""
|
||||
return [x for x in seq if x]
|
||||
|
||||
def extract(predicate, iterable):
|
||||
"""Separates the wheat from the shaft (`predicate` defines what's the wheat), and returns both.
|
||||
"""
|
||||
wheat = []
|
||||
shaft = []
|
||||
for item in iterable:
|
||||
if predicate(item):
|
||||
wheat.append(item)
|
||||
else:
|
||||
shaft.append(item)
|
||||
return wheat, shaft
|
||||
|
||||
def allsame(iterable):
|
||||
"""Returns whether all elements of 'iterable' are the same.
|
||||
"""
|
||||
it = iter(iterable)
|
||||
try:
|
||||
first_item = next(it)
|
||||
except StopIteration:
|
||||
raise ValueError("iterable cannot be empty")
|
||||
return all(element == first_item for element in it)
|
||||
|
||||
def trailiter(iterable, skipfirst=False):
|
||||
"""Yields (prev_element, element), starting with (None, first_element).
|
||||
|
||||
If skipfirst is True, there will be no (None, item1) element and we'll start
|
||||
directly with (item1, item2).
|
||||
"""
|
||||
it = iter(iterable)
|
||||
if skipfirst:
|
||||
prev = next(it)
|
||||
else:
|
||||
prev = None
|
||||
for item in it:
|
||||
yield prev, item
|
||||
prev = item
|
||||
|
||||
def iterconsume(seq, reverse=True):
|
||||
"""Iterate over ``seq`` and pops yielded objects.
|
||||
|
||||
Because we use the ``pop()`` method, we reverse ``seq`` before proceeding. If you don't need
|
||||
to do that, set ``reverse`` to ``False``.
|
||||
|
||||
This is useful in tight memory situation where you are looping over a sequence of objects that
|
||||
are going to be discarded afterwards. If you're creating other objects during that iteration
|
||||
you might want to use this to avoid ``MemoryError``.
|
||||
"""
|
||||
if reverse:
|
||||
seq.reverse()
|
||||
while seq:
|
||||
yield seq.pop()
|
||||
|
||||
#--- String related
|
||||
|
||||
def escape(s, to_escape, escape_with='\\'):
|
||||
"""Returns ``s`` with characters in ``to_escape`` all prepended with ``escape_with``.
|
||||
"""
|
||||
return ''.join((escape_with + c if c in to_escape else c) for c in s)
|
||||
|
||||
def get_file_ext(filename):
|
||||
"""Returns the lowercase extension part of filename, without the dot.
|
||||
"""
|
||||
pos = filename.rfind('.')
|
||||
if pos > -1:
|
||||
return filename[pos + 1:].lower()
|
||||
else:
|
||||
return ''
|
||||
|
||||
def rem_file_ext(filename):
|
||||
"""Returns the filename without extension.
|
||||
"""
|
||||
pos = filename.rfind('.')
|
||||
if pos > -1:
|
||||
return filename[:pos]
|
||||
else:
|
||||
return filename
|
||||
|
||||
def pluralize(number, word, decimals=0, plural_word=None):
|
||||
"""Returns a pluralized string with ``number`` in front of ``word``.
|
||||
|
||||
Adds a 's' to s if ``number`` > 1.
|
||||
``number``: The number to go in front of s
|
||||
``word``: The word to go after number
|
||||
``decimals``: The number of digits after the dot
|
||||
``plural_word``: If the plural rule for word is more complex than adding a 's', specify a plural
|
||||
"""
|
||||
number = round(number, decimals)
|
||||
format = "%%1.%df %%s" % decimals
|
||||
if number > 1:
|
||||
if plural_word is None:
|
||||
word += 's'
|
||||
else:
|
||||
word = plural_word
|
||||
return format % (number, word)
|
||||
|
||||
def format_time(seconds, with_hours=True):
|
||||
"""Transforms seconds in a hh:mm:ss string.
|
||||
|
||||
If ``with_hours`` if false, the format is mm:ss.
|
||||
"""
|
||||
minus = seconds < 0
|
||||
if minus:
|
||||
seconds *= -1
|
||||
m, s = divmod(seconds, 60)
|
||||
if with_hours:
|
||||
h, m = divmod(m, 60)
|
||||
r = '%02d:%02d:%02d' % (h, m, s)
|
||||
else:
|
||||
r = '%02d:%02d' % (m,s)
|
||||
if minus:
|
||||
return '-' + r
|
||||
else:
|
||||
return r
|
||||
|
||||
def format_time_decimal(seconds):
|
||||
"""Transforms seconds in a strings like '3.4 minutes'.
|
||||
"""
|
||||
minus = seconds < 0
|
||||
if minus:
|
||||
seconds *= -1
|
||||
if seconds < 60:
|
||||
r = pluralize(seconds, 'second', 1)
|
||||
elif seconds < 3600:
|
||||
r = pluralize(seconds / 60.0, 'minute', 1)
|
||||
elif seconds < 86400:
|
||||
r = pluralize(seconds / 3600.0, 'hour', 1)
|
||||
else:
|
||||
r = pluralize(seconds / 86400.0, 'day', 1)
|
||||
if minus:
|
||||
return '-' + r
|
||||
else:
|
||||
return r
|
||||
|
||||
SIZE_DESC = ('B','KB','MB','GB','TB','PB','EB','ZB','YB')
|
||||
SIZE_VALS = tuple(1024 ** i for i in range(1,9))
|
||||
def format_size(size, decimal=0, forcepower=-1, showdesc=True):
|
||||
"""Transform a byte count in a formatted string (KB, MB etc..).
|
||||
|
||||
``size`` is the number of bytes to format.
|
||||
``decimal`` is the number digits after the dot.
|
||||
``forcepower`` is the desired suffix. 0 is B, 1 is KB, 2 is MB etc.. if kept at -1, the suffix
|
||||
will be automatically chosen (so the resulting number is always below 1024).
|
||||
if ``showdesc`` is ``True``, the suffix will be shown after the number.
|
||||
Usage example::
|
||||
|
||||
>>> format_size(1234, decimal=2, showdesc=True)
|
||||
'1.21 KB'
|
||||
"""
|
||||
if forcepower < 0:
|
||||
i = 0
|
||||
while size >= SIZE_VALS[i]:
|
||||
i += 1
|
||||
else:
|
||||
i = forcepower
|
||||
if i > 0:
|
||||
div = SIZE_VALS[i-1]
|
||||
else:
|
||||
div = 1
|
||||
format = '%%%d.%df' % (decimal,decimal)
|
||||
negative = size < 0
|
||||
divided_size = ((0.0 + abs(size)) / div)
|
||||
if decimal == 0:
|
||||
divided_size = ceil(divided_size)
|
||||
else:
|
||||
divided_size = ceil(divided_size * (10 ** decimal)) / (10 ** decimal)
|
||||
if negative:
|
||||
divided_size *= -1
|
||||
result = format % divided_size
|
||||
if showdesc:
|
||||
result += ' ' + SIZE_DESC[i]
|
||||
return result
|
||||
|
||||
_valid_xml_range = '\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD'
|
||||
if sys.maxunicode > 0x10000:
|
||||
_valid_xml_range += '%s-%s' % (chr(0x10000), chr(min(sys.maxunicode, 0x10FFFF)))
|
||||
RE_INVALID_XML_SUB = re.compile('[^%s]' % _valid_xml_range, re.U).sub
|
||||
|
||||
def remove_invalid_xml(s, replace_with=' '):
|
||||
return RE_INVALID_XML_SUB(replace_with, s)
|
||||
|
||||
def multi_replace(s, replace_from, replace_to=''):
|
||||
"""A function like str.replace() with multiple replacements.
|
||||
|
||||
``replace_from`` is a list of things you want to replace. Ex: ['a','bc','d']
|
||||
``replace_to`` is a list of what you want to replace to.
|
||||
If ``replace_to`` is a list and has the same length as ``replace_from``, ``replace_from``
|
||||
items will be translated to corresponding ``replace_to``. A ``replace_to`` list must
|
||||
have the same length as ``replace_from``
|
||||
If ``replace_to`` is a string, all ``replace_from`` occurence will be replaced
|
||||
by that string.
|
||||
``replace_from`` can also be a str. If it is, every char in it will be translated
|
||||
as if ``replace_from`` would be a list of chars. If ``replace_to`` is a str and has
|
||||
the same length as ``replace_from``, it will be transformed into a list.
|
||||
"""
|
||||
if isinstance(replace_to, str) and (len(replace_from) != len(replace_to)):
|
||||
replace_to = [replace_to for r in replace_from]
|
||||
if len(replace_from) != len(replace_to):
|
||||
raise ValueError('len(replace_from) must be equal to len(replace_to)')
|
||||
replace = list(zip(replace_from, replace_to))
|
||||
for r_from, r_to in [r for r in replace if r[0] in s]:
|
||||
s = s.replace(r_from, r_to)
|
||||
return s
|
||||
|
||||
#--- Date related
|
||||
|
||||
def iterdaterange(start, end):
|
||||
"""Yields every day between ``start`` and ``end``.
|
||||
"""
|
||||
date = start
|
||||
while date <= end:
|
||||
yield date
|
||||
date += timedelta(1)
|
||||
|
||||
#--- Files related
|
||||
|
||||
@pathify
|
||||
def modified_after(first_path: Path, second_path: Path):
|
||||
"""Returns ``True`` if first_path's mtime is higher than second_path's mtime.
|
||||
|
||||
If one of the files doesn't exist or is ``None``, it is considered "never modified".
|
||||
"""
|
||||
try:
|
||||
first_mtime = first_path.stat().st_mtime
|
||||
except (EnvironmentError, AttributeError):
|
||||
return False
|
||||
try:
|
||||
second_mtime = second_path.stat().st_mtime
|
||||
except (EnvironmentError, AttributeError):
|
||||
return True
|
||||
return first_mtime > second_mtime
|
||||
|
||||
def find_in_path(name, paths=None):
|
||||
"""Search for `name` in all directories of `paths` and return the absolute path of the first
|
||||
occurrence. If `paths` is None, $PATH is used.
|
||||
"""
|
||||
if paths is None:
|
||||
paths = os.environ['PATH']
|
||||
if isinstance(paths, str): # if it's not a string, it's already a list
|
||||
paths = paths.split(os.pathsep)
|
||||
for path in paths:
|
||||
if op.exists(op.join(path, name)):
|
||||
return op.join(path, name)
|
||||
return None
|
||||
|
||||
@log_io_error
|
||||
@pathify
|
||||
def delete_if_empty(path: Path, files_to_delete=[]):
|
||||
"""Deletes the directory at 'path' if it is empty or if it only contains files_to_delete.
|
||||
"""
|
||||
if not path.exists() or not path.isdir():
|
||||
return
|
||||
contents = path.listdir()
|
||||
if any(p for p in contents if (p.name not in files_to_delete) or p.isdir()):
|
||||
return False
|
||||
for p in contents:
|
||||
p.remove()
|
||||
path.rmdir()
|
||||
return True
|
||||
|
||||
def open_if_filename(infile, mode='rb'):
|
||||
"""If ``infile`` is a string, it opens and returns it. If it's already a file object, it simply returns it.
|
||||
|
||||
This function returns ``(file, should_close_flag)``. The should_close_flag is True is a file has
|
||||
effectively been opened (if we already pass a file object, we assume that the responsibility for
|
||||
closing the file has already been taken). Example usage::
|
||||
|
||||
fp, shouldclose = open_if_filename(infile)
|
||||
dostuff()
|
||||
if shouldclose:
|
||||
fp.close()
|
||||
"""
|
||||
if isinstance(infile, Path):
|
||||
return (infile.open(mode), True)
|
||||
if isinstance(infile, str):
|
||||
return (open(infile, mode), True)
|
||||
else:
|
||||
return (infile, False)
|
||||
|
||||
def ensure_folder(path):
|
||||
"Create `path` as a folder if it doesn't exist."
|
||||
if not op.exists(path):
|
||||
os.makedirs(path)
|
||||
|
||||
def ensure_file(path):
|
||||
"Create `path` as an empty file if it doesn't exist."
|
||||
if not op.exists(path):
|
||||
open(path, 'w').close()
|
||||
|
||||
def delete_files_with_pattern(folder_path, pattern, recursive=True):
|
||||
"""Delete all files (or folders) in `folder_path` that match the glob `pattern`.
|
||||
"""
|
||||
to_delete = glob.glob(op.join(folder_path, pattern))
|
||||
for fn in to_delete:
|
||||
if op.isdir(fn):
|
||||
shutil.rmtree(fn)
|
||||
else:
|
||||
os.remove(fn)
|
||||
if recursive:
|
||||
subpaths = [op.join(folder_path, fn) for fn in os.listdir(folder_path)]
|
||||
subfolders = [p for p in subpaths if op.isdir(p)]
|
||||
for p in subfolders:
|
||||
delete_files_with_pattern(p, pattern, True)
|
||||
|
||||
class FileOrPath:
|
||||
"""Does the same as :func:`open_if_filename`, but it can be used with a ``with`` statement.
|
||||
|
||||
Example::
|
||||
|
||||
with FileOrPath(infile):
|
||||
dostuff()
|
||||
"""
|
||||
def __init__(self, file_or_path, mode='rb'):
|
||||
self.file_or_path = file_or_path
|
||||
self.mode = mode
|
||||
self.mustclose = False
|
||||
self.fp = None
|
||||
|
||||
def __enter__(self):
|
||||
self.fp, self.mustclose = open_if_filename(self.file_or_path, self.mode)
|
||||
return self.fp
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
if self.fp and self.mustclose:
|
||||
self.fp.close()
|
||||
|
||||
Reference in New Issue
Block a user