From 94a469205a5fe535afc9c56f3fd875ca130fac2d Mon Sep 17 00:00:00 2001 From: Virgil Dupras Date: Sat, 22 Jun 2013 21:32:23 -0400 Subject: [PATCH] Added hscommon repo as a subtree --- hscommon/.gitignore | 5 + hscommon/.tx/config | 8 + hscommon/LICENSE | 10 + hscommon/README | 9 + hscommon/__init__.py | 0 hscommon/build.py | 466 ++++++++++++++++++ hscommon/conflict.py | 62 +++ hscommon/currency.py | 456 +++++++++++++++++ hscommon/debug.py | 22 + hscommon/docs/build.rst | 25 + hscommon/docs/conf.py | 194 ++++++++ hscommon/docs/conflict.rst | 27 + hscommon/docs/currency.rst | 62 +++ hscommon/docs/index.rst | 32 ++ hscommon/docs/notify.rst | 26 + hscommon/docs/path.rst | 13 + hscommon/docs/reg.rst | 25 + hscommon/docs/sqlite.rst | 9 + hscommon/docs/util.rst | 88 ++++ hscommon/geometry.py | 218 ++++++++ hscommon/gui/__init__.py | 0 hscommon/gui/base.py | 58 +++ hscommon/gui/column.py | 160 ++++++ hscommon/gui/selectable_list.py | 131 +++++ hscommon/gui/table.py | 297 +++++++++++ hscommon/gui/text_field.py | 55 +++ hscommon/gui/tree.py | 166 +++++++ hscommon/io.py | 79 +++ hscommon/loc.py | 177 +++++++ hscommon/locale/cs/LC_MESSAGES/hscommon.po | 20 + hscommon/locale/de/LC_MESSAGES/hscommon.po | 30 ++ hscommon/locale/es/LC_MESSAGES/hscommon.po | 20 + hscommon/locale/fr/LC_MESSAGES/hscommon.po | 20 + hscommon/locale/hscommon.pot | 15 + hscommon/locale/hy/LC_MESSAGES/hscommon.po | 20 + hscommon/locale/it/LC_MESSAGES/hscommon.po | 20 + hscommon/locale/nl/LC_MESSAGES/hscommon.po | 20 + hscommon/locale/pt_BR/LC_MESSAGES/hscommon.po | 20 + hscommon/locale/ru/LC_MESSAGES/hscommon.po | 20 + hscommon/locale/uk/LC_MESSAGES/hscommon.po | 20 + hscommon/locale/zh_CN/LC_MESSAGES/hscommon.po | 31 ++ hscommon/notify.py | 69 +++ hscommon/path.py | 184 +++++++ hscommon/plat.py | 16 + hscommon/pygettext.py | 417 ++++++++++++++++ hscommon/reg.py | 179 +++++++ hscommon/sphinxgen.py | 62 +++ hscommon/sqlite.py | 137 +++++ hscommon/tests/__init__.py | 0 hscommon/tests/conflict_test.py | 104 ++++ hscommon/tests/currency_test.py | 209 ++++++++ hscommon/tests/notify_test.py | 140 ++++++ hscommon/tests/path_test.py | 209 ++++++++ hscommon/tests/reg_test.py | 68 +++ hscommon/tests/selectable_list_test.py | 65 +++ hscommon/tests/sqlite_test.py | 126 +++++ hscommon/tests/table_test.py | 313 ++++++++++++ hscommon/tests/tree_test.py | 109 ++++ hscommon/tests/util_test.py | 310 ++++++++++++ hscommon/testutil.py | 212 ++++++++ hscommon/trans.py | 138 ++++++ hscommon/util.py | 350 +++++++++++++ 62 files changed, 6553 insertions(+) create mode 100644 hscommon/.gitignore create mode 100644 hscommon/.tx/config create mode 100644 hscommon/LICENSE create mode 100644 hscommon/README create mode 100755 hscommon/__init__.py create mode 100644 hscommon/build.py create mode 100644 hscommon/conflict.py create mode 100644 hscommon/currency.py create mode 100644 hscommon/debug.py create mode 100644 hscommon/docs/build.rst create mode 100644 hscommon/docs/conf.py create mode 100644 hscommon/docs/conflict.rst create mode 100644 hscommon/docs/currency.rst create mode 100644 hscommon/docs/index.rst create mode 100644 hscommon/docs/notify.rst create mode 100644 hscommon/docs/path.rst create mode 100644 hscommon/docs/reg.rst create mode 100644 hscommon/docs/sqlite.rst create mode 100644 hscommon/docs/util.rst create mode 100644 hscommon/geometry.py create mode 100644 hscommon/gui/__init__.py create mode 100644 hscommon/gui/base.py create mode 100644 hscommon/gui/column.py create mode 100644 hscommon/gui/selectable_list.py create mode 100644 hscommon/gui/table.py create mode 100644 hscommon/gui/text_field.py create mode 100644 hscommon/gui/tree.py create mode 100644 hscommon/io.py create mode 100644 hscommon/loc.py create mode 100644 hscommon/locale/cs/LC_MESSAGES/hscommon.po create mode 100644 hscommon/locale/de/LC_MESSAGES/hscommon.po create mode 100644 hscommon/locale/es/LC_MESSAGES/hscommon.po create mode 100644 hscommon/locale/fr/LC_MESSAGES/hscommon.po create mode 100644 hscommon/locale/hscommon.pot create mode 100644 hscommon/locale/hy/LC_MESSAGES/hscommon.po create mode 100644 hscommon/locale/it/LC_MESSAGES/hscommon.po create mode 100644 hscommon/locale/nl/LC_MESSAGES/hscommon.po create mode 100644 hscommon/locale/pt_BR/LC_MESSAGES/hscommon.po create mode 100644 hscommon/locale/ru/LC_MESSAGES/hscommon.po create mode 100644 hscommon/locale/uk/LC_MESSAGES/hscommon.po create mode 100644 hscommon/locale/zh_CN/LC_MESSAGES/hscommon.po create mode 100644 hscommon/notify.py create mode 100755 hscommon/path.py create mode 100644 hscommon/plat.py create mode 100755 hscommon/pygettext.py create mode 100644 hscommon/reg.py create mode 100644 hscommon/sphinxgen.py create mode 100644 hscommon/sqlite.py create mode 100644 hscommon/tests/__init__.py create mode 100644 hscommon/tests/conflict_test.py create mode 100644 hscommon/tests/currency_test.py create mode 100644 hscommon/tests/notify_test.py create mode 100644 hscommon/tests/path_test.py create mode 100644 hscommon/tests/reg_test.py create mode 100644 hscommon/tests/selectable_list_test.py create mode 100644 hscommon/tests/sqlite_test.py create mode 100644 hscommon/tests/table_test.py create mode 100644 hscommon/tests/tree_test.py create mode 100644 hscommon/tests/util_test.py create mode 100644 hscommon/testutil.py create mode 100644 hscommon/trans.py create mode 100644 hscommon/util.py 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/.tx/config b/hscommon/.tx/config new file mode 100644 index 00000000..986f29fb --- /dev/null +++ b/hscommon/.tx/config @@ -0,0 +1,8 @@ +[main] +host = https://www.transifex.net + +[hscommon.hscommon] +file_filter = locale//LC_MESSAGES/hscommon.po +source_file = locale/hscommon.pot +source_lang = en +type = PO diff --git a/hscommon/LICENSE b/hscommon/LICENSE new file mode 100644 index 00000000..791cf728 --- /dev/null +++ b/hscommon/LICENSE @@ -0,0 +1,10 @@ +Copyright 2013, 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..efee44a7 --- /dev/null +++ b/hscommon/README @@ -0,0 +1,9 @@ +The documentation has to be built with Sphinx. You can get Sphinx at http://sphinx.pocoo.org/ + +Once you installed it, you can build the documentation with: + +cd docs +sphinx-build . ../docs_html + +The reason why you have to move in 'docs' is because hscommon.io conflicts with the builtin 'io' +module. The documentation is also available online at http://www.hardcoded.net/docs/hscommon \ No newline at end of file diff --git a/hscommon/__init__.py b/hscommon/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/hscommon/build.py b/hscommon/build.py new file mode 100644 index 00000000..dcd7ece0 --- /dev/null +++ b/hscommon/build.py @@ -0,0 +1,466 @@ +# Created By: Virgil Dupras +# Created On: 2009-03-03 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +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): + 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.") + +# `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 --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): + 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): + 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 PyQt4.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: + version = next(it) + date = next(it) + description = next(it) + yield version, date, description + + with open(filename, 'rt', encoding='utf-8') as fp: + contents = fp.read() + splitted = re_changelog_header.split(contents)[1:] # the first item is empty + # splitted = [version1, date1, desc1, version2, date2, ...] + result = [] + for version, date_str, description in iter_by_three(iter(splitted)): + date = datetime.strptime(date_str, '%Y-%m-%d').date() + d = {'date': date, 'date_str': date_str, 'version': version, 'description': description.strip()} + result.append(d) + return result + +class OSXAppStructure: + def __init__(self, dest): + self.dest = dest + self.contents = op.join(dest, 'Contents') + self.macos = op.join(self.contents, 'MacOS') + self.resources = op.join(self.contents, 'Resources') + self.frameworks = op.join(self.contents, 'Frameworks') + self.infoplist = op.join(self.contents, 'Info.plist') + + def create(self, infoplist): + ensure_empty_folder(self.dest) + os.makedirs(self.macos) + os.mkdir(self.resources) + os.mkdir(self.frameworks) + copy(infoplist, self.infoplist) + open(op.join(self.contents, 'PkgInfo'), 'wt').write("APPLxxxx") + + def copy_executable(self, executable): + info = plistlib.readPlist(self.infoplist) + self.executablename = info['CFBundleExecutable'] + self.executablepath = op.join(self.macos, self.executablename) + copy(executable, self.executablepath) + + def copy_resources(self, *resources, use_symlinks=False): + for path in resources: + resource_dest = op.join(self.resources, op.basename(path)) + action = symlink if use_symlinks else copy + action(op.abspath(path), resource_dest) + + def copy_frameworks(self, *frameworks): + for path in frameworks: + framework_dest = op.join(self.frameworks, op.basename(path)) + copy(path, framework_dest) + + +def create_osx_app_structure(dest, executable, infoplist, resources=None, frameworks=None, + symlink_resources=False): + # `dest`: A path to the destination .app folder + # `executable`: the path of the executable file that goes in "MacOS" + # `infoplist`: The path to your Info.plist file. + # `resources`: A list of paths of files or folders going in the "Resources" folder. + # `frameworks`: Same as above for "Frameworks". + # `symlink_resources`: If True, will symlink resources into the structure instead of copying them. + app = OSXAppStructure(dest, infoplist) + app.create() + app.copy_executable(executable) + app.copy_resources(*resources, use_symlinks=symlink_resources) + app.copy_frameworks(*frameworks) + +class OSXFrameworkStructure: + def __init__(self, dest): + self.dest = dest + self.contents = op.join(dest, 'Versions', 'A') + self.resources = op.join(self.contents, 'Resources') + self.headers = op.join(self.contents, 'Headers') + self.infoplist = op.join(self.resources, 'Info.plist') + self._update_executable_path() + + def _update_executable_path(self): + if not op.exists(self.infoplist): + self.executablename = self.executablepath = None + return + info = plistlib.readPlist(self.infoplist) + self.executablename = info['CFBundleExecutable'] + self.executablepath = op.join(self.contents, self.executablename) + + def create(self, infoplist): + ensure_empty_folder(self.dest) + os.makedirs(self.contents) + os.mkdir(self.resources) + os.mkdir(self.headers) + copy(infoplist, self.infoplist) + self._update_executable_path() + + def create_symlinks(self): + # Only call this after create() and copy_executable() + rel = lambda path: op.relpath(path, self.dest) + os.symlink('A', op.join(self.dest, 'Versions', 'Current')) + os.symlink(rel(self.executablepath), op.join(self.dest, self.executablename)) + os.symlink(rel(self.headers), op.join(self.dest, 'Headers')) + os.symlink(rel(self.resources), op.join(self.dest, 'Resources')) + + def copy_executable(self, executable): + copy(executable, self.executablepath) + + def copy_resources(self, *resources, use_symlinks=False): + for path in resources: + resource_dest = op.join(self.resources, op.basename(path)) + action = symlink if use_symlinks else copy + action(op.abspath(path), resource_dest) + + def copy_headers(self, *headers, use_symlinks=False): + for path in headers: + header_dest = op.join(self.headers, op.basename(path)) + action = symlink if use_symlinks else copy + action(op.abspath(path), header_dest) + + +def build_cocoalib_xibless(dest='cocoa/autogen', withfairware=True): + import xibless + ensure_folder(dest) + FNPAIRS = [ + ('progress.py', 'ProgressController_UI'), + ('error_report.py', 'HSErrorReportWindow_UI'), + ] + if withfairware: + FNPAIRS += [ + ('fairware_about.py', 'HSFairwareAboutBox_UI'), + ('demo_reminder.py', 'HSDemoReminder_UI'), + ('enter_code.py', 'HSEnterCode_UI'), + ] + else: + FNPAIRS += [ + ('about.py', 'HSAboutBox_UI'), + ] + for srcname, dstname in FNPAIRS: + srcpath = op.join('cocoalib', 'ui', srcname) + dstpath = op.join(dest, dstname) + if modified_after(srcpath, dstpath + '.h'): + xibless.generate(srcpath, dstpath, localizationTable='cocoalib') + +def copy_embeddable_python_dylib(dst): + runtime = op.join(sysconfig.get_config_var('PYTHONFRAMEWORKPREFIX'), sysconfig.get_config_var('LDLIBRARY')) + filedest = op.join(dst, 'Python') + shutil.copy(runtime, filedest) + os.chmod(filedest, 0o774) # We need write permission to use install_name_tool + cmd = 'install_name_tool -id @rpath/Python %s' % filedest + print_and_do(cmd) + +def collect_stdlib_dependencies(script, dest_folder, extra_deps=None): + sysprefix = sys.prefix # could be a virtualenv + real_lib_prefix = sysconfig.get_config_var('LIBDEST') + def is_stdlib_path(path): + # A module path is only a stdlib path if it's in either sys.prefix or + # sysconfig.get_config_var('prefix') (the 2 are different if we are in a virtualenv) and if + # there's no "site-package in the path. + if not path: + return False + if 'site-package' in path: + return False + if not (path.startswith(sysprefix) or path.startswith(real_lib_prefix)): + return False + return True + + ensure_folder(dest_folder) + mf = modulefinder.ModuleFinder() + mf.run_script(script) + modpaths = [mod.__file__ for mod in mf.modules.values()] + modpaths = filter(is_stdlib_path, modpaths) + for p in modpaths: + if p.startswith(real_lib_prefix): + relpath = op.relpath(p, real_lib_prefix) + elif p.startswith(sysprefix): + relpath = op.relpath(p, sysprefix) + assert relpath.startswith('lib/python3.') # we want to get rid of that lib/python3.x part + relpath = relpath[len('lib/python3.X/'):] + else: + raise AssertionError() + if relpath.startswith('lib-dynload'): # We copy .so files in lib-dynload directly in our dest + relpath = relpath[len('lib-dynload/'):] + if relpath.startswith('encodings') or relpath.startswith('distutils'): + # We force their inclusion later. + continue + dest_path = op.join(dest_folder, relpath) + ensure_folder(op.dirname(dest_path)) + copy(p, dest_path) + # stringprep is used by encodings. + # We use real_lib_prefix with distutils because virtualenv messes with it and we need to refer + # to the original distutils folder. + FORCED_INCLUSION = ['encodings', 'stringprep', op.join(real_lib_prefix, 'distutils')] + if extra_deps: + FORCED_INCLUSION += extra_deps + copy_packages(FORCED_INCLUSION, dest_folder) + # There's a couple of rather big exe files in the distutils folder that we absolutely don't + # need. Remove them. + delete_files_with_pattern(op.join(dest_folder, 'distutils'), '*.exe') + +def fix_qt_resource_file(path): + # pyrcc4 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]) + fn = extname + '.so' + assert op.exists(fn) + move(fn, op.join(dest, fn)) diff --git a/hscommon/conflict.py b/hscommon/conflict.py new file mode 100644 index 00000000..7eeb76cb --- /dev/null +++ b/hscommon/conflict.py @@ -0,0 +1,62 @@ +# Created By: Virgil Dupras +# Created On: 2008-01-08 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +import re +from . import io + +#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): + return re_conflict.sub('',name,1) + +def is_conflicted(name): + return re_conflict.match(name) is not None + +def _smart_move_or_copy(operation, source_path, dest_path): + ''' Use move() or copy() to move and copy file with the conflict management, but without the + slowness of the fs system. + ''' + if io.isdir(dest_path) and not io.isdir(source_path): + dest_path = dest_path + source_path[-1] + if io.exists(dest_path): + filename = dest_path[-1] + dest_dir_path = dest_path[:-1] + newname = get_conflicted_name(io.listdir(dest_dir_path), filename) + dest_path = dest_dir_path + newname + operation(source_path, dest_path) + +def smart_move(source_path, dest_path): + _smart_move_or_copy(io.move, source_path, dest_path) + +def smart_copy(source_path, dest_path): + try: + _smart_move_or_copy(io.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(io.copytree, source_path, dest_path) + else: + raise \ No newline at end of file diff --git a/hscommon/currency.py b/hscommon/currency.py new file mode 100644 index 00000000..9ffcd313 --- /dev/null +++ b/hscommon/currency.py @@ -0,0 +1,456 @@ +# Created By: Virgil Dupras +# Created On: 2008-04-20 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from datetime import datetime, date, timedelta +import logging +import sqlite3 as sqlite +import threading +from queue import Queue, Empty + +from . import io +from .path import Path + +class Currency: + all = [] + by_code = {} + by_name = {} + rates_db = None + + def __new__(cls, code=None, name=None): + """Returns the currency with the given code.""" + assert (code is None and name is not None) or (code is not None and name is None) + if code is not None: + try: + return cls.by_code[code] + except KeyError: + raise ValueError('Unknown currency code: %r' % code) + else: + try: + return cls.by_name[name] + except KeyError: + raise ValueError('Unknown currency name: %r' % name) + + def __getnewargs__(self): + return (self.code,) + + def __getstate__(self): + return None + + def __setstate__(self, state): + pass + + def __repr__(self): + return '' % self.code + + @staticmethod + def register(code, name, exponent=2, start_date=None, start_rate=1, stop_date=None, latest_rate=1): + """Registers a new currency and returns it.""" + assert code not in Currency.by_code + assert name not in Currency.by_name + currency = object.__new__(Currency) + currency.code = code + currency.name = name + currency.exponent = exponent + currency.start_date = start_date + currency.start_rate = start_rate + currency.stop_date = stop_date + currency.latest_rate = latest_rate + Currency.by_code[code] = currency + Currency.by_name[name] = currency + Currency.all.append(currency) + return currency + + @staticmethod + def set_rates_db(db): + Currency.rates_db = db + + @staticmethod + def get_rates_db(): + if Currency.rates_db is None: + Currency.rates_db = RatesDB() # Make sure we always have some db to work with + return Currency.rates_db + + def rates_date_range(self): + """Returns the range of date for which rates are available for this currency.""" + return self.get_rates_db().date_range(self.code) + + def value_in(self, currency, date): + """Returns the value of this currency in terms of the other currency on the given date.""" + if self.start_date is not None and date < self.start_date: + return self.start_rate + elif self.stop_date is not None and date > self.stop_date: + return self.latest_rate + else: + return self.get_rates_db().get_rate(date, self.code, currency.code) + + def set_CAD_value(self, value, date): + """Sets the currency's value in CAD on the given date.""" + self.get_rates_db().set_CAD_value(date, self.code, value) + + +BUILTIN_CURRENCIES = { +# In order we want to list them +Currency.register('USD', 'U.S. dollar', + start_date=date(1998, 1, 2), start_rate=1.425, latest_rate=1.0128), +Currency.register('EUR', 'European Euro', + start_date=date(1999, 1, 4), start_rate=1.8123, latest_rate=1.3298), +Currency.register('GBP', 'U.K. pound sterling', + start_date=date(1998, 1, 2), start_rate=2.3397, latest_rate=1.5349), +Currency.register('CAD', 'Canadian dollar', + latest_rate=1), +Currency.register('AUD', 'Australian dollar', + start_date=date(1998, 1, 2), start_rate=0.9267, latest_rate=0.9336), +Currency.register('JPY', 'Japanese yen', + exponent=0, start_date=date(1998, 1, 2), start_rate=0.01076, latest_rate=0.01076), +Currency.register('INR', 'Indian rupee', + start_date=date(1998, 1, 2), start_rate=0.03627, latest_rate=0.02273), +Currency.register('NZD', 'New Zealand dollar', + start_date=date(1998, 1, 2), start_rate=0.8225, latest_rate=0.7257), +Currency.register('CHF', 'Swiss franc', + start_date=date(1998, 1, 2), start_rate=0.9717, latest_rate=0.9273), +Currency.register('ZAR', 'South African rand', + start_date=date(1998, 1, 2), start_rate=0.292, latest_rate=0.1353), +# The rest, alphabetical +Currency.register('AED', 'U.A.E. dirham', + start_date=date(2007, 9, 4), start_rate=0.2858, latest_rate=0.2757), +Currency.register('ANG', 'Neth. Antilles florin', + start_date=date(1998, 1, 2), start_rate=0.7961, latest_rate=0.5722), +Currency.register('ARS', 'Argentine peso', + start_date=date(1998, 1, 2), start_rate=1.4259, latest_rate=0.2589), +Currency.register('ATS', 'Austrian schilling', + start_date=date(1998, 1, 2), start_rate=0.1123, stop_date=date(2001, 12, 31), latest_rate=0.10309), # obsolete (euro) +Currency.register('BBD', 'Barbadian dollar', + start_date=date(2010, 4, 30), start_rate=0.5003, latest_rate=0.5003), +Currency.register('BEF', 'Belgian franc', + start_date=date(1998, 1, 2), start_rate=0.03832, stop_date=date(2001, 12, 31), latest_rate=0.03516), # obsolete (euro) +Currency.register('BHD', 'Bahraini dinar', + exponent=3, start_date=date(2008, 11, 8), start_rate=3.1518, latest_rate=2.6603), +Currency.register('BRL', 'Brazilian real', + start_date=date(1998, 1, 2), start_rate=1.2707, latest_rate=0.5741), +Currency.register('BSD', 'Bahamian dollar', + start_date=date(1998, 1, 2), start_rate=1.425, latest_rate=1.0128), +Currency.register('CLP', 'Chilean peso', + exponent=0, start_date=date(1998, 1, 2), start_rate=0.003236, latest_rate=0.001923), +Currency.register('CNY', 'Chinese renminbi', + start_date=date(1998, 1, 2), start_rate=0.1721, latest_rate=0.1484), +Currency.register('COP', 'Colombian peso', + start_date=date(1998, 1, 2), start_rate=0.00109, latest_rate=0.000513), +Currency.register('CZK', 'Czech Republic koruna', + start_date=date(1998, 2, 2), start_rate=0.04154, latest_rate=0.05202), +Currency.register('DEM', 'German deutsche mark', + start_date=date(1998, 1, 2), start_rate=0.7904, stop_date=date(2001, 12, 31), latest_rate=0.7253), # obsolete (euro) +Currency.register('DKK', 'Danish krone', + start_date=date(1998, 1, 2), start_rate=0.2075, latest_rate=0.1785), +Currency.register('EGP', 'Egyptian Pound', + start_date=date(2008, 11, 27), start_rate=0.2232, latest_rate=0.1805), +Currency.register('ESP', 'Spanish peseta', + exponent=0, start_date=date(1998, 1, 2), start_rate=0.009334, stop_date=date(2001, 12, 31), latest_rate=0.008526), # obsolete (euro) +Currency.register('FIM', 'Finnish markka', + start_date=date(1998, 1, 2), start_rate=0.2611, stop_date=date(2001, 12, 31), latest_rate=0.2386), # obsolete (euro) +Currency.register('FJD', 'Fiji dollar', + start_date=date(1998, 1, 2), start_rate=0.9198, latest_rate=0.5235), +Currency.register('FRF', 'French franc', + start_date=date(1998, 1, 2), start_rate=0.2362, stop_date=date(2001, 12, 31), latest_rate=0.2163), # obsolete (euro) +Currency.register('GHC', 'Ghanaian cedi (old)', + start_date=date(1998, 1, 2), start_rate=0.00063, stop_date=date(2007, 6, 29), latest_rate=0.000115), # obsolete +Currency.register('GHS', 'Ghanaian cedi', + start_date=date(2007, 7, 3), start_rate=1.1397, latest_rate=0.7134), +Currency.register('GRD', 'Greek drachma', + start_date=date(1998, 1, 2), start_rate=0.005, stop_date=date(2001, 12, 31), latest_rate=0.004163), # obsolete (euro) +Currency.register('GTQ', 'Guatemalan quetzal', + start_date=date(2004, 12, 21), start_rate=0.15762, latest_rate=0.1264), +Currency.register('HKD', 'Hong Kong dollar', + start_date=date(1998, 1, 2), start_rate=0.1838, latest_rate=0.130385), +Currency.register('HNL', 'Honduran lempira', + start_date=date(1998, 1, 2), start_rate=0.108, latest_rate=0.0536), +Currency.register('HRK', 'Croatian kuna', + start_date=date(2002, 3, 1), start_rate=0.1863, latest_rate=0.1837), +Currency.register('HUF', 'Hungarian forint', + start_date=date(1998, 2, 2), start_rate=0.007003, latest_rate=0.00493), +Currency.register('IDR', 'Indonesian rupiah', + start_date=date(1998, 2, 2), start_rate=0.000145, latest_rate=0.000112), +Currency.register('IEP', 'Irish pound', + start_date=date(1998, 1, 2), start_rate=2.0235, stop_date=date(2001, 12, 31), latest_rate=1.8012), # obsolete (euro) +Currency.register('ILS', 'Israeli new shekel', + start_date=date(1998, 1, 2), start_rate=0.4021, latest_rate=0.2706), +Currency.register('ISK', 'Icelandic krona', + exponent=0, start_date=date(1998, 1, 2), start_rate=0.01962, latest_rate=0.00782), +Currency.register('ITL', 'Italian lira', + exponent=0, start_date=date(1998, 1, 2), start_rate=0.000804, stop_date=date(2001, 12, 31), latest_rate=0.000733), # obsolete (euro) +Currency.register('JMD', 'Jamaican dollar', + start_date=date(2001, 6, 25), start_rate=0.03341, latest_rate=0.01145), +Currency.register('KRW', 'South Korean won', + exponent=0, start_date=date(1998, 1, 2), start_rate=0.000841, latest_rate=0.000905), +Currency.register('LKR', 'Sri Lanka rupee', + start_date=date(1998, 1, 2), start_rate=0.02304, latest_rate=0.0089), +Currency.register('LTL', 'Lithuanian litas', + start_date=date(2010, 4, 29), start_rate=0.384, latest_rate=0.384), +Currency.register('LVL', 'Latvian lats', + start_date=date(2011, 2, 6), start_rate=1.9136, latest_rate=1.9136), +Currency.register('MAD', 'Moroccan dirham', + start_date=date(1998, 1, 2), start_rate=0.1461, latest_rate=0.1195), +Currency.register('MMK', 'Myanmar (Burma) kyat', + start_date=date(1998, 1, 2), start_rate=0.226, latest_rate=0.1793), +Currency.register('MXN', 'Mexican peso', + start_date=date(1998, 1, 2), start_rate=0.1769, latest_rate=0.08156), +Currency.register('MYR', 'Malaysian ringgit', + start_date=date(1998, 1, 2), start_rate=0.3594, latest_rate=0.3149), +# MZN in not supported in any of my sources, so I'm just creating it with a fixed rate. +Currency.register('MZN', 'Mozambican metical', + start_date=date(2011, 2, 6), start_rate=0.03, stop_date=date(2011, 2, 5), latest_rate=0.03), +Currency.register('NIO', 'Nicaraguan córdoba', + start_date=date(2011, 10, 12), start_rate=0.0448, latest_rate=0.0448), +Currency.register('NLG', 'Netherlands guilder', + start_date=date(1998, 1, 2), start_rate=0.7013, stop_date=date(2001, 12, 31), latest_rate=0.6437), # obsolete (euro) +Currency.register('NOK', 'Norwegian krone', + start_date=date(1998, 1, 2), start_rate=0.1934, latest_rate=0.1689), +Currency.register('PAB', 'Panamanian balboa', + start_date=date(1998, 1, 2), start_rate=1.425, latest_rate=1.0128), +Currency.register('PEN', 'Peruvian new sol', + start_date=date(1998, 1, 2), start_rate=0.5234, latest_rate=0.3558), +Currency.register('PHP', 'Philippine peso', + start_date=date(1998, 1, 2), start_rate=0.0345, latest_rate=0.02262), +Currency.register('PKR', 'Pakistan rupee', + start_date=date(1998, 1, 2), start_rate=0.03238, latest_rate=0.01206), +Currency.register('PLN', 'Polish zloty', + start_date=date(1998, 2, 2), start_rate=0.4108, latest_rate=0.3382), +Currency.register('PTE', 'Portuguese escudo', + exponent=0, start_date=date(1998, 1, 2), start_rate=0.007726, stop_date=date(2001, 12, 31), latest_rate=0.007076), # obsolete (euro) +Currency.register('RON', 'Romanian new leu', + start_date=date(2007, 9, 4), start_rate=0.4314, latest_rate=0.3215), +Currency.register('RSD', 'Serbian dinar', + start_date=date(2007, 9, 4), start_rate=0.0179, latest_rate=0.01338), +Currency.register('RUB', 'Russian rouble', + start_date=date(1998, 1, 2), start_rate=0.2375, latest_rate=0.03443), +Currency.register('SAR', 'Saudi riyal', + start_date=date(2012, 9, 13), start_rate=0.26, latest_rate=0.26), +Currency.register('SEK', 'Swedish krona', + start_date=date(1998, 1, 2), start_rate=0.1787, latest_rate=0.1378), +Currency.register('SGD', 'Singapore dollar', + start_date=date(1998, 1, 2), start_rate=0.84, latest_rate=0.7358), +Currency.register('SIT', 'Slovenian tolar', + start_date=date(2002, 3, 1), start_rate=0.006174, stop_date=date(2006, 12, 29), latest_rate=0.006419), # obsolete (euro) +Currency.register('SKK', 'Slovak koruna', + start_date=date(2002, 3, 1), start_rate=0.03308, stop_date=date(2008, 12, 31), latest_rate=0.05661), # obsolete (euro) +Currency.register('THB', 'Thai baht', + start_date=date(1998, 1, 2), start_rate=0.0296, latest_rate=0.03134), +Currency.register('TND', 'Tunisian dinar', + exponent=3, start_date=date(1998, 1, 2), start_rate=1.2372, latest_rate=0.7037), +Currency.register('TRL', 'Turkish lira', + exponent=0, start_date=date(1998, 1, 2), start_rate=7.0e-06, stop_date=date(2004, 12, 31), latest_rate=8.925e-07), # obsolete +Currency.register('TWD', 'Taiwanese new dollar', + start_date=date(1998, 1, 2), start_rate=0.04338, latest_rate=0.03218), +Currency.register('UAH', 'Ukrainian hryvnia', + start_date=date(2010, 4, 29), start_rate=0.1266, latest_rate=0.1266), +Currency.register('VEB', 'Venezuelan bolivar', + exponent=0, start_date=date(1998, 1, 2), start_rate=0.002827, stop_date=date(2007, 12, 31), latest_rate=0.00046), # obsolete +Currency.register('VEF', 'Venezuelan bolivar fuerte', + start_date=date(2008, 1, 2), start_rate=0.4623, latest_rate=0.2358), +Currency.register('VND', 'Vietnamese dong', + start_date=date(2004, 1, 1), start_rate=8.2e-05, latest_rate=5.3e-05), +Currency.register('XAF', 'CFA franc', + exponent=0, start_date=date(1998, 1, 2), start_rate=0.002362, latest_rate=0.002027), +Currency.register('XCD', 'East Caribbean dollar', + start_date=date(1998, 1, 2), start_rate=0.5278, latest_rate=0.3793), +Currency.register('XPF', 'CFP franc', + exponent=0, start_date=date(1998, 1, 2), start_rate=0.01299, latest_rate=0.01114), +} +BUILTIN_CURRENCY_CODES = {c.code for c in BUILTIN_CURRENCIES} + +# For legacy purpose, we need to maintain these global variables +CAD = Currency(code='CAD') +USD = Currency(code='USD') +EUR = Currency(code='EUR') + +class CurrencyNotSupportedException(Exception): + """The current exchange rate provider doesn't support the requested currency.""" + +class RatesDB: + """Stores exchange rates for currencies. + + The currencies are identified with ISO 4217 code (USD, CAD, EUR, etc.). + The rates are represented as float and represent the value of the currency in CAD. + """ + def __init__(self, db_or_path=':memory:', async=True): + self._cache = {} # {(date, currency): CAD value + self.db_or_path = db_or_path + if isinstance(db_or_path, (str, Path)): + self.con = sqlite.connect(str(db_or_path)) + else: + self.con = db_or_path + self._execute("select * from rates where 1=2") + self._rate_providers = [] + self.async = async + self._fetched_values = Queue() + self._fetched_ranges = {} # a currency --> (start, end) map + + def _execute(self, *args, **kwargs): + def create_tables(): + # date is stored as a TEXT YYYYMMDD + sql = "create table rates(date TEXT, currency TEXT, rate REAL NOT NULL)" + self.con.execute(sql) + sql = "create unique index idx_rate on rates (date, currency)" + self.con.execute(sql) + + try: + return self.con.execute(*args, **kwargs) + except sqlite.OperationalError: # new db, or other problems + try: + create_tables() + except Exception: + logging.warning("Messy problems with the currency db, starting anew with a memory db") + self.con = sqlite.connect(':memory:') + create_tables() + except sqlite.DatabaseError: # corrupt db + logging.warning("Corrupt currency database at {0}. Starting over.".format(repr(self.db_or_path))) + if isinstance(self.db_or_path, (str, Path)): + self.con.close() + io.remove(Path(self.db_or_path)) + self.con = sqlite.connect(str(self.db_or_path)) + else: + logging.warning("Can't re-use the file, using a memory table") + self.con = sqlite.connect(':memory:') + create_tables() + return self.con.execute(*args, **kwargs) # try again + + def _seek_value_in_CAD(self, str_date, currency_code): + if currency_code == 'CAD': + return 1 + def seek(date_op, desc): + sql = "select rate from rates where date %s ? and currency = ? order by date %s limit 1" % (date_op, desc) + cur = self._execute(sql, [str_date, currency_code]) + row = cur.fetchone() + if row: + return row[0] + return seek('<=', 'desc') or seek('>=', '') or Currency(currency_code).latest_rate + + def _save_fetched_rates(self): + while True: + try: + rates, currency = self._fetched_values.get_nowait() + for rate_date, rate in rates: + self.set_CAD_value(rate_date, currency, rate) + except Empty: + break + + def clear_cache(self): + self._cache = {} + + def date_range(self, currency_code): + """Returns (start, end) of the cached rates for currency""" + sql = "select min(date), max(date) from rates where currency = '%s'" % currency_code + cur = self._execute(sql) + start, end = cur.fetchone() + if start and end: + convert = lambda s: datetime.strptime(s, '%Y%m%d').date() + return convert(start), convert(end) + else: + return None + + def get_rate(self, date, currency1_code, currency2_code): + """Returns the exchange rate between currency1 and currency2 for date. + + The rate returned means '1 unit of currency1 is worth X units of currency2'. + The rate of the nearest date that is smaller than 'date' is returned. If + there is none, a seek for a rate with a higher date will be made. + """ + # We want to check self._fetched_values for rates to add. + if not self._fetched_values.empty(): + self._save_fetched_rates() + # This method is a bottleneck and has been optimized for speed. + value1 = None + value2 = None + if currency1_code == 'CAD': + value1 = 1 + else: + value1 = self._cache.get((date, currency1_code)) + if currency2_code == 'CAD': + value2 = 1 + else: + value2 = self._cache.get((date, currency2_code)) + if value1 is None or value2 is None: + str_date = '%d%02d%02d' % (date.year, date.month, date.day) + if value1 is None: + value1 = self._seek_value_in_CAD(str_date, currency1_code) + self._cache[(date, currency1_code)] = value1 + if value2 is None: + value2 = self._seek_value_in_CAD(str_date, currency2_code) + self._cache[(date, currency2_code)] = value2 + return value1 / value2 + + def set_CAD_value(self, date, currency_code, value): + """Sets the daily value in CAD for currency at date""" + # we must clear the whole cache because there might be other dates affected by this change + # (dates when the currency server has no rates). + self.clear_cache() + str_date = '%d%02d%02d' % (date.year, date.month, date.day) + sql = "replace into rates(date, currency, rate) values(?, ?, ?)" + self._execute(sql, [str_date, currency_code, value]) + self.con.commit() + + def register_rate_provider(self, rate_provider): + """Adds `rate_provider` to the list of providers supported by this DB. + + A provider if a function(currency, start_date, end_date) that returns a list of + (rate_date, float_rate) as a result. This function will be called asyncronously, so it's ok + if it takes a long time to return. + + The rates returned must be the value of 1 `currency` in CAD (Canadian Dollars) at the + specified date. + + The provider can be asked for any currency. If it doesn't support it, it has to raise + CurrencyNotSupportedException. + + If we support the currency but that there is no rate available for the specified range, + simply return an empty list or None. + """ + self._rate_providers.append(rate_provider) + + def ensure_rates(self, start_date, currencies): + """Ensures that the DB has all the rates it needs for 'currencies' between 'start_date' and today + + If there is any rate missing, a request will be made to the currency server. The requests + are made asynchronously. + """ + def do(): + for currency, fetch_start, fetch_end in currencies_and_range: + for rate_provider in self._rate_providers: + try: + values = rate_provider(currency, fetch_start, fetch_end) + except CurrencyNotSupportedException: + continue + else: + if values: + self._fetched_values.put((values, currency)) + + currencies_and_range = [] + for currency in currencies: + if currency == 'CAD': + continue + try: + cached_range = self._fetched_ranges[currency] + except KeyError: + cached_range = self.date_range(currency) + range_start = start_date + range_end = date.today() + if cached_range is not None: + cached_start, cached_end = cached_range + if range_start >= cached_start: + # Make a forward fetch + range_start = cached_end + timedelta(days=1) + else: + # Make a backward fetch + range_end = cached_start - timedelta(days=1) + if range_start <= range_end: + currencies_and_range.append((currency, range_start, range_end)) + self._fetched_ranges[currency] = (start_date, date.today()) + if self.async: + threading.Thread(target=do).start() + else: + do() + diff --git a/hscommon/debug.py b/hscommon/debug.py new file mode 100644 index 00000000..070119a5 --- /dev/null +++ b/hscommon/debug.py @@ -0,0 +1,22 @@ +# Created By: Virgil Dupras +# Created On: 2011-04-19 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +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/docs/build.rst b/hscommon/docs/build.rst new file mode 100644 index 00000000..4db5316d --- /dev/null +++ b/hscommon/docs/build.rst @@ -0,0 +1,25 @@ +========================================== +:mod:`build` - Build utilities for HS apps +========================================== + +This module is a collection of function to help in HS apps build process. + +.. function:: print_and_do(cmd) + + Prints ``cmd`` and executes it in the shell. + +.. function:: build_all_qt_ui(base_dir='.') + + Calls Qt's ``pyuic4`` for each file in ``base_dir`` with a ".ui" extension. The resulting file is saved under ``{base_name}_ui.py``. + +.. function:: build_dmg(app_path, dest_path) + + 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. + +.. function:: add_to_pythonpath(path) + + Adds ``path`` to both ``PYTHONPATH`` env variable and ``sys.path``. + +.. function:: copy_packages(packages_names, dest) + + Copy python packages ``packages_names`` to ``dest``, but 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. \ No newline at end of file diff --git a/hscommon/docs/conf.py b/hscommon/docs/conf.py new file mode 100644 index 00000000..9ec64693 --- /dev/null +++ b/hscommon/docs/conf.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +# +# hscommon documentation build configuration file, created by +# sphinx-quickstart on Fri Mar 12 16:00:37 2010. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.append(os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'hscommon' +copyright = '2011, Hardcoded Software' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '1.0.0' +# The full version, including alpha/beta/rc tags. +release = '1.0.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of documents that shouldn't be included in the build. +#unused_docs = [] + +# List of directories, relative to source directory, that shouldn't be searched +# for source files. +exclude_trees = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. Major themes that come with +# Sphinx are currently 'default' and 'sphinxdoc'. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_use_modindex = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = '' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'hscommondoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +# The paper size ('letter' or 'a4'). +#latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +#latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'hscommon.tex', 'hscommon Documentation', + 'Hardcoded Software', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# Additional stuff for the LaTeX preamble. +#latex_preamble = '' + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_use_modindex = True diff --git a/hscommon/docs/conflict.rst b/hscommon/docs/conflict.rst new file mode 100644 index 00000000..c186d809 --- /dev/null +++ b/hscommon/docs/conflict.rst @@ -0,0 +1,27 @@ +=================================================== +:mod:`conflict` - Detect and resolve name conflicts +=================================================== + +.. module:: conflict + +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. + +.. function:: get_conflicted_name(other_names, name) + + Returns a name based on ``name`` that is guaranteed not to be in ``other_names``. Name conflicts are resolved by prepending numbers in ``[]`` brackets to the name. + +.. function:: get_unconflicted_name(name) + + Returns ``name`` without ``[]`` brackets. + +.. function:: is_conflicted(name) + + Returns whether ``name`` is prepended with a bracketed number. + +.. function:: smart_copy(source_path, dest_path) + + Copies ``source_path`` to ``dest_path``, recursively. However, it does conflict resolution using functions in this module. + +.. function:: smart_move(source_path, dest_path) + + Same as :func:`smart_copy`, but it moves files instead. diff --git a/hscommon/docs/currency.rst b/hscommon/docs/currency.rst new file mode 100644 index 00000000..13d2992f --- /dev/null +++ b/hscommon/docs/currency.rst @@ -0,0 +1,62 @@ +=================================== +:mod:`currency` - Manage currencies +=================================== + +This module facilitates currencies management. It exposes :class:`Currency` which lets you easily figure out their exchange value. + +The ``Currency`` class +====================== + +.. class:: Currency(code=None, name=None) + + A ``Currency`` instance is created with either a 3-letter ISO code or with a full name. If it's present in the database, an instance will be returned. If not, ``ValueError`` is raised. The easiest way to access a currency instance, however, if by using module-level constants. For example:: + + >>> from hscommon.currency import USD, EUR + >>> from datetime import date + >>> USD.value_in(EUR, date.today()) + 0.6339119851386843 + + Unless a :class:`currency.RatesDB` global instance is set through :meth:`Currency.set_rate_db` however, only fallback values will be used as exchange rates. + + .. staticmethod:: Currency.register(code, name, exponent=2, fallback_rate=1) + + Register a new currency in the currency list. + + .. staticmethod:: Currency.set_rates_db(db) + + Sets a new currency ``RatesDB`` instance to be used with all ``Currency`` instances. + + .. staticmethod:: Currency.set_rates_db() + + Returns the current ``RatesDB`` instance. + + .. method:: Currency.rates_date_range() + + Returns the range of date for which rates are available for this currency. + + .. method:: Currency.value_in(currency, date) + + Returns the value of this currency in terms of the other currency on the given date. + + .. method:: Currency.set_CAD_value(value, date) + + Sets currency's value in CAD on the given date. + +The ``RatesDB`` class +===================== + +.. class:: RatesDB(db_or_path=':memory:') + + A sqlite database that stores currency/date/value pairs, "value" being the value of the currency in CAD at the given date. Currencies are referenced by their 3-letter ISO code in the database and it its arguments (so ``currency_code`` arguments must be 3-letter strings). + + .. method:: RatesDB.date_range(currency_code) + + Returns a tuple ``(start_date, end_date)`` representing dates covered in the database for currency ``currency_code``. If there are gaps, they are not accounted for (subclasses that automatically update themselves are not supposed to introduce gaps in the db). + + .. method:: RatesDB.get_rate(date, currency1_code, currency2_code) + + Returns the exchange rate between currency1 and currency2 for date. The rate returned means '1 unit of currency1 is worth X units of currency2'. The rate of the nearest date that is smaller than 'date' is returned. If there is none, a seek for a rate with a higher date will be made. + + .. method:: RatesDB.set_CAD_value(date, currency_code, value) + + Sets the CAD value of ``currency_code`` at ``date`` to ``value`` in the database. diff --git a/hscommon/docs/index.rst b/hscommon/docs/index.rst new file mode 100644 index 00000000..ce4cd37b --- /dev/null +++ b/hscommon/docs/index.rst @@ -0,0 +1,32 @@ +============================================== +hscommon - Common code used throughout HS apps +============================================== + +:Author: `Hardcoded Software `_ +:Dev website: http://hg.hardcoded.net/hscommon +:License: BSD License + +Introduction +============ + +``hscommon`` is a collection of tools used throughout HS apps. + +Dependencies +============ + +Python 3.1 is required. `py.test `_ is required to run the tests. + +API Documentation +================= + +.. toctree:: + :maxdepth: 2 + + build + conflict + currency + notify + path + reg + sqlite + util diff --git a/hscommon/docs/notify.rst b/hscommon/docs/notify.rst new file mode 100644 index 00000000..af7dbdfe --- /dev/null +++ b/hscommon/docs/notify.rst @@ -0,0 +1,26 @@ +========================================== +:mod:`notify` - Simple notification system +========================================== + +.. module:: notify + +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. + +.. class:: Broadcaster + + .. method:: notify(msg) + + Notify all connected listeners of ``msg``. That means that each listeners will have their method with the same name as ``msg`` called. + +.. class:: Listener(broadcaster) + + A listener is initialized with the broadcaster it's going to listen to. Initially, it is not connected. + + .. method:: connect() + + Connects the listener to its broadcaster. + + .. method:: disconnect() + + Disconnects the listener from its broadcaster. + diff --git a/hscommon/docs/path.rst b/hscommon/docs/path.rst new file mode 100644 index 00000000..8c594fae --- /dev/null +++ b/hscommon/docs/path.rst @@ -0,0 +1,13 @@ +======================================== +:mod:`path` - Work with paths +======================================== + +.. module:: path + +.. class:: Path(value, separator=None) + + ``Path`` instances can be created from strings, other ``Path`` instances or tuples. If ``separator`` is not specified, the one from the OS is used. Once created, paths can be manipulated like a tuple, each element being an element of the path. It makes a few common operations easy, such as getting the filename (``path[-1]``) or the directory name or parent (``path[:-1]``). + + HS apps pretty much always refer to ``Path`` instances when a variable name ends with ``path``. If a variable is of another type, that type is usually explicited in the name. + + To make common operations (from ``os.path``, ``os`` and ``shutil``) convenient, the :mod:`io` module wraps these functions and converts paths to strings. diff --git a/hscommon/docs/reg.rst b/hscommon/docs/reg.rst new file mode 100644 index 00000000..0d074ebc --- /dev/null +++ b/hscommon/docs/reg.rst @@ -0,0 +1,25 @@ +======================================== +:mod:`reg` - Manage app registration +======================================== + +.. module:: reg + +.. class:: RegistrableApplication + + HS main application classes subclass this. It provides an easy interface for managing whether the app should be in demo mode or not. + + .. method:: _setup_as_registered() + + Virtual. This is called whenever the app is unlocked. This is the one place to put code that changes to UI to indicate that the app is unlocked. + + .. method:: validate_code(code, email) + + Validates ``code`` with email. If it's valid, it does nothing. Otherwise, it raises ``InvalidCodeError`` with a message indicating why it's invalid (wrong product, wrong code format, fields swapped). + + .. method:: set_registration(code, email) + + If ``code`` and ``email`` are valid, sets ``registered`` to True as well as ``registration_code`` and ``registration_email`` and then calls :meth:`_setup_as_registered`. + +.. exception:: InvalidCodeError + + Raised during :meth:`RegistrableApplication.validate_code`. diff --git a/hscommon/docs/sqlite.rst b/hscommon/docs/sqlite.rst new file mode 100644 index 00000000..1faa8cdf --- /dev/null +++ b/hscommon/docs/sqlite.rst @@ -0,0 +1,9 @@ +========================================== +:mod:`sqlite` - Threaded sqlite connection +========================================== + +.. module:: sqlite + +.. class:: ThreadedConn(dbname, autocommit) + + ``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. \ No newline at end of file diff --git a/hscommon/docs/util.rst b/hscommon/docs/util.rst new file mode 100644 index 00000000..ac5d54f1 --- /dev/null +++ b/hscommon/docs/util.rst @@ -0,0 +1,88 @@ +======================================== +:mod:`util` - Miscellaneous utilities +======================================== + +.. module:: misc + +.. function:: nonone(value, replace_value) + + Returns ``value`` if value is not None. Returns ``replace_value`` otherwise. + +.. function:: dedupe(iterable) + + Returns a list of elements in ``iterable`` with all dupes removed. The order of the elements is preserved. + +.. function:: flatten(iterables, start_with=None) + + Takes the list of iterable ``iterables`` and returns a list containing elements of every iterable. + + 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. + +.. function:: first(iterable) + + Returns the first item of ``iterable`` or ``None`` if empty. + +.. function:: tryint(value, default=0) + + Tries to convert ``value`` to in ``int`` and returns ``default`` if it fails. + +.. function:: escape(s, to_escape, escape_with='\\') + + Returns ``s`` with characters in ``to_escape`` all prepended with ``escape_with``. + +.. function:: format_size(size, decimal=0, forcepower=-1, showdesc=True) + + Transform a byte count ``size`` in a formatted string (KB, MB etc..). ``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' + +.. function:: format_time(seconds, with_hours=True) + + Transforms seconds in a hh:mm:ss string. + + If `with_hours` if false, the format is mm:ss. + +.. function:: format_time_decimal(seconds) + + Transforms seconds in a strings like '3.4 minutes'. + +.. function:: get_file_ext(filename) + + Returns the lowercase extension part of ``filename``, without the dot. + +.. function:: pluralize(number, word, decimals=0, plural_word=None) + + Returns a string with ``number`` in front of ``word``, and adds a 's' to ``word`` if ``number`` > 1. If ``plural_word`` is defined, it will replace ``word`` in plural cases instead of appending a 's'. + +.. function:: rem_file_ext(filename) + + Returns ``filename`` without extension. + +.. function:: 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`` occurences will be replaced by that string. ``replace_from`` can also be a string. 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 string and has the same length as ``replace_from``, it will be transformed into a list. + +.. function:: 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() + +.. class:: FileOrPath(file_or_path, mode='rb') + + Does the same as :func:`open_if_filename`, but it can be used with a ``with`` statement. Example:: + + with FileOrPath(infile): + dostuff() + +.. function:: delete_if_empty(path, files_to_delete=[]) + + Same as with :func:`clean_empty_dirs`, but not recursive. + +.. function:: modified_after(first_path, second_path) + + Returns True if ``first_path``'s mtime is higher than ``second_path``'s mtime. \ No newline at end of file diff --git a/hscommon/geometry.py b/hscommon/geometry.py new file mode 100644 index 00000000..0f53d967 --- /dev/null +++ b/hscommon/geometry.py @@ -0,0 +1,218 @@ +# Created By: Virgil Dupras +# Created On: 2011-08-05 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +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..31b94db9 --- /dev/null +++ b/hscommon/gui/base.py @@ -0,0 +1,58 @@ +# Created By: Virgil Dupras +# Created On: 2011/09/09 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +def noop(*args, **kwargs): + pass + +class NoopGUI: + def __getattr__(self, func_name): + return noop + +# 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 multiple what we call here a "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 "view", that is why we set it as 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 _view_updated(). If you need another type of action on +# view instantiation, just override the method. +class GUIObject: + def __init__(self): + self._view = None + + def _view_updated(self): + pass #virtual + + def has_view(self): + return (self._view is not None) and (not isinstance(self._view, NoopGUI)) + + @property + def view(self): + return self._view + + @view.setter + def view(self, value): + # There's two times at which we set the view property: On initialization, where we set the + # view that we'll use for your 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). + if self._view is None: + # Initial view assignment + if value is None: + return + self._view = value + self._view_updated() + else: + assert value is None + # Instead of None, we put a NoopGUI() there to avoid rogue view callback raising an + # exception. + self._view = NoopGUI() + diff --git a/hscommon/gui/column.py b/hscommon/gui/column.py new file mode 100644 index 00000000..2204e9bf --- /dev/null +++ b/hscommon/gui/column.py @@ -0,0 +1,160 @@ +# Created By: Virgil Dupras +# Created On: 2010-07-25 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +import copy + +from .base import GUIObject + +class Column: + def __init__(self, name, display='', visible=True, optional=False): + self.name = name + self.logical_index = 0 + self.ordered_index = 0 + self.width = 0 + self.default_width = 0 + self.display = display + self.visible = visible + self.default_visible = visible + self.optional = optional + + +class Columns(GUIObject): + 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 self.column_list[index] + + def column_by_name(self, name): + return self.coldata[name] + + def columns_count(self): + return len(self.column_list) + + def column_display(self, colname): + return self._get_colname_attr(colname, 'display', '') + + def column_is_visible(self, colname): + return self._get_colname_attr(colname, 'visible', True) + + def column_width(self, colname): + return self._get_colname_attr(colname, 'width', 0) + + def columns_to_right(self, colname): + 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 (display_name, marked) items for each optional column in the current + # view (marked means that it's visible). + return [(c.display, c.visible) for c in self._optional_columns()] + + def move_column(self, colname, index): + colnames = self.colnames + colnames.remove(colname) + colnames.insert(index, colname) + self.set_column_order(colnames) + + def reset_to_defaults(self): + 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): + self._set_colname_attr(colname, 'width', newwidth) + + def restore_columns(self): + 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): + 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): + 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): + 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): + self._set_colname_attr(colname, 'default_width', width) + + def toggle_menu_item(self, index): + col = self._optional_columns()[index] + self.set_column_visible(col.name, not col.visible) + return col.visible + + #--- Properties + @property + def ordered_columns(self): + return [col for col in sorted(self.column_list, key=lambda col: col.ordered_index)] + + @property + def colnames(self): + return [col.name for col in self.ordered_columns] + diff --git a/hscommon/gui/selectable_list.py b/hscommon/gui/selectable_list.py new file mode 100644 index 00000000..9bf457f0 --- /dev/null +++ b/hscommon/gui/selectable_list.py @@ -0,0 +1,131 @@ +# Created By: Virgil Dupras +# Created On: 2011-09-06 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from collections import Sequence, MutableSequence + +from .base import GUIObject + +class Selectable(Sequence): + 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): + # Takes the table's selection and does appropriates updates on the view and/or model, when + # appropriate. 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. A redesign of how this whole + # thing works is probably in order, but not now, there's too much breakage at once involved. + pass + + #--- Public + def select(self, indexes): + if isinstance(indexes, int): + indexes = [indexes] + self.selected_indexes = indexes + self._update_selection() + + #--- Properties + @property + def selected_index(self): + 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): + 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): + 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): + pass + + #--- Public + def search_by_prefix(self, prefix): + prefix = prefix.lower() + for index, s in enumerate(self): + if s.lower().startswith(prefix): + return index + return -1 + + +class GUISelectableList(SelectableList, GUIObject): + #--- View interface + # refresh() + # update_selection() + # + + def __init__(self, items=None): + SelectableList.__init__(self, items) + GUIObject.__init__(self) + + def _view_updated(self): + self.view.refresh() + + def _update_selection(self): + self.view.update_selection() + + def _on_change(self): + self.view.refresh() diff --git a/hscommon/gui/table.py b/hscommon/gui/table.py new file mode 100644 index 00000000..0192eb5e --- /dev/null +++ b/hscommon/gui/table.py @@ -0,0 +1,297 @@ +# Created By: Eric Mc Sween +# Created On: 2008-05-29 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +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 + +# Adding and removing footer here and there might seem (and is) hackish, but it's much simpler than +# the alternative, 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. +class Table(MutableSequence, 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): + if self._footer is not None: + self._rows.insert(-1, item) + else: + self._rows.append(item) + + def insert(self, index, item): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + return [self[index] for index in self.selected_indexes] + + +SortDescriptor = namedtuple('SortDescriptor', 'column desc') +class GUITable(Table, GUIObject): + def __init__(self): + GUIObject.__init__(self) + Table.__init__(self) + self.edited = None + self._sort_descriptor = None + + #--- Virtual + def _do_add(self): + # Creates a new row, adds it in the table and returns (row, insert_index) + raise NotImplementedError() + + def _do_delete(self): + # Delete the selected rows + pass + + def _fill(self): + # Called by refresh() + # Fills the table with all the rows that this table is supposed to have. + pass + + def _is_edited_new(self): + return False + + def _restore_selection(self, previous_selection): + if not self.selected_indexes: + if previous_selection: + self.select(previous_selection) + else: + self.select([len(self) - 1]) + + #--- Public + def add(self): + self.view.stop_editing() + if self.edited is not None: + self.save_edits() + row, insert_index = self._do_add() + self.insert(insert_index, row) + self.select([insert_index]) + self.edited = row + self.view.refresh() + self.view.start_editing() + + def can_edit_cell(self, column_name, row_index): + # A row is, by default, editable as soon as it has an attr with the same name as `column`. + # If 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 + row = self[row_index] + return row.can_edit_cell(column_name) + + def cancel_edits(self): + 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): + 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): + 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): + if self.edited is None: + return + row = self.edited + self.edited = None + row.save() + + def sort_by(self, column_name, desc=False): + 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: + 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): + return True + + def load(self): + raise NotImplementedError() + + def save(self): + raise NotImplementedError() + + def sort_key_for_column(self, column_name): + # Most of the time, the adequate sort key for a column is the column name with '_' prepended + # to it. This member usually corresponds to the unformated version of the column. If it's + # not there, we try the column_name without underscores + # Of course, override for exceptions. + try: + return getattr(self, '_' + column_name) + except AttributeError: + return getattr(self, column_name) + + #--- Public + def can_edit_cell(self, column_name): + 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): + if attrname == 'from': + attrname = 'from_' + return getattr(self, attrname) + + def set_cell_value(self, attrname, value): + 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..ab636bd2 --- /dev/null +++ b/hscommon/gui/text_field.py @@ -0,0 +1,55 @@ +# Created On: 2012/01/23 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from .base import GUIObject +from ..util import nonone + +class TextField(GUIObject): + def __init__(self): + GUIObject.__init__(self) + self._text = '' + self._value = None + + #--- Virtual + def _parse(self, text): + return text + + def _format(self, value): + return value + + def _update(self, newvalue): + pass + + #--- Override + def _view_updated(self): + self.view.refresh() + + #--- Public + def refresh(self): + self.view.refresh() + + @property + def text(self): + return self._text + + @text.setter + def text(self, newtext): + self.value = self._parse(nonone(newtext, '')) + + @property + def value(self): + 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..ec84d67b --- /dev/null +++ b/hscommon/gui/tree.py @@ -0,0 +1,166 @@ +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from collections import MutableSequence + +from .base import GUIObject + +class Node(MutableSequence): + 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): + del self[:] + + def find(self, predicate, include_self=True): + try: + return next(self.findall(predicate, include_self=include_self)) + except StopIteration: + return None + + def findall(self, predicate, include_self=True): + 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): + result = self + if index_path: + for index in index_path: + result = result[index] + return result + + def get_path(self, target_node): + if target_node is None: + return None + return target_node.path + + @property + def children_count(self): + return len(self) + + @property + def name(self): + return self._name + + @property + def parent(self): + return self._parent + + @property + def path(self): + 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): + if self._parent is None: + return self + else: + return self._parent.root + + +class Tree(Node, GUIObject): + def __init__(self): + Node.__init__(self, '') + GUIObject.__init__(self) + self._selected_nodes = [] + + #--- Virtual + def _select_nodes(self, nodes): + # all selection changes go through this method, so you can override this if you want to + # customize the tree's behavior. + self._selected_nodes = nodes + + #--- Override + def _view_updated(self): + self.view.refresh() + + #--- Public + def clear(self): + self._selected_nodes = [] + Node.clear(self) + + @property + def selected_node(self): + 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): + return self._selected_nodes + + @selected_nodes.setter + def selected_nodes(self, nodes): + self._select_nodes(nodes) + + @property + def selected_path(self): + 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): + 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/io.py b/hscommon/io.py new file mode 100644 index 00000000..dd0fb4f4 --- /dev/null +++ b/hscommon/io.py @@ -0,0 +1,79 @@ +# Created By: Virgil Dupras +# Created On: 2007-10-23 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +# HS code should only deal with Path instances, not string paths. One of the annoyances of this +# is to always have to convert Path instances with unicode() when calling open() or listdir() etc.. +# this unit takes care of this + +import builtins +import os +import os.path +import shutil +import logging + +def log_io_error(func): + """ Catches OSError, IOError and WindowsError and log them + """ + 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 + +def copy(source_path, dest_path): + return shutil.copy(str(source_path), str(dest_path)) + +def copytree(source_path, dest_path, *args, **kwargs): + return shutil.copytree(str(source_path), str(dest_path), *args, **kwargs) + +def exists(path): + return os.path.exists(str(path)) + +def isdir(path): + return os.path.isdir(str(path)) + +def isfile(path): + return os.path.isfile(str(path)) + +def islink(path): + return os.path.islink(str(path)) + +def listdir(path): + return os.listdir(str(path)) + +def mkdir(path, *args, **kwargs): + return os.mkdir(str(path), *args, **kwargs) + +def makedirs(path, *args, **kwargs): + return os.makedirs(str(path), *args, **kwargs) + +def move(source_path, dest_path): + return shutil.move(str(source_path), str(dest_path)) + +def open(path, *args, **kwargs): + return builtins.open(str(path), *args, **kwargs) + +def remove(path): + return os.remove(str(path)) + +def rename(source_path, dest_path): + return os.rename(str(source_path), str(dest_path)) + +def rmdir(path): + return os.rmdir(str(path)) + +def rmtree(path): + return shutil.rmtree(str(path)) + +def stat(path): + return os.stat(str(path)) diff --git a/hscommon/loc.py b/hscommon/loc.py new file mode 100644 index 00000000..9be85bfb --- /dev/null +++ b/hscommon/loc.py @@ -0,0 +1,177 @@ +import os +import os.path as op +import shutil +import re +import tempfile + +import polib + +from . import pygettext +from .util import modified_after, dedupe, ensure_folder, ensure_file +from .build import print_and_do, ensure_empty_folder, copy + +LC_MESSAGES = 'LC_MESSAGES' + +def get_langs(folder): + return [name for name in os.listdir(folder) if op.isdir(op.join(folder, name))] + +def files_with_ext(folder, ext): + return [op.join(folder, fn) for fn in os.listdir(folder) if fn.endswith(ext)] + +def generate_pot(folders, outpath, keywords, merge=False): + if merge and not op.exists(outpath): + merge = False + if merge: + _, genpath = tempfile.mkstemp() + else: + genpath = outpath + pyfiles = [] + for folder in folders: + for root, dirs, filenames in os.walk(folder): + keep = [fn for fn in filenames if fn.endswith('.py')] + pyfiles += [op.join(root, fn) for fn in keep] + pygettext.main(pyfiles, outpath=genpath, keywords=keywords) + if merge: + merge_po_and_preserve(genpath, outpath) + os.remove(genpath) + +def compile_all_po(base_folder): + langs = get_langs(base_folder) + for lang in langs: + pofolder = op.join(base_folder, lang, LC_MESSAGES) + pofiles = files_with_ext(pofolder, '.po') + for pofile in pofiles: + p = polib.pofile(pofile) + p.save_as_mofile(pofile[:-3] + '.mo') + +def merge_locale_dir(target, mergeinto): + langs = get_langs(target) + for lang in langs: + if not op.exists(op.join(mergeinto, lang)): + continue + mofolder = op.join(target, lang, LC_MESSAGES) + mofiles = files_with_ext(mofolder, '.mo') + for mofile in mofiles: + shutil.copy(mofile, op.join(mergeinto, lang, LC_MESSAGES)) + +def merge_pots_into_pos(folder): + # We're going to take all pot files in `folder` and for each lang, merge it with the po file + # with the same name. + potfiles = files_with_ext(folder, '.pot') + for potfile in potfiles: + refpot = polib.pofile(potfile) + refname = op.splitext(op.basename(potfile))[0] + for lang in get_langs(folder): + po = polib.pofile(op.join(folder, lang, LC_MESSAGES, refname + '.po')) + po.merge(refpot) + po.save() + +def merge_po_and_preserve(source, dest): + # Merges source entries into dest, but keep old entries intact + sourcepo = polib.pofile(source) + destpo = polib.pofile(dest) + for entry in sourcepo: + if destpo.find(entry.msgid) is not None: + # The entry is already there + continue + destpo.append(entry) + destpo.save() + +#--- 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) + ensure_file(dest) + po = polib.pofile(dest) + 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() + +def allstrings2pot(lprojpath, dest, excludes=None): + allstrings = files_with_ext(lprojpath, '.strings') + if excludes: + allstrings = [p for p in allstrings if op.splitext(op.basename(p))[0] not in excludes] + for strings_path in allstrings: + strings2pot(strings_path, dest) + +def po2strings(pofile, en_strings, dest): + # Takes en_strings and replace all righthand parts of "foo" = "bar"; entries with translations + # in pofile, then puts the result in dest. + po = polib.pofile(pofile) + if not modified_after(pofile, dest): + return + ensure_folder(op.dirname(dest)) + print("Creating {} from {}".format(dest, pofile)) + with open(en_strings, 'rt', encoding='utf-8') as fp: + contents = fp.read() + re_trans = re.compile(r'(?<= = ").*(?=";\n)') + def repl(match): + s = match.group(0) + unescaped = unescape_cocoa_strings(s) + entry = po.find(unescaped) + if entry is None: + print("WARNING: Could not find entry '{}' in .po file".format(s)) + return s + trans = entry.msgstr + return escape_cocoa_strings(trans) if trans else s + contents = re_trans.sub(repl, contents) + with open(dest, 'wt', encoding='utf-8') as fp: + fp.write(contents) + +def generate_cocoa_strings_from_code(code_folder, dest_folder): + # Uses the "genstrings" command to generate strings file from all .m files in "code_folder". + # The strings file (their name depends on the localization table used in the source) will be + # placed in "dest_folder". + # genstrings produces utf-16 files with comments. After having generated the files, we convert + # them to utf-8 and remove the comments. + ensure_empty_folder(dest_folder) + print_and_do('genstrings -o "{}" `find "{}" -name *.m | xargs`'.format(dest_folder, code_folder)) + for stringsfile in os.listdir(dest_folder): + stringspath = op.join(dest_folder, stringsfile) + with open(stringspath, 'rt', encoding='utf-16') as fp: + content = fp.read() + content = re.sub('/\*.*?\*/', '', content) + content = re.sub('\n{2,}', '\n', content) + # I have no idea why, but genstrings seems to have problems with "%" character in strings + # and inserts (number)$ after it. Find these bogus inserts and remove them. + content = re.sub('%\d\$', '%', content) + with open(stringspath, 'wt', encoding='utf-8') as fp: + fp.write(content) + +def build_cocoa_localizations(app, en_stringsfile=op.join('cocoa', 'en.lproj', 'Localizable.strings')): + # Creates .lproj folders with Localizable.strings and cocoalib.strings based on cocoalib.po and + # ui.po for all available languages as well as base strings files in en.lproj. These lproj + # folders are created in `app`'s (a OSXAppStructure) resource folder. + print("Creating lproj folders based on .po files") + en_cocoastringsfile = op.join('cocoalib', 'en.lproj', 'cocoalib.strings') + for lang in get_langs('locale'): + pofile = op.join('locale', lang, 'LC_MESSAGES', 'ui.po') + dest_lproj = op.join(app.resources, lang + '.lproj') + ensure_folder(dest_lproj) + po2strings(pofile, en_stringsfile, op.join(dest_lproj, 'Localizable.strings')) + pofile = op.join('cocoalib', 'locale', lang, 'LC_MESSAGES', 'cocoalib.po') + po2strings(pofile, en_cocoastringsfile, op.join(dest_lproj, 'cocoalib.strings')) + # We also have to copy the "en.lproj" strings + en_lproj = op.join(app.resources, 'en.lproj') + ensure_folder(en_lproj) + copy(en_stringsfile, en_lproj) + copy(en_cocoastringsfile, en_lproj) diff --git a/hscommon/locale/cs/LC_MESSAGES/hscommon.po b/hscommon/locale/cs/LC_MESSAGES/hscommon.po new file mode 100644 index 00000000..7aa9754f --- /dev/null +++ b/hscommon/locale/cs/LC_MESSAGES/hscommon.po @@ -0,0 +1,20 @@ +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:29+0000\n" +"Last-Translator: hsoft \n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Language: cs\n" +"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" + +#: hscommon/reg.py:32 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "{name} je fairware, což znamená \"open source software vyvíjený v očekávání poctivých příspěvků od uživatelů\". Jde o velmi zajímavý nápad, a po roce se ukazuje, že většina lidí se zajímá o cenu vývoje, ale jsou jim ukradené povídačky o duševním vlastnictví.\n\nTakže vás nebudu otravovat a řeknu to bez okolků: {name} si můžete zdarma vyzkoušet, ale pokud ho chcete používat bez omezení, musíte si ho koupit. V demo režimu, {name} {limitation}.\n\nA to je celé. Pokud se o fairware chcete dozvědět více, klepněte na tlačítko \"Fairware?\"." diff --git a/hscommon/locale/de/LC_MESSAGES/hscommon.po b/hscommon/locale/de/LC_MESSAGES/hscommon.po new file mode 100644 index 00000000..95ac10f7 --- /dev/null +++ b/hscommon/locale/de/LC_MESSAGES/hscommon.po @@ -0,0 +1,30 @@ +# +msgid "" +msgstr "Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: utf-8\n" + +#: hscommon/reg.py:39 +msgid "" +"{name} is Fairware, which means \"open source software developed with expectation of fair contributions from users\". Hours have been invested in this software with the expectation that users will be fair enough to compensate them. The \"Unpaid hours\" figure you see below is the hours that have yet to be compensated for this project.\n" +"\n" +"If you like this application, please make a contribution that you consider fair. Thanks!\n" +"\n" +"If you cannot afford to contribute, you can either ignore this reminder or send an e-mail at support@hardcoded.net so I can send you a registration key.\n" +"\n" +"This dialog doesn't show when there are no unpaid hours or when you have a valid contribution key." +msgstr "" +"{name} ist Fairware, das bedeutet \"Open Source Software, entwickelt in der Hoffnung auf einen fairen Beitrag von den Benutzern\". Viel Zeit wurde in die Software investiert, mit der Erwartung der Nutzer möge fair genug sein die Entwickler für ihren Einsatz zu kompensieren. Die \"Unbezahlte Stunden\" Abbildung zeigt die Anzahl der Stunden die noch nicht bezahlt wurden.\n" +"Wenn Sie diese Anwendung mögen, so spenden Sie bitte einen Ihrer Ansicht nach angemessenen Betrag. Danke!\n" +"\n" +"Wenn Sie es sich nicht leisten können zu spenden, können Sie diese Erinnerung entweder ignorieren oder mir eine Anfrage an hsoft@hardcoded.net schicken, mit der Bitte für einen Registrierungsschlüssel.\n" +"\n" +"Dieser Dialog erscheint nicht, wenn es keine unbezahlten Stunden gibt oder Sie einen gültigen Registrierungsschlüssel besitzen." + +#: hscommon/reg.py:51 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "" diff --git a/hscommon/locale/es/LC_MESSAGES/hscommon.po b/hscommon/locale/es/LC_MESSAGES/hscommon.po new file mode 100644 index 00000000..fed8d342 --- /dev/null +++ b/hscommon/locale/es/LC_MESSAGES/hscommon.po @@ -0,0 +1,20 @@ +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:29+0000\n" +"Last-Translator: hsoft \n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Language: es\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: hscommon/reg.py:32 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "{name} es Fairware, es decir \"software de código abierto desarrollado con la expectativa de recibir una retribución justa por parte de los usuarios\". Es una idea muy interesante, aunque tras más de un año de uso es evidente que la mayoría de los usuarios sólo están interesados en el precio del producto y no en teorías sobre la propiedad intelectual.\n\nAsí pues seré claro: puede probar {name} gratuitamente pero debe comprarlo para un uso completo sin limitaciones. En el modo de prueba, {name} {limitation}.\n\nEn resumen, si tiene curiosidad por conocer fairware le animo a que lea sobre ello pulsando el botón de \"¿Fairware?\"." diff --git a/hscommon/locale/fr/LC_MESSAGES/hscommon.po b/hscommon/locale/fr/LC_MESSAGES/hscommon.po new file mode 100644 index 00000000..b060b834 --- /dev/null +++ b/hscommon/locale/fr/LC_MESSAGES/hscommon.po @@ -0,0 +1,20 @@ +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:29+0000\n" +"Last-Translator: hsoft \n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: hscommon/reg.py:32 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "{name} est Fairware, ce qui signifie \"open source développé avec des attentes de contributions justes de la part des utilisateurs\". C'est un concept excessivement intéressant, mais un an de fairware a révélé que la plupart des gens ne sont que peu intéressés à des discours sur la propriété intellectuelle et veulent simplement savoir combien ça coûte.\n\nDonc, je serai bref et direct: Vous pouvez essayer {name} gratuitement, mais un achat est requis pour un usage sans limitation. En mode démo, {name} {limitation}.\n\nC'est aussi simple que ça. Par contre, si vous êtes curieux, je vous encourage à cliquer sur le bouton \"Fairware?\" pour en savoir plus." diff --git a/hscommon/locale/hscommon.pot b/hscommon/locale/hscommon.pot new file mode 100644 index 00000000..88252696 --- /dev/null +++ b/hscommon/locale/hscommon.pot @@ -0,0 +1,15 @@ + +msgid "" +msgstr "" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: utf-8\n" + +#: hscommon/reg.py:32 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "" + diff --git a/hscommon/locale/hy/LC_MESSAGES/hscommon.po b/hscommon/locale/hy/LC_MESSAGES/hscommon.po new file mode 100644 index 00000000..97eb4d82 --- /dev/null +++ b/hscommon/locale/hy/LC_MESSAGES/hscommon.po @@ -0,0 +1,20 @@ +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:29+0000\n" +"Last-Translator: hsoft \n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Language: hy\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: hscommon/reg.py:32 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "{name}-ը fairware է, ինչը նշանակում է \"ազատ կոդով ծրագիր, որը զարգացվում է՝ ակնկալելով օգտվողների աջակցությունը\": Սա շատ հետաքրքիր սկզբունք է, սակայն մեկ տարվա fairware-ի արդյունքը ցույց է տալիս, որ շատ մարդիկ պարզապես ցանկանում են իմանալ, թե այն ինչ արժե, բայց չեն մտահոգվում ինտելեկտուալ սեփականության մասին:\n\nՈւստի ես չեմ ցանկանում խանգարել Ձեզ և կլինեմ շատ պարզ. Կարող եք փորձել {name}-ը ազատորեն, բայց պետք է գնեք ծրագիրը՝ հանելու համար բոլոր սահմանափակումները: Փորձնական եղանակում {name} {limitation}:\n\nԱմեն ինչ պարզ է, եթե Ձեզ հետաքրքիր է fairware-ը, ապա կարող եք մանրամասն կարդաք՝ սեղմելով այս հղմանը՝ \"Fairware է՞\":" diff --git a/hscommon/locale/it/LC_MESSAGES/hscommon.po b/hscommon/locale/it/LC_MESSAGES/hscommon.po new file mode 100644 index 00000000..04392ac6 --- /dev/null +++ b/hscommon/locale/it/LC_MESSAGES/hscommon.po @@ -0,0 +1,20 @@ +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:29+0000\n" +"Last-Translator: hsoft \n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Language: it\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: hscommon/reg.py:32 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "{name} è denominato \"Fairware\", che significa \"software 'open source' sviluppato aspettandosi un contributo equo e corretto da parte degli utilizzatori\". E' un concetto molto interessante, ma un anno di 'fairware' ha dimostrato che la maggior parte della gente vuole solo sapere 'quanto costa' e non essere scocciata con delle teorie sulla proprietà intellettuale.\n\nCosì non vi disturberò oltre e sarò diretto: potete provare {name} gratuitamente ma dovrete acquistarlo per usarlo senza limitazioni. In modalità 'demo', {name} {limitation}.\n\nIn questo modo è semplice. Se siete curiosi e volete approfondire il concetto di 'fairware', vi invito a leggere di più sull'argomento cliccando sul pulsante \"Fairware?\"." diff --git a/hscommon/locale/nl/LC_MESSAGES/hscommon.po b/hscommon/locale/nl/LC_MESSAGES/hscommon.po new file mode 100644 index 00000000..3df491f7 --- /dev/null +++ b/hscommon/locale/nl/LC_MESSAGES/hscommon.po @@ -0,0 +1,20 @@ +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:29+0000\n" +"Last-Translator: hsoft \n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Language: nl\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: hscommon/reg.py:32 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "{name} is Fairware, dit betekent \"open source software ontwikkeld in de hoop op een redelijke bijdrage van gebruikers\". Het is een interessant concept, maar na een jaar fairware is gebleken dat de meeste mensen gewoon willen weten wat het kost en niet lastig gevallen willen worden met theorieën over intellectueel eigendom.\n\nIk zal u dus niet lastig vallen en duidelijk zijn: U kunt {name} gratis proberen, maar moet het kopen om het zonder beperkingen te kunnen gebruiken. In demo mode {name} {limitation}.\n\nHet is dus eigenlijk heel simpel. Als u toch geïnteresseerd bent in fairware, raad ik u aan hier meer over te lezen door op de knop \"Fairware?\" te klikken." diff --git a/hscommon/locale/pt_BR/LC_MESSAGES/hscommon.po b/hscommon/locale/pt_BR/LC_MESSAGES/hscommon.po new file mode 100644 index 00000000..11f26b2c --- /dev/null +++ b/hscommon/locale/pt_BR/LC_MESSAGES/hscommon.po @@ -0,0 +1,20 @@ +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:29+0000\n" +"Last-Translator: hsoft \n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Language: pt_BR\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: hscommon/reg.py:32 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "{name} é fairware, o que quer dizer \"software de código aberto desenvolvido sob a expectativa de contribuição justa de seus usuários\". É um conceito muito interessante, mas um ano de fairware mostrou que a maioria das pessoas só deseja saber quanto o software custa, sem ser incomodada com teorias sobre propriedade intelectual.\n\nPortanto não o incomodarei e serei bem direto: você pode testar {name} de graça, mas deverá comprá-lo para usá-lo sem limitações. Em modo demo, {name} {limitation}.\n\nÉ simples assim. Caso você tenha curiosidade sobre fairware, recomendo que leia mais sobre o assunto clicando o botão \"Fairware?\"." diff --git a/hscommon/locale/ru/LC_MESSAGES/hscommon.po b/hscommon/locale/ru/LC_MESSAGES/hscommon.po new file mode 100644 index 00000000..7a4ddb85 --- /dev/null +++ b/hscommon/locale/ru/LC_MESSAGES/hscommon.po @@ -0,0 +1,20 @@ +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:29+0000\n" +"Last-Translator: hsoft \n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Language: ru\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" + +#: hscommon/reg.py:32 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "{name} является fairware, что означает \"программное обеспечение с открытым исходным кодом, с ожиданием справедливого вклада от пользователей\". Это очень интересная концепция, но один год fairware показал, что большинство людей просто хотят знать, сколько это стоит, а не возиться с теориями об интеллектуальной собственности.\n\nТак что, я не буду утомлять вас и буду очень краток: вы можете попробовать {name} бесплатно, но вам придётся купить её, чтобы использовать без ограничений. В демо-режиме {name} {limitation}.\n\nТо есть, это просто. Если вам интересно, о fairware вы можете больше узнать нажав на кнопку \"Fairware?\"." diff --git a/hscommon/locale/uk/LC_MESSAGES/hscommon.po b/hscommon/locale/uk/LC_MESSAGES/hscommon.po new file mode 100644 index 00000000..aa6100eb --- /dev/null +++ b/hscommon/locale/uk/LC_MESSAGES/hscommon.po @@ -0,0 +1,20 @@ +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: hscommon\n" +"PO-Revision-Date: 2013-04-28 18:29+0000\n" +"Last-Translator: hsoft \n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: utf-8\n" +"Language: uk\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" + +#: hscommon/reg.py:32 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "{name} є fairware, що означає \"програмне забезпечення з відкритим вихідним кодом, з очікуванням справедливого вкладу від користувачів\". Це дуже цікава концепція, але один рік fairware показав, що більшість людей просто хочуть знати, скільки це коштує, а не возитися з теоріями про інтелектуальну власність.\n\nТож я не буду втомлювати Вас і поясню просто: Ви можете спробувати {name} безкоштовно, але Вам доведеться купити його, щоб використовувати її без обмежень. У демо-режимі, {name} {limitation}.\n\nОсь так це просто. Якщо Вас цікавить ідея fairware, то я запрошую Вас дізнатися більше про це, натиснувши на кнопку \"Fairware?\"." diff --git a/hscommon/locale/zh_CN/LC_MESSAGES/hscommon.po b/hscommon/locale/zh_CN/LC_MESSAGES/hscommon.po new file mode 100644 index 00000000..0c4fa817 --- /dev/null +++ b/hscommon/locale/zh_CN/LC_MESSAGES/hscommon.po @@ -0,0 +1,31 @@ +# +msgid "" +msgstr "Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: utf-8\n" + +#: hscommon/reg.py:39 +msgid "" +"{name} is Fairware, which means \"open source software developed with expectation of fair contributions from users\". Hours have been invested in this software with the expectation that users will be fair enough to compensate them. The \"Unpaid hours\" figure you see below is the hours that have yet to be compensated for this project.\n" +"\n" +"If you like this application, please make a contribution that you consider fair. Thanks!\n" +"\n" +"If you cannot afford to contribute, you can either ignore this reminder or send an e-mail at support@hardcoded.net so I can send you a registration key.\n" +"\n" +"This dialog doesn't show when there are no unpaid hours or when you have a valid contribution key." +msgstr "" +"{name} 是一款捐助软件,也就是说 \"用户对研发开源软件所花费的时间进行符合用户意愿的捐助\"。用户可以根据研发人员花费在开发软件上的时间进行合理的补偿。用户在下面看到的 \"未支付的时间\" (Unpaid hours)表示需要对该软件进行补偿的时间。\n" +" \n" +"如果您喜欢这款软件,我诚挚的希望您可以进行必要的捐助。谢谢!\n" +"\n" +"如果您无法承担捐助,您也可以忽略此提醒,或者发送电子邮件至 support@hardcoded.net ,我会发送给您一个注册密钥。\n" +"\n" +"当软件没有未支付的时间或您已使用一个有效的注册密钥,此对话框将不会再显示。" + +#: hscommon/reg.py:51 +msgid "" +"{name} is fairware, which means \"open source software developed with expectation of fair contributions from users\". It's a very interesting concept, but one year of fairware has shown that most people just want to know how much it costs and not be bothered with theories about intellectual property.\n" +"\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have to buy it in order to use it without limitations. In demo mode, {name} {limitation}.\n" +"\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read more about it by clicking on the \"Fairware?\" button." +msgstr "" diff --git a/hscommon/notify.py b/hscommon/notify.py new file mode 100644 index 00000000..13421f01 --- /dev/null +++ b/hscommon/notify.py @@ -0,0 +1,69 @@ +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from collections import defaultdict + +class Broadcaster: + def __init__(self): + self.listeners = set() + + def add_listener(self, listener): + self.listeners.add(listener) + + def notify(self, msg): + 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: + 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): + self.broadcaster.add_listener(self) + + def disconnect(self): + 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 100755 index 00000000..a6f1675a --- /dev/null +++ b/hscommon/path.py @@ -0,0 +1,184 @@ +# Created By: Virgil Dupras +# Created On: 2006/02/21 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +import logging +import os +import os.path as op +import shutil +import sys +from itertools import takewhile + +class Path(tuple): + """A handy class to work with paths. + + path[index] returns a string + path[start:stop] returns a Path + start and stop can be int, but the can also be path instances. When start + or stop are Path like in refpath[p1:p2], it is the same thing as typing + refpath[len(p1):-len(p2)], except that it will only slice out stuff that are + equal. For example, 'a/b/c/d'['a/z':'z/d'] returns 'b/c', not ''. + See the test units for more details. + + You can use the + operator, which is the same thing as with tuples, but + returns a Path. + + In HS applications, all paths variable should be Path instances. These Path instances should + be converted to str only at the last moment (when it is needed in an external function, such + as os.rename) + """ + # 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)) + else: + return tuple.__getitem__(self, key) + + def __getslice__(self, i, j): #I have to override it because tuple uses it. + return Path(tuple.__getslice__(self, i, j)) + + 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 remove_drive_letter(self): + if self.has_drive_letter(): + return self[1:] + else: + return self + + def tobytes(self): + return str(self).encode(sys.getfilesystemencoding()) + + # 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 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)) + diff --git a/hscommon/plat.py b/hscommon/plat.py new file mode 100644 index 00000000..c27f0bd2 --- /dev/null +++ b/hscommon/plat.py @@ -0,0 +1,16 @@ +# Created On: 2011/09/22 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +# 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 100755 index 00000000..5d2305ba --- /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) + 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) + 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') + closep = 1 + try: + eater.write(fp) + finally: + if closep: + fp.close() diff --git a/hscommon/reg.py b/hscommon/reg.py new file mode 100644 index 00000000..3b5a57e0 --- /dev/null +++ b/hscommon/reg.py @@ -0,0 +1,179 @@ +# Created By: Virgil Dupras +# Created On: 2009-05-16 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +import re +from hashlib import md5 + +from .trans import trget + +tr = trget('hscommon') + +ALL_APPS = [ + (1, 'dupeGuru'), + (2, 'moneyGuru'), + (3, 'musicGuru'), + (6, 'PdfMasher'), +] + +OLDAPPIDS = { + 1: {1, 4, 5}, + 2: {6, }, + 3: {2, }, +} + +class InvalidCodeError(Exception): + """The supplied code is invalid.""" + +DEMO_PROMPT = tr("{name} is fairware, which means \"open source software developed with expectation " +"of fair contributions from users\". It's a very interesting concept, but one year of fairware has " +"shown that most people just want to know how much it costs and not be bothered with theories " +"about intellectual property." +"\n\n" +"So I won't bother you and will be very straightforward: You can try {name} for free but you have " +"to buy it in order to use it without limitations. In demo mode, {name} {limitation}." +"\n\n" +"So it's as simple as this. If you're curious about fairware, however, I encourage you to read " +"more about it by clicking on the \"Fairware?\" button.") + +class RegistrableApplication: + #--- View interface + # get_default(key_name) + # set_default(key_name, value) + # setup_as_registered() + # show_message(msg) + # show_demo_nag(prompt) + # open_url(url) + + PROMPT_NAME = "" + DEMO_LIMITATION = "" + + def __init__(self, view, appid): + self.view = view + self.appid = appid + self.registered = False + self.fairware_mode = False + self.registration_code = '' + self.registration_email = '' + self._unpaid_hours = None + + @staticmethod + def _is_code_valid(appid, code, email): + if len(code) != 32: + return False + appid = str(appid) + for i in range(100): + blob = appid + email + str(i) + 'aybabtu' + digest = md5(blob.encode('utf-8')).hexdigest() + if digest == code: + return True + return False + + def _set_registration(self, code, email): + self.validate_code(code, email) + self.registration_code = code + self.registration_email = email + self.registered = True + self.view.setup_as_registered() + + def initial_registration_setup(self): + # Should be called only after the app is finished launching + if self.registered: + # We've already set registration in a hardcoded way (for example, for the Ubuntu Store) + # Just ignore registration, but not before having set as registered. + self.view.setup_as_registered() + return + code = self.view.get_default('RegistrationCode') + email = self.view.get_default('RegistrationEmail') + if code and email: + try: + self._set_registration(code, email) + except InvalidCodeError: + pass + if not self.registered: + if self.view.get_default('FairwareMode'): + self.fairware_mode = True + if not self.fairware_mode: + prompt = DEMO_PROMPT.format(name=self.PROMPT_NAME, limitation=self.DEMO_LIMITATION) + self.view.show_demo_nag(prompt) + + def validate_code(self, code, email): + code = code.strip().lower() + email = email.strip().lower() + if self._is_code_valid(self.appid, code, email): + return + # Check if it's not an old reg code + for oldappid in OLDAPPIDS.get(self.appid, []): + if self._is_code_valid(oldappid, code, email): + return + # let's see if the user didn't mix the fields up + if self._is_code_valid(self.appid, email, code): + msg = "Invalid Code. It seems like you inverted the 'Registration Code' and"\ + "'Registration E-mail' field." + raise InvalidCodeError(msg) + # Is the code a paypal transaction id? + if re.match(r'^[a-z\d]{17}$', code) is not None: + msg = "The code you submitted looks like a Paypal transaction ID. Registration codes are "\ + "32 digits codes which you should have received in a separate e-mail. If you haven't "\ + "received it yet, please visit http://www.hardcoded.net/support/" + raise InvalidCodeError(msg) + # Invalid, let's see if it's a code for another app. + for appid, appname in ALL_APPS: + if self._is_code_valid(appid, code, email): + msg = "This code is a {0} code. You're running the wrong application. You can "\ + "download the correct application at http://www.hardcoded.net".format(appname) + raise InvalidCodeError(msg) + DEFAULT_MSG = "Your code is invalid. Make sure that you wrote the good code. Also make sure "\ + "that the e-mail you gave is the same as the e-mail you used for your purchase." + raise InvalidCodeError(DEFAULT_MSG) + + def set_registration(self, code, email, register_os): + if not self.fairware_mode and 'fairware' in {code.strip().lower(), email.strip().lower()}: + self.fairware_mode = True + self.view.set_default('FairwareMode', True) + self.view.show_message("Fairware mode enabled.") + return True + try: + self._set_registration(code, email) + self.view.show_message("Your code is valid. Thanks!") + if register_os: + self.register_os() + self.view.set_default('RegistrationCode', self.registration_code) + self.view.set_default('RegistrationEmail', self.registration_email) + return True + except InvalidCodeError as e: + self.view.show_message(str(e)) + return False + + def register_os(self): + # We don't do that anymore. + pass + + def contribute(self): + self.view.open_url("http://open.hardcoded.net/contribute/") + + def buy(self): + self.view.open_url("http://www.hardcoded.net/purchase.htm") + + def about_fairware(self): + self.view.open_url("http://open.hardcoded.net/about/") + + @property + def should_show_fairware_reminder(self): + return (not self.registered) and (self.fairware_mode) and (self.unpaid_hours >= 1) + + @property + def should_apply_demo_limitation(self): + return (not self.registered) and (not self.fairware_mode) + + @property + def unpaid_hours(self): + # We don't bother verifying unpaid hours anymore, the only app that still has fairware + # dialogs is dupeGuru and it has a huge surplus. Now, "fairware mode" really means + # "free mode". + return 0 + diff --git a/hscommon/sphinxgen.py b/hscommon/sphinxgen.py new file mode 100644 index 00000000..7ca48365 --- /dev/null +++ b/hscommon/sphinxgen.py @@ -0,0 +1,62 @@ +# Created By: Virgil Dupras +# Created On: 2011-01-12 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +import os.path as op +import re + +from .build import print_and_do, read_changelog_file, filereplace + +CHANGELOG_FORMAT = """ +{version} ({date}) +---------------------- + +{description} +""" + +def tixgen(tixurl): + """This is a filter *generator*. tixurl is a url pattern for the tix with a {0} placeholder + for the tix # + """ + urlpattern = tixurl.format('\\1') # will be replaced buy the content of the first group in re + R = re.compile(r'#(\d+)') + repl = '`#\\1 <{}>`__'.format(urlpattern) + return lambda text: R.sub(repl, text) + +def gen(basepath, destpath, changelogpath, tixurl, confrepl=None, confpath=None, changelogtmpl=None): + """Generate sphinx docs with all bells and whistles. + + basepath: The base sphinx source path. + destpath: The final path of html files + changelogpath: The path to the changelog file to insert in changelog.rst. + tixurl: The URL (with one formattable argument for the tix number) to the ticket system. + confrepl: Dictionary containing replacements that have to be made in conf.py. {name: replacement} + """ + if confrepl is None: + confrepl = {} + if confpath is None: + confpath = op.join(basepath, 'conf.tmpl') + if changelogtmpl is None: + changelogtmpl = op.join(basepath, 'changelog.tmpl') + changelog = read_changelog_file(changelogpath) + tix = tixgen(tixurl) + rendered_logs = [] + for log in changelog: + description = tix(log['description']) + # The format of the changelog descriptions is in markdown, but since we only use bulled list + # and links, it's not worth depending on the markdown package. A simple regexp suffice. + description = re.sub(r'\[(.*?)\]\((.*?)\)', '`\\1 <\\2>`__', description) + rendered = CHANGELOG_FORMAT.format(version=log['version'], date=log['date_str'], + description=description) + rendered_logs.append(rendered) + confrepl['version'] = changelog[0]['version'] + changelog_out = op.join(basepath, 'changelog.rst') + filereplace(changelogtmpl, changelog_out, changelog='\n'.join(rendered_logs)) + conf_out = op.join(basepath, 'conf.py') + filereplace(confpath, conf_out, **confrepl) + cmd = 'sphinx-build "{}" "{}"'.format(basepath, destpath) + print_and_do(cmd) diff --git a/hscommon/sqlite.py b/hscommon/sqlite.py new file mode 100644 index 00000000..f61314d8 --- /dev/null +++ b/hscommon/sqlite.py @@ -0,0 +1,137 @@ +# Created By: Virgil Dupras +# Created On: 2007/05/19 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +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: + 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..825c48a2 --- /dev/null +++ b/hscommon/tests/conflict_test.py @@ -0,0 +1,104 @@ +# Created By: Virgil Dupras +# Created On: 2008-01-08 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +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)) + io.open(self.path + 'foo', 'w').close() + io.open(self.path + 'bar', 'w').close() + io.mkdir(self.path + 'dir') + + def test_move_no_conflict(self, do_setup): + smart_move(self.path + 'foo', self.path + 'baz') + assert io.exists(self.path + 'baz') + assert not io.exists(self.path + 'foo') + + 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 io.exists(self.path + 'baz') + assert io.exists(self.path + 'foo') + + def test_move_no_conflict_dest_is_dir(self, do_setup): + smart_move(self.path + 'foo', self.path + 'dir') + assert io.exists(self.path + ('dir', 'foo')) + assert not io.exists(self.path + 'foo') + + def test_move_conflict(self, do_setup): + smart_move(self.path + 'foo', self.path + 'bar') + assert io.exists(self.path + '[000] bar') + assert not io.exists(self.path + 'foo') + + 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 io.exists(self.path + ('dir', 'foo')) + assert io.exists(self.path + ('dir', '[000] foo')) + assert not io.exists(self.path + 'foo') + assert not io.exists(self.path + 'bar') + + def test_copy_folder(self, tmpdir): + # smart_copy also works on folders + path = Path(str(tmpdir)) + io.mkdir(path + 'foo') + io.mkdir(path + 'bar') + smart_copy(path + 'foo', path + 'bar') # no crash + assert io.exists(path + '[000] bar') + diff --git a/hscommon/tests/currency_test.py b/hscommon/tests/currency_test.py new file mode 100644 index 00000000..e6ca0d01 --- /dev/null +++ b/hscommon/tests/currency_test.py @@ -0,0 +1,209 @@ +# Created By: Virgil Dupras +# Created On: 2008-04-20 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from datetime import date +import sqlite3 as sqlite + +from .. import io +from ..testutil import eq_, assert_almost_equal +from ..currency import Currency, RatesDB, CAD, EUR, PLN, USD + +def setup_module(module): + global FOO + global BAR + FOO = Currency.register('FOO', 'Currency with start date', start_date=date(2009, 1, 12), start_rate=2) + BAR = Currency.register('BAR', 'Currency with stop date', stop_date=date(2010, 1, 12), latest_rate=2) + +def teardown_module(module): + # We must unset our test currencies or else we might mess up with other tests. + from .. import currency + import imp + imp.reload(currency) + +def teardown_function(function): + Currency.set_rates_db(None) + +def test_currency_creation(): + # Different ways to create a currency. + eq_(Currency('CAD'), CAD) + eq_(Currency(name='Canadian dollar'), CAD) + +def test_currency_copy(): + # Currencies can be copied. + import copy + eq_(copy.copy(CAD), CAD) + eq_(copy.deepcopy(CAD), CAD) + +def test_get_rate_on_empty_db(): + # When there is no data available, use the start_rate. + eq_(CAD.value_in(USD, date(2008, 4, 20)), 1 / USD.latest_rate) + +def test_physical_rates_db_remember_rates(tmpdir): + # When a rates db uses a real file, rates are remembered + dbpath = str(tmpdir.join('foo.db')) + db = RatesDB(dbpath) + db.set_CAD_value(date(2008, 4, 20), 'USD', 1/0.996115) + db = RatesDB(dbpath) + assert_almost_equal(db.get_rate(date(2008, 4, 20), 'CAD', 'USD'), 0.996115) + +def test_db_with_connection(): + # the supplied connection is used by the rates db. + con = sqlite.connect(':memory:') + db = RatesDB(con) + try: + con.execute("select * from rates where 1=2") + except sqlite.OperationalError: # new db + raise AssertionError() + +def test_corrupt_db(tmpdir): + dbpath = str(tmpdir.join('foo.db')) + fh = io.open(dbpath, 'w') + fh.write('corrupted') + fh.close() + db = RatesDB(dbpath) # no crash. deletes the old file and start a new db + db.set_CAD_value(date(2008, 4, 20), 'USD', 42) + db = RatesDB(dbpath) + eq_(db.get_rate(date(2008, 4, 20), 'USD', 'CAD'), 42) + +#--- Daily rate +def setup_daily_rate(): + USD.set_CAD_value(1/0.996115, date(2008, 4, 20)) + +def test_get_rate_with_daily_rate(): + # Getting the rate exactly as set_rate happened returns the same rate. + setup_daily_rate() + assert_almost_equal(CAD.value_in(USD, date(2008, 4, 20)), 0.996115) + +def test_get_rate_different_currency(): + # Use fallback rates when necessary. + setup_daily_rate() + eq_(CAD.value_in(EUR, date(2008, 4, 20)), 1 / EUR.latest_rate) + eq_(EUR.value_in(USD, date(2008, 4, 20)), EUR.latest_rate * 0.996115) + +def test_get_rate_reverse(): + # It's possible to get the reverse value of a rate using the same data. + setup_daily_rate() + assert_almost_equal(USD.value_in(CAD, date(2008, 4, 20)), 1 / 0.996115) + +def test_set_rate_twice(): + # When setting a rate for an index that already exists, the old rate is replaced by the new. + setup_daily_rate() + USD.set_CAD_value(1/42, date(2008, 4, 20)) + assert_almost_equal(CAD.value_in(USD, date(2008, 4, 20)), 42) + +def test_set_rate_after_get(): + # When setting a rate after a get of the same rate, the rate cache is correctly updated. + setup_daily_rate() + CAD.value_in(USD, date(2008, 4, 20)) # value will be cached + USD.set_CAD_value(1/42, date(2008, 4, 20)) + assert_almost_equal(CAD.value_in(USD, date(2008, 4, 20)), 42) + +def test_set_rate_after_get_the_day_after(): + # When setting a rate, the cache for the whole currency is reset, or else we get old fallback + # values for dates where the currency server returned no value. + setup_daily_rate() + CAD.value_in(USD, date(2008, 4, 21)) # value will be cached + USD.set_CAD_value(1/42, date(2008, 4, 20)) + assert_almost_equal(CAD.value_in(USD, date(2008, 4, 21)), 42) + +#--- Two daily rates +def setup_two_daily_rate(): + # Don't change the set order, it's important for the tests + USD.set_CAD_value(1/0.997115, date(2008, 4, 25)) + USD.set_CAD_value(1/0.996115, date(2008, 4, 20)) + +def test_date_range_range(): + # USD.rates_date_range() returns the USD's limits. + setup_two_daily_rate() + eq_(USD.rates_date_range(), (date(2008, 4, 20), date(2008, 4, 25))) + +def test_date_range_for_unfetched_currency(): + # If the curency is not in the DB, return None. + setup_two_daily_rate() + assert PLN.rates_date_range() is None + +def test_seek_rate_middle(): + # A rate request with seek in the middle will return the lowest date. + setup_two_daily_rate() + eq_(USD.value_in(CAD, date(2008, 4, 24)), 1/0.996115) + +def test_seek_rate_after(): + # Make sure that the *nearest* lowest rate is returned. Because the 25th have been set + # before the 20th, an order by clause is required in the seek SQL to make this test pass. + setup_two_daily_rate() + eq_(USD.value_in(CAD, date(2008, 4, 26)), 1/0.997115) + +def test_seek_rate_before(): + # If there are no rate in the past, seek for a rate in the future. + setup_two_daily_rate() + eq_(USD.value_in(CAD, date(2008, 4, 19)), 1/0.996115) + +#--- Rates of multiple currencies +def setup_rates_of_multiple_currencies(): + USD.set_CAD_value(1/0.996115, date(2008, 4, 20)) + EUR.set_CAD_value(1/0.633141, date(2008, 4, 20)) + +def test_get_rate_multiple_currencies(): + # Don't mix currency rates up. + setup_rates_of_multiple_currencies() + assert_almost_equal(CAD.value_in(USD, date(2008, 4, 20)), 0.996115) + assert_almost_equal(CAD.value_in(EUR, date(2008, 4, 20)), 0.633141) + +def test_get_rate_with_pivotal(): + # It's possible to get a rate by using 2 records. + # if 1 CAD = 0.996115 USD and 1 CAD = 0.633141 then 0.996115 USD = 0.633141 then 1 USD = 0.633141 / 0.996115 EUR + setup_rates_of_multiple_currencies() + assert_almost_equal(USD.value_in(EUR, date(2008, 4, 20)), 0.633141 / 0.996115) + +def test_get_rate_doesnt_exist(): + # Don't crash when trying to do pivotal calculation with non-existing currencies. + setup_rates_of_multiple_currencies() + eq_(USD.value_in(PLN, date(2008, 4, 20)), 1 / 0.996115 / PLN.latest_rate) + +#--- Problems after connection +def get_problematic_db(): + class MockConnection(sqlite.Connection): # can't mock sqlite3.Connection's attribute, so we subclass it + mocking = False + def execute(self, *args, **kwargs): + if self.mocking: + raise sqlite.OperationalError() + else: + return sqlite.Connection.execute(self, *args, **kwargs) + + con = MockConnection(':memory:') + db = RatesDB(con) + con.mocking = True + return db + +def test_date_range_with_problematic_db(): + db = get_problematic_db() + db.date_range('USD') # no crash + +def test_get_rate_with_problematic_db(): + db = get_problematic_db() + db.get_rate(date(2008, 4, 20), 'USD', 'CAD') # no crash + +def test_set_rate_with_problematic_db(): + db = get_problematic_db() + db.set_CAD_value(date(2008, 4, 20), 'USD', 42) # no crash + +#--- DB that doesn't allow get_rate calls +def setup_db_raising_error_on_getrate(): + db = RatesDB() + def mock_get_rate(*args, **kwargs): + raise AssertionError() + db.get_rate = mock_get_rate + Currency.set_rates_db(db) + +def test_currency_with_start_date(): + setup_db_raising_error_on_getrate() + eq_(FOO.value_in(CAD, date(2009, 1, 11)), 2) + +def test_currency_with_stop_date(): + setup_db_raising_error_on_getrate() + eq_(BAR.value_in(CAD, date(2010, 1, 13)), 2) diff --git a/hscommon/tests/notify_test.py b/hscommon/tests/notify_test.py new file mode 100644 index 00000000..dac6301a --- /dev/null +++ b/hscommon/tests/notify_test.py @@ -0,0 +1,140 @@ +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +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..75c41665 --- /dev/null +++ b/hscommon/tests/path_test.py @@ -0,0 +1,209 @@ +# Created By: Virgil Dupras +# Created On: 2006/02/21 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +import sys + +from pytest import raises, mark + +from ..path import * +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) + self.fail() + 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_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 + self.fail() + 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_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 + +@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')) \ No newline at end of file diff --git a/hscommon/tests/reg_test.py b/hscommon/tests/reg_test.py new file mode 100644 index 00000000..49d89872 --- /dev/null +++ b/hscommon/tests/reg_test.py @@ -0,0 +1,68 @@ +# Created By: Virgil Dupras +# Created On: 2010-01-31 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from hashlib import md5 + +from ..testutil import CallLogger +from ..reg import RegistrableApplication, InvalidCodeError + +def md5s(s): + return md5(s.encode('utf-8')).hexdigest() + +def assert_valid(appid, code, email): + app = RegistrableApplication(CallLogger(), appid) + try: + app.validate_code(code, email) + except InvalidCodeError as e: + raise AssertionError("Registration failed: {0}".format(str(e))) + +def assert_invalid(appid, code, email, msg_contains=None): + app = RegistrableApplication(CallLogger(), appid) + try: + app.validate_code(code, email) + except InvalidCodeError as e: + if msg_contains: + assert msg_contains in str(e) + else: + raise AssertionError("InvalidCodeError not raised") + +def test_valid_code(): + email = 'foo@bar.com' + appid = 42 + code = md5s('42' + email + '43' + 'aybabtu') + assert_valid(appid, code, email) + +def test_invalid_code(): + email = 'foo@bar.com' + appid = 42 + code = md5s('43' + email + '43' + 'aybabtu') + assert_invalid(appid, code, email) + +def test_suggest_other_apps(): + # If a code is valid for another app, say so in the error message. + email = 'foo@bar.com' + appid = 42 + # 2 is moneyGuru's appid + code = md5s('2' + email + '43' + 'aybabtu') + assert_invalid(appid, code, email, msg_contains="moneyGuru") + +def test_invert_code_and_email(): + # Try inverting code and email during validation in case the user mixed the fields up. + # We still show an error here. It kind of sucks, but if we don't, the email and code fields + # end up mixed up in the preferences. It's not as if this kind of error happened all the time... + email = 'foo@bar.com' + appid = 42 + code = md5s('42' + email + '43' + 'aybabtu') + assert_invalid(appid, email, code, msg_contains="inverted") + +def test_paypal_transaction(): + # If the code looks like a paypal transaction, mention it in the error message. + email = 'foo@bar.com' + appid = 42 + code = '2A693827WX9676888' + assert_invalid(appid, code, email, 'Paypal transaction') diff --git a/hscommon/tests/selectable_list_test.py b/hscommon/tests/selectable_list_test.py new file mode 100644 index 00000000..dfcd28bd --- /dev/null +++ b/hscommon/tests/selectable_list_test.py @@ -0,0 +1,65 @@ +# Created By: Virgil Dupras +# Created On: 2011-09-06 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +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..3455d3e2 --- /dev/null +++ b/hscommon/tests/sqlite_test.py @@ -0,0 +1,126 @@ +# Created By: Virgil Dupras +# Created On: 2007/05/19 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +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..4b805b95 --- /dev/null +++ b/hscommon/tests/table_test.py @@ -0,0 +1,313 @@ +# Created By: Virgil Dupras +# Created On: 2008-08-12 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from ..testutil import CallLogger, eq_ +from ..gui.table import Table, GUITable, Row + +class TestRow(Row): + def __init__(self, table, index, is_new=False): + Row.__init__(self, table) + self.is_new = is_new + self._index = index + + def load(self): + pass + + def save(self): + self.is_new = False + + @property + def index(self): + return self._index + + +class TestGUITable(GUITable): + def __init__(self, rowcount): + GUITable.__init__(self) + self.view = CallLogger() + self.rowcount = rowcount + self.updated_rows = None + + def _do_add(self): + return TestRow(self, len(self), is_new=True), len(self) + + def _is_edited_new(self): + return self.edited is not None and self.edited.is_new + + def _fill(self): + for i in range(self.rowcount): + self.append(TestRow(self, i)) + + def _update_selection(self): + self.updated_rows = self.selected_rows[:] + + +def table_with_footer(): + table = Table() + table.append(TestRow(table, 0)) + footer = TestRow(table, 1) + table.footer = footer + return table, footer + +def table_with_header(): + table = Table() + table.append(TestRow(table, 1)) + header = TestRow(table, 0) + table.header = header + return table, header + +#--- Tests +def test_allow_edit_when_attr_is_property_with_fset(): + # When a row has a property that has a fset, by default, make that cell editable. + class TestRow(Row): + @property + def foo(self): + pass + @property + def bar(self): + pass + @bar.setter + def bar(self, value): + pass + + row = TestRow(Table()) + assert row.can_edit_cell('bar') + assert not row.can_edit_cell('foo') + assert not row.can_edit_cell('baz') # doesn't exist, can't edit + +def test_can_edit_prop_has_priority_over_fset_checks(): + # When a row has a cen_edit_* property, it's the result of that property that is used, not the + # result of a fset check. + class TestRow(Row): + @property + def bar(self): + pass + @bar.setter + def bar(self, value): + pass + can_edit_bar = False + + row = TestRow(Table()) + assert not row.can_edit_cell('bar') + +def test_in(): + # When a table is in a list, doing "in list" with another instance returns false, even if + # they're the same as lists. + table = Table() + some_list = [table] + assert Table() not in some_list + +def test_footer_del_all(): + # Removing all rows doesn't crash when doing the footer check. + table, footer = table_with_footer() + del table[:] + assert table.footer is None + +def test_footer_del_row(): + # Removing the footer row sets it to None + table, footer = table_with_footer() + del table[-1] + assert table.footer is None + eq_(len(table), 1) + +def test_footer_is_appened_to_table(): + # A footer is appended at the table's bottom + table, footer = table_with_footer() + eq_(len(table), 2) + assert table[1] is footer + +def test_footer_remove(): + # remove() on footer sets it to None + table, footer = table_with_footer() + table.remove(footer) + assert table.footer is None + +def test_footer_replaces_old_footer(): + table, footer = table_with_footer() + other = Row(table) + table.footer = other + assert table.footer is other + eq_(len(table), 2) + assert table[1] is other + +def test_footer_rows_and_row_count(): + # rows() and row_count() ignore footer. + table, footer = table_with_footer() + eq_(table.row_count, 1) + eq_(table.rows, table[:-1]) + +def test_footer_setting_to_none_removes_old_one(): + table, footer = table_with_footer() + table.footer = None + assert table.footer is None + eq_(len(table), 1) + +def test_footer_stays_there_on_append(): + # Appending another row puts it above the footer + table, footer = table_with_footer() + table.append(Row(table)) + eq_(len(table), 3) + assert table[2] is footer + +def test_footer_stays_there_on_insert(): + # Inserting another row puts it above the footer + table, footer = table_with_footer() + table.insert(3, Row(table)) + eq_(len(table), 3) + assert table[2] is footer + +def test_header_del_all(): + # Removing all rows doesn't crash when doing the header check. + table, header = table_with_header() + del table[:] + assert table.header is None + +def test_header_del_row(): + # Removing the header row sets it to None + table, header = table_with_header() + del table[0] + assert table.header is None + eq_(len(table), 1) + +def test_header_is_inserted_in_table(): + # A header is inserted at the table's top + table, header = table_with_header() + eq_(len(table), 2) + assert table[0] is header + +def test_header_remove(): + # remove() on header sets it to None + table, header = table_with_header() + table.remove(header) + assert table.header is None + +def test_header_replaces_old_header(): + table, header = table_with_header() + other = Row(table) + table.header = other + assert table.header is other + eq_(len(table), 2) + assert table[0] is other + +def test_header_rows_and_row_count(): + # rows() and row_count() ignore header. + table, header = table_with_header() + eq_(table.row_count, 1) + eq_(table.rows, table[1:]) + +def test_header_setting_to_none_removes_old_one(): + table, header = table_with_header() + table.header = None + assert table.header is None + eq_(len(table), 1) + +def test_header_stays_there_on_insert(): + # Inserting another row at the top puts it below the header + table, header = table_with_header() + table.insert(0, Row(table)) + eq_(len(table), 3) + assert table[0] is header + +def test_refresh_view_on_refresh(): + # If refresh_view is not False, we refresh the table's view on refresh() + table = TestGUITable(1) + table.refresh() + table.view.check_gui_calls(['refresh']) + table.view.clear_calls() + table.refresh(refresh_view=False) + table.view.check_gui_calls([]) + +def test_restore_selection(): + # By default, after a refresh, selection goes on the last row + table = TestGUITable(10) + table.refresh() + eq_(table.selected_indexes, [9]) + +def test_restore_selection_after_cancel_edits(): + # _restore_selection() is called after cancel_edits(). Previously, only _update_selection would + # be called. + class MyTable(TestGUITable): + def _restore_selection(self, previous_selection): + self.selected_indexes = [6] + + table = MyTable(10) + table.refresh() + table.add() + table.cancel_edits() + eq_(table.selected_indexes, [6]) + +def test_restore_selection_with_previous_selection(): + # By default, we try to restore the selection that was there before a refresh + table = TestGUITable(10) + table.refresh() + table.selected_indexes = [2, 4] + table.refresh() + eq_(table.selected_indexes, [2, 4]) + +def test_restore_selection_custom(): + # After a _fill() called, the virtual _restore_selection() is called so that it's possible for a + # GUITable subclass to customize its post-refresh selection behavior. + class MyTable(TestGUITable): + def _restore_selection(self, previous_selection): + self.selected_indexes = [6] + + table = MyTable(10) + table.refresh() + eq_(table.selected_indexes, [6]) + +def test_row_cell_value(): + # *_cell_value() correctly mangles attrnames that are Python reserved words. + row = Row(Table()) + row.from_ = 'foo' + eq_(row.get_cell_value('from'), 'foo') + row.set_cell_value('from', 'bar') + eq_(row.get_cell_value('from'), 'bar') + +def test_sort_table_also_tries_attributes_without_underscores(): + # When determining a sort key, after having unsuccessfully tried the attribute with the, + # underscore, try the one without one. + table = Table() + row1 = Row(table) + row1._foo = 'a' # underscored attr must be checked first + row1.foo = 'b' + row1.bar = 'c' + row2 = Row(table) + row2._foo = 'b' + row2.foo = 'a' + row2.bar = 'b' + table.append(row1) + table.append(row2) + table.sort_by('foo') + assert table[0] is row1 + assert table[1] is row2 + table.sort_by('bar') + assert table[0] is row2 + assert table[1] is row1 + +def test_sort_table_updates_selection(): + table = TestGUITable(10) + table.refresh() + table.select([2, 4]) + table.sort_by('index', desc=True) + # Now, the updated rows should be 7 and 5 + eq_(len(table.updated_rows), 2) + r1, r2 = table.updated_rows + eq_(r1.index, 7) + eq_(r2.index, 5) + +def test_sort_table_with_footer(): + # Sorting a table with a footer keeps it at the bottom + table, footer = table_with_footer() + table.sort_by('index', desc=True) + assert table[-1] is footer + +def test_sort_table_with_header(): + # Sorting a table with a header keeps it at the top + table, header = table_with_header() + table.sort_by('index', desc=True) + assert table[0] is header \ No newline at end of file diff --git a/hscommon/tests/tree_test.py b/hscommon/tests/tree_test.py new file mode 100644 index 00000000..87c017f8 --- /dev/null +++ b/hscommon/tests/tree_test.py @@ -0,0 +1,109 @@ +# Created By: Virgil Dupras +# Created On: 2010-02-12 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +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..4d9967fd --- /dev/null +++ b/hscommon/tests/util_test.py @@ -0,0 +1,310 @@ +# Created By: Virgil Dupras +# Created On: 2011-01-11 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from io import StringIO + +from pytest import raises + +from ..testutil import eq_ +from .. import io +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 + +#--- 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 + + +class TestCase_delete_if_empty: + def test_is_empty(self, tmpdir): + testpath = Path(str(tmpdir)) + assert delete_if_empty(testpath) + assert not io.exists(testpath) + + def test_not_empty(self, tmpdir): + testpath = Path(str(tmpdir)) + io.mkdir(testpath + 'foo') + assert not delete_if_empty(testpath) + assert io.exists(testpath) + + def test_with_files_to_delete(self, tmpdir): + testpath = Path(str(tmpdir)) + io.open(testpath + 'foo', 'w') + io.open(testpath + 'bar', 'w') + assert delete_if_empty(testpath, ['foo', 'bar']) + assert not io.exists(testpath) + + def test_directory_in_files_to_delete(self, tmpdir): + testpath = Path(str(tmpdir)) + io.mkdir(testpath + 'foo') + assert not delete_if_empty(testpath, ['foo']) + assert io.exists(testpath) + + def test_delete_files_to_delete_only_if_dir_is_empty(self, tmpdir): + testpath = Path(str(tmpdir)) + io.open(testpath + 'foo', 'w') + io.open(testpath + 'bar', 'w') + assert not delete_if_empty(testpath, ['foo']) + assert io.exists(testpath) + assert io.exists(testpath + 'foo') + + 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' + io.open(p, '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(io, '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..d5b9ee8d --- /dev/null +++ b/hscommon/testutil.py @@ -0,0 +1,212 @@ +# Created By: Virgil Dupras +# Created On: 2010-11-14 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +import threading +import py.path + +def eq_(a, b, msg=None): + __tracebackhide__ = True + assert a == b, msg or "%r != %r" % (a, b) + +def assert_almost_equal(a, b, places=7): + __tracebackhide__ = True + assert round(a, ndigits=places) == round(b, ndigits=places) + +def callcounter(): + def f(*args, **kwargs): + f.callcount += 1 + + f.callcount = 0 + return f + +class TestData: + def __init__(self, datadirpath): + self.datadirpath = py.path.local(datadirpath) + + def filepath(self, relative_path, *args): + """Returns the path of a file in testdata. + + 'relative_path' can be anything that can be added to a Path + if args is not empty, it will be joined to relative_path + """ + resultpath = self.datadirpath.join(relative_path) + if args: + resultpath = resultpath.join(*args) + assert resultpath.check() + return str(resultpath) + + +class CallLogger: + """This is a dummy object that logs all calls made to it. + + It is used to simulate the GUI layer. + """ + def __init__(self): + self.calls = [] + + def __getattr__(self, func_name): + def func(*args, **kw): + self.calls.append(func_name) + return func + + def clear_calls(self): + del self.calls[:] + + def check_gui_calls(self, expected, verify_order=False): + """Checks that the expected calls have been made to 'self', then clears the log. + + `expected` is an iterable of strings representing method names. + If `verify_order` is True, the order of the calls matters. + """ + __tracebackhide__ = True + if verify_order: + eq_(self.calls, expected) + else: + eq_(set(self.calls), set(expected)) + self.clear_calls() + + def check_gui_calls_partial(self, expected=None, not_expected=None, verify_order=False): + """Checks that the expected calls have been made to 'self', then clears the log. + + `expected` is an iterable of strings representing method names. Order doesn't matter. + Moreover, if calls have been made that are not in expected, no failure occur. + `not_expected` can be used for a more explicit check (rather than calling `check_gui_calls` + with an empty `expected`) to assert that calls have *not* been made. + """ + __tracebackhide__ = True + if expected is not None: + not_called = set(expected) - set(self.calls) + assert not not_called, "These calls haven't been made: {0}".format(not_called) + if verify_order: + max_index = 0 + for call in expected: + index = self.calls.index(call) + if index < max_index: + raise AssertionError("The call {0} hasn't been made in the correct order".format(call)) + max_index = index + if not_expected is not None: + called = set(not_expected) & set(self.calls) + assert not called, "These calls shouldn't have been made: {0}".format(called) + self.clear_calls() + + +class TestApp: + def __init__(self): + self._call_loggers = [] + + def clear_gui_calls(self): + for logger in self._call_loggers: + logger.clear_calls() + + def make_logger(self, class_=CallLogger, *initargs): + logger = class_(*initargs) + self._call_loggers.append(logger) + return logger + + def make_gui(self, name, class_, view=None, parent=None, holder=None): + if view is None: + view = self.make_logger() + if parent is None: + # The attribute "default_parent" has to be set for this to work correctly + parent = self.default_parent + if holder is None: + holder = self + setattr(holder, '{0}_gui'.format(name), view) + gui = class_(parent) + gui.view = view + setattr(holder, name, gui) + return gui + + +# To use @with_app, you have to import pytest_funcarg__app in your conftest.py file. +def with_app(setupfunc): + def decorator(func): + func.setupfunc = setupfunc + return func + return decorator + +def pytest_funcarg__app(request): + setupfunc = request.function.setupfunc + if hasattr(setupfunc, '__code__'): + argnames = setupfunc.__code__.co_varnames[:setupfunc.__code__.co_argcount] + def getarg(name): + if name == 'self': + return request.function.__self__ + else: + return request.getfuncargvalue(name) + args = [getarg(argname) for argname in argnames] + else: + args = [] + app = setupfunc(*args) + return app + +def jointhreads(): + """Join all threads to the main thread""" + for thread in threading.enumerate(): + if hasattr(thread, 'BUGGY'): + continue + if thread.getName() != 'MainThread' and thread.isAlive(): + if hasattr(thread, 'close'): + thread.close() + thread.join(1) + if thread.isAlive(): + print("Thread problem. Some thread doesn't want to stop.") + thread.BUGGY = True + +def _unify_args(func, args, kwargs, args_to_ignore=None): + ''' Unify args and kwargs in the same dictionary. + + The result is kwargs with args added to it. func.func_code.co_varnames is used to determine + under what key each elements of arg will be mapped in kwargs. + + if you want some arguments not to be in the results, supply a list of arg names in + args_to_ignore. + + if f is a function that takes *args, func_code.co_varnames is empty, so args will be put + under 'args' in kwargs. + + def foo(bar, baz) + _unifyArgs(foo, (42,), {'baz': 23}) --> {'bar': 42, 'baz': 23} + _unifyArgs(foo, (42,), {'baz': 23}, ['bar']) --> {'baz': 23} + ''' + result = kwargs.copy() + if hasattr(func, '__code__'): # built-in functions don't have func_code + args = list(args) + if getattr(func, '__self__', None) is not None: # bound method, we have to add self to args list + args = [func.__self__] + args + defaults = list(func.__defaults__) if func.__defaults__ is not None else [] + arg_count = func.__code__.co_argcount + arg_names = list(func.__code__.co_varnames) + if len(args) < arg_count: # We have default values + required_arg_count = arg_count - len(args) + args = args + defaults[-required_arg_count:] + for arg_name, arg in zip(arg_names, args): + # setdefault is used because if the arg is already in kwargs, we don't want to use default values + result.setdefault(arg_name, arg) + else: + #'func' has a *args argument + result['args'] = args + if args_to_ignore: + for kw in args_to_ignore: + del result[kw] + return result + +def log_calls(func): + ''' Logs all func calls' arguments under func.calls. + + func.calls is a list of _unify_args() result (dict). + + Mostly used for unit testing. + ''' + def wrapper(*args, **kwargs): + unifiedArgs = _unify_args(func, args, kwargs) + wrapper.calls.append(unifiedArgs) + return func(*args, **kwargs) + + wrapper.calls = [] + return wrapper diff --git a/hscommon/trans.py b/hscommon/trans.py new file mode 100644 index 00000000..1f2f148b --- /dev/null +++ b/hscommon/trans.py @@ -0,0 +1,138 @@ +# Created By: Virgil Dupras +# Created On: 2010-06-23 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +# 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 = {'fr': 'fra', 'de': 'deu', 'it': 'ita', 'zh_CN': 'chs', 'cs': 'czy', + 'nl': 'nld', 'ru': 'rus', 'pt_BR': 'ptb', 'es': 'esn'} + else: + LANG2LOCALENAME = {'fr': 'fr_FR', 'de': 'de_DE', 'it': 'it_IT', 'zh_CN': 'zh_CN', + 'cs': 'cs_CZ', 'nl': 'nl_NL', 'hy': 'hy_AM', 'ru': 'ru_RU', 'uk': 'uk_UA', + 'pt_BR': 'pt_BR', 'es': 'es_ES'} + 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 PyQt4.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 PyQt4.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..8d562d89 --- /dev/null +++ b/hscommon/util.py @@ -0,0 +1,350 @@ +# Created By: Virgil Dupras +# Created On: 2011-01-11 +# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +import sys +import os +import os.path as op +import re +from math import ceil +import glob +import shutil + +from . import io +from .path import Path + +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): + 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. + ''' + 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 'lists' and returns a list containing elements of every list. + + If start_with is not None, the result will start with start_with items, exactly as if + start_with would be the first item of lists. + ''' + result = [] + if start_with: + result.extend(start_with) + for iterable in iterables: + result.extend(iterable) + return result + +def first(iterable): + """Returns the first item of 'iterable' + """ + try: + return next(iter(iterable)) + except StopIteration: + return None + +def stripfalse(seq): + """Returns a sequence with all false elements stripped out of seq. + """ + return [x for x in seq if x] + +def extract(predicate, iterable): + """Separates the wheat from the shaft (`predicate` defines what's the wheat), and returns both. + """ + wheat = [] + shaft = [] + for item in iterable: + if predicate(item): + wheat.append(item) + else: + shaft.append(item) + return wheat, shaft + +def allsame(iterable): + """Returns whether all elements of 'iterable' are the same. + """ + it = iter(iterable) + try: + first_item = next(it) + except StopIteration: + raise ValueError("iterable cannot be empty") + return all(element == first_item for element in it) + +def trailiter(iterable, skipfirst=False): + """Yields (prev_element, element), starting with (None, first_element). + + If skipfirst is True, there will be no (None, item1) element and we'll start + directly with (item1, item2). + """ + it = iter(iterable) + if skipfirst: + prev = next(it) + else: + prev = None + for item in it: + yield prev, item + prev = item + +#--- String related + +def escape(s, to_escape, 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 string with number in front of s, and 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. + """ + 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 basestring, 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 + +#--- Files related + +def modified_after(first_path, second_path): + """Returns True if first_path's mtime is higher than second_path's mtime.""" + try: + first_mtime = io.stat(first_path).st_mtime + except EnvironmentError: + return False + try: + second_mtime = io.stat(second_path).st_mtime + except EnvironmentError: + 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 + +@io.log_io_error +def delete_if_empty(path, files_to_delete=[]): + ''' Deletes the directory at 'path' if it is empty or if it only contains files_to_delete. + ''' + if not io.exists(path) or not io.isdir(path): + return + contents = io.listdir(path) + if any(name for name in contents if (name not in files_to_delete) or io.isdir(path + name)): + return False + for name in contents: + io.remove(path + name) + io.rmdir(path) + return True + +def open_if_filename(infile, mode='rb'): + """ + infile can be either a string or a file-like object. + if it is a string, a file will be opened with mode. + Returns a tuple (shouldbeclosed,infile) infile is a file object + """ + if isinstance(infile, Path): + return (io.open(infile, 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: + 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() +