1
0
mirror of https://github.com/arsenetar/dupeguru.git synced 2026-03-12 03:31:37 +00:00

Compare commits

..

8 Commits

Author SHA1 Message Date
ade5d7f8c1 Some type-hinting for various qt base objects 2022-06-30 22:52:54 -05:00
Marcus Yanello
b9aabb8545 Redirect stdout from custom command to the log files (#1008)
Send the logs for the custom command subprocess to the logs
Closes #1007
2022-06-13 21:04:40 -05:00
d5eeab4a17 Additional type hints in hscommon 2022-05-11 00:50:34 -05:00
7865e4aeac Type hinting hscommon & cleanup 2022-05-09 23:36:39 -05:00
58863b1728 Change to use a real temporary directory for test
app_test was not using a real temporary location originally
2022-05-09 01:46:42 -05:00
e382683f66 Replace all relative imports 2022-05-09 01:40:08 -05:00
f7ed1c801c Add type hinting to desktop.py 2022-05-09 01:15:25 -05:00
f587c7b5d8 Removed unused code in hscommon/util
Also added type hints throughout
2022-05-09 00:47:57 -05:00
88 changed files with 572 additions and 1040 deletions

View File

@@ -23,20 +23,20 @@ from hscommon.util import delete_if_empty, first, escape, nonone, allsame
from hscommon.trans import tr
from hscommon import desktop
from . import se, me, pe
from .pe.photo import get_delta_dimensions
from .util import cmp_value, fix_surrogate_encoding
from . import directories, results, export, fs, prioritize
from .ignore import IgnoreList
from .exclude import ExcludeDict as ExcludeList
from .scanner import ScanType
from .gui.deletion_options import DeletionOptions
from .gui.details_panel import DetailsPanel
from .gui.directory_tree import DirectoryTree
from .gui.ignore_list_dialog import IgnoreListDialog
from .gui.exclude_list_dialog import ExcludeListDialogCore
from .gui.problem_dialog import ProblemDialog
from .gui.stats_label import StatsLabel
from core import se, me, pe
from core.pe.photo import get_delta_dimensions
from core.util import cmp_value, fix_surrogate_encoding
from core import directories, results, export, fs, prioritize
from core.ignore import IgnoreList
from core.exclude import ExcludeDict as ExcludeList
from core.scanner import ScanType
from core.gui.deletion_options import DeletionOptions
from core.gui.details_panel import DetailsPanel
from core.gui.directory_tree import DirectoryTree
from core.gui.ignore_list_dialog import IgnoreListDialog
from core.gui.exclude_list_dialog import ExcludeListDialogCore
from core.gui.problem_dialog import ProblemDialog
from core.gui.stats_label import StatsLabel
HAD_FIRST_LAUNCH_PREFERENCE = "HadFirstLaunch"
DEBUG_MODE_PREFERENCE = "DebugMode"
@@ -134,7 +134,7 @@ class DupeGuru(Broadcaster):
logging.debug("Debug mode enabled")
Broadcaster.__init__(self)
self.view = view
self.appdata = desktop.special_folder_path(desktop.SpecialFolder.APPDATA, appname=self.NAME, portable=portable)
self.appdata = desktop.special_folder_path(desktop.SpecialFolder.APPDATA, portable=portable)
if not op.exists(self.appdata):
os.makedirs(self.appdata)
self.app_mode = AppMode.STANDARD
@@ -555,9 +555,13 @@ class DupeGuru(Broadcaster):
# a workaround to make the damn thing work.
exepath, args = match.groups()
path, exename = op.split(exepath)
subprocess.Popen(exename + args, shell=True, cwd=path)
p = subprocess.Popen(exename + args, shell=True, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
output = p.stdout.read()
logging.info("Custom command %s %s: %s", exename, args, output)
else:
subprocess.Popen(dupe_cmd, shell=True)
p = subprocess.Popen(dupe_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
output = p.stdout.read()
logging.info("Custom command %s: %s", dupe_cmd, output)
def load(self):
"""Load directory selection and ignore list from files in appdata.

View File

@@ -13,7 +13,7 @@ from hscommon.jobprogress import job
from hscommon.util import FileOrPath
from hscommon.trans import tr
from . import fs
from core import fs
__all__ = [
"Directories",

View File

@@ -2,7 +2,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from .markable import Markable
from core.markable import Markable
from xml.etree import ElementTree as ET
# TODO: perhaps use regex module for better Unicode support? https://pypi.org/project/regex/

View File

@@ -7,7 +7,7 @@
# http://www.gnu.org/licenses/gpl-3.0.html
from hscommon.gui.base import GUIObject
from .base import DupeGuruGUIObject
from core.gui.base import DupeGuruGUIObject
class DetailsPanel(GUIObject, DupeGuruGUIObject):

View File

@@ -8,8 +8,8 @@
from hscommon.gui.tree import Tree, Node
from ..directories import DirectoryState
from .base import DupeGuruGUIObject
from core.directories import DirectoryState
from core.gui.base import DupeGuruGUIObject
STATE_ORDER = [DirectoryState.NORMAL, DirectoryState.REFERENCE, DirectoryState.EXCLUDED]

View File

@@ -5,7 +5,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from .exclude_list_table import ExcludeListTable
from core.gui.exclude_list_table import ExcludeListTable
from core.exclude import has_sep
from os import sep
import logging

View File

@@ -2,7 +2,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from .base import DupeGuruGUIObject
from core.gui.base import DupeGuruGUIObject
from hscommon.gui.table import GUITable, Row
from hscommon.gui.column import Column, Columns
from hscommon.trans import trget

View File

@@ -6,7 +6,7 @@
# http://www.gnu.org/licenses/gpl-3.0.html
from hscommon.trans import tr
from .ignore_list_table import IgnoreListTable
from core.gui.ignore_list_table import IgnoreListTable
class IgnoreListDialog:

View File

@@ -8,7 +8,7 @@
from hscommon import desktop
from .problem_table import ProblemTable
from core.gui.problem_table import ProblemTable
class ProblemDialog:

View File

@@ -11,7 +11,7 @@ from operator import attrgetter
from hscommon.gui.table import GUITable, Row
from hscommon.gui.column import Columns
from .base import DupeGuruGUIObject
from core.gui.base import DupeGuruGUIObject
class DupeRow(Row):

View File

@@ -6,7 +6,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from .base import DupeGuruGUIObject
from core.gui.base import DupeGuruGUIObject
class StatsLabel(DupeGuruGUIObject):

View File

@@ -1 +1 @@
from . import fs, prioritize, result_table, scanner # noqa
from core.me import fs, prioritize, result_table, scanner # noqa

View File

@@ -1,8 +1,7 @@
from . import ( # noqa
from core.pe import ( # noqa
block,
cache,
exif,
iphoto_plist,
matchblock,
matchexif,
photo,

View File

@@ -6,7 +6,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2 # NOQA
from core.pe._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2 # NOQA
# Converted to C
# def getblock(image):

View File

@@ -4,7 +4,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ._cache import string_to_colors # noqa
from core.pe._cache import string_to_colors # noqa
def colors_to_string(colors):

View File

@@ -10,7 +10,7 @@ import shelve
import tempfile
from collections import namedtuple
from .cache import string_to_colors, colors_to_string
from core.pe.cache import string_to_colors, colors_to_string
def wrap_path(path):

View File

@@ -9,7 +9,7 @@ import os.path as op
import logging
import sqlite3 as sqlite
from .cache import string_to_colors, colors_to_string
from core.pe.cache import string_to_colors, colors_to_string
class SqliteCache:

View File

@@ -15,7 +15,7 @@ from hscommon.trans import tr
from hscommon.jobprogress import job
from core.engine import Match
from .block import avgdiff, DifferentBlockCountError, NoBlocksError
from core.pe.block import avgdiff, DifferentBlockCountError, NoBlocksError
# OPTIMIZATION NOTES:
# The bottleneck of the matching phase is CPU, which is why we use multiprocessing. However, another
@@ -51,11 +51,11 @@ except Exception:
def get_cache(cache_path, readonly=False):
if cache_path.endswith("shelve"):
from .cache_shelve import ShelveCache
from core.pe.cache_shelve import ShelveCache
return ShelveCache(cache_path, readonly=readonly)
else:
from .cache_sqlite import SqliteCache
from core.pe.cache_sqlite import SqliteCache
return SqliteCache(cache_path, readonly=readonly)

View File

@@ -9,7 +9,7 @@ from hscommon.util import get_file_ext, format_size
from core.util import format_timestamp, format_perc, format_dupe_count
from core import fs
from . import exif
from core.pe import exif
# This global value is set by the platform-specific subclasser of the Photo base class
PLAT_SPECIFIC_PHOTO_CLASS = None

View File

@@ -8,7 +8,7 @@ from hscommon.trans import tr
from core.scanner import Scanner, ScanType, ScanOption
from . import matchblock, matchexif
from core.pe import matchblock, matchexif
class ScannerPE(Scanner):

View File

@@ -17,8 +17,8 @@ from hscommon.conflict import get_conflicted_name
from hscommon.util import flatten, nonone, FileOrPath, format_size
from hscommon.trans import tr
from . import engine
from .markable import Markable
from core import engine
from core.markable import Markable
class Results(Markable):

View File

@@ -13,7 +13,7 @@ from hscommon.jobprogress import job
from hscommon.util import dedupe, rem_file_ext, get_file_ext
from hscommon.trans import tr
from . import engine
from core import engine
# It's quite ugly to have scan types from all editions all put in the same class, but because there's
# there will be some nasty bugs popping up (ScanType is used in core when in should exclusively be

View File

@@ -1 +1 @@
from . import fs, result_table, scanner # noqa
from core.se import fs, result_table, scanner # noqa

View File

@@ -7,6 +7,7 @@
import os
import os.path as op
import logging
import tempfile
import pytest
from pathlib import Path
@@ -15,10 +16,10 @@ import hscommon.util
from hscommon.testutil import eq_, log_calls
from hscommon.jobprogress.job import Job
from .base import TestApp
from .results_test import GetTestGroups
from .. import app, fs, engine
from ..scanner import ScanType
from core.tests.base import TestApp
from core.tests.results_test import GetTestGroups
from core import app, fs, engine
from core.scanner import ScanType
def add_fake_files_to_directories(directories, files):
@@ -68,11 +69,12 @@ class TestCaseDupeGuru:
dgapp = TestApp().app
dgapp.directories.add_path(p)
[f] = dgapp.directories.get_files()
dgapp.copy_or_move(f, True, "some_destination", 0)
eq_(1, len(hscommon.conflict.smart_copy.calls))
call = hscommon.conflict.smart_copy.calls[0]
eq_(call["dest_path"], Path("some_destination", "foo"))
eq_(call["source_path"], f.path)
with tempfile.TemporaryDirectory() as tmp_dir:
dgapp.copy_or_move(f, True, tmp_dir, 0)
eq_(1, len(hscommon.conflict.smart_copy.calls))
call = hscommon.conflict.smart_copy.calls[0]
eq_(call["dest_path"], Path(tmp_dir, "foo"))
eq_(call["source_path"], f.path)
def test_copy_or_move_clean_empty_dirs(self, tmpdir, monkeypatch):
tmppath = Path(str(tmpdir))

View File

@@ -10,12 +10,11 @@ from hscommon.util import get_file_ext, format_size
from hscommon.gui.column import Column
from hscommon.jobprogress.job import nulljob, JobCancelled
from .. import engine
from .. import prioritize
from ..engine import getwords
from ..app import DupeGuru as DupeGuruBase
from ..gui.result_table import ResultTable as ResultTableBase
from ..gui.prioritize_dialog import PrioritizeDialog
from core import engine, prioritize
from core.engine import getwords
from core.app import DupeGuru as DupeGuruBase
from core.gui.result_table import ResultTable as ResultTableBase
from core.gui.prioritize_dialog import PrioritizeDialog
class DupeGuruView:

View File

@@ -9,7 +9,7 @@ from pytest import raises, skip
from hscommon.testutil import eq_
try:
from ..pe.block import avgdiff, getblocks2, NoBlocksError, DifferentBlockCountError
from core.pe.block import avgdiff, getblocks2, NoBlocksError, DifferentBlockCountError
except ImportError:
skip("Can't import the block module, probably hasn't been compiled.")

View File

@@ -10,9 +10,9 @@ from pytest import raises, skip
from hscommon.testutil import eq_
try:
from ..pe.cache import colors_to_string, string_to_colors
from ..pe.cache_sqlite import SqliteCache
from ..pe.cache_shelve import ShelveCache
from core.pe.cache import colors_to_string, string_to_colors
from core.pe.cache_sqlite import SqliteCache
from core.pe.cache_shelve import ShelveCache
except ImportError:
skip("Can't import the cache module, probably hasn't been compiled.")

View File

@@ -14,14 +14,14 @@ from pathlib import Path
from hscommon.testutil import eq_
from hscommon.plat import ISWINDOWS
from ..fs import File
from ..directories import (
from core.fs import File
from core.directories import (
Directories,
DirectoryState,
AlreadyThereError,
InvalidPathError,
)
from ..exclude import ExcludeList, ExcludeDict
from core.exclude import ExcludeList, ExcludeDict
def create_fake_fs(rootpath):

View File

@@ -10,9 +10,9 @@ from hscommon.jobprogress import job
from hscommon.util import first
from hscommon.testutil import eq_, log_calls
from .base import NamedObject
from .. import engine
from ..engine import (
from core.tests.base import NamedObject
from core import engine
from core.engine import (
get_match,
getwords,
Group,

View File

@@ -10,8 +10,8 @@ from xml.etree import ElementTree as ET
from hscommon.testutil import eq_
from hscommon.plat import ISWINDOWS
from .base import DupeGuru
from ..exclude import ExcludeList, ExcludeDict, default_regexes, AlreadyThereException
from core.tests.base import DupeGuru
from core.exclude import ExcludeList, ExcludeDict, default_regexes, AlreadyThereException
from re import error

View File

@@ -13,7 +13,7 @@ from pathlib import Path
from hscommon.testutil import eq_
from core.tests.directories_test import create_fake_fs
from .. import fs
from core import fs
hasher: typing.Callable
try:

View File

@@ -10,7 +10,7 @@ from xml.etree import ElementTree as ET
from pytest import raises
from hscommon.testutil import eq_
from ..ignore import IgnoreList
from core.ignore import IgnoreList
def test_empty():

View File

@@ -6,7 +6,7 @@
from hscommon.testutil import eq_
from ..markable import MarkableList, Markable
from core.markable import MarkableList, Markable
def gen():

View File

@@ -9,8 +9,8 @@
import os.path as op
from itertools import combinations
from .base import TestApp, NamedObject, with_app, eq_
from ..engine import Group, Match
from core.tests.base import TestApp, NamedObject, with_app, eq_
from core.engine import Group, Match
no = NamedObject

View File

@@ -6,7 +6,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from .base import TestApp, GetTestGroups
from core.tests.base import TestApp, GetTestGroups
def app_with_results():

View File

@@ -12,10 +12,9 @@ from xml.etree import ElementTree as ET
from pytest import raises
from hscommon.testutil import eq_
from hscommon.util import first
from .. import engine
from .base import NamedObject, GetTestGroups, DupeGuru
from ..results import Results
from core import engine
from core.tests.base import NamedObject, GetTestGroups, DupeGuru
from core.results import Results
class TestCaseResultsEmpty:

View File

@@ -10,11 +10,11 @@ from hscommon.jobprogress import job
from pathlib import Path
from hscommon.testutil import eq_
from .. import fs
from ..engine import getwords, Match
from ..ignore import IgnoreList
from ..scanner import Scanner, ScanType
from ..me.scanner import ScannerME
from core import fs
from core.engine import getwords, Match
from core.ignore import IgnoreList
from core.scanner import Scanner, ScanType
from core.me.scanner import ScannerME
class NamedObject:

5
hscommon/.gitignore vendored
View File

@@ -1,5 +0,0 @@
*.pyc
*.mo
*.so
.DS_Store
/docs_html

View File

@@ -9,6 +9,7 @@
"""This module is a collection of function to help in HS apps build process.
"""
from argparse import ArgumentParser
import os
import sys
import os.path as op
@@ -20,18 +21,19 @@ import re
import importlib
from datetime import datetime
import glob
from typing import Any, AnyStr, Callable, Dict, List, Union
from .plat import ISWINDOWS
from hscommon.plat import ISWINDOWS
def print_and_do(cmd):
def print_and_do(cmd: str) -> int:
"""Prints ``cmd`` and executes it in the shell."""
print(cmd)
p = Popen(cmd, shell=True)
return p.wait()
def _perform(src, dst, action, actionname):
def _perform(src: os.PathLike, dst: os.PathLike, action: Callable, actionname: str) -> None:
if not op.lexists(src):
print("Copying %s failed: it doesn't exist." % src)
return
@@ -44,30 +46,22 @@ def _perform(src, dst, action, actionname):
action(src, dst)
def copy_file_or_folder(src, dst):
def copy_file_or_folder(src: os.PathLike, dst: os.PathLike) -> None:
if op.isdir(src):
shutil.copytree(src, dst, symlinks=True)
else:
shutil.copy(src, dst)
def move(src, dst):
def move(src: os.PathLike, dst: os.PathLike) -> None:
_perform(src, dst, os.rename, "Moving")
def copy(src, dst):
def copy(src: os.PathLike, dst: os.PathLike) -> None:
_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):
def _perform_on_all(pattern: AnyStr, dst: os.PathLike, action: Callable) -> None:
# 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)
@@ -76,22 +70,15 @@ def _perform_on_all(pattern, dst, action):
action(fn, destpath)
def move_all(pattern, dst):
def move_all(pattern: AnyStr, dst: os.PathLike) -> None:
_perform_on_all(pattern, dst, move)
def copy_all(pattern, dst):
def copy_all(pattern: AnyStr, dst: os.PathLike) -> None:
_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):
def filereplace(filename: os.PathLike, outfilename: Union[os.PathLike, None] = None, **kwargs) -> None:
"""Reads `filename`, replaces all {variables} in kwargs, and writes the result to `outfilename`."""
if outfilename is None:
outfilename = filename
@@ -106,12 +93,12 @@ def filereplace(filename, outfilename=None, **kwargs):
fp.close()
def get_module_version(modulename):
def get_module_version(modulename: str) -> str:
mod = importlib.import_module(modulename)
return mod.__version__
def setup_package_argparser(parser):
def setup_package_argparser(parser: ArgumentParser):
parser.add_argument(
"--sign",
dest="sign_identity",
@@ -138,7 +125,7 @@ def setup_package_argparser(parser):
# `args` come from an ArgumentParser updated with setup_package_argparser()
def package_cocoa_app_in_dmg(app_path, destfolder, args):
def package_cocoa_app_in_dmg(app_path: os.PathLike, destfolder: os.PathLike, args) -> None:
# 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.
@@ -154,13 +141,14 @@ def package_cocoa_app_in_dmg(app_path, destfolder, args):
build_dmg(app_path, destfolder)
def build_dmg(app_path, destfolder):
def build_dmg(app_path: os.PathLike, destfolder: os.PathLike) -> None:
"""Builds a DMG volume with application at ``app_path`` and puts it in ``dest_path``.
The name of the resulting DMG volume is determined by the app's name and version.
"""
print(repr(op.join(app_path, "Contents", "Info.plist")))
plist = plistlib.readPlist(op.join(app_path, "Contents", "Info.plist"))
with open(op.join(app_path, "Contents", "Info.plist"), "rb") as fp:
plist = plistlib.load(fp)
workpath = tempfile.mkdtemp()
dmgpath = op.join(workpath, plist["CFBundleName"])
os.mkdir(dmgpath)
@@ -178,7 +166,7 @@ def build_dmg(app_path, destfolder):
print("Build Complete")
def add_to_pythonpath(path):
def add_to_pythonpath(path: os.PathLike) -> None:
"""Adds ``path`` to both ``PYTHONPATH`` env and ``sys.path``."""
abspath = op.abspath(path)
pythonpath = os.environ.get("PYTHONPATH", "")
@@ -191,7 +179,12 @@ def add_to_pythonpath(path):
# 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):
def copy_packages(
packages_names: List[str],
dest: os.PathLike,
create_links: bool = False,
extra_ignores: Union[List[str], None] = None,
) -> None:
"""Copy python packages ``packages_names`` to ``dest``, spurious data.
Copy will happen without tests, testdata, mercurial data or C extension module source with it.
@@ -229,13 +222,13 @@ def copy_packages(packages_names, dest, create_links=False, extra_ignores=None):
def build_debian_changelog(
changelogpath,
destfile,
pkgname,
from_version=None,
distribution="precise",
fix_version=None,
):
changelogpath: os.PathLike,
destfile: os.PathLike,
pkgname: str,
from_version: Union[str, None] = None,
distribution: str = "precise",
fix_version: Union[str, None] = None,
) -> 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
@@ -288,7 +281,7 @@ def build_debian_changelog(
re_changelog_header = re.compile(r"=== ([\d.b]*) \(([\d\-]*)\)")
def read_changelog_file(filename):
def read_changelog_file(filename: os.PathLike) -> List[Dict[str, Any]]:
def iter_by_three(it):
while True:
try:
@@ -315,7 +308,7 @@ def read_changelog_file(filename):
return result
def fix_qt_resource_file(path):
def fix_qt_resource_file(path: os.PathLike) -> None:
# pyrcc5 under Windows, if the locale is non-english, can produce a source file with a date
# containing accented characters. If it does, the encoding is wrong and it prevents the file
# from being correctly frozen by cx_freeze. To work around that, we open the file, strip all

View File

@@ -1,30 +0,0 @@
# Copyright 2016 Virgil Dupras
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import argparse
from setuptools import setup, Extension
def get_parser():
parser = argparse.ArgumentParser(description="Build an arbitrary Python extension.")
parser.add_argument("source_files", nargs="+", help="List of source files to compile")
parser.add_argument("name", nargs=1, help="Name of the resulting extension")
return parser
def main():
args = get_parser().parse_args()
print(f"Building {args.name[0]}...")
ext = Extension(args.name[0], args.source_files)
setup(
script_args=["build_ext", "--inplace"],
ext_modules=[ext],
)
if __name__ == "__main__":
main()

View File

@@ -15,6 +15,7 @@ import os
import shutil
from pathlib import Path
from typing import Callable, List
# This matches [123], but not [12] (3 digits being the minimum).
# It also matches [1234] [12345] etc..
@@ -22,7 +23,7 @@ from pathlib import Path
re_conflict = re.compile(r"^\[\d{3}\d*\] ")
def get_conflicted_name(other_names, name):
def get_conflicted_name(other_names: List[str], name: str) -> str:
"""Returns name with a ``[000]`` number in front of it.
The number between brackets depends on how many conlicted filenames
@@ -39,7 +40,7 @@ def get_conflicted_name(other_names, name):
i += 1
def get_unconflicted_name(name):
def get_unconflicted_name(name: str) -> str:
"""Returns ``name`` without ``[]`` brackets.
Brackets which, of course, might have been added by func:`get_conflicted_name`.
@@ -47,12 +48,12 @@ def get_unconflicted_name(name):
return re_conflict.sub("", name, 1)
def is_conflicted(name):
def is_conflicted(name: str) -> bool:
"""Returns whether ``name`` is prepended with a bracketed number."""
return re_conflict.match(name) is not None
def _smart_move_or_copy(operation, source_path: Path, dest_path: Path):
def _smart_move_or_copy(operation: Callable, source_path: Path, dest_path: Path) -> None:
"""Use move() or copy() to move and copy file with the conflict management."""
if dest_path.is_dir() and not source_path.is_dir():
dest_path = dest_path.joinpath(source_path.name)
@@ -64,12 +65,12 @@ def _smart_move_or_copy(operation, source_path: Path, dest_path: Path):
operation(str(source_path), str(dest_path))
def smart_move(source_path, dest_path):
def smart_move(source_path: Path, dest_path: Path) -> None:
"""Same as :func:`smart_copy`, but it moves files instead."""
_smart_move_or_copy(shutil.move, source_path, dest_path)
def smart_copy(source_path, dest_path):
def smart_copy(source_path: Path, dest_path: Path) -> None:
"""Copies ``source_path`` to ``dest_path``, recursively and with conflict resolution."""
try:
_smart_move_or_copy(shutil.copy, source_path, dest_path)

View File

@@ -6,31 +6,33 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from enum import Enum
from os import PathLike
import os.path as op
import logging
class SpecialFolder:
class SpecialFolder(Enum):
APPDATA = 1
CACHE = 2
def open_url(url):
def open_url(url: str) -> None:
"""Open ``url`` with the default browser."""
_open_url(url)
def open_path(path):
def open_path(path: PathLike) -> None:
"""Open ``path`` with its associated application."""
_open_path(str(path))
def reveal_path(path):
def reveal_path(path: PathLike) -> None:
"""Open the folder containing ``path`` with the default file browser."""
_reveal_path(str(path))
def special_folder_path(special_folder, appname=None, portable=False):
def special_folder_path(special_folder: SpecialFolder, portable: bool = False) -> str:
"""Returns the path of ``special_folder``.
``special_folder`` is a SpecialFolder.* const. The result is the special folder for the current
@@ -38,7 +40,7 @@ def special_folder_path(special_folder, appname=None, portable=False):
You can override the application name with ``appname``. This argument is ingored under Qt.
"""
return _special_folder_path(special_folder, appname, portable=portable)
return _special_folder_path(special_folder, portable=portable)
try:
@@ -49,14 +51,14 @@ try:
from hscommon.plat import ISWINDOWS, ISOSX
import subprocess
def _open_url(url):
def _open_url(url: str) -> None:
QDesktopServices.openUrl(QUrl(url))
def _open_path(path):
def _open_path(path: str) -> None:
url = QUrl.fromLocalFile(str(path))
QDesktopServices.openUrl(url)
def _reveal_path(path):
def _reveal_path(path: str) -> None:
if ISWINDOWS:
subprocess.run(["explorer", "/select,", op.abspath(path)])
elif ISOSX:
@@ -64,7 +66,7 @@ try:
else:
_open_path(op.dirname(str(path)))
def _special_folder_path(special_folder, appname=None, portable=False):
def _special_folder_path(special_folder: SpecialFolder, portable: bool = False) -> str:
if special_folder == SpecialFolder.CACHE:
if ISWINDOWS and portable:
folder = op.join(executable_folder(), "cache")
@@ -79,13 +81,17 @@ except ImportError:
# weird situation. Let's just have dummy fallbacks.
logging.warning("Can't setup desktop functions!")
def _open_path(path):
def _open_url(url: str) -> None:
# Dummy for tests
pass
def _reveal_path(path):
def _open_path(path: str) -> None:
# Dummy for tests
pass
def _special_folder_path(special_folder, appname=None, portable=False):
def _reveal_path(path: str) -> None:
# Dummy for tests
pass
def _special_folder_path(special_folder: SpecialFolder, portable: bool = False) -> str:
return "/tmp"

View File

@@ -36,11 +36,11 @@ class GUIObject:
``multibind`` flag to ``True`` and the safeguard will be disabled.
"""
def __init__(self, multibind=False):
def __init__(self, multibind: bool = False) -> None:
self._view = None
self._multibind = multibind
def _view_updated(self):
def _view_updated(self) -> None:
"""(Virtual) Called after :attr:`view` has been set.
Doing nothing by default, this method is called after :attr:`view` has been set (it isn't
@@ -48,7 +48,7 @@ class GUIObject:
(which is often the whole of the initialization code).
"""
def has_view(self):
def has_view(self) -> bool:
return (self._view is not None) and (not isinstance(self._view, NoopGUI))
@property
@@ -67,7 +67,7 @@ class GUIObject:
return self._view
@view.setter
def view(self, value):
def view(self, value) -> None:
if self._view is None and value is None:
# Initial view assignment
return

View File

@@ -7,8 +7,10 @@
# http://www.gnu.org/licenses/gpl-3.0.html
import copy
from typing import Any, List, Tuple, Union
from .base import GUIObject
from hscommon.gui.base import GUIObject
from hscommon.gui.table import GUITable
class Column:
@@ -17,7 +19,7 @@ class Column:
These attributes are then used to correctly configure the column on the "view" side.
"""
def __init__(self, name, display="", visible=True, optional=False):
def __init__(self, name: str, display: str = "", visible: bool = True, optional: bool = False) -> None:
#: "programmatical" (not for display) name. Used as a reference in a couple of place, such
#: as :meth:`Columns.column_by_name`.
self.name = name
@@ -52,14 +54,14 @@ class ColumnsView:
callbacks.
"""
def restore_columns(self):
def restore_columns(self) -> None:
"""Update all columns according to the model.
When this is called, our view has to update the columns title, order and visibility of all
columns.
"""
def set_column_visible(self, colname, visible):
def set_column_visible(self, colname: str, visible: bool) -> None:
"""Update visibility of column ``colname``.
Called when the user toggles the visibility of a column, we must update the column
@@ -73,13 +75,13 @@ class PrefAccessInterface:
*Not actually used in the code. For documentation purposes only.*
"""
def get_default(self, key, fallback_value):
def get_default(self, key: str, fallback_value: Union[Any, None]) -> Any:
"""Retrieve the value for ``key`` in the currently running app's preference store.
If the key doesn't exist, return ``fallback_value``.
"""
def set_default(self, key, value):
def set_default(self, key: str, value: Any) -> None:
"""Set the value ``value`` for ``key`` in the currently running app's preference store."""
@@ -104,65 +106,65 @@ class Columns(GUIObject):
have that same prefix.
"""
def __init__(self, table, prefaccess=None, savename=None):
def __init__(self, table: GUITable, prefaccess=None, savename: Union[str, None] = 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))
self.column_list: List[Column] = 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):
def _get_colname_attr(self, colname: str, attrname: str, default: Any) -> Any:
try:
return getattr(self.coldata[colname], attrname)
except KeyError:
return default
def _set_colname_attr(self, colname, attrname, value):
def _set_colname_attr(self, colname: str, attrname: str, value: Any) -> None:
try:
col = self.coldata[colname]
setattr(col, attrname, value)
except KeyError:
pass
def _optional_columns(self):
def _optional_columns(self) -> List[Column]:
return [c for c in self.column_list if c.optional]
# --- Override
def _view_updated(self):
def _view_updated(self) -> None:
self.restore_columns()
# --- Public
def column_by_index(self, index):
def column_by_index(self, index: int):
"""Return the :class:`Column` having the :attr:`~Column.logical_index` ``index``."""
return self.column_list[index]
def column_by_name(self, name):
def column_by_name(self, name: str):
"""Return the :class:`Column` having the :attr:`~Column.name` ``name``."""
return self.coldata[name]
def columns_count(self):
def columns_count(self) -> int:
"""Returns the number of columns in our set."""
return len(self.column_list)
def column_display(self, colname):
def column_display(self, colname: str) -> str:
"""Returns display name for column named ``colname``, or ``''`` if there's none."""
return self._get_colname_attr(colname, "display", "")
def column_is_visible(self, colname):
def column_is_visible(self, colname: str) -> bool:
"""Returns visibility for column named ``colname``, or ``True`` if there's none."""
return self._get_colname_attr(colname, "visible", True)
def column_width(self, colname):
def column_width(self, colname: str) -> int:
"""Returns width for column named ``colname``, or ``0`` if there's none."""
return self._get_colname_attr(colname, "width", 0)
def columns_to_right(self, colname):
def columns_to_right(self, colname: str) -> List[str]:
"""Returns the list of all columns to the right of ``colname``.
"right" meaning "having a higher :attr:`Column.ordered_index`" in our left-to-right
@@ -172,7 +174,7 @@ class Columns(GUIObject):
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):
def menu_items(self) -> List[Tuple[str, bool]]:
"""Returns a list of items convenient for quick visibility menu generation.
Returns a list of ``(display_name, is_marked)`` items for each optional column in the
@@ -184,7 +186,7 @@ class Columns(GUIObject):
"""
return [(c.display, c.visible) for c in self._optional_columns()]
def move_column(self, colname, index):
def move_column(self, colname: str, index: int) -> None:
"""Moves column ``colname`` to ``index``.
The column will be placed just in front of the column currently having that index, or to the
@@ -195,7 +197,7 @@ class Columns(GUIObject):
colnames.insert(index, colname)
self.set_column_order(colnames)
def reset_to_defaults(self):
def reset_to_defaults(self) -> None:
"""Reset all columns' width and visibility to their default values."""
self.set_column_order([col.name for col in self.column_list])
for col in self._optional_columns():
@@ -203,11 +205,11 @@ class Columns(GUIObject):
col.width = col.default_width
self.view.restore_columns()
def resize_column(self, colname, newwidth):
def resize_column(self, colname: str, newwidth: int) -> None:
"""Set column ``colname``'s width to ``newwidth``."""
self._set_colname_attr(colname, "width", newwidth)
def restore_columns(self):
def restore_columns(self) -> None:
"""Restore's column persistent attributes from the last :meth:`save_columns`."""
if not (self.prefaccess and self.savename and self.coldata):
if (not self.savename) and (self.coldata):
@@ -226,7 +228,7 @@ class Columns(GUIObject):
col.visible = coldata["visible"]
self.view.restore_columns()
def save_columns(self):
def save_columns(self) -> None:
"""Save column attributes in persistent storage for restoration in :meth:`restore_columns`."""
if not (self.prefaccess and self.savename and self.coldata):
return
@@ -237,7 +239,8 @@ class Columns(GUIObject):
coldata["visible"] = col.visible
self.prefaccess.set_default(pref_name, coldata)
def set_column_order(self, colnames):
# TODO annotate colnames
def set_column_order(self, colnames) -> None:
"""Change the columns order so it matches the order in ``colnames``.
:param colnames: A list of column names in the desired order.
@@ -247,17 +250,17 @@ class Columns(GUIObject):
col = self.coldata[colname]
col.ordered_index = i
def set_column_visible(self, colname, visible):
def set_column_visible(self, colname: str, visible: bool) -> None:
"""Set the visibility of column ``colname``."""
self.table.save_edits() # the table on the GUI side will stop editing when the columns change
self._set_colname_attr(colname, "visible", visible)
self.view.set_column_visible(colname, visible)
def set_default_width(self, colname, width):
def set_default_width(self, colname: str, width: int) -> None:
"""Set the default width or column ``colname``."""
self._set_colname_attr(colname, "default_width", width)
def toggle_menu_item(self, index):
def toggle_menu_item(self, index: int) -> bool:
"""Toggles the visibility of an optional column.
You know, that optional column menu you've generated in :meth:`menu_items`? Well, ``index``
@@ -271,11 +274,11 @@ class Columns(GUIObject):
# --- Properties
@property
def ordered_columns(self):
def ordered_columns(self) -> List[Column]:
"""List of :class:`Column` in visible order."""
return [col for col in sorted(self.column_list, key=lambda col: col.ordered_index)]
@property
def colnames(self):
def colnames(self) -> List[str]:
"""List of column names in visible order."""
return [col.name for col in self.ordered_columns]

View File

@@ -4,9 +4,10 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ..jobprogress.performer import ThreadedJobPerformer
from .base import GUIObject
from .text_field import TextField
from typing import Callable, Tuple, Union
from hscommon.jobprogress.performer import ThreadedJobPerformer
from hscommon.gui.base import GUIObject
from hscommon.gui.text_field import TextField
class ProgressWindowView:
@@ -20,13 +21,13 @@ class ProgressWindowView:
It's also expected to call :meth:`ProgressWindow.cancel` when the cancel button is clicked.
"""
def show(self):
def show(self) -> None:
"""Show the dialog."""
def close(self):
def close(self) -> None:
"""Close the dialog."""
def set_progress(self, progress):
def set_progress(self, progress: int) -> None:
"""Set the progress of the progress bar to ``progress``.
Not all jobs are equally responsive on their job progress report and it is recommended that
@@ -60,7 +61,11 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
called as if the job terminated normally.
"""
def __init__(self, finish_func, error_func=None):
def __init__(
self,
finish_func: Callable[[Union[str, None]], None],
error_func: Callable[[Union[str, None], Exception], bool] = None,
) -> None:
# finish_func(jobid) is the function that is called when a job is completed.
GUIObject.__init__(self)
ThreadedJobPerformer.__init__(self)
@@ -71,9 +76,9 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
#: :class:`.TextField`. It contains the job textual update that the function might yield
#: during its course.
self.progressdesc_textfield = TextField()
self.jobid = None
self.jobid: Union[str, None] = None
def cancel(self):
def cancel(self) -> None:
"""Call for a user-initiated job cancellation."""
# The UI is sometimes a bit buggy and calls cancel() on self.view.close(). We just want to
# make sure that this doesn't lead us to think that the user acually cancelled the task, so
@@ -81,7 +86,7 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
if self._job_running:
self.job_cancelled = True
def pulse(self):
def pulse(self) -> None:
"""Update progress reports in the GUI.
Call this regularly from the GUI main run loop. The values might change before
@@ -111,7 +116,7 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
self.progressdesc_textfield.text = last_desc
self.view.set_progress(last_progress)
def run(self, jobid, title, target, args=()):
def run(self, jobid: str, title: str, target: Callable, args: Tuple = ()):
"""Starts a threaded job.
The ``target`` function will be sent, as its first argument, a :class:`.Job` instance which

View File

@@ -8,7 +8,7 @@
from collections.abc import Sequence, MutableSequence
from .base import GUIObject
from hscommon.gui.base import GUIObject
class Selectable(Sequence):

View File

@@ -8,9 +8,10 @@
from collections.abc import MutableSequence
from collections import namedtuple
from typing import Any, List, Tuple, Union
from .base import GUIObject
from .selectable_list import Selectable
from hscommon.gui.base import GUIObject
from hscommon.gui.selectable_list import Selectable
# We used to directly subclass list, but it caused problems at some point with deepcopy
@@ -27,12 +28,16 @@ class Table(MutableSequence, Selectable):
Subclasses :class:`.Selectable`.
"""
def __init__(self):
Selectable.__init__(self)
self._rows = []
self._header = None
self._footer = None
# Should be List[Column], but have circular import...
COLUMNS: List = []
def __init__(self) -> None:
Selectable.__init__(self)
self._rows: List["Row"] = []
self._header: Union["Row", None] = None
self._footer: Union["Row", None] = None
# TODO type hint for key
def __delitem__(self, key):
self._rows.__delitem__(key)
if self._header is not None and ((not self) or (self[0] is not self._header)):
@@ -41,16 +46,18 @@ class Table(MutableSequence, Selectable):
self._footer = None
self._check_selection_range()
def __getitem__(self, key):
# TODO type hint for key
def __getitem__(self, key) -> Any:
return self._rows.__getitem__(key)
def __len__(self):
def __len__(self) -> int:
return len(self._rows)
def __setitem__(self, key, value):
# TODO type hint for key
def __setitem__(self, key, value: Any) -> None:
self._rows.__setitem__(key, value)
def append(self, item):
def append(self, item: "Row") -> None:
"""Appends ``item`` at the end of the table.
If there's a footer, the item is inserted before it.
@@ -60,7 +67,7 @@ class Table(MutableSequence, Selectable):
else:
self._rows.append(item)
def insert(self, index, item):
def insert(self, index: int, item: "Row") -> None:
"""Inserts ``item`` at ``index`` in the table.
If there's a header, will make sure we don't insert before it, and if there's a footer, will
@@ -72,7 +79,7 @@ class Table(MutableSequence, Selectable):
index = len(self) - 1
self._rows.insert(index, item)
def remove(self, row):
def remove(self, row: "Row") -> None:
"""Removes ``row`` from table.
If ``row`` is a header or footer, that header or footer will be set to ``None``.
@@ -84,7 +91,7 @@ class Table(MutableSequence, Selectable):
self._rows.remove(row)
self._check_selection_range()
def sort_by(self, column_name, desc=False):
def sort_by(self, column_name: str, desc: bool = False) -> None:
"""Sort table by ``column_name``.
Sort key for each row is computed from :meth:`Row.sort_key_for_column`.
@@ -105,7 +112,7 @@ class Table(MutableSequence, Selectable):
# --- Properties
@property
def footer(self):
def footer(self) -> Union["Row", None]:
"""If set, a row that always stay at the bottom of the table.
:class:`Row`. *get/set*.
@@ -128,7 +135,7 @@ class Table(MutableSequence, Selectable):
return self._footer
@footer.setter
def footer(self, value):
def footer(self, value: Union["Row", None]) -> None:
if self._footer is not None:
self._rows.pop()
if value is not None:
@@ -136,7 +143,7 @@ class Table(MutableSequence, Selectable):
self._footer = value
@property
def header(self):
def header(self) -> Union["Row", None]:
"""If set, a row that always stay at the bottom of the table.
See :attr:`footer` for details.
@@ -144,7 +151,7 @@ class Table(MutableSequence, Selectable):
return self._header
@header.setter
def header(self, value):
def header(self, value: Union["Row", None]) -> None:
if self._header is not None:
self._rows.pop(0)
if value is not None:
@@ -152,7 +159,7 @@ class Table(MutableSequence, Selectable):
self._header = value
@property
def row_count(self):
def row_count(self) -> int:
"""Number or rows in the table (without counting header and footer).
*int*. *read-only*.
@@ -165,7 +172,7 @@ class Table(MutableSequence, Selectable):
return result
@property
def rows(self):
def rows(self) -> List["Row"]:
"""List of rows in the table, excluding header and footer.
List of :class:`Row`. *read-only*.
@@ -179,7 +186,7 @@ class Table(MutableSequence, Selectable):
return self[start:end]
@property
def selected_row(self):
def selected_row(self) -> "Row":
"""Selected row according to :attr:`Selectable.selected_index`.
:class:`Row`. *get/set*.
@@ -190,14 +197,14 @@ class Table(MutableSequence, Selectable):
return self[self.selected_index] if self.selected_index is not None else None
@selected_row.setter
def selected_row(self, value):
def selected_row(self, value: int) -> None:
try:
self.selected_index = self.index(value)
except ValueError:
pass
@property
def selected_rows(self):
def selected_rows(self) -> List["Row"]:
"""List of selected rows based on :attr:`.selected_indexes`.
List of :class:`Row`. *read-only*.
@@ -219,20 +226,20 @@ class GUITableView:
Whenever the user changes the selection, we expect the view to call :meth:`Table.select`.
"""
def refresh(self):
def refresh(self) -> None:
"""Refreshes the contents of the table widget.
Ensures that the contents of the table widget is synced with the model. This includes
selection.
"""
def start_editing(self):
def start_editing(self) -> None:
"""Start editing the currently selected row.
Begin whatever inline editing support that the view supports.
"""
def stop_editing(self):
def stop_editing(self) -> None:
"""Stop editing if there's an inline editing in effect.
There's no "aborting" implied in this call, so it's appropriate to send whatever the user
@@ -260,33 +267,33 @@ class GUITable(Table, GUIObject):
:class:`GUITableView`.
"""
def __init__(self):
def __init__(self) -> None:
GUIObject.__init__(self)
Table.__init__(self)
#: The row being currently edited by the user. ``None`` if no edit is taking place.
self.edited = None
self._sort_descriptor = None
self.edited: Union["Row", None] = None
self._sort_descriptor: Union[SortDescriptor, None] = None
# --- Virtual
def _do_add(self):
def _do_add(self) -> Tuple["Row", int]:
"""(Virtual) Creates a new row, adds it in the table.
Returns ``(row, insert_index)``.
"""
raise NotImplementedError()
def _do_delete(self):
def _do_delete(self) -> None:
"""(Virtual) Delete the selected rows."""
pass
def _fill(self):
def _fill(self) -> None:
"""(Virtual/Required) Fills the table with all the rows that this table is supposed to have.
Called by :meth:`refresh`. Does nothing by default.
"""
pass
def _is_edited_new(self):
def _is_edited_new(self) -> bool:
"""(Virtual) Returns whether the currently edited row should be considered "new".
This is used in :meth:`cancel_edits` to know whether the cancellation of the edit means a
@@ -315,7 +322,7 @@ class GUITable(Table, GUIObject):
self.select([len(self) - 1])
# --- Public
def add(self):
def add(self) -> None:
"""Add a new row in edit mode.
Requires :meth:`do_add` to be implemented. The newly added row will be selected and in edit
@@ -334,7 +341,7 @@ class GUITable(Table, GUIObject):
self.edited = row
self.view.start_editing()
def can_edit_cell(self, column_name, row_index):
def can_edit_cell(self, column_name: str, row_index: int) -> bool:
"""Returns whether the cell at ``row_index`` and ``column_name`` can be edited.
A row is, by default, editable as soon as it has an attr with the same name as `column`.
@@ -346,7 +353,7 @@ class GUITable(Table, GUIObject):
row = self[row_index]
return row.can_edit_cell(column_name)
def cancel_edits(self):
def cancel_edits(self) -> None:
"""Cancels the current edit operation.
If there's an :attr:`edited` row, it will be re-initialized (with :meth:`Row.load`).
@@ -364,7 +371,7 @@ class GUITable(Table, GUIObject):
self.edited = None
self.view.refresh()
def delete(self):
def delete(self) -> None:
"""Delete the currently selected rows.
Requires :meth:`_do_delete` for this to have any effect on the model. Cancels editing if
@@ -377,7 +384,7 @@ class GUITable(Table, GUIObject):
if self:
self._do_delete()
def refresh(self, refresh_view=True):
def refresh(self, refresh_view: bool = True) -> None:
"""Empty the table and re-create its rows.
:meth:`_fill` is called after we emptied the table to create our rows. Previous sort order
@@ -399,7 +406,7 @@ class GUITable(Table, GUIObject):
if refresh_view:
self.view.refresh()
def save_edits(self):
def save_edits(self) -> None:
"""Commit user edits to the model.
This is done by calling :meth:`Row.save`.
@@ -410,7 +417,7 @@ class GUITable(Table, GUIObject):
self.edited = None
row.save()
def sort_by(self, column_name, desc=False):
def sort_by(self, column_name: str, desc: bool = False) -> None:
"""Sort table by ``column_name``.
Overrides :meth:`Table.sort_by`. After having performed sorting, calls
@@ -450,18 +457,18 @@ class Row:
Of course, this is only default behavior. This can be overriden.
"""
def __init__(self, table):
def __init__(self, table: GUITable) -> None:
super().__init__()
self.table = table
def _edit(self):
def _edit(self) -> None:
if self.table.edited is self:
return
assert self.table.edited is None
self.table.edited = self
# --- Virtual
def can_edit(self):
def can_edit(self) -> bool:
"""(Virtual) Whether the whole row can be edited.
By default, always returns ``True``. This is for the *whole* row. For individual cells, it's
@@ -469,7 +476,7 @@ class Row:
"""
return True
def load(self):
def load(self) -> None:
"""(Virtual/Required) Loads up values from the model to be presented in the table.
Usually, our model instances contain values that are not quite ready for display. If you
@@ -478,7 +485,7 @@ class Row:
"""
raise NotImplementedError()
def save(self):
def save(self) -> None:
"""(Virtual/Required) Saves user edits into your model.
If your table is editable, this is called when the user commits his changes. Usually, these
@@ -487,7 +494,7 @@ class Row:
"""
raise NotImplementedError()
def sort_key_for_column(self, column_name):
def sort_key_for_column(self, column_name: str) -> Any:
"""(Virtual) Return the value that is to be used to sort by column ``column_name``.
By default, looks for an attribute with the same name as ``column_name``, but with an
@@ -500,7 +507,7 @@ class Row:
return getattr(self, column_name)
# --- Public
def can_edit_cell(self, column_name):
def can_edit_cell(self, column_name: str) -> bool:
"""Returns whether cell for column ``column_name`` can be edited.
By the default, the check is done in many steps:
@@ -530,7 +537,7 @@ class Row:
return False
return bool(getattr(prop, "fset", None))
def get_cell_value(self, attrname):
def get_cell_value(self, attrname: str) -> Any:
"""Get cell value for ``attrname``.
By default, does a simple ``getattr()``, but it is used to allow subclasses to have
@@ -540,7 +547,7 @@ class Row:
attrname = "from_"
return getattr(self, attrname)
def set_cell_value(self, attrname, value):
def set_cell_value(self, attrname: str, value: Any) -> None:
"""Set cell value to ``value`` for ``attrname``.
By default, does a simple ``setattr()``, but it is used to allow subclasses to have

View File

@@ -5,8 +5,8 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from .base import GUIObject
from ..util import nonone
from hscommon.gui.base import GUIObject
from hscommon.util import nonone
class TextFieldView:

View File

@@ -6,7 +6,7 @@
from collections.abc import MutableSequence
from .base import GUIObject
from hscommon.gui.base import GUIObject
class Node(MutableSequence):

View File

@@ -7,6 +7,9 @@
# http://www.gnu.org/licenses/gpl-3.0.html
from typing import Any, Callable, Generator, Iterator, List, Union
class JobCancelled(Exception):
"The user has cancelled the job"
@@ -36,7 +39,7 @@ class Job:
"""
# ---Magic functions
def __init__(self, job_proportions, callback):
def __init__(self, job_proportions: Union[List[int], int], callback: Callable) -> None:
"""Initialize the Job with 'jobcount' jobs. Start every job with
start_job(). Every time the job progress is updated, 'callback' is called
'callback' takes a 'progress' int param, and a optional 'desc'
@@ -55,12 +58,12 @@ class Job:
self._currmax = 1
# ---Private
def _subjob_callback(self, progress, desc=""):
def _subjob_callback(self, progress: int, desc: str = "") -> bool:
"""This is the callback passed to children jobs."""
self.set_progress(progress, desc)
return True # if JobCancelled has to be raised, it will be at the highest level
def _do_update(self, desc):
def _do_update(self, desc: str) -> None:
"""Calls the callback function with a % progress as a parameter.
The parameter is a int in the 0-100 range.
@@ -78,13 +81,16 @@ class Job:
raise JobCancelled()
# ---Public
def add_progress(self, progress=1, desc=""):
def add_progress(self, progress: int = 1, desc: str = "") -> None:
self.set_progress(self._progress + progress, desc)
def check_if_cancelled(self):
def check_if_cancelled(self) -> None:
self._do_update("")
def iter_with_progress(self, iterable, desc_format=None, every=1, count=None):
# TODO type hint iterable
def iter_with_progress(
self, iterable, desc_format: Union[str, None] = None, every: int = 1, count: Union[int, None] = None
) -> Generator[Any, None, None]:
"""Iterate through ``iterable`` while automatically adding progress.
WARNING: We need our iterable's length. If ``iterable`` is not a sequence (that is,
@@ -107,7 +113,7 @@ class Job:
desc = desc_format % (count, count)
self.set_progress(100, desc)
def start_job(self, max_progress=100, desc=""):
def start_job(self, max_progress: int = 100, desc: str = "") -> None:
"""Begin work on the next job. You must not call start_job more than
'jobcount' (in __init__) times.
'max' is the job units you are to perform.
@@ -122,7 +128,7 @@ class Job:
self._currmax = max(1, max_progress)
self._do_update(desc)
def start_subjob(self, job_proportions, desc=""):
def start_subjob(self, job_proportions: Union[List[int], int], desc: str = "") -> "Job":
"""Starts a sub job. Use this when you want to split a job into
multiple smaller jobs. Pretty handy when starting a process where you
know how many subjobs you will have, but don't know the work unit count
@@ -132,7 +138,7 @@ class Job:
self.start_job(100, desc)
return Job(job_proportions, self._subjob_callback)
def set_progress(self, progress, desc=""):
def set_progress(self, progress: int, desc: str = "") -> None:
"""Sets the progress of the current job to 'progress', and call the
callback
"""
@@ -143,29 +149,29 @@ class Job:
class NullJob:
def __init__(self, *args, **kwargs):
def __init__(self, *args, **kwargs) -> None:
# Null job does nothing
pass
def add_progress(self, *args, **kwargs):
def add_progress(self, *args, **kwargs) -> None:
# Null job does nothing
pass
def check_if_cancelled(self):
def check_if_cancelled(self) -> None:
# Null job does nothing
pass
def iter_with_progress(self, sequence, *args, **kwargs):
def iter_with_progress(self, sequence, *args, **kwargs) -> Iterator:
return iter(sequence)
def start_job(self, *args, **kwargs):
def start_job(self, *args, **kwargs) -> None:
# Null job does nothing
pass
def start_subjob(self, *args, **kwargs):
def start_subjob(self, *args, **kwargs) -> "NullJob":
return NullJob()
def set_progress(self, *args, **kwargs):
def set_progress(self, *args, **kwargs) -> None:
# Null job does nothing
pass

View File

@@ -8,8 +8,9 @@
from threading import Thread
import sys
from typing import Callable, Tuple, Union
from .job import Job, JobInProgressError, JobCancelled
from hscommon.jobprogress.job import Job, JobInProgressError, JobCancelled
class ThreadedJobPerformer:
@@ -28,15 +29,15 @@ class ThreadedJobPerformer:
last_error = None
# --- Protected
def create_job(self):
def create_job(self) -> Job:
if self._job_running:
raise JobInProgressError()
self.last_progress = -1
self.last_progress: Union[int, None] = -1
self.last_desc = ""
self.job_cancelled = False
return Job(1, self._update_progress)
def _async_run(self, *args):
def _async_run(self, *args) -> None:
target = args[0]
args = tuple(args[1:])
self._job_running = True
@@ -52,7 +53,7 @@ class ThreadedJobPerformer:
self._job_running = False
self.last_progress = None
def reraise_if_error(self):
def reraise_if_error(self) -> None:
"""Reraises the error that happened in the thread if any.
Call this after the caller of run_threaded detected that self._job_running returned to False
@@ -60,13 +61,13 @@ class ThreadedJobPerformer:
if self.last_error is not None:
raise self.last_error.with_traceback(self.last_traceback)
def _update_progress(self, newprogress, newdesc=""):
def _update_progress(self, newprogress: int, newdesc: str = "") -> bool:
self.last_progress = newprogress
if newdesc:
self.last_desc = newdesc
return not self.job_cancelled
def run_threaded(self, target, args=()):
def run_threaded(self, target: Callable, args: Tuple = ()) -> None:
if self._job_running:
raise JobInProgressError()
args = (target,) + args

View File

@@ -2,34 +2,24 @@ import os
import os.path as op
import shutil
import tempfile
from typing import Any, List
import polib
from . import pygettext
from hscommon import pygettext
LC_MESSAGES = "LC_MESSAGES"
# There isn't a 1-on-1 exact fit between .po language codes and cocoa ones
PO2COCOA = {
"pl_PL": "pl",
"pt_BR": "pt-BR",
"zh_CN": "zh-Hans",
}
COCOA2PO = {v: k for k, v in PO2COCOA.items()}
STRING_EXT = ".strings"
def get_langs(folder):
def get_langs(folder: str) -> List[str]:
return [name for name in os.listdir(folder) if op.isdir(op.join(folder, name))]
def files_with_ext(folder, ext):
def files_with_ext(folder: str, ext: str) -> List[str]:
return [op.join(folder, fn) for fn in os.listdir(folder) if fn.endswith(ext)]
def generate_pot(folders, outpath, keywords, merge=False):
def generate_pot(folders: List[str], outpath: str, keywords: Any, merge: bool = False) -> None:
if merge and not op.exists(outpath):
merge = False
if merge:
@@ -50,7 +40,7 @@ def generate_pot(folders, outpath, keywords, merge=False):
print("Exception while removing temporary folder %s\n", genpath)
def compile_all_po(base_folder):
def compile_all_po(base_folder: str) -> None:
langs = get_langs(base_folder)
for lang in langs:
pofolder = op.join(base_folder, lang, LC_MESSAGES)
@@ -60,7 +50,7 @@ def compile_all_po(base_folder):
p.save_as_mofile(pofile[:-3] + ".mo")
def merge_locale_dir(target, mergeinto):
def merge_locale_dir(target: str, mergeinto: str) -> None:
langs = get_langs(target)
for lang in langs:
if not op.exists(op.join(mergeinto, lang)):
@@ -71,7 +61,7 @@ def merge_locale_dir(target, mergeinto):
shutil.copy(mofile, op.join(mergeinto, lang, LC_MESSAGES))
def merge_pots_into_pos(folder):
def merge_pots_into_pos(folder: str) -> None:
# 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")
@@ -84,7 +74,7 @@ def merge_pots_into_pos(folder):
po.save()
def merge_po_and_preserve(source, dest):
def merge_po_and_preserve(source: str, dest: str) -> None:
# Merges source entries into dest, but keep old entries intact
sourcepo = polib.pofile(source)
destpo = polib.pofile(dest)
@@ -96,7 +86,7 @@ def merge_po_and_preserve(source, dest):
destpo.save()
def normalize_all_pos(base_folder):
def normalize_all_pos(base_folder: str) -> None:
"""Normalize the format of .po files in base_folder.
When getting POs from external sources, such as Transifex, we end up with spurious diffs because

View File

@@ -13,6 +13,7 @@ the method with the same name as the broadcasted message is called on the listen
"""
from collections import defaultdict
from typing import Callable, DefaultDict, List
class Broadcaster:
@@ -21,10 +22,10 @@ class Broadcaster:
def __init__(self):
self.listeners = set()
def add_listener(self, listener):
def add_listener(self, listener: "Listener") -> None:
self.listeners.add(listener)
def notify(self, msg):
def notify(self, msg: str) -> None:
"""Notify all connected listeners of ``msg``.
That means that each listeners will have their method with the same name as ``msg`` called.
@@ -33,18 +34,18 @@ class Broadcaster:
if listener in self.listeners: # disconnected during notification
listener.dispatch(msg)
def remove_listener(self, listener):
def remove_listener(self, listener: "Listener") -> None:
self.listeners.discard(listener)
class Listener:
"""A listener is initialized with the broadcaster it's going to listen to. Initially, it is not connected."""
def __init__(self, broadcaster):
def __init__(self, broadcaster: Broadcaster) -> None:
self.broadcaster = broadcaster
self._bound_notifications = defaultdict(list)
self._bound_notifications: DefaultDict[str, List[Callable]] = defaultdict(list)
def bind_messages(self, messages, func):
def bind_messages(self, messages: str, func: Callable) -> None:
"""Binds multiple message to the same function.
Often, we perform the same thing on multiple messages. Instead of having the same function
@@ -54,15 +55,15 @@ class Listener:
for message in messages:
self._bound_notifications[message].append(func)
def connect(self):
def connect(self) -> None:
"""Connects the listener to its broadcaster."""
self.broadcaster.add_listener(self)
def disconnect(self):
def disconnect(self) -> None:
"""Disconnects the listener from its broadcaster."""
self.broadcaster.remove_listener(self)
def dispatch(self, msg):
def dispatch(self, msg: str) -> None:
if msg in self._bound_notifications:
for func in self._bound_notifications[msg]:
func()
@@ -74,14 +75,14 @@ class Listener:
class Repeater(Broadcaster, Listener):
REPEATED_NOTIFICATIONS = None
def __init__(self, broadcaster):
def __init__(self, broadcaster: Broadcaster) -> None:
Broadcaster.__init__(self)
Listener.__init__(self, broadcaster)
def _repeat_message(self, msg):
def _repeat_message(self, msg: str) -> None:
if not self.REPEATED_NOTIFICATIONS or msg in self.REPEATED_NOTIFICATIONS:
self.notify(msg)
def dispatch(self, msg):
def dispatch(self, msg: str) -> None:
Listener.dispatch(self, msg)
self._repeat_message(msg)

View File

@@ -6,8 +6,9 @@
from pathlib import Path
import re
from typing import Callable, Dict, Union
from .build import read_changelog_file, filereplace
from hscommon.build import read_changelog_file, filereplace
from sphinx.cmd.build import build_main as sphinx_build
CHANGELOG_FORMAT = """
@@ -18,7 +19,7 @@ CHANGELOG_FORMAT = """
"""
def tixgen(tixurl):
def tixgen(tixurl: str) -> Callable[[str], str]:
"""This is a filter *generator*. tixurl is a url pattern for the tix with a {0} placeholder
for the tix #
"""
@@ -29,14 +30,14 @@ def tixgen(tixurl):
def gen(
basepath,
destpath,
changelogpath,
tixurl,
confrepl=None,
confpath=None,
changelogtmpl=None,
):
basepath: Path,
destpath: Path,
changelogpath: Path,
tixurl: str,
confrepl: Union[Dict[str, str], None] = None,
confpath: Union[Path, None] = None,
changelogtmpl: Union[Path, None] = None,
) -> None:
"""Generate sphinx docs with all bells and whistles.
basepath: The base sphinx source path.

View File

@@ -1,141 +0,0 @@
# Created By: Virgil Dupras
# Created On: 2007/05/19
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import os
import os.path as op
import threading
from queue import Queue
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.daemon = True
self.start()
def _query(self, query):
with self._lock:
wait_token = object()
self._waiting_list.add(wait_token)
self._queries.put(query)
self._waiting_list.remove(wait_token)
result = self._results.get()
return result
def close(self):
if not self._run:
return
self._query(STOP)
def commit(self):
if not self._run:
return None # Connection closed
self._query(COMMIT)
def execute(self, sql, values=()):
if not self._run:
return None # Connection closed
result = self._query((sql, values))
if isinstance(result, Exception):
raise result
return result
def rollback(self):
if not self._run:
return None # Connection closed
self._query(ROLLBACK)
def run(self):
# The whole chdir thing is because sqlite doesn't handle directory names with non-asci char in the AT ALL.
oldpath = os.getcwd()
dbdir, dbname = op.split(self._dbname)
if dbdir:
os.chdir(dbdir)
if self._autocommit:
con = sqlite.connect(dbname, isolation_level=None)
else:
con = sqlite.connect(dbname)
os.chdir(oldpath)
while self._run or self._waiting_list:
query = self._queries.get()
result = None
if query is STOP:
self._run = False
elif query is COMMIT:
con.commit()
elif query is ROLLBACK:
con.rollback()
else:
sql, values = query
try:
cur = con.execute(sql, values)
self.lastrowid = cur.lastrowid
result = FakeCursor(cur.fetchall())
result.lastrowid = cur.lastrowid
except Exception as e:
result = e
self._results.put(result)
con.close()
class ThreadedConn:
"""``sqlite`` connections can't be used across threads. ``TheadedConn`` opens a sqlite
connection in its own thread and sends it queries through a queue, making it suitable in
multi-threaded environment.
"""
def __init__(self, dbname, autocommit):
self._t = _ActualThread(dbname, autocommit)
self.lastrowid = -1
def __del__(self):
self.close()
def close(self):
self._t.close()
def commit(self):
self._t.commit()
def execute(self, sql, values=()):
result = self._t.execute(sql, values)
self.lastrowid = self._t.lastrowid
return result
def rollback(self):
self._t.rollback()

View File

@@ -8,7 +8,7 @@
import pytest
from ..conflict import (
from hscommon.conflict import (
get_conflicted_name,
get_unconflicted_name,
is_conflicted,
@@ -16,7 +16,7 @@ from ..conflict import (
smart_move,
)
from pathlib import Path
from ..testutil import eq_
from hscommon.testutil import eq_
class TestCaseGetConflictedName:

View File

@@ -4,8 +4,8 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ..testutil import eq_
from ..notify import Broadcaster, Listener, Repeater
from hscommon.testutil import eq_
from hscommon.notify import Broadcaster, Listener, Repeater
class HelloListener(Listener):

View File

@@ -6,7 +6,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ..path import pathify
from hscommon.path import pathify
from pathlib import Path

View File

@@ -6,8 +6,8 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ..testutil import eq_, callcounter, CallLogger
from ..gui.selectable_list import SelectableList, GUISelectableList
from hscommon.testutil import eq_, callcounter, CallLogger
from hscommon.gui.selectable_list import SelectableList, GUISelectableList
def test_in():

View File

@@ -1,137 +0,0 @@
# Created By: Virgil Dupras
# Created On: 2007/05/19
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import time
import threading
import os
import sqlite3 as sqlite
from pytest import raises
from ..testutil import eq_
from ..sqlite import ThreadedConn
# Threading is hard to test. In a lot of those tests, a failure means that the test run will
# hang forever. Well... I don't know a better alternative.
def test_can_access_from_multiple_threads():
def run():
con.execute("insert into foo(bar) values('baz')")
con = ThreadedConn(":memory:", True)
con.execute("create table foo(bar TEXT)")
t = threading.Thread(target=run)
t.start()
t.join()
result = con.execute("select * from foo")
eq_(1, len(result))
eq_("baz", result[0][0])
def test_exception_during_query():
con = ThreadedConn(":memory:", True)
con.execute("create table foo(bar TEXT)")
with raises(sqlite.OperationalError):
con.execute("select * from bleh")
def test_not_autocommit(tmpdir):
dbpath = str(tmpdir.join("foo.db"))
con = ThreadedConn(dbpath, False)
con.execute("create table foo(bar TEXT)")
con.execute("insert into foo(bar) values('baz')")
del con
# The data shouldn't have been inserted
con = ThreadedConn(dbpath, False)
result = con.execute("select * from foo")
eq_(0, len(result))
con.execute("insert into foo(bar) values('baz')")
con.commit()
del con
# Now the data should be there
con = ThreadedConn(dbpath, False)
result = con.execute("select * from foo")
eq_(1, len(result))
def test_rollback():
con = ThreadedConn(":memory:", False)
con.execute("create table foo(bar TEXT)")
con.execute("insert into foo(bar) values('baz')")
con.rollback()
result = con.execute("select * from foo")
eq_(0, len(result))
def test_query_palceholders():
con = ThreadedConn(":memory:", True)
con.execute("create table foo(bar TEXT)")
con.execute("insert into foo(bar) values(?)", ["baz"])
result = con.execute("select * from foo")
eq_(1, len(result))
eq_("baz", result[0][0])
def test_make_sure_theres_no_messup_between_queries():
def run(expected_rowid):
time.sleep(0.1)
result = con.execute("select rowid from foo where rowid = ?", [expected_rowid])
assert expected_rowid == result[0][0]
con = ThreadedConn(":memory:", True)
con.execute("create table foo(bar TEXT)")
for i in range(100):
con.execute("insert into foo(bar) values('baz')")
threads = []
for i in range(1, 101):
t = threading.Thread(target=run, args=(i,))
t.start()
threads.append(t)
while threads:
time.sleep(0.1)
threads = [t for t in threads if t.is_alive()]
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)

View File

@@ -6,8 +6,8 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ..testutil import CallLogger, eq_
from ..gui.table import Table, GUITable, Row
from hscommon.testutil import CallLogger, eq_
from hscommon.gui.table import Table, GUITable, Row
class TestRow(Row):

View File

@@ -6,8 +6,8 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ..testutil import eq_
from ..gui.tree import Tree, Node
from hscommon.testutil import eq_
from hscommon.gui.tree import Tree, Node
def tree_with_some_nodes():

View File

@@ -10,23 +10,19 @@ from io import StringIO
from pytest import raises
from ..testutil import eq_
from hscommon.testutil import eq_
from pathlib import Path
from ..util import (
from hscommon.util import (
nonone,
tryint,
minmax,
first,
flatten,
dedupe,
stripfalse,
extract,
allsame,
trailiter,
format_time,
format_time_decimal,
format_size,
remove_invalid_xml,
multi_replace,
delete_if_empty,
open_if_filename,
@@ -51,12 +47,6 @@ def test_tryint():
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
@@ -75,10 +65,6 @@ def test_dedupe():
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])
@@ -93,14 +79,6 @@ def test_allsame():
assert allsame(iter([42, 42, 42]))
def test_trailiter():
eq_(list(trailiter([])), [])
eq_(list(trailiter(["foo"])), [(None, "foo")])
eq_(list(trailiter(["foo", "bar"])), [(None, "foo"), ("foo", "bar")])
eq_(list(trailiter(["foo", "bar"], skipfirst=True)), [("foo", "bar")])
eq_(list(trailiter([], skipfirst=True)), []) # no crash
def test_iterconsume():
# We just want to make sure that we return *all* items and that we're not mistakenly skipping
# one.
@@ -213,14 +191,6 @@ def test_format_size():
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"), " "))

View File

@@ -8,28 +8,12 @@
import pytest
import threading
import py.path
def eq_(a, b, msg=None):
__tracebackhide__ = True
assert a == b, msg or "{!r} != {!r}".format(a, b)
def eq_sorted(a, b, msg=None):
"""If both a and b are iterable sort them and compare using eq_, otherwise just pass them through to eq_ anyway."""
try:
eq_(sorted(a), sorted(b), msg)
except TypeError:
eq_(a, b, msg)
def assert_almost_equal(a, b, places=7):
__tracebackhide__ = True
assert round(a, ndigits=places) == round(b, ndigits=places)
def callcounter():
def f(*args, **kwargs):
f.callcount += 1
@@ -38,23 +22,6 @@ def callcounter():
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.
@@ -168,20 +135,6 @@ def app(request):
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.

View File

@@ -11,16 +11,18 @@
import locale
import logging
import os
import os.path as op
from typing import Callable, Union
from .plat import ISLINUX
from hscommon.plat import ISLINUX
_trfunc = None
_trget = None
installed_lang = None
def tr(s, context=None):
def tr(s: str, context: Union[str, None] = None) -> str:
if _trfunc is None:
return s
else:
@@ -30,7 +32,7 @@ def tr(s, context=None):
return _trfunc(s)
def trget(domain):
def trget(domain: str) -> Callable[[str], str]:
# Returns a tr() function for the specified domain.
if _trget is None:
return lambda s: tr(s, domain)
@@ -38,14 +40,16 @@ def trget(domain):
return _trget(domain)
def set_tr(new_tr, new_trget=None):
def set_tr(
new_tr: Callable[[str, Union[str, None]], str], new_trget: Union[Callable[[str], Callable[[str], str]], None] = None
) -> None:
global _trfunc, _trget
_trfunc = new_tr
if new_trget is not None:
_trget = new_trget
def get_locale_name(lang):
def get_locale_name(lang: str) -> Union[str, None]:
# Removed old conversion code as windows seems to support these
LANG2LOCALENAME = {
"cs": "cs_CZ",
@@ -77,7 +81,7 @@ def get_locale_name(lang):
# --- Qt
def install_qt_trans(lang=None):
def install_qt_trans(lang: str = None) -> None:
from PyQt5.QtCore import QCoreApplication, QTranslator, QLocale
if not lang:
@@ -97,17 +101,19 @@ def install_qt_trans(lang=None):
qtr2.load(":/%s" % lang)
QCoreApplication.installTranslator(qtr2)
def qt_tr(s, context="core"):
def qt_tr(s: str, context: Union[str, None] = "core") -> str:
if context is None:
context = "core"
return str(QCoreApplication.translate(context, s, None))
set_tr(qt_tr)
# --- gettext
def install_gettext_trans(base_folder, lang):
def install_gettext_trans(base_folder: os.PathLike, lang: str) -> None:
import gettext
def gettext_trget(domain):
def gettext_trget(domain: str) -> Callable[[str], str]:
if not lang:
return lambda s: s
try:
@@ -117,7 +123,7 @@ def install_gettext_trans(base_folder, lang):
default_gettext = gettext_trget("core")
def gettext_tr(s, context=None):
def gettext_tr(s: str, context: Union[str, None] = None) -> str:
if not context:
return default_gettext(s)
else:
@@ -129,7 +135,7 @@ def install_gettext_trans(base_folder, lang):
installed_lang = lang
def install_gettext_trans_under_qt(base_folder, lang=None):
def install_gettext_trans_under_qt(base_folder: os.PathLike, lang: str = None) -> 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.

View File

@@ -6,20 +6,14 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import sys
import os
import os.path as op
import re
from math import ceil
import glob
import shutil
from datetime import timedelta
from pathlib import Path
from .path import pathify, log_io_error
from hscommon.path import pathify, log_io_error
from typing import IO, Any, Callable, Generator, Iterable, List, Tuple, Union
def nonone(value, replace_value):
def nonone(value: Any, replace_value: Any) -> Any:
"""Returns ``value`` if ``value`` is not ``None``. Returns ``replace_value`` otherwise."""
if value is None:
return replace_value
@@ -27,7 +21,7 @@ def nonone(value, replace_value):
return value
def tryint(value, default=0):
def tryint(value: Any, default: int = 0) -> int:
"""Tries to convert ``value`` to in ``int`` and returns ``default`` if it fails."""
try:
return int(value)
@@ -35,15 +29,10 @@ def tryint(value, default=0):
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):
def dedupe(iterable: Iterable[Any]) -> List[Any]:
"""Returns a list of elements in ``iterable`` with all dupes removed.
The order of the elements is preserved.
@@ -58,13 +47,13 @@ def dedupe(iterable):
return result
def flatten(iterables, start_with=None):
def flatten(iterables: Iterable[Iterable], start_with: Iterable[Any] = None) -> List[Any]:
"""Takes a list of lists ``iterables`` and returns a list containing elements of every list.
If ``start_with`` is not ``None``, the result will start with ``start_with`` items, exactly as
if ``start_with`` would be the first item of lists.
"""
result = []
result: List[Any] = []
if start_with:
result.extend(start_with)
for iterable in iterables:
@@ -72,7 +61,7 @@ def flatten(iterables, start_with=None):
return result
def first(iterable):
def first(iterable: Iterable[Any]):
"""Returns the first item of ``iterable``."""
try:
return next(iter(iterable))
@@ -80,12 +69,7 @@ def first(iterable):
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):
def extract(predicate: Callable[[Any], bool], iterable: Iterable[Any]) -> Tuple[List[Any], List[Any]]:
"""Separates the wheat from the shaft (`predicate` defines what's the wheat), and returns both."""
wheat = []
shaft = []
@@ -97,7 +81,7 @@ def extract(predicate, iterable):
return wheat, shaft
def allsame(iterable):
def allsame(iterable: Iterable[Any]) -> bool:
"""Returns whether all elements of 'iterable' are the same."""
it = iter(iterable)
try:
@@ -107,26 +91,7 @@ def allsame(iterable):
return all(element == first_item for element in it)
def trailiter(iterable, skipfirst=False):
"""Yields (prev_element, element), starting with (None, first_element).
If skipfirst is True, there will be no (None, item1) element and we'll start
directly with (item1, item2).
"""
it = iter(iterable)
if skipfirst:
try:
prev = next(it)
except StopIteration:
return
else:
prev = None
for item in it:
yield prev, item
prev = item
def iterconsume(seq, reverse=True):
def iterconsume(seq: List[Any], reverse: bool = True) -> Generator[Any, None, None]:
"""Iterate over ``seq`` and pops yielded objects.
Because we use the ``pop()`` method, we reverse ``seq`` before proceeding. If you don't need
@@ -145,12 +110,12 @@ def iterconsume(seq, reverse=True):
# --- String related
def escape(s, to_escape, escape_with="\\"):
def escape(s: str, to_escape: str, escape_with: str = "\\") -> str:
"""Returns ``s`` with characters in ``to_escape`` all prepended with ``escape_with``."""
return "".join((escape_with + c if c in to_escape else c) for c in s)
def get_file_ext(filename):
def get_file_ext(filename: str) -> str:
"""Returns the lowercase extension part of filename, without the dot."""
pos = filename.rfind(".")
if pos > -1:
@@ -159,7 +124,7 @@ def get_file_ext(filename):
return ""
def rem_file_ext(filename):
def rem_file_ext(filename: str) -> str:
"""Returns the filename without extension."""
pos = filename.rfind(".")
if pos > -1:
@@ -168,7 +133,8 @@ def rem_file_ext(filename):
return filename
def pluralize(number, word, decimals=0, plural_word=None):
# TODO type hint number
def pluralize(number, word: str, decimals: int = 0, plural_word: Union[str, None] = None) -> str:
"""Returns a pluralized string with ``number`` in front of ``word``.
Adds a 's' to s if ``number`` > 1.
@@ -187,7 +153,7 @@ def pluralize(number, word, decimals=0, plural_word=None):
return plural_format % (number, word)
def format_time(seconds, with_hours=True):
def format_time(seconds: int, with_hours: bool = True) -> str:
"""Transforms seconds in a hh:mm:ss string.
If ``with_hours`` if false, the format is mm:ss.
@@ -207,7 +173,7 @@ def format_time(seconds, with_hours=True):
return r
def format_time_decimal(seconds):
def format_time_decimal(seconds: int) -> str:
"""Transforms seconds in a strings like '3.4 minutes'."""
minus = seconds < 0
if minus:
@@ -230,7 +196,7 @@ 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):
def format_size(size: int, decimal: int = 0, forcepower: int = -1, showdesc: bool = True) -> str:
"""Transform a byte count in a formatted string (KB, MB etc..).
``size`` is the number of bytes to format.
@@ -268,17 +234,7 @@ def format_size(size, decimal=0, forcepower=-1, showdesc=True):
return result
_valid_xml_range = "\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD"
if sys.maxunicode > 0x10000:
_valid_xml_range += "{}-{}".format(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=""):
def multi_replace(s: str, replace_from: Union[str, List[str]], replace_to: Union[str, List[str]] = "") -> str:
"""A function like str.replace() with multiple replacements.
``replace_from`` is a list of things you want to replace. Ex: ['a','bc','d']
@@ -302,61 +258,15 @@ def multi_replace(s, replace_from, replace_to=""):
return s
# --- Date related
# It might seem like needless namespace pollution, but the speedup gained by this constant is
# significant, so it stays.
ONE_DAY = timedelta(1)
def iterdaterange(start, end):
"""Yields every day between ``start`` and ``end``."""
date = start
while date <= end:
yield date
date += ONE_DAY
# --- Files related
@pathify
def modified_after(first_path: Path, second_path: Path):
"""Returns ``True`` if first_path's mtime is higher than second_path's mtime.
If one of the files doesn't exist or is ``None``, it is considered "never modified".
"""
try:
first_mtime = first_path.stat().st_mtime
except (OSError, AttributeError):
return False
try:
second_mtime = second_path.stat().st_mtime
except (OSError, AttributeError):
return True
return first_mtime > second_mtime
def find_in_path(name, paths=None):
"""Search for `name` in all directories of `paths` and return the absolute path of the first
occurrence. If `paths` is None, $PATH is used.
"""
if paths is None:
paths = os.environ["PATH"]
if isinstance(paths, str): # if it's not a string, it's already a list
paths = paths.split(os.pathsep)
for path in paths:
if op.exists(op.join(path, name)):
return op.join(path, name)
return None
@log_io_error
@pathify
def delete_if_empty(path: Path, files_to_delete=[]):
def delete_if_empty(path: Path, files_to_delete: List[str] = []) -> bool:
"""Deletes the directory at 'path' if it is empty or if it only contains files_to_delete."""
if not path.exists() or not path.is_dir():
return
return False
contents = list(path.glob("*"))
if any(p for p in contents if (p.name not in files_to_delete) or p.is_dir()):
return False
@@ -366,7 +276,10 @@ def delete_if_empty(path: Path, files_to_delete=[]):
return True
def open_if_filename(infile, mode="rb"):
def open_if_filename(
infile: Union[Path, str, IO],
mode: str = "rb",
) -> Tuple[IO, bool]:
"""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
@@ -386,33 +299,6 @@ def open_if_filename(infile, mode="rb"):
return (infile, False)
def ensure_folder(path):
"Create `path` as a folder if it doesn't exist."
if not op.exists(path):
os.makedirs(path)
def ensure_file(path):
"Create `path` as an empty file if it doesn't exist."
if not op.exists(path):
open(path, "w").close()
def delete_files_with_pattern(folder_path, pattern, recursive=True):
"""Delete all files (or folders) in `folder_path` that match the glob `pattern`."""
to_delete = glob.glob(op.join(folder_path, pattern))
for fn in to_delete:
if op.isdir(fn):
shutil.rmtree(fn)
else:
os.remove(fn)
if recursive:
subpaths = [op.join(folder_path, fn) for fn in os.listdir(folder_path)]
subfolders = [p for p in subpaths if op.isdir(p)]
for p in subfolders:
delete_files_with_pattern(p, pattern, True)
class FileOrPath:
"""Does the same as :func:`open_if_filename`, but it can be used with a ``with`` statement.
@@ -422,16 +308,16 @@ class FileOrPath:
dostuff()
"""
def __init__(self, file_or_path, mode="rb"):
def __init__(self, file_or_path: Union[Path, str], mode: str = "rb") -> None:
self.file_or_path = file_or_path
self.mode = mode
self.mustclose = False
self.fp = None
self.fp: Union[IO, None] = None
def __enter__(self):
def __enter__(self) -> IO:
self.fp, self.mustclose = open_if_filename(self.file_or_path, self.mode)
return self.fp
def __exit__(self, exc_type, exc_value, traceback):
def __exit__(self, exc_type, exc_value, traceback) -> None:
if self.fp and self.mustclose:
self.fp.close()

View File

@@ -21,22 +21,22 @@ from qt.progress_window import ProgressWindow
from core.app import AppMode, DupeGuru as DupeGuruModel
import core.pe.photo
from . import platform
from .preferences import Preferences
from .result_window import ResultWindow
from .directories_dialog import DirectoriesDialog
from .problem_dialog import ProblemDialog
from .ignore_list_dialog import IgnoreListDialog
from .exclude_list_dialog import ExcludeListDialog
from .deletion_options import DeletionOptions
from .se.details_dialog import DetailsDialog as DetailsDialogStandard
from .me.details_dialog import DetailsDialog as DetailsDialogMusic
from .pe.details_dialog import DetailsDialog as DetailsDialogPicture
from .se.preferences_dialog import PreferencesDialog as PreferencesDialogStandard
from .me.preferences_dialog import PreferencesDialog as PreferencesDialogMusic
from .pe.preferences_dialog import PreferencesDialog as PreferencesDialogPicture
from .pe.photo import File as PlatSpecificPhoto
from .tabbed_window import TabBarWindow, TabWindow
from qt import platform
from qt.preferences import Preferences
from qt.result_window import ResultWindow
from qt.directories_dialog import DirectoriesDialog
from qt.problem_dialog import ProblemDialog
from qt.ignore_list_dialog import IgnoreListDialog
from qt.exclude_list_dialog import ExcludeListDialog
from qt.deletion_options import DeletionOptions
from qt.se.details_dialog import DetailsDialog as DetailsDialogStandard
from qt.me.details_dialog import DetailsDialog as DetailsDialogMusic
from qt.pe.details_dialog import DetailsDialog as DetailsDialogPicture
from qt.se.preferences_dialog import PreferencesDialog as PreferencesDialogStandard
from qt.me.preferences_dialog import PreferencesDialog as PreferencesDialogMusic
from qt.pe.preferences_dialog import PreferencesDialog as PreferencesDialogPicture
from qt.pe.photo import File as PlatSpecificPhoto
from qt.tabbed_window import TabBarWindow, TabWindow
tr = trget("ui")

View File

@@ -10,7 +10,7 @@ from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDockWidget, QWidget
from qt.util import move_to_screen_center
from .details_table import DetailsModel
from qt.details_table import DetailsModel
from hscommon.plat import ISLINUX

View File

@@ -31,8 +31,8 @@ from qt.radio_box import RadioBox
from qt.recent import Recent
from qt.util import move_to_screen_center, create_actions
from . import platform
from .directories_model import DirectoriesModel, DirectoriesDelegate
from qt import platform
from qt.directories_model import DirectoriesModel, DirectoriesDelegate
tr = trget("ui")

View File

@@ -16,7 +16,7 @@ from PyQt5.QtWidgets import (
QSizePolicy,
QHeaderView,
)
from .exclude_list_table import ExcludeListTable
from qt.exclude_list_table import ExcludeListTable
from core.exclude import AlreadyThereException
from hscommon.trans import trget

View File

@@ -17,7 +17,7 @@ from PyQt5.QtWidgets import (
from hscommon.trans import trget
from qt.util import horizontal_wrap
from .ignore_list_table import IgnoreListTable
from qt.ignore_list_table import IgnoreListTable
tr = trget("ui")

View File

@@ -8,14 +8,14 @@ from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import QAbstractItemView
from hscommon.trans import trget
from ..details_dialog import DetailsDialog as DetailsDialogBase
from ..details_table import DetailsTable
from qt.details_dialog import DetailsDialog as DetailsDialogBase
from qt.details_table import DetailsTable
tr = trget("ui")
class DetailsDialog(DetailsDialogBase):
def _setupUi(self):
def _setupUi(self) -> None:
self.setWindowTitle(tr("Details"))
self.resize(502, 295)
self.setMinimumSize(QSize(250, 250))

View File

@@ -4,27 +4,22 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from typing import Callable
from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import (
QVBoxLayout,
QHBoxLayout,
QLabel,
QSizePolicy,
QSpacerItem,
QWidget,
)
from PyQt5.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QWidget, QCheckBox
from hscommon.trans import trget
from core.app import AppMode
from core.scanner import ScanType
from qt.preferences import Preferences
from ..preferences_dialog import PreferencesDialogBase
from qt.preferences_dialog import PreferencesDialogBase, Sections
tr = trget("ui")
class PreferencesDialog(PreferencesDialogBase):
def _setupPreferenceWidgets(self):
def _setupPreferenceWidgets(self) -> None:
self._setupFilterHardnessBox()
self.widgetsVLayout.addLayout(self.filterHardnessHLayout)
self.widget = QWidget(self)
@@ -70,7 +65,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches)
self._setupBottomPart()
def _load(self, prefs, setchecked, section):
def _load(self, prefs: Preferences, setchecked: Callable[[QCheckBox, bool], None], section: Sections) -> None:
setchecked(self.tagTrackBox, prefs.scan_tag_track)
setchecked(self.tagArtistBox, prefs.scan_tag_artist)
setchecked(self.tagAlbumBox, prefs.scan_tag_album)
@@ -99,7 +94,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.tagGenreBox.setEnabled(tag_based)
self.tagYearBox.setEnabled(tag_based)
def _save(self, prefs, ischecked):
def _save(self, prefs: Preferences, ischecked: Callable[[QCheckBox], bool]) -> None:
prefs.scan_tag_track = ischecked(self.tagTrackBox)
prefs.scan_tag_artist = ischecked(self.tagArtistBox)
prefs.scan_tag_album = ischecked(self.tagAlbumBox)

View File

@@ -5,7 +5,7 @@
# http://www.gnu.org/licenses/gpl-3.0.html
from qt.column import Column
from ..results_model import ResultsModel as ResultsModelBase
from qt.results_model import ResultsModel as ResultsModelBase
class ResultsModel(ResultsModelBase):

View File

@@ -6,7 +6,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ._block_qt import getblocks # NOQA
from qt.pe._block_qt import getblocks # NOQA
# Converted to C
# def getblock(image):

View File

@@ -4,27 +4,29 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame
from typing import Union
from PyQt5.QtCore import Qt, QSize, pyqtSignal
from PyQt5.QtWidgets import QWidget, QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame
from PyQt5.QtGui import QResizeEvent
from hscommon.trans import trget
from ..details_dialog import DetailsDialog as DetailsDialogBase
from ..details_table import DetailsTable
from .image_viewer import ViewerToolBar, ScrollAreaImageViewer, ScrollAreaController
from qt import app
from qt.details_dialog import DetailsDialog as DetailsDialogBase
from qt.details_table import DetailsTable
from qt.pe.image_viewer import ViewerToolBar, ScrollAreaImageViewer, ScrollAreaController
tr = trget("ui")
class DetailsDialog(DetailsDialogBase):
def __init__(self, parent, app):
self.vController = None
def __init__(self, parent: QWidget, app: "app.DupeGuru") -> None:
self.vController: Union[ScrollAreaController, None] = None
super().__init__(parent, app)
def _setupUi(self):
def _setupUi(self) -> None:
self.setWindowTitle(tr("Details"))
self.resize(502, 502)
self.setMinimumSize(QSize(250, 250))
self.splitter = QSplitter(Qt.Vertical)
self.splitter = QSplitter(Qt.Orientation.Vertical)
self.topFrame = EmittingFrame()
self.topFrame.setFrameShape(QFrame.StyledPanel)
self.horizontalLayout = QGridLayout()
@@ -47,8 +49,8 @@ class DetailsDialog(DetailsDialogBase):
self.vController = ScrollAreaController(self)
self.verticalToolBar = ViewerToolBar(self, self.vController)
self.verticalToolBar.setOrientation(Qt.Orientation(Qt.Vertical))
self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter)
self.verticalToolBar.setOrientation(Qt.Orientation.Vertical)
self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignmentFlag.AlignCenter)
self.referenceImageViewer = ScrollAreaImageViewer(self, "referenceImage")
self.horizontalLayout.addWidget(self.referenceImageViewer, 0, 2, 3, 1)
@@ -73,7 +75,7 @@ class DetailsDialog(DetailsDialogBase):
self.topFrame.resized.connect(self.resizeEvent)
def _update(self):
def _update(self) -> None:
if self.vController is None: # Not yet constructed!
return
if not self.app.model.selected_dupes:
@@ -87,15 +89,14 @@ class DetailsDialog(DetailsDialogBase):
self.vController.updateView(ref, dupe, group)
# --- Override
@pyqtSlot(QResizeEvent)
def resizeEvent(self, event):
def resizeEvent(self, event: QResizeEvent) -> None:
self.ensure_same_sizes()
if self.vController is None or not self.vController.bestFit:
return
# Only update the scaled down pixmaps
self.vController.updateBothImages()
def show(self):
def show(self) -> None:
# Give the splitter a maximum height to reach. This is assuming that
# all rows below their headers have the same height
self.tableView.setMaximumHeight(
@@ -108,7 +109,7 @@ class DetailsDialog(DetailsDialogBase):
self.ensure_same_sizes()
self._update()
def ensure_same_sizes(self):
def ensure_same_sizes(self) -> None:
# HACK This ensures same size while shrinking.
# ReferenceViewer might be 1 pixel shorter in width
# due to the toolbar in the middle keeping the same width,
@@ -126,7 +127,7 @@ class DetailsDialog(DetailsDialogBase):
self.selectedImageViewer.resize(self.referenceImageViewer.size())
# model --> view
def refresh(self):
def refresh(self) -> None:
DetailsDialogBase.refresh(self)
if self.isVisible():
self._update()
@@ -137,5 +138,5 @@ class EmittingFrame(QFrame):
resized = pyqtSignal(QResizeEvent)
def resizeEvent(self, event):
def resizeEvent(self, event: QResizeEvent) -> None:
self.resized.emit(event)

View File

@@ -2,6 +2,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from typing import Union, cast
from PyQt5.QtCore import QObject, Qt, QSize, QRectF, QPointF, QPoint, pyqtSlot, pyqtSignal, QEvent
from PyQt5.QtGui import QPixmap, QPainter, QPalette, QCursor, QIcon, QKeySequence
from PyQt5.QtWidgets import (
@@ -17,6 +18,7 @@ from PyQt5.QtWidgets import (
QAbstractScrollArea,
QStyle,
)
from qt.details_dialog import DetailsDialog
from hscommon.trans import trget
from hscommon.plat import ISLINUX
@@ -26,7 +28,7 @@ MAX_SCALE = 12.0
MIN_SCALE = 0.1
def create_actions(actions, target):
def create_actions(actions: list, target: QObject) -> None:
# actions are list of (name, shortcut, icon, desc, func)
for name, shortcut, icon, desc, func in actions:
action = QAction(target)
@@ -40,9 +42,9 @@ def create_actions(actions, target):
class ViewerToolBar(QToolBar):
def __init__(self, parent, controller):
def __init__(self, parent: DetailsDialog, controller: "BaseController") -> None:
super().__init__(parent)
self.parent = parent
self.setParent(parent)
self.controller = controller
self.setupActions(controller)
self.createButtons()
@@ -52,24 +54,21 @@ class ViewerToolBar(QToolBar):
self.buttonNormalSize.setEnabled(False)
self.buttonBestFit.setEnabled(False)
def setupActions(self, controller):
def setupActions(self, controller: "BaseController") -> None:
override_icons = cast(DetailsDialog, self.parent()).app.prefs.details_dialog_override_theme_icons
# actions are list of (name, shortcut, icon, desc, func)
ACTIONS = [
(
"actionZoomIn",
QKeySequence.ZoomIn,
QIcon.fromTheme("zoom-in")
if ISLINUX and not self.parent.app.prefs.details_dialog_override_theme_icons
else QIcon(QPixmap(":/" + "zoom_in")),
QIcon.fromTheme("zoom-in") if ISLINUX and not override_icons else QIcon(QPixmap(":/" + "zoom_in")),
tr("Increase zoom"),
controller.zoomIn,
),
(
"actionZoomOut",
QKeySequence.ZoomOut,
QIcon.fromTheme("zoom-out")
if ISLINUX and not self.parent.app.prefs.details_dialog_override_theme_icons
else QIcon(QPixmap(":/" + "zoom_out")),
QIcon.fromTheme("zoom-out") if ISLINUX and not override_icons else QIcon(QPixmap(":/" + "zoom_out")),
tr("Decrease zoom"),
controller.zoomOut,
),
@@ -77,7 +76,7 @@ class ViewerToolBar(QToolBar):
"actionNormalSize",
tr("Ctrl+/"),
QIcon.fromTheme("zoom-original")
if ISLINUX and not self.parent.app.prefs.details_dialog_override_theme_icons
if ISLINUX and not override_icons
else QIcon(QPixmap(":/" + "zoom_original")),
tr("Normal size"),
controller.zoomNormalSize,
@@ -86,7 +85,7 @@ class ViewerToolBar(QToolBar):
"actionBestFit",
tr("Ctrl+*"),
QIcon.fromTheme("zoom-best-fit")
if ISLINUX and not self.parent.app.prefs.details_dialog_override_theme_icons
if ISLINUX and not override_icons
else QIcon(QPixmap(":/" + "zoom_best_fit")),
tr("Best fit"),
controller.zoomBestFit,
@@ -96,12 +95,12 @@ class ViewerToolBar(QToolBar):
# the popup menu work in the toolbar (if resized below minimum height)
create_actions(ACTIONS, self)
def createButtons(self):
def createButtons(self) -> None:
self.buttonImgSwap = QToolButton(self)
self.buttonImgSwap.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.buttonImgSwap.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.buttonImgSwap.setIcon(
QIcon.fromTheme("view-refresh", self.style().standardIcon(QStyle.SP_BrowserReload))
if ISLINUX and not self.parent.app.prefs.details_dialog_override_theme_icons
QIcon.fromTheme("view-refresh", self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload))
if ISLINUX and not cast(DetailsDialog, self.parent()).app.prefs.details_dialog_override_theme_icons
else QIcon(QPixmap(":/" + "exchange"))
)
self.buttonImgSwap.setText("Swap images")
@@ -110,22 +109,22 @@ class ViewerToolBar(QToolBar):
self.buttonImgSwap.released.connect(self.controller.swapImages)
self.buttonZoomIn = QToolButton(self)
self.buttonZoomIn.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.buttonZoomIn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.buttonZoomIn.setDefaultAction(self.actionZoomIn)
self.buttonZoomIn.setEnabled(False)
self.buttonZoomOut = QToolButton(self)
self.buttonZoomOut.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.buttonZoomOut.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.buttonZoomOut.setDefaultAction(self.actionZoomOut)
self.buttonZoomOut.setEnabled(False)
self.buttonNormalSize = QToolButton(self)
self.buttonNormalSize.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.buttonNormalSize.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.buttonNormalSize.setDefaultAction(self.actionNormalSize)
self.buttonNormalSize.setEnabled(True)
self.buttonBestFit = QToolButton(self)
self.buttonBestFit.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.buttonBestFit.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.buttonBestFit.setDefaultAction(self.actionBestFit)
self.buttonBestFit.setEnabled(False)
@@ -141,10 +140,10 @@ class BaseController(QObject):
Base proxy interface to keep image viewers synchronized.
Relays function calls, keep tracks of things."""
def __init__(self, parent):
def __init__(self, parent: QObject) -> None:
super().__init__()
self.selectedViewer = None
self.referenceViewer = None
self.selectedViewer: Union[ScrollAreaImageViewer, None] = None
self.referenceViewer: Union[ScrollAreaImageViewer, None] = None
# cached pixmaps
self.selectedPixmap = QPixmap()
self.referencePixmap = QPixmap()
@@ -152,22 +151,24 @@ class BaseController(QObject):
self.scaledReferencePixmap = QPixmap()
self.current_scale = 1.0
self.bestFit = True
self.parent = parent # To change buttons' states
self.setParent(parent) # To change buttons' states
self.cached_group = None
self.same_dimensions = True
def setupViewers(self, selected_viewer, reference_viewer):
def setupViewers(self, selected_viewer: "ScrollAreaImageViewer", reference_viewer: "ScrollAreaImageViewer") -> None:
self.selectedViewer = selected_viewer
self.referenceViewer = reference_viewer
self.selectedViewer.controller = self
self.referenceViewer.controller = self
self._setupConnections()
def _setupConnections(self):
self.selectedViewer.connectMouseSignals()
self.referenceViewer.connectMouseSignals()
def _setupConnections(self) -> None:
if self.selectedViewer is not None:
self.selectedViewer.connectMouseSignals()
if self.referenceViewer is not None:
self.referenceViewer.connectMouseSignals()
def updateView(self, ref, dupe, group):
def updateView(self, ref, dupe, group) -> None:
# To keep current scale accross dupes from the same group
previous_same_dimensions = self.same_dimensions
self.same_dimensions = True
@@ -206,13 +207,15 @@ class BaseController(QObject):
if ignore_update:
self.selectedViewer.ignore_signal = False
def _updateImage(self, pixmap, viewer, same_group=False):
def _updateImage(
self, pixmap: QPixmap, viewer: "ScrollAreaImageViewer", same_group: bool = False
) -> Union[QSize, None]:
# WARNING this is called on every resize event, might need to split
# into a separate function depending on the implementation used
if pixmap.isNull():
# This should disable the blank widget
viewer.setImage(pixmap)
return
return None
target_size = viewer.size()
if not viewer.bestFit:
if same_group:
@@ -220,14 +223,18 @@ class BaseController(QObject):
return target_size
# zoomed in state, expand
# only if not same_group, we need full update
scaledpixmap = pixmap.scaled(target_size, Qt.KeepAspectRatioByExpanding, Qt.FastTransformation)
scaledpixmap = pixmap.scaled(
target_size, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.FastTransformation
)
else:
# best fit, keep ratio always
scaledpixmap = pixmap.scaled(target_size, Qt.KeepAspectRatio, Qt.FastTransformation)
scaledpixmap = pixmap.scaled(
target_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation
)
viewer.setImage(scaledpixmap)
return target_size
def resetState(self):
def resetState(self) -> None:
"""Only called when the group of dupes has changed. We reset our
controller internal state and buttons, center view on viewers."""
self.selectedPixmap = QPixmap()
@@ -248,7 +255,7 @@ class BaseController(QObject):
self.parent.verticalToolBar.buttonNormalSize.setEnabled(True)
self.parent.verticalToolBar.buttonBestFit.setEnabled(False) # active mode by default
def resetViewersState(self):
def resetViewersState(self) -> None:
"""No item from the model, disable and clear everything."""
# only called by the details dialog
self.selectedPixmap = QPixmap()
@@ -277,36 +284,40 @@ class BaseController(QObject):
self.referenceViewer.setEnabled(False)
@pyqtSlot()
def zoomIn(self):
def zoomIn(self) -> None:
self.scaleImagesBy(1.25)
@pyqtSlot()
def zoomOut(self):
def zoomOut(self) -> None:
self.scaleImagesBy(0.8)
@pyqtSlot(float)
def scaleImagesBy(self, factor):
def scaleImagesBy(self, factor: float) -> None:
"""Compute new scale from factor and scale."""
self.current_scale *= factor
self.selectedViewer.scaleBy(factor)
self.referenceViewer.scaleBy(factor)
if self.selectedViewer is not None:
self.selectedViewer.scaleBy(factor)
if self.referenceViewer is not None:
self.referenceViewer.scaleBy(factor)
self.updateButtons()
@pyqtSlot(float)
def scaleImagesAt(self, scale):
def scaleImagesAt(self, scale: float) -> None:
"""Scale at a pre-computed scale."""
self.current_scale = scale
self.selectedViewer.scaleAt(scale)
self.referenceViewer.scaleAt(scale)
if self.selectedViewer is not None:
self.selectedViewer.scaleAt(scale)
if self.referenceViewer is not None:
self.referenceViewer.scaleAt(scale)
self.updateButtons()
def updateButtons(self):
def updateButtons(self) -> None:
self.parent.verticalToolBar.buttonZoomIn.setEnabled(self.current_scale < MAX_SCALE)
self.parent.verticalToolBar.buttonZoomOut.setEnabled(self.current_scale > MIN_SCALE)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(round(self.current_scale, 1) != 1.0)
self.parent.verticalToolBar.buttonBestFit.setEnabled(self.bestFit is False)
def updateButtonsAsPerDimensions(self, previous_same_dimensions):
def updateButtonsAsPerDimensions(self, previous_same_dimensions: bool) -> None:
if not self.same_dimensions:
self.parent.verticalToolBar.buttonZoomIn.setEnabled(False)
self.parent.verticalToolBar.buttonZoomOut.setEnabled(False)
@@ -323,7 +334,7 @@ class BaseController(QObject):
self.parent.verticalToolBar.buttonImgSwap.setEnabled(False)
@pyqtSlot()
def zoomBestFit(self):
def zoomBestFit(self) -> None:
"""Setup before scaling to bestfit"""
self.setBestFit(True)
self.current_scale = 1.0
@@ -352,7 +363,7 @@ class BaseController(QObject):
self.referenceViewer.bestFit = value
@pyqtSlot()
def zoomNormalSize(self):
def zoomNormalSize(self) -> None:
self.setBestFit(False)
self.current_scale = 1.0
@@ -373,14 +384,14 @@ class BaseController(QObject):
self.parent.verticalToolBar.buttonNormalSize.setEnabled(False)
self.parent.verticalToolBar.buttonBestFit.setEnabled(True)
def centerViews(self, only_selected=False):
def centerViews(self, only_selected: bool = False) -> None:
self.selectedViewer.centerViewAndUpdate()
if only_selected:
return
self.referenceViewer.centerViewAndUpdate()
@pyqtSlot()
def swapImages(self):
def swapImages(self) -> None:
# swap the columns in the details table as well
self.parent.tableView.horizontalHeader().swapSections(0, 1)
@@ -388,17 +399,17 @@ class BaseController(QObject):
class QWidgetController(BaseController):
"""Specialized version for QWidget-based viewers."""
def __init__(self, parent):
def __init__(self, parent: QObject) -> None:
super().__init__(parent)
def _updateImage(self, *args):
def _updateImage(self, *args) -> Union[QSize, None]:
ret = super()._updateImage(*args)
# Fix alignment when resizing window
self.centerViews()
return ret
@pyqtSlot(QPointF)
def onDraggedMouse(self, delta):
def onDraggedMouse(self, delta) -> None:
if not self.same_dimensions:
return
if self.sender() is self.referenceViewer:
@@ -407,7 +418,7 @@ class QWidgetController(BaseController):
self.referenceViewer.onDraggedMouse(delta)
@pyqtSlot()
def swapImages(self):
def swapImages(self) -> None:
self.selectedViewer._pixmap.swap(self.referenceViewer._pixmap)
self.selectedViewer.centerViewAndUpdate()
self.referenceViewer.centerViewAndUpdate()
@@ -417,15 +428,15 @@ class QWidgetController(BaseController):
class ScrollAreaController(BaseController):
"""Specialized version fro QLabel-based viewers."""
def __init__(self, parent):
def __init__(self, parent: QObject) -> None:
super().__init__(parent)
def _setupConnections(self):
def _setupConnections(self) -> None:
super()._setupConnections()
self.selectedViewer.connectScrollBars()
self.referenceViewer.connectScrollBars()
def updateBothImages(self, same_group=False):
def updateBothImages(self, same_group: bool = False) -> None:
super().updateBothImages(same_group)
if not self.referenceViewer.isEnabled():
return
@@ -433,7 +444,7 @@ class ScrollAreaController(BaseController):
self.referenceViewer._verticalScrollBar.setValue(self.selectedViewer._verticalScrollBar.value())
@pyqtSlot(QPoint)
def onDraggedMouse(self, delta):
def onDraggedMouse(self, delta) -> None:
self.selectedViewer.ignore_signal = True
self.referenceViewer.ignore_signal = True
@@ -450,21 +461,21 @@ class ScrollAreaController(BaseController):
self.referenceViewer.ignore_signal = False
@pyqtSlot()
def swapImages(self):
def swapImages(self) -> None:
self.referenceViewer._pixmap.swap(self.selectedViewer._pixmap)
self.referenceViewer.setCachedPixmap()
self.selectedViewer.setCachedPixmap()
super().swapImages()
@pyqtSlot(float, QPointF)
def onMouseWheel(self, scale, delta):
def onMouseWheel(self, scale: float, delta: QPointF) -> None:
self.scaleImagesAt(scale)
self.selectedViewer.adjustScrollBarsScaled(delta)
# Signal from scrollbars will automatically change the other:
# self.referenceViewer.adjustScrollBarsScaled(delta)
@pyqtSlot(int)
def onVScrollBarChanged(self, value):
def onVScrollBarChanged(self, value: int) -> None:
if not self.same_dimensions:
return
if self.sender() is self.referenceViewer._verticalScrollBar:
@@ -475,7 +486,7 @@ class ScrollAreaController(BaseController):
self.referenceViewer._verticalScrollBar.setValue(value)
@pyqtSlot(int)
def onHScrollBarChanged(self, value):
def onHScrollBarChanged(self, value: int) -> None:
if not self.same_dimensions:
return
if self.sender() is self.referenceViewer._horizontalScrollBar:
@@ -486,13 +497,13 @@ class ScrollAreaController(BaseController):
self.referenceViewer._horizontalScrollBar.setValue(value)
@pyqtSlot(float)
def scaleImagesBy(self, factor):
def scaleImagesBy(self, factor: float) -> None:
super().scaleImagesBy(factor)
# The other is automatically updated via sigals
self.selectedViewer.adjustScrollBarsFactor(factor)
@pyqtSlot()
def zoomBestFit(self):
def zoomBestFit(self) -> None:
# Disable scrollbars to avoid GridLayout size rounding glitch
super().zoomBestFit()
if self.referencePixmap.isNull():

View File

@@ -10,7 +10,7 @@ from PyQt5.QtGui import QImage, QImageReader, QTransform
from core.pe.photo import Photo as PhotoBase
from .block import getblocks
from qt.pe.block import getblocks
class File(PhotoBase):

View File

@@ -12,7 +12,7 @@ from qt.radio_box import RadioBox
from core.scanner import ScanType
from core.app import AppMode
from ..preferences_dialog import PreferencesDialogBase
from qt.preferences_dialog import PreferencesDialogBase
tr = trget("ui")

View File

@@ -5,7 +5,7 @@
# http://www.gnu.org/licenses/gpl-3.0.html
from qt.column import Column
from ..results_model import ResultsModel as ResultsModelBase
from qt.results_model import ResultsModel as ResultsModelBase
class ResultsModel(ResultsModelBase):

View File

@@ -4,6 +4,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from typing import Callable, Union, cast
from PyQt5.QtCore import Qt, QSize, pyqtSlot
from PyQt5.QtWidgets import (
QDialog,
@@ -28,16 +29,17 @@ from PyQt5.QtWidgets import (
QGroupBox,
QFormLayout,
)
from PyQt5.QtGui import QPixmap, QIcon
from PyQt5.QtGui import QPixmap, QIcon, QShowEvent
from hscommon import desktop, plat
from hscommon.trans import trget
from hscommon.plat import ISLINUX
from qt import app
from qt.util import horizontal_wrap, move_to_screen_center
from qt.preferences import get_langnames
from enum import Flag, auto
from .preferences import Preferences
from qt.preferences import Preferences
tr = trget("ui")
@@ -52,8 +54,10 @@ class Sections(Flag):
class PreferencesDialogBase(QDialog):
def __init__(self, parent, app, **kwargs):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
def __init__(self, parent: QWidget, app: "app.DupeGuru", **kwargs) -> None:
flags = Qt.WindowType(
Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint
)
super().__init__(parent, flags, **kwargs)
self.app = app
self.supportedLanguages = dict(sorted(get_langnames().items(), key=lambda item: item[1]))
@@ -65,7 +69,7 @@ class PreferencesDialogBase(QDialog):
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
def _setupFilterHardnessBox(self):
def _setupFilterHardnessBox(self) -> None:
self.filterHardnessHLayout = QHBoxLayout()
self.filterHardnessLabel = QLabel(self)
self.filterHardnessLabel.setText(tr("Filter Hardness:"))
@@ -84,7 +88,7 @@ class PreferencesDialogBase(QDialog):
self.filterHardnessSlider.setMinimum(1)
self.filterHardnessSlider.setMaximum(100)
self.filterHardnessSlider.setTracking(True)
self.filterHardnessSlider.setOrientation(Qt.Horizontal)
self.filterHardnessSlider.setOrientation(Qt.Orientation.Horizontal)
self.filterHardnessHLayoutSub1.addWidget(self.filterHardnessSlider)
self.filterHardnessLabel = QLabel(self)
self.filterHardnessLabel.setText("100")
@@ -104,7 +108,7 @@ class PreferencesDialogBase(QDialog):
self.filterHardnessVLayout.addLayout(self.filterHardnessHLayoutSub2)
self.filterHardnessHLayout.addLayout(self.filterHardnessVLayout)
def _setupBottomPart(self):
def _setupBottomPart(self) -> None:
# The bottom part of the pref panel is always the same in all editions.
self.copyMoveLabel = QLabel(self)
self.copyMoveLabel.setText(tr("Copy and Move:"))
@@ -120,7 +124,7 @@ class PreferencesDialogBase(QDialog):
self.customCommandEdit = QLineEdit(self)
self.widgetsVLayout.addWidget(self.customCommandEdit)
def _setupDisplayPage(self):
def _setupDisplayPage(self) -> None:
self.ui_groupbox = QGroupBox("&" + tr("General Interface"))
layout = QVBoxLayout()
self.languageLabel = QLabel(tr("Language:"), self)
@@ -171,7 +175,7 @@ On MacOS, the tab bar will fill up the window's width instead."
formlayout.addRow(tr("Reference background color:"), self.result_table_ref_background_color)
self.result_table_delta_foreground_color = ColorPickerButton(self)
formlayout.addRow(tr("Delta foreground color:"), self.result_table_delta_foreground_color)
formlayout.setLabelAlignment(Qt.AlignLeft)
formlayout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
# Keep same vertical spacing as parent layout for consistency
formlayout.setVerticalSpacing(self.displayVLayout.spacing())
@@ -213,7 +217,7 @@ use the modifier key to drag the floating window around"
details_groupbox.setLayout(self.details_groupbox_layout)
self.displayVLayout.addWidget(details_groupbox)
def _setupDebugPage(self):
def _setupDebugPage(self) -> None:
self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)"))
self._setupAddCheckbox("profile_scan_box", tr("Profile scan operation"))
self.profile_scan_box.setToolTip(tr("Profile the scan operation and save logs for optimization."))
@@ -221,22 +225,22 @@ use the modifier key to drag the floating window around"
self.debugVLayout.addWidget(self.profile_scan_box)
self.debug_location_label = QLabel(
tr('Logs located in: <a href="{}">{}</a>').format(self.app.model.appdata, self.app.model.appdata),
wordWrap=True,
)
self.debug_location_label.setWordWrap(True)
self.debugVLayout.addWidget(self.debug_location_label)
def _setupAddCheckbox(self, name, label, parent=None):
def _setupAddCheckbox(self, name: str, label: str, parent: Union[QWidget, None] = None) -> None:
if parent is None:
parent = self
cb = QCheckBox(parent)
cb.setText(label)
setattr(self, name, cb)
def _setupPreferenceWidgets(self):
def _setupPreferenceWidgets(self) -> None:
# Edition-specific
pass
def _setupUi(self):
def _setupUi(self) -> None:
self.setWindowTitle(tr("Options"))
self.setSizeGripEnabled(False)
self.setModal(True)
@@ -262,7 +266,7 @@ use the modifier key to drag the floating window around"
)
self.mainVLayout.addWidget(self.tabwidget)
self.mainVLayout.addWidget(self.buttonBox)
self.layout().setSizeConstraint(QLayout.SetFixedSize)
self.layout().setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
self.tabwidget.addTab(self.page_general, tr("General"))
self.tabwidget.addTab(self.page_display, tr("Display"))
self.tabwidget.addTab(self.page_debug, tr("Debug"))
@@ -270,20 +274,20 @@ use the modifier key to drag the floating window around"
self.widgetsVLayout.addStretch(0)
self.debugVLayout.addStretch(0)
def _load(self, prefs, setchecked, section):
def _load(self, prefs: Preferences, setchecked: Callable[[QCheckBox, bool], None], section: Sections) -> None:
# Edition-specific
pass
def _save(self, prefs, ischecked):
def _save(self, prefs: Preferences, ischecked: Callable[[QCheckBox], bool]) -> None:
# Edition-specific
pass
def load(self, prefs=None, section=Sections.ALL):
def load(self, prefs: Union[Preferences, None] = None, section: Sections = Sections.ALL) -> None:
if prefs is None:
prefs = self.app.prefs
def setchecked(cb, b):
cb.setCheckState(Qt.Checked if b else Qt.Unchecked)
def setchecked(cb: QCheckBox, b: bool) -> None:
cb.setCheckState(Qt.CheckState.Checked if b else Qt.CheckState.Unchecked)
if section & Sections.GENERAL:
self.filterHardnessSlider.setValue(prefs.filter_hardness)
@@ -323,12 +327,12 @@ use the modifier key to drag the floating window around"
setchecked(self.profile_scan_box, prefs.profile_scan)
self._load(prefs, setchecked, section)
def save(self):
def save(self) -> None:
prefs = self.app.prefs
prefs.filter_hardness = self.filterHardnessSlider.value()
def ischecked(cb):
return cb.checkState() == Qt.Checked
def ischecked(cb: QCheckBox) -> bool:
return cb.checkState() == Qt.CheckState.Checked
prefs.mix_file_kind = ischecked(self.mixFileKindBox)
prefs.use_regexp = ischecked(self.useRegexpBox)
@@ -363,11 +367,11 @@ use the modifier key to drag the floating window around"
self.app.prefs.language = lang_code
self._save(prefs, ischecked)
def resetToDefaults(self, section_to_update):
def resetToDefaults(self, section_to_update: Sections) -> None:
self.load(Preferences(), section_to_update)
# --- Events
def buttonClicked(self, button):
def buttonClicked(self, button: QDialogButtonBox) -> None:
role = self.buttonBox.buttonRole(button)
if role == QDialogButtonBox.ResetRole:
current_tab = self.tabwidget.currentWidget()
@@ -380,30 +384,32 @@ use the modifier key to drag the floating window around"
section_to_update = Sections.DEBUG
self.resetToDefaults(section_to_update)
def showEvent(self, event):
def showEvent(self, event: QShowEvent) -> None:
# have to do this here as the frameGeometry is not correct until shown
move_to_screen_center(self)
super().showEvent(event)
class ColorPickerButton(QPushButton):
def __init__(self, parent):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent)
self.parent = parent
self.setParent(parent)
self.color = None
self.clicked.connect(self.onClicked)
@pyqtSlot()
def onClicked(self):
color = QColorDialog.getColor(self.color if self.color is not None else Qt.white, self.parent)
def onClicked(self) -> None:
color = QColorDialog.getColor(
self.color if self.color is not None else Qt.GlobalColor.white, cast(QWidget, self.parent())
)
self.setColor(color)
def setColor(self, color):
def setColor(self, color) -> None:
size = QSize(16, 16)
px = QPixmap(size)
if color is None:
size.width = 0
size.height = 0
size.setWidth(0)
size.setHeight(0)
elif not color.isValid():
return
else:

View File

@@ -21,7 +21,7 @@ from PyQt5.QtWidgets import (
from qt.util import move_to_screen_center
from hscommon.trans import trget
from .problem_table import ProblemTable
from qt.problem_table import ProblemTable
tr = trget("ui")

View File

@@ -28,12 +28,12 @@ from qt.util import move_to_screen_center, horizontal_wrap, create_actions
from qt.search_edit import SearchEdit
from core.app import AppMode
from .results_model import ResultsView
from .stats_label import StatsLabel
from .prioritize_dialog import PrioritizeDialog
from .se.results_model import ResultsModel as ResultsModelStandard
from .me.results_model import ResultsModel as ResultsModelMusic
from .pe.results_model import ResultsModel as ResultsModelPicture
from qt.results_model import ResultsView
from qt.stats_label import StatsLabel
from qt.prioritize_dialog import PrioritizeDialog
from qt.se.results_model import ResultsModel as ResultsModelStandard
from qt.me.results_model import ResultsModel as ResultsModelMusic
from qt.pe.results_model import ResultsModel as ResultsModelPicture
tr = trget("ui")

View File

@@ -8,8 +8,8 @@ from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import QAbstractItemView
from hscommon.trans import trget
from ..details_dialog import DetailsDialog as DetailsDialogBase
from ..details_table import DetailsTable
from qt.details_dialog import DetailsDialog as DetailsDialogBase
from qt.details_table import DetailsTable
tr = trget("ui")

View File

@@ -20,7 +20,7 @@ from hscommon.trans import trget
from core.app import AppMode
from core.scanner import ScanType
from ..preferences_dialog import PreferencesDialogBase
from qt.preferences_dialog import PreferencesDialogBase
tr = trget("ui")

View File

@@ -5,7 +5,7 @@
# http://www.gnu.org/licenses/gpl-3.0.html
from qt.column import Column
from ..results_model import ResultsModel as ResultsModelBase
from qt.results_model import ResultsModel as ResultsModelBase
class ResultsModel(ResultsModelBase):

View File

@@ -15,10 +15,10 @@ from PyQt5.QtWidgets import (
)
from hscommon.trans import trget
from qt.util import move_to_screen_center, create_actions
from .directories_dialog import DirectoriesDialog
from .result_window import ResultWindow
from .ignore_list_dialog import IgnoreListDialog
from .exclude_list_dialog import ExcludeListDialog
from qt.directories_dialog import DirectoriesDialog
from qt.result_window import ResultWindow
from qt.ignore_list_dialog import IgnoreListDialog
from qt.exclude_list_dialog import ExcludeListDialog
tr = trget("ui")

View File

@@ -15,7 +15,7 @@ from PyQt5.QtCore import (
QItemSelection,
)
from .column import Columns, Column
from qt.column import Columns, Column
class Table(QAbstractTableModel):