diff --git a/hscommon/.gitignore b/hscommon/.gitignore new file mode 100644 index 00000000..642f9e32 --- /dev/null +++ b/hscommon/.gitignore @@ -0,0 +1,5 @@ +*.pyc +*.mo +*.so +.DS_Store +/docs_html \ No newline at end of file diff --git a/hscommon/LICENSE b/hscommon/LICENSE new file mode 100644 index 00000000..5a8d3ceb --- /dev/null +++ b/hscommon/LICENSE @@ -0,0 +1,10 @@ +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. \ No newline at end of file diff --git a/hscommon/README b/hscommon/README new file mode 100644 index 00000000..acc2ccbc --- /dev/null +++ b/hscommon/README @@ -0,0 +1,3 @@ +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. diff --git a/hscommon/__init__.py b/hscommon/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hscommon/build.py b/hscommon/build.py new file mode 100644 index 00000000..86cdd328 --- /dev/null +++ b/hscommon/build.py @@ -0,0 +1,475 @@ +# 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 {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: + try: + version = next(it) + date = next(it) + description = next(it) + except StopIteration: + return + 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 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)) diff --git a/hscommon/build_ext.py b/hscommon/build_ext.py new file mode 100644 index 00000000..f62e38a5 --- /dev/null +++ b/hscommon/build_ext.py @@ -0,0 +1,33 @@ +# Copyright 2016 Virgil Dupras + +# 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 argparse + +from setuptools import setup, Extension + +def get_parser(): + parser = argparse.ArgumentParser(description="Build an arbitrary Python extension.") + parser.add_argument( + 'source_files', nargs='+', + help="List of source files to compile" + ) + parser.add_argument( + 'name', nargs=1, + help="Name of the resulting extension" + ) + return parser + +def main(): + args = get_parser().parse_args() + print("Building {}...".format(args.name[0])) + ext = Extension(args.name[0], args.source_files) + setup( + script_args=['build_ext', '--inplace'], + ext_modules=[ext], + ) + +if __name__ == '__main__': + main() diff --git a/hscommon/conflict.py b/hscommon/conflict.py new file mode 100644 index 00000000..9a62aa90 --- /dev/null +++ b/hscommon/conflict.py @@ -0,0 +1,79 @@ +# 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 \ No newline at end of file diff --git a/hscommon/debug.py b/hscommon/debug.py new file mode 100644 index 00000000..e7903152 --- /dev/null +++ b/hscommon/debug.py @@ -0,0 +1,22 @@ +# 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) \ No newline at end of file diff --git a/hscommon/desktop.py b/hscommon/desktop.py new file mode 100644 index 00000000..71d8078d --- /dev/null +++ b/hscommon/desktop.py @@ -0,0 +1,93 @@ +# 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' diff --git a/hscommon/geometry.py b/hscommon/geometry.py new file mode 100644 index 00000000..d67738bd --- /dev/null +++ b/hscommon/geometry.py @@ -0,0 +1,218 @@ +# 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 ''.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 ''.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 ''.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 + diff --git a/hscommon/gui/__init__.py b/hscommon/gui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hscommon/gui/base.py b/hscommon/gui/base.py new file mode 100644 index 00000000..b475daec --- /dev/null +++ b/hscommon/gui/base.py @@ -0,0 +1,80 @@ +# Copyright 2016 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. + + Most of the time, you will only one to bind a view once in the lifetime of your GUI object. + That is why there are safeguards, when setting ``view`` to ensure that we don't double-assign. + However, sometimes you want to be able to re-bind another view. In this case, set the + ``multibind`` flag to ``True`` and the safeguard will be disabled. + """ + def __init__(self, multibind=False): + self._view = None + self._multibind = multibind + + 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 and value is None: + # Initial view assignment + return + if self._view is None or self._multibind: + if value is None: + value = NoopGUI() + 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() + diff --git a/hscommon/gui/column.py b/hscommon/gui/column.py new file mode 100644 index 00000000..66e22cfd --- /dev/null +++ b/hscommon/gui/column.py @@ -0,0 +1,289 @@ +# 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] + diff --git a/hscommon/gui/progress_window.py b/hscommon/gui/progress_window.py new file mode 100644 index 00000000..e8f83e71 --- /dev/null +++ b/hscommon/gui/progress_window.py @@ -0,0 +1,133 @@ +# Copyright 2016 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 finish_func: A function ``f(jobid)`` that is called when a job is completed. ``jobid`` is + an arbitrary id passed to :meth:`run`. + :param error_func: A function ``f(jobid, err)`` that is called when an exception is raised and + unhandled during the job. If not specified, the error will be raised in the + main thread. If it's specified, it's your responsibility to raise the error + if you want to. If the function returns ``True``, ``finish_func()`` will be + called as if the job terminated normally. + """ + def __init__(self, finish_func, error_func=None): + # 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 + self._error_func = error_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() + should_continue = True + if self.last_error is not None: + err = self.last_error.with_traceback(self.last_traceback) + if self._error_func is not None: + should_continue = self._error_func(self.jobid, err) + else: + raise err + if not self.job_cancelled and should_continue: + 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() + diff --git a/hscommon/gui/selectable_list.py b/hscommon/gui/selectable_list.py new file mode 100644 index 00000000..df6ed357 --- /dev/null +++ b/hscommon/gui/selectable_list.py @@ -0,0 +1,208 @@ +# 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() diff --git a/hscommon/gui/table.py b/hscommon/gui/table.py new file mode 100644 index 00000000..1e3c0c44 --- /dev/null +++ b/hscommon/gui/table.py @@ -0,0 +1,546 @@ +# 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:`Selectable.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.view.refresh() + # We have to set "edited" after calling refresh() because some UI are trigger-happy + # about calling save_edits() and they do so during calls to refresh(). We don't want + # a call to save_edits() during refresh prematurely mess with our newly added item. + self.edited = row + 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_``, 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) + diff --git a/hscommon/gui/text_field.py b/hscommon/gui/text_field.py new file mode 100644 index 00000000..e918c58a --- /dev/null +++ b/hscommon/gui/text_field.py @@ -0,0 +1,108 @@ +# 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() + diff --git a/hscommon/gui/tree.py b/hscommon/gui/tree.py new file mode 100644 index 00000000..5d58d36a --- /dev/null +++ b/hscommon/gui/tree.py @@ -0,0 +1,251 @@ +# 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 '' % 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) + diff --git a/hscommon/jobprogress/__init__.py b/hscommon/jobprogress/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hscommon/jobprogress/job.py b/hscommon/jobprogress/job.py new file mode 100644 index 00000000..214a9889 --- /dev/null +++ b/hscommon/jobprogress/job.py @@ -0,0 +1,166 @@ +# 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() diff --git a/hscommon/jobprogress/performer.py b/hscommon/jobprogress/performer.py new file mode 100644 index 00000000..12e0dc52 --- /dev/null +++ b/hscommon/jobprogress/performer.py @@ -0,0 +1,72 @@ +# 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() + diff --git a/hscommon/jobprogress/qt.py b/hscommon/jobprogress/qt.py new file mode 100644 index 00000000..70901385 --- /dev/null +++ b/hscommon/jobprogress/qt.py @@ -0,0 +1,52 @@ +# 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) + diff --git a/hscommon/loc.py b/hscommon/loc.py new file mode 100644 index 00000000..c00bc73d --- /dev/null +++ b/hscommon/loc.py @@ -0,0 +1,208 @@ +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' + +# There isn't a 1-on-1 exact fit between .po language codes and cocoa ones +PO2COCOA = { + 'pl_PL': 'pl', + 'pt_BR': 'pt-BR', + 'zh_CN': 'zh-Hans', +} + +COCOA2PO = {v: k for k, v in PO2COCOA.items()} + +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 generate_cocoa_strings_from_xib(xib_folder): + xibs = [op.join(xib_folder, fn) for fn in os.listdir(xib_folder) if fn.endswith('.xib')] + for xib in xibs: + dest = xib.replace('.xib', '.strings') + print_and_do('ibtool {} --generate-strings-file {}'.format(xib, dest)) + print_and_do('iconv -f utf-16 -t utf-8 {0} | tee {0}'.format(dest)) + +def localize_stringsfile(stringsfile, dest_root_folder): + stringsfile_name = op.basename(stringsfile) + for lang in get_langs('locale'): + pofile = op.join('locale', lang, 'LC_MESSAGES', 'ui.po') + cocoa_lang = PO2COCOA.get(lang, lang) + dest_lproj = op.join(dest_root_folder, cocoa_lang + '.lproj') + ensure_folder(dest_lproj) + po2strings(pofile, stringsfile, op.join(dest_lproj, stringsfile_name)) + +def localize_all_stringsfiles(src_folder, dest_root_folder): + stringsfiles = [op.join(src_folder, fn) for fn in os.listdir(src_folder) if fn.endswith('.strings')] + for path in stringsfiles: + localize_stringsfile(path, dest_root_folder) diff --git a/hscommon/notify.py b/hscommon/notify.py new file mode 100644 index 00000000..92b5e3dd --- /dev/null +++ b/hscommon/notify.py @@ -0,0 +1,89 @@ +# 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) + diff --git a/hscommon/path.py b/hscommon/path.py new file mode 100644 index 00000000..b508e6fe --- /dev/null +++ b/hscommon/path.py @@ -0,0 +1,243 @@ +# 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 diff --git a/hscommon/plat.py b/hscommon/plat.py new file mode 100644 index 00000000..fa5f1737 --- /dev/null +++ b/hscommon/plat.py @@ -0,0 +1,16 @@ +# 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') \ No newline at end of file diff --git a/hscommon/pygettext.py b/hscommon/pygettext.py new file mode 100644 index 00000000..ad3157b6 --- /dev/null +++ b/hscommon/pygettext.py @@ -0,0 +1,417 @@ +# This module was taken from CPython's Tools/i18n and dirtily hacked to bypass the need for cmdline +# invocation. + +# Originally written by Barry Warsaw +# +# Minimally patched to make it even more xgettext compatible +# by Peter Funk +# +# 2002-11-22 Jürgen Hermann +# 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() diff --git a/hscommon/sphinxgen.py b/hscommon/sphinxgen.py new file mode 100644 index 00000000..1f75c19b --- /dev/null +++ b/hscommon/sphinxgen.py @@ -0,0 +1,80 @@ +# Copyright 2018 Virgil Dupras +# +# 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 distutils.version import LooseVersion +from pkg_resources import load_entry_point, get_distribution + +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)) + if op.exists(confpath): + conf_out = op.join(basepath, 'conf.py') + filereplace(confpath, conf_out, **confrepl) + if LooseVersion(get_distribution("sphinx").version) >= LooseVersion("1.7.0"): + from sphinx.cmd.build import build_main as sphinx_build + # Call the sphinx_build function, which is the same as doing sphinx-build from cli + try: + sphinx_build([basepath, destpath]) + except SystemExit: + print("Sphinx called sys.exit(), but we're cancelling it because we don't actually want to exit") + else: + # 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") diff --git a/hscommon/sqlite.py b/hscommon/sqlite.py new file mode 100644 index 00000000..30af4b7c --- /dev/null +++ b/hscommon/sqlite.py @@ -0,0 +1,141 @@ +# 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() + diff --git a/hscommon/tests/__init__.py b/hscommon/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hscommon/tests/conflict_test.py b/hscommon/tests/conflict_test.py new file mode 100644 index 00000000..2b5d0f1a --- /dev/null +++ b/hscommon/tests/conflict_test.py @@ -0,0 +1,104 @@ +# 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() + diff --git a/hscommon/tests/notify_test.py b/hscommon/tests/notify_test.py new file mode 100644 index 00000000..efc1b59e --- /dev/null +++ b/hscommon/tests/notify_test.py @@ -0,0 +1,140 @@ +# 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) diff --git a/hscommon/tests/path_test.py b/hscommon/tests/path_test.py new file mode 100644 index 00000000..a1ee2805 --- /dev/null +++ b/hscommon/tests/path_test.py @@ -0,0 +1,256 @@ +# 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 diff --git a/hscommon/tests/selectable_list_test.py b/hscommon/tests/selectable_list_test.py new file mode 100644 index 00000000..10b36ef0 --- /dev/null +++ b/hscommon/tests/selectable_list_test.py @@ -0,0 +1,65 @@ +# 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) \ No newline at end of file diff --git a/hscommon/tests/sqlite_test.py b/hscommon/tests/sqlite_test.py new file mode 100644 index 00000000..58bea9e3 --- /dev/null +++ b/hscommon/tests/sqlite_test.py @@ -0,0 +1,126 @@ +# 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) diff --git a/hscommon/tests/table_test.py b/hscommon/tests/table_test.py new file mode 100644 index 00000000..9c4ecee3 --- /dev/null +++ b/hscommon/tests/table_test.py @@ -0,0 +1,325 @@ +# 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, viewclass=CallLogger): + GUITable.__init__(self) + self.view = viewclass() + self.view.model = self + 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 + +def test_add_with_view_that_saves_during_refresh(): + # Calling save_edits during refresh() called by add() is ignored. + class TableView(CallLogger): + def refresh(self): + self.model.save_edits() + + table = TestGUITable(10, viewclass=TableView) + table.add() + assert table.edited is not None # still in edit mode + diff --git a/hscommon/tests/tree_test.py b/hscommon/tests/tree_test.py new file mode 100644 index 00000000..b3bada73 --- /dev/null +++ b/hscommon/tests/tree_test.py @@ -0,0 +1,109 @@ +# 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 diff --git a/hscommon/tests/util_test.py b/hscommon/tests/util_test.py new file mode 100644 index 00000000..73c860b8 --- /dev/null +++ b/hscommon/tests/util_test.py @@ -0,0 +1,325 @@ +# 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) + diff --git a/hscommon/testutil.py b/hscommon/testutil.py new file mode 100644 index 00000000..2af87550 --- /dev/null +++ b/hscommon/testutil.py @@ -0,0 +1,221 @@ +# 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 eq_sorted(a, b, msg=None): + """If both a and b are iterable sort them and compare using eq_, otherwise just pass them through to eq_ anyway.""" + try: + eq_(sorted(a), sorted(b), msg) + except TypeError: + eq_(a, b, msg) + +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, logger=None): + if logger is None: + logger = CallLogger() + 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.getfixturevalue(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 + diff --git a/hscommon/trans.py b/hscommon/trans.py new file mode 100644 index 00000000..dc3951a2 --- /dev/null +++ b/hscommon/trans.py @@ -0,0 +1,164 @@ +# 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', + 'el': 'grc', + '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', + 'el': 'el_GR', + '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) diff --git a/hscommon/util.py b/hscommon/util.py new file mode 100644 index 00000000..a85748ee --- /dev/null +++ b/hscommon/util.py @@ -0,0 +1,413 @@ +# 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: + try: + prev = next(it) + except StopIteration: + return + 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 + +# It might seem like needless namespace pollution, but the speedup gained by this constant is +# significant, so it stays. +ONE_DAY = timedelta(1) +def iterdaterange(start, end): + """Yields every day between ``start`` and ``end``. + """ + date = start + while date <= end: + yield date + date += ONE_DAY + +#--- 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() + diff --git a/qtlib/.gitignore b/qtlib/.gitignore new file mode 100644 index 00000000..7f1e166c --- /dev/null +++ b/qtlib/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +*.pyc +*.mo +.DS_Store \ No newline at end of file diff --git a/qtlib/.tx/config b/qtlib/.tx/config new file mode 100644 index 00000000..67e19649 --- /dev/null +++ b/qtlib/.tx/config @@ -0,0 +1,8 @@ +[main] +host = https://www.transifex.com + +[hscommon.qtlib] +file_filter = locale//LC_MESSAGES/qtlib.po +source_file = locale/qtlib.pot +source_lang = en +type = PO diff --git a/qtlib/LICENSE b/qtlib/LICENSE new file mode 100644 index 00000000..5a8d3ceb --- /dev/null +++ b/qtlib/LICENSE @@ -0,0 +1,10 @@ +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. \ No newline at end of file diff --git a/qtlib/__init__.py b/qtlib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qtlib/about_box.py b/qtlib/about_box.py new file mode 100644 index 00000000..573296f2 --- /dev/null +++ b/qtlib/about_box.py @@ -0,0 +1,76 @@ +# Created By: Virgil Dupras +# Created On: 2009-05-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 + +from PyQt5.QtCore import Qt, QCoreApplication +from PyQt5.QtGui import QPixmap, QFont +from PyQt5.QtWidgets import (QDialog, QDialogButtonBox, QSizePolicy, QHBoxLayout, QVBoxLayout, + QLabel, QApplication) + +from hscommon.trans import trget + +tr = trget('qtlib') + +class AboutBox(QDialog): + def __init__(self, parent, app, **kwargs): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint | Qt.MSWindowsFixedSizeDialogHint + super().__init__(parent, flags, **kwargs) + self.app = app + self._setupUi() + + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + def _setupUi(self): + self.setWindowTitle(tr("About {}").format(QCoreApplication.instance().applicationName())) + self.resize(400, 190) + sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.sizePolicy().hasHeightForWidth()) + self.setSizePolicy(sizePolicy) + self.horizontalLayout = QHBoxLayout(self) + self.logoLabel = QLabel(self) + self.logoLabel.setPixmap(QPixmap(':/%s_big' % self.app.LOGO_NAME)) + self.horizontalLayout.addWidget(self.logoLabel) + self.verticalLayout = QVBoxLayout() + self.nameLabel = QLabel(self) + font = QFont() + font.setWeight(75) + font.setBold(True) + self.nameLabel.setFont(font) + self.nameLabel.setText(QCoreApplication.instance().applicationName()) + self.verticalLayout.addWidget(self.nameLabel) + self.versionLabel = QLabel(self) + self.versionLabel.setText(tr("Version {}").format(QCoreApplication.instance().applicationVersion())) + self.verticalLayout.addWidget(self.versionLabel) + self.label_3 = QLabel(self) + self.verticalLayout.addWidget(self.label_3) + self.label_3.setText(tr("Licensed under GPLv3")) + self.label = QLabel(self) + font = QFont() + font.setWeight(75) + font.setBold(True) + self.label.setFont(font) + self.verticalLayout.addWidget(self.label) + self.buttonBox = QDialogButtonBox(self) + self.buttonBox.setOrientation(Qt.Horizontal) + self.buttonBox.setStandardButtons(QDialogButtonBox.Ok) + self.verticalLayout.addWidget(self.buttonBox) + self.horizontalLayout.addLayout(self.verticalLayout) + + +if __name__ == '__main__': + import sys + app = QApplication([]) + QCoreApplication.setOrganizationName('Hardcoded Software') + QCoreApplication.setApplicationName('FooApp') + QCoreApplication.setApplicationVersion('1.2.3') + app.LOGO_NAME = '' + dialog = AboutBox(None, app) + dialog.show() + sys.exit(app.exec_()) diff --git a/qtlib/app.py b/qtlib/app.py new file mode 100644 index 00000000..4e070bd3 --- /dev/null +++ b/qtlib/app.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Created By: Virgil Dupras +# Created On: 2009-10-16 +# 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 PyQt5.QtCore import pyqtSignal, QTimer, QObject + +class Application(QObject): + finishedLaunching = pyqtSignal() + + def __init__(self): + QObject.__init__(self) + QTimer.singleShot(0, self.__launchTimerTimedOut) + + def __launchTimerTimedOut(self): + self.finishedLaunching.emit() + diff --git a/qtlib/column.py b/qtlib/column.py new file mode 100644 index 00000000..c9989628 --- /dev/null +++ b/qtlib/column.py @@ -0,0 +1,98 @@ +# Created By: Virgil Dupras +# Created On: 2009-11-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 + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QHeaderView + +class Column: + def __init__(self, attrname, defaultWidth, editor=None, alignment=Qt.AlignLeft, cantTruncate=False, painter=None, resizeToFit=False): + self.attrname = attrname + self.defaultWidth = defaultWidth + self.editor = editor + # See moneyguru #15. Painter attribute was added to allow custom painting of amount value and + # currency information. Can be used as a pattern for custom painting of any column. + self.painter = painter + self.alignment = alignment + # This is to indicate, during printing, that a column can't have its data truncated. + self.cantTruncate = cantTruncate + self.resizeToFit = resizeToFit + + +class Columns: + def __init__(self, model, columns, headerView): + self.model = model + self._headerView = headerView + self._headerView.setDefaultAlignment(Qt.AlignLeft) + def setspecs(col, modelcol): + modelcol.default_width = col.defaultWidth + modelcol.editor = col.editor + modelcol.painter = col.painter + modelcol.resizeToFit = col.resizeToFit + modelcol.alignment = col.alignment + modelcol.cantTruncate = col.cantTruncate + if columns: + for col in columns: + modelcol = self.model.column_by_name(col.attrname) + setspecs(col, modelcol) + else: + col = Column('', 100) + for modelcol in self.model.column_list: + setspecs(col, modelcol) + self.model.view = self + self._headerView.sectionMoved.connect(self.headerSectionMoved) + self._headerView.sectionResized.connect(self.headerSectionResized) + + # See moneyguru #14 and #15. This was added in order to allow automatic resizing of columns. + for column in self.model.column_list: + if column.resizeToFit: + self._headerView.setSectionResizeMode(column.logical_index, QHeaderView.ResizeToContents) + + #--- Public + def setColumnsWidth(self, widths): + #`widths` can be None. If it is, then default widths are set. + columns = self.model.column_list + if not widths: + widths = [column.default_width for column in columns] + for column, width in zip(columns, widths): + if width == 0: # column was hidden before. + width = column.default_width + self._headerView.resizeSection(column.logical_index, width) + + def setColumnsOrder(self, columnIndexes): + if not columnIndexes: + return + for destIndex, columnIndex in enumerate(columnIndexes): + # moveSection takes 2 visual index arguments, so we have to get our visual index first + visualIndex = self._headerView.visualIndex(columnIndex) + self._headerView.moveSection(visualIndex, destIndex) + + #--- Events + def headerSectionMoved(self, logicalIndex, oldVisualIndex, newVisualIndex): + attrname = self.model.column_by_index(logicalIndex).name + self.model.move_column(attrname, newVisualIndex) + + def headerSectionResized(self, logicalIndex, oldSize, newSize): + attrname = self.model.column_by_index(logicalIndex).name + self.model.resize_column(attrname, newSize) + + #--- model --> view + def restore_columns(self): + columns = self.model.ordered_columns + indexes = [col.logical_index for col in columns] + self.setColumnsOrder(indexes) + widths = [col.width for col in self.model.column_list] + if not any(widths): + widths = None + self.setColumnsWidth(widths) + for column in self.model.column_list: + visible = self.model.column_is_visible(column.name) + self._headerView.setSectionHidden(column.logical_index, not visible) + + def set_column_visible(self, colname, visible): + column = self.model.column_by_name(colname) + self._headerView.setSectionHidden(column.logical_index, not visible) diff --git a/qtlib/error_report_dialog.py b/qtlib/error_report_dialog.py new file mode 100644 index 00000000..58a03cac --- /dev/null +++ b/qtlib/error_report_dialog.py @@ -0,0 +1,84 @@ +# Created By: Virgil Dupras +# Created On: 2009-05-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 + +import traceback +import sys +import os + +from PyQt5.QtCore import Qt, QCoreApplication, QSize +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton + +from hscommon.trans import trget +from hscommon.desktop import open_url +from .util import horizontalSpacer + +tr = trget('qtlib') + +class ErrorReportDialog(QDialog): + def __init__(self, parent, github_url, error, **kwargs): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + super().__init__(parent, flags, **kwargs) + self._setupUi() + name = QCoreApplication.applicationName() + version = QCoreApplication.applicationVersion() + errorText = "Application Name: {}\nVersion: {}\n\n{}".format(name, version, error) + # Under windows, we end up with an error report without linesep if we don't mangle it + errorText = errorText.replace('\n', os.linesep) + self.errorTextEdit.setPlainText(errorText) + self.github_url = github_url + + self.sendButton.clicked.connect(self.goToGithub) + self.dontSendButton.clicked.connect(self.reject) + + def _setupUi(self): + self.setWindowTitle(tr("Error Report")) + self.resize(553, 349) + self.verticalLayout = QVBoxLayout(self) + self.label = QLabel(self) + self.label.setText(tr("Something went wrong. How about reporting the error?")) + self.label.setWordWrap(True) + self.verticalLayout.addWidget(self.label) + self.errorTextEdit = QPlainTextEdit(self) + self.errorTextEdit.setReadOnly(True) + self.verticalLayout.addWidget(self.errorTextEdit) + msg = tr( + "Error reports should be reported as Github issues. You can copy the error traceback " + "above and paste it in a new issue (bonus point if you run a search to make sure the " + "issue doesn't already exist). What usually really helps is if you add a description " + "of how you got the error. Thanks!" + "\n\n" + "Although the application should continue to run after this error, it may be in an " + "unstable state, so it is recommended that you restart the application." + ) + self.label2 = QLabel(msg) + self.label2.setWordWrap(True) + self.verticalLayout.addWidget(self.label2) + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.addItem(horizontalSpacer()) + self.dontSendButton = QPushButton(self) + self.dontSendButton.setText(tr("Close")) + self.dontSendButton.setMinimumSize(QSize(110, 0)) + self.horizontalLayout.addWidget(self.dontSendButton) + self.sendButton = QPushButton(self) + self.sendButton.setText(tr("Go to Github")) + self.sendButton.setMinimumSize(QSize(110, 0)) + self.sendButton.setDefault(True) + self.horizontalLayout.addWidget(self.sendButton) + self.verticalLayout.addLayout(self.horizontalLayout) + + def goToGithub(self): + open_url(self.github_url) + + +def install_excepthook(github_url): + def my_excepthook(exctype, value, tb): + s = ''.join(traceback.format_exception(exctype, value, tb)) + dialog = ErrorReportDialog(None, github_url, s) + dialog.exec_() + + sys.excepthook = my_excepthook diff --git a/qtlib/images/search_clear_13.png b/qtlib/images/search_clear_13.png new file mode 100644 index 00000000..99e5b04d Binary files /dev/null and b/qtlib/images/search_clear_13.png differ diff --git a/qtlib/locale/cs/LC_MESSAGES/qtlib.po b/qtlib/locale/cs/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..f15b4840 --- /dev/null +++ b/qtlib/locale/cs/LC_MESSAGES/qtlib.po @@ -0,0 +1,121 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:31+0000\n" +"Last-Translator: hsoft \n" +"Language: cs\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "" diff --git a/qtlib/locale/de/LC_MESSAGES/qtlib.po b/qtlib/locale/de/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..748eea5e --- /dev/null +++ b/qtlib/locale/de/LC_MESSAGES/qtlib.po @@ -0,0 +1,121 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:31+0000\n" +"Last-Translator: hsoft \n" +"Language: de\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "Englisch" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "Französisch" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "Deutsch" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "" diff --git a/qtlib/locale/el/LC_MESSAGES/qtlib.po b/qtlib/locale/el/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..3fd4ad1e --- /dev/null +++ b/qtlib/locale/el/LC_MESSAGES/qtlib.po @@ -0,0 +1,132 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2016-07-22 11:30+0000\n" +"Last-Translator: 1kakarot\n" +"Language: el\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "Σχετικά {}" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "Έκδοση {}" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "Αναφορά σφάλματος" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "Κάτι πήγε στραβά. Μήπως να αναφερθεί το σφάλμα;" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" +"Οι αναφορές σφαλμάτων θα πρέπει να αναφέρονται ως θέματα προς επίλυση στο " +"Github. Μπορείτε να αντιγράψετε την ακολουθία σφάλματος παραπάνω και να την " +"επικολλήσετε σε ένα νέο θέμα (Καλό θα ήταν να εκτελέσετε μια αναζήτηση για " +"να βεβαιωθείτε ότι το θέμα δεν υπάρχει ήδη). Αυτό που συνήθως βοηθά " +"πραγματικά είναι αν προσθέσετε μια περιγραφή του πώς πήρατε το σφάλμα. " +"Ευχαριστώ!\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +"Παρά το γεγονός ότι η εφαρμογή θα συνεχίσει να εκτελείται μετά από αυτό το " +"σφάλμα, μπορεί να είναι σε μια ασταθή κατάσταση, γι 'αυτό συνιστάται να " +"κάνετε επανεκκίνηση της εφαρμογής." + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "Κλείσιμο" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "Επίσκεψη Github" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "Αγγλικά" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "Γαλλικά" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "Γερμανικά" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "Ελληνικά" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "Κινέζικα (Απλοποιημένα)" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "Τσέχικα" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "Ιταλικά" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "Αρμένικα" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "Ρώσικα" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "Ουκρανέζικα" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "Γερμανικά" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "Βραζιλιάνικα" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "Ισπανικά" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "Βιετναμέζικα" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "Εκκαθάριση λίστας" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "Αναζήτηση..." diff --git a/qtlib/locale/es/LC_MESSAGES/qtlib.po b/qtlib/locale/es/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..d8a1abb6 --- /dev/null +++ b/qtlib/locale/es/LC_MESSAGES/qtlib.po @@ -0,0 +1,121 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:31+0000\n" +"Last-Translator: hsoft \n" +"Language: es\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "Acerca de {}" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "Versión {}" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "Informe de error" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "Inglés" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "Francés" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "Alemán" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "Chino (simplificado)" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "Checo" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "Italiano" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "Armenio" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "Ruso" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "Ucraniano" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "Holandés" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "Brasileño" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "Español" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "Limpiar lista" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "Búsqueda..." diff --git a/qtlib/locale/fr/LC_MESSAGES/qtlib.po b/qtlib/locale/fr/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..18802992 --- /dev/null +++ b/qtlib/locale/fr/LC_MESSAGES/qtlib.po @@ -0,0 +1,121 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:31+0000\n" +"Last-Translator: hsoft \n" +"Language: fr\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "Anglais" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "Français" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "Allemand" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "Chinois (Simplifié)" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "Tchèque" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "Italien" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "Arménien" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "Russe" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "Ukrainien" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "Néerlandais" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "Vider la liste" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "Recherche..." diff --git a/qtlib/locale/hy/LC_MESSAGES/qtlib.po b/qtlib/locale/hy/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..4767b1d9 --- /dev/null +++ b/qtlib/locale/hy/LC_MESSAGES/qtlib.po @@ -0,0 +1,121 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:31+0000\n" +"Last-Translator: hsoft \n" +"Language: hy\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "Սխալի զեկույցը" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "Անգլերեն" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "Ֆրանսերեն" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "Գերմաներեն" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "Չինարեն (Պարզեցված)" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "Չեխերեն" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "Իտալերեն" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "Մաքրել ցանկը" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "" diff --git a/qtlib/locale/it/LC_MESSAGES/qtlib.po b/qtlib/locale/it/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..a83ef9f0 --- /dev/null +++ b/qtlib/locale/it/LC_MESSAGES/qtlib.po @@ -0,0 +1,121 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:31+0000\n" +"Last-Translator: hsoft \n" +"Language: it\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "" diff --git a/qtlib/locale/ko/LC_MESSAGES/qtlib.po b/qtlib/locale/ko/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..6b331583 --- /dev/null +++ b/qtlib/locale/ko/LC_MESSAGES/qtlib.po @@ -0,0 +1,122 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2015-07-20 16:42+0000\n" +"Last-Translator: Virgil Dupras \n" +"Language-Team: Korean (http://www.transifex.com/p/hscommon/language/ko/)\n" +"Language: ko\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "" diff --git a/qtlib/locale/nl/LC_MESSAGES/qtlib.po b/qtlib/locale/nl/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..1191404b --- /dev/null +++ b/qtlib/locale/nl/LC_MESSAGES/qtlib.po @@ -0,0 +1,121 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:31+0000\n" +"Last-Translator: hsoft \n" +"Language: nl\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "Engels" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "Frans" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "Duits" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "Tsjechisch" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "Italiaans" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "" diff --git a/qtlib/locale/pl_PL/LC_MESSAGES/qtlib.po b/qtlib/locale/pl_PL/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..89023271 --- /dev/null +++ b/qtlib/locale/pl_PL/LC_MESSAGES/qtlib.po @@ -0,0 +1,124 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2015-07-20 16:53+0000\n" +"Last-Translator: Virgil Dupras \n" +"Language-Team: Polish (Poland) (http://www.transifex.com/p/hscommon/language/" +"pl_PL/)\n" +"Language: pl_PL\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " +"|| n%100>=20) ? 1 : 2);\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "" diff --git a/qtlib/locale/pt_BR/LC_MESSAGES/qtlib.po b/qtlib/locale/pt_BR/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..6355f5b0 --- /dev/null +++ b/qtlib/locale/pt_BR/LC_MESSAGES/qtlib.po @@ -0,0 +1,136 @@ +# +# Translators: +# Victor Figueiredo , 2013-2014 +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2014-04-30 18:58+0000\n" +"Last-Translator: Victor Figueiredo \n" +"Language-Team: Portuguese (Brazil) (http://www.transifex.com/hsoft/hscommon/" +"language/pt_BR/)\n" +"Language: pt_BR\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "Sobre o {}" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "Versão {}" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "Relatório de Erro" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "Algo deu errado. Deseja relatar o erro?" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" +"Os erros devem ser relatados como problemas no Github. Copie o código de " +"rastreamento acima e cole-o em um problema novo (pontos bônus caso você " +"busque o erro, certificando-se de que ele ainda não exista). O que mais " +"ajuda é adicionar uma descrição de como o erro ocorreu. Obrigado!\n" +"\n" +"Embora o aplicativo continue a funcionar após esse erro, ele pode estar " +"“instável”. É recomendável reiniciá-lo." + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "Fechar" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "Ir para o Github" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "Inglês" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "Francês" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "Alemão" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "Chinês (Simplificado)" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "Tcheco" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "Italiano" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "Armênio" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "Russo" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "Ucraniano" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "Holandês" + +#: qtlib/preferences.py:30 +#, fuzzy +msgid "Polish" +msgstr "Inglês" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "Português Brasileiro" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "Espanhol" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "Vietnamita" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "Limpar Lista" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "Buscar…" + +#~ msgid "Copyright Hardcoded Software 2014" +#~ msgstr "Copyright Hardcoded Software 2014" diff --git a/qtlib/locale/qtlib.pot b/qtlib/locale/qtlib.pot new file mode 100644 index 00000000..07bab6b8 --- /dev/null +++ b/qtlib/locale/qtlib.pot @@ -0,0 +1,113 @@ + +msgid "" +msgstr "" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: utf-8\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error traceback above and paste it in a new issue (bonus point if you run a search to make sure the issue doesn't already exist). What usually really helps is if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "" + diff --git a/qtlib/locale/ru/LC_MESSAGES/qtlib.po b/qtlib/locale/ru/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..8278a356 --- /dev/null +++ b/qtlib/locale/ru/LC_MESSAGES/qtlib.po @@ -0,0 +1,135 @@ +# Translators: +# Igor Fokusov , 2015 +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2015-04-14 21:16+0000\n" +"Last-Translator: Igor Fokusov \n" +"Language-Team: Russian (http://www.transifex.com/projects/p/hscommon/" +"language/ru/)\n" +"Language: ru\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "О {}" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "Версия {}" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "Сообщение об ошибке" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "Что-то пошло не так. Хотите отправить отчёт об ошибке?" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" +"Отчеты об ошибках нужно отправлять в Github issues проекта. Скопируйте текст " +"ошибки выше и вставьте в созданную заметку о проблеме (перед этим желательно " +"проверить - не создано ли уже такой проблемы до вас). Также нам очень " +"поможет краткое описание как вы получили такую ошибку. Спасибо!\n" +"\n" +"В принципе, программа может продолжать работу, но стабильная работа не " +"гарантируется. Поэтому желательно перезапустить программу." + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "Закрыть" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "Перейти на Github" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "Английский" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "Французский" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "Немецкий" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "Китайский (упрощенный)" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "Чешский" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "Итальянский" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "Армянский" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "Русский" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "Украинский" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "Голландский" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "Бразильский" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "Испанский" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "Вьетнамский" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "Очистить список" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "Искать..." + +#~ msgid "Copyright Hardcoded Software 2014" +#~ msgstr "Copyright Hardcoded Software 2014" diff --git a/qtlib/locale/uk/LC_MESSAGES/qtlib.po b/qtlib/locale/uk/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..fec48dba --- /dev/null +++ b/qtlib/locale/uk/LC_MESSAGES/qtlib.po @@ -0,0 +1,125 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:31+0000\n" +"Last-Translator: hsoft \n" +"Language: uk\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "Повідомлення про помилки" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "Очистити список" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "Шукати..." + +#~ msgid "Copyright Hardcoded Software 2014" +#~ msgstr "Авторське право Hardcoded Software 2014" diff --git a/qtlib/locale/vi/LC_MESSAGES/qtlib.po b/qtlib/locale/vi/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..a1793198 --- /dev/null +++ b/qtlib/locale/vi/LC_MESSAGES/qtlib.po @@ -0,0 +1,123 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-07-05 11:23+0000\n" +"Last-Translator: hsoft \n" +"Language-Team: Vietnamese (http://www.transifex.com/projects/p/hscommon/" +"language/vi/)\n" +"Language: vi\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "" diff --git a/qtlib/locale/zh_CN/LC_MESSAGES/qtlib.po b/qtlib/locale/zh_CN/LC_MESSAGES/qtlib.po new file mode 100644 index 00000000..4d06bf09 --- /dev/null +++ b/qtlib/locale/zh_CN/LC_MESSAGES/qtlib.po @@ -0,0 +1,121 @@ +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:31+0000\n" +"Last-Translator: hsoft \n" +"Language: zh_CN\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: qtlib/about_box.py:29 +msgid "About {}" +msgstr "" + +#: qtlib/about_box.py:49 +msgid "Version {}" +msgstr "" + +#: qtlib/about_box.py:53 +msgid "Licensed under GPLv3" +msgstr "" + +#: qtlib/error_report_dialog.py:39 +msgid "Error Report" +msgstr "" + +#: qtlib/error_report_dialog.py:43 +msgid "Something went wrong. How about reporting the error?" +msgstr "" + +#: qtlib/error_report_dialog.py:49 +msgid "" +"Error reports should be reported as Github issues. You can copy the error " +"traceback above and paste it in a new issue (bonus point if you run a search " +"to make sure the issue doesn't already exist). What usually really helps is " +"if you add a description of how you got the error. Thanks!\n" +"\n" +"Although the application should continue to run after this error, it may be " +"in an unstable state, so it is recommended that you restart the application." +msgstr "" + +#: qtlib/error_report_dialog.py:64 +msgid "Close" +msgstr "" + +#: qtlib/error_report_dialog.py:68 +msgid "Go to Github" +msgstr "" + +#: qtlib/preferences.py:18 +msgid "English" +msgstr "英语" + +#: qtlib/preferences.py:19 +msgid "French" +msgstr "法语" + +#: qtlib/preferences.py:20 +msgid "German" +msgstr "德语" + +#: qtlib/preferences.py:21 +msgid "Greek" +msgstr "" + +#: qtlib/preferences.py:22 +msgid "Chinese (Simplified)" +msgstr "简体中文" + +#: qtlib/preferences.py:23 +msgid "Czech" +msgstr "" + +#: qtlib/preferences.py:24 +msgid "Italian" +msgstr "" + +#: qtlib/preferences.py:25 +msgid "Armenian" +msgstr "" + +#: qtlib/preferences.py:26 +msgid "Korean" +msgstr "" + +#: qtlib/preferences.py:27 +msgid "Russian" +msgstr "" + +#: qtlib/preferences.py:28 +msgid "Ukrainian" +msgstr "" + +#: qtlib/preferences.py:29 +msgid "Dutch" +msgstr "" + +#: qtlib/preferences.py:30 +msgid "Polish" +msgstr "" + +#: qtlib/preferences.py:31 +msgid "Brazilian" +msgstr "" + +#: qtlib/preferences.py:32 +msgid "Spanish" +msgstr "" + +#: qtlib/preferences.py:33 +msgid "Vietnamese" +msgstr "" + +#: qtlib/recent.py:53 +msgid "Clear List" +msgstr "清空列表" + +#: qtlib/search_edit.py:77 +msgid "Search..." +msgstr "" diff --git a/qtlib/preferences.py b/qtlib/preferences.py new file mode 100644 index 00000000..59211820 --- /dev/null +++ b/qtlib/preferences.py @@ -0,0 +1,134 @@ +# Created By: Virgil Dupras +# Created On: 2009-05-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 + +from PyQt5.QtCore import Qt, QSettings, QRect, QObject, pyqtSignal + +from hscommon.trans import trget +from hscommon.util import tryint + +tr = trget('qtlib') + +def get_langnames(): + return { + 'en': tr("English"), + 'fr': tr("French"), + 'de': tr("German"), + 'el': tr("Greek"), + 'zh_CN': tr("Chinese (Simplified)"), + 'cs': tr("Czech"), + 'it': tr("Italian"), + 'hy': tr("Armenian"), + 'ko': tr("Korean"), + 'ru': tr("Russian"), + 'uk': tr("Ukrainian"), + 'nl': tr('Dutch'), + 'pl_PL': tr("Polish"), + 'pt_BR': tr("Brazilian"), + 'es': tr("Spanish"), + 'vi': tr("Vietnamese"), + } + +def normalize_for_serialization(v): + # QSettings doesn't consider set/tuple as "native" typs for serialization, so if we don't + # change them into a list, we get a weird serialized QVariant value which isn't a very + # "portable" value. + if isinstance(v, (set, tuple)): + v = list(v) + if isinstance(v, list): + v = [normalize_for_serialization(item) for item in v] + return v + +def adjust_after_deserialization(v): + # In some cases, when reading from prefs, we end up with strings that are supposed to be + # bool or int. Convert these. + if isinstance(v, list): + return [adjust_after_deserialization(sub) for sub in v] + if isinstance(v, str): + # might be bool or int, try them + if v == 'true': + return True + elif v == 'false': + return False + else: + return tryint(v, v) + return v + +# About QRect conversion: +# I think Qt supports putting basic structures like QRect directly in QSettings, but I prefer not +# to rely on it and stay with generic structures. + +class Preferences(QObject): + prefsChanged = pyqtSignal() + + def __init__(self): + QObject.__init__(self) + self.reset() + self._settings = QSettings() + + def _load_values(self, settings, get): + pass + + def get_rect(self, name, default=None): + r = self.get_value(name, default) + if r is not None: + return QRect(*r) + else: + return None + + def get_value(self, name, default=None): + if self._settings.contains(name): + result = adjust_after_deserialization(self._settings.value(name)) + if result is not None: + return result + else: + # If result is None, but still present in self._settings, it usually means a value + # like "@Invalid". + return default + else: + return default + + def load(self): + self.reset() + self._load_values(self._settings) + + def reset(self): + pass + + def _save_values(self, settings, set_): + pass + + def save(self): + self._save_values(self._settings) + self._settings.sync() + + def set_rect(self, name, r): + if isinstance(r, QRect): + rectAsList = [r.x(), r.y(), r.width(), r.height()] + self.set_value(name, rectAsList) + + def set_value(self, name, value): + self._settings.setValue(name, normalize_for_serialization(value)) + + def saveGeometry(self, name, widget): + # We save geometry under a 5-sized int array: first item is a flag for whether the widget + # is maximized and the other 4 are (x, y, w, h). + m = 1 if widget.isMaximized() else 0 + r = widget.geometry() + rectAsList = [r.x(), r.y(), r.width(), r.height()] + self.set_value(name, [m] + rectAsList) + + def restoreGeometry(self, name, widget): + l = self.get_value(name) + if l and len(l) == 5: + m, x, y, w, h = l + if m: + widget.setWindowState(Qt.WindowMaximized) + else: + r = QRect(x, y, w, h) + widget.setGeometry(r) + diff --git a/qtlib/progress_window.py b/qtlib/progress_window.py new file mode 100644 index 00000000..3a41b743 --- /dev/null +++ b/qtlib/progress_window.py @@ -0,0 +1,55 @@ +# Copyright 2016 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 Qt, QTimer +from PyQt5.QtWidgets import QProgressDialog + +class ProgressWindow: + def __init__(self, parent, model): + self._window = None + self.parent = parent + self.model = model + model.view = self + # We don't have access to QProgressDialog's labels directly, so we se the model label's view + # to self and we'll refresh them together. + self.model.jobdesc_textfield.view = self + self.model.progressdesc_textfield.view = self + + # --- Callbacks + def refresh(self): # Labels + if self._window is not None: + self._window.setWindowTitle(self.model.jobdesc_textfield.text) + self._window.setLabelText(self.model.progressdesc_textfield.text) + + def set_progress(self, last_progress): + if self._window is not None: + self._window.setValue(last_progress) + + def show(self): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + self._window = QProgressDialog('', "Cancel", 0, 100, self.parent, flags) + self._window.setModal(True) + self._window.setAutoReset(False) + self._window.setAutoClose(False) + self._timer = QTimer(self._window) + self._timer.timeout.connect(self.model.pulse) + self._window.show() + self._window.canceled.connect(self.model.cancel) + self._timer.start(500) + + def close(self): + # it seems it is possible for close to be called without a corresponding + # show, only perform a close if there is a window to close + if self._window is not None: + self._timer.stop() + del self._timer + # For some weird reason, canceled() signal is sent upon close, whether the user canceled + # or not. If we don't want a false cancellation, we have to disconnect it. + self._window.canceled.disconnect() + self._window.close() + self._window.setParent(None) + self._window = None + diff --git a/qtlib/radio_box.py b/qtlib/radio_box.py new file mode 100644 index 00000000..feabf357 --- /dev/null +++ b/qtlib/radio_box.py @@ -0,0 +1,88 @@ +# Created On: 2010-06-02 +# 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 PyQt5.QtCore import pyqtSignal +from PyQt5.QtWidgets import QWidget, QHBoxLayout, QRadioButton + +from .util import horizontalSpacer + +class RadioBox(QWidget): + def __init__(self, parent=None, items=None, spread=True, **kwargs): + # If spread is False, insert a spacer in the layout so that the items don't use all the + # space they're given but rather align left. + if items is None: + items = [] + super().__init__(parent, **kwargs) + self._buttons = [] + self._labels = items + self._selected_index = 0 + self._spacer = horizontalSpacer() if not spread else None + self._layout = QHBoxLayout(self) + self._update_buttons() + + #--- Private + def _update_buttons(self): + if self._spacer is not None: + self._layout.removeItem(self._spacer) + to_remove = self._buttons[len(self._labels):] + for button in to_remove: + self._layout.removeWidget(button) + button.setParent(None) + del self._buttons[len(self._labels):] + to_add = self._labels[len(self._buttons):] + for _ in to_add: + button = QRadioButton(self) + self._buttons.append(button) + self._layout.addWidget(button) + button.toggled.connect(self.buttonToggled) + if self._spacer is not None: + self._layout.addItem(self._spacer) + if not self._buttons: + return + for button, label in zip(self._buttons, self._labels): + button.setText(label) + self._update_selection() + + def _update_selection(self): + self._selected_index = max(0, min(self._selected_index, len(self._buttons)-1)) + selected = self._buttons[self._selected_index] + selected.setChecked(True) + + #--- Event Handlers + def buttonToggled(self): + for i, button in enumerate(self._buttons): + if button.isChecked(): + self._selected_index = i + self.itemSelected.emit(i) + break + + #--- Signals + itemSelected = pyqtSignal(int) + + #--- Properties + @property + def buttons(self): + return self._buttons[:] + + @property + def items(self): + return self._labels[:] + + @items.setter + def items(self, value): + self._labels = value + self._update_buttons() + + @property + def selected_index(self): + return self._selected_index + + @selected_index.setter + def selected_index(self, value): + self._selected_index = value + self._update_selection() + diff --git a/qtlib/recent.py b/qtlib/recent.py new file mode 100644 index 00000000..e1524e54 --- /dev/null +++ b/qtlib/recent.py @@ -0,0 +1,95 @@ +# Created By: Virgil Dupras +# Created On: 2009-11-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 collections import namedtuple + +from PyQt5.QtCore import pyqtSignal, QObject +from PyQt5.QtWidgets import QAction + +from hscommon.trans import trget +from hscommon.util import dedupe + +tr = trget('qtlib') + +MenuEntry = namedtuple('MenuEntry', 'menu fixedItemCount') + +class Recent(QObject): + def __init__(self, app, prefName, maxItemCount=10, **kwargs): + super().__init__(**kwargs) + self._app = app + self._menuEntries = [] + self._prefName = prefName + self._maxItemCount = maxItemCount + self._items = [] + self._loadFromPrefs() + + self._app.willSavePrefs.connect(self._saveToPrefs) + + #--- Private + def _loadFromPrefs(self): + items = getattr(self._app.prefs, self._prefName) + if not isinstance(items, list): + items = [] + self._items = items + + def _insertItem(self, item): + self._items = dedupe([item] + self._items)[:self._maxItemCount] + + def _refreshMenu(self, menuEntry): + menu, fixedItemCount = menuEntry + for action in menu.actions()[fixedItemCount:]: + menu.removeAction(action) + for item in self._items: + action = QAction(item, menu) + action.setData(item) + action.triggered.connect(self.menuItemWasClicked) + menu.addAction(action) + menu.addSeparator() + action = QAction(tr("Clear List"), menu) + action.triggered.connect(self.clear) + menu.addAction(action) + + def _refreshAllMenus(self): + for menuEntry in self._menuEntries: + self._refreshMenu(menuEntry) + + def _saveToPrefs(self): + setattr(self._app.prefs, self._prefName, self._items) + + #--- Public + def addMenu(self, menu): + menuEntry = MenuEntry(menu, len(menu.actions())) + self._menuEntries.append(menuEntry) + self._refreshMenu(menuEntry) + + def clear(self): + self._items = [] + self._refreshAllMenus() + self.itemsChanged.emit() + + def insertItem(self, item): + self._insertItem(str(item)) + self._refreshAllMenus() + self.itemsChanged.emit() + + def isEmpty(self): + return not bool(self._items) + + #--- Event Handlers + def menuItemWasClicked(self): + action = self.sender() + if action is not None: + item = action.data() + self.mustOpenItem.emit(item) + self._refreshAllMenus() + + #--- Signals + mustOpenItem = pyqtSignal(str) + itemsChanged = pyqtSignal() + + diff --git a/qtlib/search_edit.py b/qtlib/search_edit.py new file mode 100644 index 00000000..ce0bc6d8 --- /dev/null +++ b/qtlib/search_edit.py @@ -0,0 +1,120 @@ +# Created By: Virgil Dupras +# Created On: 2009-12-10 +# 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 PyQt5.QtCore import pyqtSignal, Qt +from PyQt5.QtGui import QIcon, QPixmap, QPainter, QPalette +from PyQt5.QtWidgets import QToolButton, QLineEdit, QStyle, QStyleOptionFrame + +from hscommon.trans import trget + +tr = trget('qtlib') + +# IMPORTANT: For this widget to work propertly, you have to add "search_clear_13" from the +# "images" folder in your resources. + +class LineEditButton(QToolButton): + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + pixmap = QPixmap(':/search_clear_13') + self.setIcon(QIcon(pixmap)) + self.setIconSize(pixmap.size()) + self.setCursor(Qt.ArrowCursor) + self.setPopupMode(QToolButton.InstantPopup) + stylesheet = "QToolButton { border: none; padding: 0px; }" + self.setStyleSheet(stylesheet) + + +class ClearableEdit(QLineEdit): + def __init__(self, parent=None, is_clearable=True, **kwargs): + super().__init__(parent, **kwargs) + self._is_clearable = is_clearable + if is_clearable: + self._clearButton = LineEditButton(self) + frameWidth = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth) + paddingRight = self._clearButton.sizeHint().width() + frameWidth + 1 + stylesheet = "QLineEdit {{ padding-right:{0}px; }}".format(paddingRight) + self.setStyleSheet(stylesheet) + self._updateClearButton() + + self._clearButton.clicked.connect(self._clearSearch) + self.textChanged.connect(self._textChanged) + + #--- Private + def _clearSearch(self): + self.clear() + + def _updateClearButton(self): + self._clearButton.setVisible(self._hasClearableContent()) + + def _hasClearableContent(self): + return bool(self.text()) + + #--- QLineEdit overrides + def resizeEvent(self, event): + if self._is_clearable: + frameWidth = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth) + rect = self.rect() + rightHint = self._clearButton.sizeHint() + rightX = rect.right() - frameWidth - rightHint.width() + rightY = (rect.bottom() - rightHint.height()) // 2 + self._clearButton.move(rightX, rightY) + + #--- Event Handlers + def _textChanged(self, text): + if self._is_clearable: + self._updateClearButton() + + +class SearchEdit(ClearableEdit): + def __init__(self, parent=None, immediate=False): + # immediate: send searchChanged signals at each keystroke. + ClearableEdit.__init__(self, parent, is_clearable=True) + self.inactiveText = tr("Search...") + self.immediate = immediate + + self.returnPressed.connect(self._returnPressed) + + #--- Overrides + def _clearSearch(self): + ClearableEdit._clearSearch(self) + self.searchChanged.emit() + + def _textChanged(self, text): + ClearableEdit._textChanged(self, text) + if self.immediate: + self.searchChanged.emit() + + def keyPressEvent(self, event): + key = event.key() + if key == Qt.Key_Escape: + self._clearSearch() + else: + ClearableEdit.keyPressEvent(self, event) + + def paintEvent(self, event): + ClearableEdit.paintEvent(self, event) + if not bool(self.text()) and self.inactiveText and not self.hasFocus(): + panel = QStyleOptionFrame() + self.initStyleOption(panel) + textRect = self.style().subElementRect(QStyle.SE_LineEditContents, panel, self) + leftMargin = 2 + rightMargin = self._clearButton.iconSize().width() + textRect.adjust(leftMargin, 0, -rightMargin, 0) + painter = QPainter(self) + disabledColor = self.palette().brush(QPalette.Disabled, QPalette.Text).color() + painter.setPen(disabledColor) + painter.drawText(textRect, Qt.AlignLeft|Qt.AlignVCenter, self.inactiveText) + + #--- Event Handlers + def _returnPressed(self): + if not self.immediate: + self.searchChanged.emit() + + #--- Signals + searchChanged = pyqtSignal() # Emitted when return is pressed or when the test is cleared + diff --git a/qtlib/selectable_list.py b/qtlib/selectable_list.py new file mode 100644 index 00000000..cb789298 --- /dev/null +++ b/qtlib/selectable_list.py @@ -0,0 +1,98 @@ +# 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 PyQt5.QtCore import Qt, QAbstractListModel, QItemSelection, QItemSelectionModel + +class SelectableList(QAbstractListModel): + def __init__(self, model, view, **kwargs): + super().__init__(**kwargs) + self._updating = False + self.view = view + self.model = model + self.view.setModel(self) + self.model.view = self + + #--- Override + def data(self, index, role): + if not index.isValid(): + return None + # We need EditRole for QComboBoxes with setEditable(True) + if role in {Qt.DisplayRole, Qt.EditRole}: + return self.model[index.row()] + return None + + def rowCount(self, index): + if index.isValid(): + return 0 + return len(self.model) + + #--- Virtual + def _updateSelection(self): + raise NotImplementedError() + + def _restoreSelection(self): + raise NotImplementedError() + + #--- model --> view + def refresh(self): + self._updating = True + self.beginResetModel() + self.endResetModel() + self._updating = False + self._restoreSelection() + + def update_selection(self): + self._restoreSelection() + +class ComboboxModel(SelectableList): + def __init__(self, model, view, **kwargs): + super().__init__(model, view, **kwargs) + self.view.currentIndexChanged[int].connect(self.selectionChanged) + + #--- Override + def _updateSelection(self): + index = self.view.currentIndex() + if index != self.model.selected_index: + self.model.select(index) + + def _restoreSelection(self): + index = self.model.selected_index + if index is not None: + self.view.setCurrentIndex(index) + + #--- Events + def selectionChanged(self, index): + if not self._updating: + self._updateSelection() + +class ListviewModel(SelectableList): + def __init__(self, model, view, **kwargs): + super().__init__(model, view, **kwargs) + self.view.selectionModel().selectionChanged[(QItemSelection, QItemSelection)].connect( + self.selectionChanged) + + #--- Override + def _updateSelection(self): + newIndexes = [modelIndex.row() for modelIndex in self.view.selectionModel().selectedRows()] + if newIndexes != self.model.selected_indexes: + self.model.select(newIndexes) + + def _restoreSelection(self): + newSelection = QItemSelection() + for index in self.model.selected_indexes: + newSelection.select(self.createIndex(index, 0), self.createIndex(index, 0)) + self.view.selectionModel().select(newSelection, QItemSelectionModel.ClearAndSelect) + if len(newSelection.indexes()): + currentIndex = newSelection.indexes()[0] + self.view.selectionModel().setCurrentIndex(currentIndex, QItemSelectionModel.Current) + self.view.scrollTo(currentIndex) + #--- Events + def selectionChanged(self, index): + if not self._updating: + self._updateSelection() + diff --git a/qtlib/table.py b/qtlib/table.py new file mode 100644 index 00000000..251842cd --- /dev/null +++ b/qtlib/table.py @@ -0,0 +1,152 @@ +# Created By: Virgil Dupras +# Created On: 2009-11-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 PyQt5.QtCore import Qt, QAbstractTableModel, QModelIndex, QItemSelectionModel, QItemSelection + +from .column import Columns + +class Table(QAbstractTableModel): + # Flags you want when index.isValid() is False. In those cases, _getFlags() is never called. + INVALID_INDEX_FLAGS = Qt.ItemIsEnabled + COLUMNS = [] + + def __init__(self, model, view, **kwargs): + super().__init__(**kwargs) + self.model = model + self.view = view + self.view.setModel(self) + self.model.view = self + if hasattr(self.model, 'columns'): + self.columns = Columns(self.model.columns, self.COLUMNS, view.horizontalHeader()) + + self.view.selectionModel().selectionChanged[(QItemSelection, QItemSelection)].connect(self.selectionChanged) + + def _updateModelSelection(self): + # Takes the selection on the view's side and update the model with it. + # an _updateViewSelection() call will normally result in an _updateModelSelection() call. + # to avoid infinite loops, we check that the selection will actually change before calling + # model.select() + newIndexes = [modelIndex.row() for modelIndex in self.view.selectionModel().selectedRows()] + if newIndexes != self.model.selected_indexes: + self.model.select(newIndexes) + + def _updateViewSelection(self): + # Takes the selection on the model's side and update the view with it. + newSelection = QItemSelection() + columnCount = self.columnCount(QModelIndex()) + for index in self.model.selected_indexes: + newSelection.select(self.createIndex(index, 0), self.createIndex(index, columnCount-1)) + self.view.selectionModel().select(newSelection, QItemSelectionModel.ClearAndSelect) + if len(newSelection.indexes()): + currentIndex = newSelection.indexes()[0] + self.view.selectionModel().setCurrentIndex(currentIndex, QItemSelectionModel.Current) + self.view.scrollTo(currentIndex) + + #--- Data Model methods + # Virtual + def _getData(self, row, column, role): + if role in (Qt.DisplayRole, Qt.EditRole): + attrname = column.name + return row.get_cell_value(attrname) + elif role == Qt.TextAlignmentRole: + return column.alignment + return None + + # Virtual + def _getFlags(self, row, column): + flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + if row.can_edit_cell(column.name): + flags |= Qt.ItemIsEditable + return flags + + # Virtual + def _setData(self, row, column, value, role): + if role == Qt.EditRole: + attrname = column.name + if attrname == 'from': + attrname = 'from_' + setattr(row, attrname, value) + return True + return False + + def columnCount(self, index): + return self.model.columns.columns_count() + + def data(self, index, role): + if not index.isValid(): + return None + row = self.model[index.row()] + column = self.model.columns.column_by_index(index.column()) + return self._getData(row, column, role) + + def flags(self, index): + if not index.isValid(): + return self.INVALID_INDEX_FLAGS + row = self.model[index.row()] + column = self.model.columns.column_by_index(index.column()) + return self._getFlags(row, column) + + def headerData(self, section, orientation, role): + if orientation != Qt.Horizontal: + return None + if section >= self.model.columns.columns_count(): + return None + column = self.model.columns.column_by_index(section) + if role == Qt.DisplayRole: + return column.display + elif role == Qt.TextAlignmentRole: + return column.alignment + else: + return None + + def revert(self): + self.model.cancel_edits() + + def rowCount(self, index): + if index.isValid(): + return 0 + return len(self.model) + + def setData(self, index, value, role): + if not index.isValid(): + return False + row = self.model[index.row()] + column = self.model.columns.column_by_index(index.column()) + return self._setData(row, column, value, role) + + def sort(self, section, order): + column = self.model.columns.column_by_index(section) + attrname = column.name + self.model.sort_by(attrname, desc=order==Qt.DescendingOrder) + + def submit(self): + self.model.save_edits() + return True + + #--- Events + def selectionChanged(self, selected, deselected): + self._updateModelSelection() + + #--- model --> view + def refresh(self): + self.beginResetModel() + self.endResetModel() + self._updateViewSelection() + + def show_selected_row(self): + if self.model.selected_index is not None: + self.view.showRow(self.model.selected_index) + + def start_editing(self): + self.view.editSelected() + + def stop_editing(self): + self.view.setFocus() # enough to stop editing + + def update_selection(self): + self._updateViewSelection() diff --git a/qtlib/text_field.py b/qtlib/text_field.py new file mode 100644 index 00000000..a0be3fd2 --- /dev/null +++ b/qtlib/text_field.py @@ -0,0 +1,23 @@ +# 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 + +class TextField: + def __init__(self, model, view): + self.model = model + self.view = view + self.model.view = self + # Make TextField also work for QLabel, which doesn't allow editing + if hasattr(self.view, 'editingFinished'): + self.view.editingFinished.connect(self.editingFinished) + + def editingFinished(self): + self.model.text = self.view.text() + + # model --> view + def refresh(self): + self.view.setText(self.model.text) + diff --git a/qtlib/tree_model.py b/qtlib/tree_model.py new file mode 100644 index 00000000..da93cb36 --- /dev/null +++ b/qtlib/tree_model.py @@ -0,0 +1,173 @@ +# Created By: Virgil Dupras +# Created On: 2009-09-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 logging + +from PyQt5.QtCore import QAbstractItemModel, QModelIndex + +class NodeContainer: + def __init__(self): + self._subnodes = None + self._ref2node = {} + + #--- Protected + def _createNode(self, ref, row): + # This returns a TreeNode instance from ref + raise NotImplementedError() + + def _getChildren(self): + # This returns a list of ref instances, not TreeNode instances + raise NotImplementedError() + + #--- Public + def invalidate(self): + # Invalidates cached data and list of subnodes without resetting ref2node. + self._subnodes = None + + #--- Properties + @property + def subnodes(self): + if self._subnodes is None: + children = self._getChildren() + self._subnodes = [] + for index, child in enumerate(children): + if child in self._ref2node: + node = self._ref2node[child] + node.row = index + else: + node = self._createNode(child, index) + self._ref2node[child] = node + self._subnodes.append(node) + return self._subnodes + + +class TreeNode(NodeContainer): + def __init__(self, model, parent, row): + NodeContainer.__init__(self) + self.model = model + self.parent = parent + self.row = row + + @property + def index(self): + return self.model.createIndex(self.row, 0, self) + + +class RefNode(TreeNode): + """Node pointing to a reference node. + + Use this if your Qt model wraps around a tree model that has iterable nodes. + """ + def __init__(self, model, parent, ref, row): + TreeNode.__init__(self, model, parent, row) + self.ref = ref + + def _createNode(self, ref, row): + return RefNode(self.model, self, ref, row) + + def _getChildren(self): + return list(self.ref) + + +# We use a specific TreeNode subclass to easily spot dummy nodes, especially in exception tracebacks. +class DummyNode(TreeNode): + pass + +class TreeModel(QAbstractItemModel, NodeContainer): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._dummyNodes = set() # dummy nodes' reference have to be kept to avoid segfault + + #--- Private + def _createDummyNode(self, parent, row): + # In some cases (drag & drop row removal, to be precise), there's a temporary discrepancy + # between a node's subnodes and what the model think it has. This leads to invalid indexes + # being queried. Rather than going through complicated row removal crap, it's simpler to + # just have rows with empty data replacing removed rows for the millisecond that the drag & + # drop lasts. Override this to return a node of the correct type. + return DummyNode(self, parent, row) + + def _lastIndex(self): + """Index of the very last item in the tree. + """ + currentIndex = QModelIndex() + rowCount = self.rowCount(currentIndex) + while rowCount > 0: + currentIndex = self.index(rowCount-1, 0, currentIndex) + rowCount = self.rowCount(currentIndex) + return currentIndex + + #--- Overrides + def index(self, row, column, parent): + if not self.subnodes: + return QModelIndex() + node = parent.internalPointer() if parent.isValid() else self + try: + return self.createIndex(row, column, node.subnodes[row]) + except IndexError: + logging.debug("Wrong tree index called (%r, %r, %r). Returning DummyNode", + row, column, node) + parentNode = parent.internalPointer() if parent.isValid() else None + dummy = self._createDummyNode(parentNode, row) + self._dummyNodes.add(dummy) + return self.createIndex(row, column, dummy) + + def parent(self, index): + if not index.isValid(): + return QModelIndex() + node = index.internalPointer() + if node.parent is None: + return QModelIndex() + else: + return self.createIndex(node.parent.row, 0, node.parent) + + def reset(self): + super().beginResetModel() + self.invalidate() + self._ref2node = {} + self._dummyNodes = set() + super().endResetModel() + + def rowCount(self, parent=QModelIndex()): + node = parent.internalPointer() if parent.isValid() else self + return len(node.subnodes) + + #--- Public + def findIndex(self, rowPath): + """Returns the QModelIndex at `rowPath` + + `rowPath` is a sequence of node rows. For example, [1, 2, 1] is the 2nd child of the + 3rd child of the 2nd child of the root. + """ + result = QModelIndex() + for row in rowPath: + result = self.index(row, 0, result) + return result + + @staticmethod + def pathForIndex(index): + reversedPath = [] + while index.isValid(): + reversedPath.append(index.row()) + index = index.parent() + return list(reversed(reversedPath)) + + def refreshData(self): + """Updates the data on all nodes, but without having to perform a full reset. + + A full reset on a tree makes us lose selection and expansion states. When all we ant to do + is to refresh the data on the nodes without adding or removing a node, a call on + dataChanged() is better. But of course, Qt makes our life complicated by asking us topLeft + and bottomRight indexes. This is a convenience method refreshing the whole tree. + """ + columnCount = self.columnCount() + topLeft = self.index(0, 0, QModelIndex()) + bottomLeft = self._lastIndex() + bottomRight = self.sibling(bottomLeft.row(), columnCount-1, bottomLeft) + self.dataChanged.emit(topLeft, bottomRight) + diff --git a/qtlib/util.py b/qtlib/util.py new file mode 100644 index 00000000..07386d95 --- /dev/null +++ b/qtlib/util.py @@ -0,0 +1,109 @@ +# Created By: Virgil Dupras +# Created On: 2011-02-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 + +import sys +import io +import os.path as op +import os +import logging + +from hscommon.util import first + +from PyQt5.QtCore import QStandardPaths +from PyQt5.QtGui import QPixmap, QIcon +from PyQt5.QtWidgets import QDesktopWidget, QSpacerItem, QSizePolicy, QAction, QHBoxLayout + +def moveToScreenCenter(widget): + frame = widget.frameGeometry() + frame.moveCenter(QDesktopWidget().availableGeometry().center()) + widget.move(frame.topLeft()) + +def verticalSpacer(size=None): + if size: + return QSpacerItem(1, size, QSizePolicy.Fixed, QSizePolicy.Fixed) + else: + return QSpacerItem(1, 1, QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) + +def horizontalSpacer(size=None): + if size: + return QSpacerItem(size, 1, QSizePolicy.Fixed, QSizePolicy.Fixed) + else: + return QSpacerItem(1, 1, QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) + +def horizontalWrap(widgets): + """Wrap all widgets in `widgets` in a horizontal layout. + + If, instead of placing a widget in your list, you place an int or None, an horizontal spacer + with the width corresponding to the int will be placed (0 or None means an expanding spacer). + """ + layout = QHBoxLayout() + for widget in widgets: + if widget is None or isinstance(widget, int): + layout.addItem(horizontalSpacer(size=widget)) + else: + layout.addWidget(widget) + return layout + +def createActions(actions, target): + # actions = [(name, shortcut, icon, desc, func)] + for name, shortcut, icon, desc, func in actions: + action = QAction(target) + if icon: + action.setIcon(QIcon(QPixmap(':/' + icon))) + if shortcut: + action.setShortcut(shortcut) + action.setText(desc) + action.triggered.connect(func) + setattr(target, name, action) + +def setAccelKeys(menu): + actions = menu.actions() + titles = [a.text() for a in actions] + available_characters = {c.lower() for s in titles for c in s if c.isalpha()} + for action in actions: + text = action.text() + c = first(c for c in text if c.lower() in available_characters) + if c is None: + continue + i = text.index(c) + newtext = text[:i] + '&' + text[i:] + available_characters.remove(c.lower()) + action.setText(newtext) + +def getAppData(): + return QStandardPaths.standardLocations(QStandardPaths.DataLocation)[0] + +class SysWrapper(io.IOBase): + def write(self, s): + if s.strip(): # don't log empty stuff + logging.warning(s) + +def setupQtLogging(level=logging.WARNING, log_to_stdout=False): + # Under Qt, we log in "debug.log" in appdata. Moreover, when under cx_freeze, we have a + # problem because sys.stdout and sys.stderr are None, so we need to replace them with a + # wrapper that logs with the logging module. + appdata = getAppData() + if not op.exists(appdata): + os.makedirs(appdata) + # Setup logging + # Have to use full configuration over basicConfig as FileHandler encoding was not being set. + filename = op.join(appdata, 'debug.log') if not log_to_stdout else None + log = logging.getLogger() + handler = logging.FileHandler(filename, 'a', 'utf-8') + formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + handler.setFormatter(formatter) + log.addHandler(handler) + if sys.stderr is None: # happens under a cx_freeze environment + sys.stderr = SysWrapper() + if sys.stdout is None: + sys.stdout = SysWrapper() + +def escapeamp(s): + # Returns `s` with escaped ampersand (& --> &&). QAction text needs to have & escaped because + # that character is used to define "accel keys". + return s.replace('&', '&&')