mirror of
https://github.com/arsenetar/dupeguru.git
synced 2026-03-12 03:31:37 +00:00
Compare commits
8 Commits
40ff40bea8
...
feature/gu
| Author | SHA1 | Date | |
|---|---|---|---|
|
ade5d7f8c1
|
|||
|
|
b9aabb8545 | ||
|
d5eeab4a17
|
|||
|
7865e4aeac
|
|||
|
58863b1728
|
|||
|
e382683f66
|
|||
|
f7ed1c801c
|
|||
|
f587c7b5d8
|
38
core/app.py
38
core/app.py
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
from hscommon import desktop
|
||||
|
||||
from .problem_table import ProblemTable
|
||||
from core.gui.problem_table import ProblemTable
|
||||
|
||||
|
||||
class ProblemDialog:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1 +1 @@
|
||||
from . import fs, prioritize, result_table, scanner # noqa
|
||||
from core.me import fs, prioritize, result_table, scanner # noqa
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from . import ( # noqa
|
||||
from core.pe import ( # noqa
|
||||
block,
|
||||
cache,
|
||||
exif,
|
||||
iphoto_plist,
|
||||
matchblock,
|
||||
matchexif,
|
||||
photo,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
from . import fs, result_table, scanner # noqa
|
||||
from core.se import fs, result_table, scanner # noqa
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
from hscommon.testutil import eq_
|
||||
|
||||
from ..markable import MarkableList, Markable
|
||||
from core.markable import MarkableList, Markable
|
||||
|
||||
|
||||
def gen():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
5
hscommon/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
*.pyc
|
||||
*.mo
|
||||
*.so
|
||||
.DS_Store
|
||||
/docs_html
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
from collections.abc import Sequence, MutableSequence
|
||||
|
||||
from .base import GUIObject
|
||||
from hscommon.gui.base import GUIObject
|
||||
|
||||
|
||||
class Selectable(Sequence):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
from collections.abc import MutableSequence
|
||||
|
||||
from .base import GUIObject
|
||||
from hscommon.gui.base import GUIObject
|
||||
|
||||
|
||||
class Node(MutableSequence):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"), " "))
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
176
hscommon/util.py
176
hscommon/util.py
@@ -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()
|
||||
|
||||
32
qt/app.py
32
qt/app.py
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from PyQt5.QtCore import (
|
||||
QItemSelection,
|
||||
)
|
||||
|
||||
from .column import Columns, Column
|
||||
from qt.column import Columns, Column
|
||||
|
||||
|
||||
class Table(QAbstractTableModel):
|
||||
|
||||
Reference in New Issue
Block a user