1
0
mirror of https://github.com/arsenetar/dupeguru.git synced 2026-01-24 23:51:38 +00:00

Compare commits

..

40 Commits
4.2.1 ... 4.3.1

Author SHA1 Message Date
1f1dfa88dc Update version & changelog for 4.3.1 release 2022-07-07 22:06:06 -05:00
916c5204cf Update translations from transifex 2022-07-07 21:57:59 -05:00
71af825b37 Move try/except of cache db to get() and put()
- Move the try/except of cache db calls to the calls themselves.
- Add some additional information to logging statements on cache db
  exception to improve troubleshooting.
2022-07-07 21:52:22 -05:00
97f490b8b7 Fix typo in engine.py 2022-07-07 19:06:35 -05:00
d369bcddd7 Updates from investigation of #1015
- Add protection for empty hash digests in comparison of non-zero size
  files
- Bump version to 4.3.1-dev for identification
2022-07-07 19:00:09 -05:00
360dceca7b Update to version 4.3.0, update changelog 2022-06-30 23:27:14 -05:00
92b27801c3 Update translations, remove iphoto_plist.py 2022-06-30 23:03:40 -05:00
Marcus Yanello
b9aabb8545 Redirect stdout from custom command to the log files (#1008)
Send the logs for the custom command subprocess to the logs
Closes #1007
2022-06-13 21:04:40 -05:00
d5eeab4a17 Additional type hints in hscommon 2022-05-11 00:50:34 -05:00
7865e4aeac Type hinting hscommon & cleanup 2022-05-09 23:36:39 -05:00
58863b1728 Change to use a real temporary directory for test
app_test was not using a real temporary location originally
2022-05-09 01:46:42 -05:00
e382683f66 Replace all relative imports 2022-05-09 01:40:08 -05:00
f7ed1c801c Add type hinting to desktop.py 2022-05-09 01:15:25 -05:00
f587c7b5d8 Removed unused code in hscommon/util
Also added type hints throughout
2022-05-09 00:47:57 -05:00
40ff40bea8 Move create_qsettings() out of preferences
- Load order was impacting translations
- Fix by moving create_qsettings() for now
2022-05-08 20:33:31 -05:00
7a44c72a0a Complete removal of qtlib locale files 2022-05-08 19:52:25 -05:00
66aff9f74e Update pot files
This "moves" the translation points from qtlib.pot to ui.pot.
Needs further updates to propagate across.
2022-05-08 19:28:37 -05:00
5451f55219 Move qtlib localization files to top level 2022-05-08 19:23:13 -05:00
36280b01e6 Finish moving all qtlib py files to qt 2022-05-08 19:22:08 -05:00
18359c3ea6 Start flattening Qtlib into qt
- Remove app.py from qtlib (unused)
- Remove .gitignore from qtlib (unecessary)
- Move contents of preferences.py in qtlib to qt, clean up references
- Simplify language dropdown code
2022-05-08 18:51:10 -05:00
0a4e61edf5 Additional cleanup per mypy
- Add Callable type to hasher (should realy be more specific...)
- Add type hint to COLUMNS in qtlib/table.py
- Use Qt.ItemFlag.ItemIsEnabled instead of Qt.itemIsEnabled in qtlib/table.py
2022-04-30 05:16:46 -05:00
d73a85b82e Add type hints for compiled modules 2022-04-30 05:11:54 -05:00
81c593399e Format changes with black 2022-04-27 20:59:20 -05:00
6a732a79a8 Remove old tx config 2022-04-27 20:58:30 -05:00
63dd4d4561 Apply pyupgrade changes 2022-04-27 20:53:12 -05:00
e0061d7bc1 Fix #989, typo in debian control file 2022-04-02 16:43:19 -05:00
c5818b1d1f Add option to profile scans
- Add preference for profiling scans
- Move debug options to tab in preferences
- Add label with clickable link to debug output (appdata) to debug tab in preferences
- Update translation source files
2022-03-31 00:16:37 -05:00
a470a8de25 Update fs.py to optimize stat() calls
- Update to get size and mtime at time of class creation when os.DirEntry is used for initialization.
- Folders still calculate size later for folder scans.
- Ref #962, #959
2022-03-30 22:58:01 -05:00
a37b5b0eeb Fix #988 2022-03-30 01:06:51 -05:00
efd500ecc1 Update directory scanning to use os.scandir()
- Change to use os.scandir() instead of os.walk() to leverage DirEntry objects.
- Avoids extra calls to stat() on files during fs.can_handle()
- See 3x speed improvement on Windows in some cases
2022-03-29 23:37:56 -05:00
43fcc52291 Replace pathlib.glob() with os.scandir() in fs.py 2022-03-29 22:35:38 -05:00
50f5db1543 Update fs to support DirEntry on get_file() 2022-03-29 22:32:36 -05:00
a5b0ccdd02 Improve performance of Directories.get_state() 2022-03-29 21:48:14 -05:00
143147cb8e Remove Cocoa specific and other unused code 2022-03-28 00:47:46 -05:00
ebb81d9f03 Remove pathlib function added in Python 3.9 2022-03-28 00:06:32 -05:00
da9f8b2b9d Squashed commit of the following:
commit 8b15fe9a502ebf4841c6529e7098cef03a6a5e6f
Author: Andrew Senetar <arsenetar@gmail.com>
Date:   Sun Mar 27 23:48:15 2022 -0500

    Finish up changes to copy_or_move

commit 21f6a32cf3186a400af8f30e67ad2743dc9a49bd
Author: Andrew Senetar <arsenetar@gmail.com>
Date:   Thu Mar 17 23:56:52 2022 -0500

    Migrate from hscommon.path to pathlib
    - Part one, this gets all hscommon and core tests passing
    - App appears to be able to load directories and complete scans, need further testing
    - app.py copy_or_move needs some additional work
2022-03-27 23:50:03 -05:00
5ed5eddde6 Add polib back to requirements.txt 2022-03-27 22:35:34 -05:00
9f40e4e786 Squashed commit of the following:
commit 5eb515f666bfa1ff06c2e96bdc351a4b7456580e
Author: Andrew Senetar <arsenetar@gmail.com>
Date:   Sun Mar 27 22:19:39 2022 -0500

    Add fallback to md5 if xxhash not available

    Mainly here for the case when distributions have not packaged python3-xxhash.

commit 51b18d4c84
Author: Andrew Senetar <arsenetar@gmail.com>
Date:   Sat Mar 19 15:25:46 2022 -0500

    Switch file hashing to xxhash instead of md5

    - Improves performance significantly in some cases
    - Add xxhash to requirements.txt and sort requirements
    - Rename md5 based members to digest
    - Update all tests to use new member names and hashing methods
    - Update hash db code to upgrade schema

    NOTE: May consider supporting multiple hashing algorithms in the future.
2022-03-27 22:27:13 -05:00
86bf9b39d0 Add update check function and call from about
- Implement a update check against the GitHub releases via the api
- Add semantic-version dependency
- Add automatic check when opening about dialog
2022-03-27 21:13:27 -05:00
c0be0aecbd Minor documentation update 2022-03-27 21:04:37 -05:00
191 changed files with 5322 additions and 6866 deletions

View File

@@ -13,12 +13,6 @@ source_file = locale/core.pot
source_lang = en
type = PO
[o:voltaicideas:p:dupeguru-1:r:qtlib]
file_filter = qtlib/locale/<lang>/LC_MESSAGES/qtlib.po
source_file = qtlib/locale/qtlib.pot
source_lang = en
type = PO
[o:voltaicideas:p:dupeguru-1:r:ui]
file_filter = locale/<lang>/LC_MESSAGES/ui.po
source_file = locale/ui.pot

View File

@@ -3,4 +3,3 @@ recursive-include core *.m
include run.py
graft locale
graft help
graft qtlib/locale

View File

@@ -35,7 +35,7 @@ endif
# Our build scripts are not very "make like" yet and perform their task in a bundle. For now, we
# use one of each file to act as a representative, a target, of these groups.
packages = hscommon qtlib core qt
packages = hscommon core qt
localedirs = $(wildcard locale/*/LC_MESSAGES)
pofiles = $(wildcard locale/*/LC_MESSAGES/*.po)
mofiles = $(patsubst %.po,%.mo,$(pofiles))

View File

@@ -1,16 +1,12 @@
# dupeGuru
[dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in
a system. It is written mostly in Python 3 and has the peculiarity of using
[multiple GUI toolkits][cross-toolkit], all using the same core Python code. On OS X, the UI layer
is written in Objective-C and uses Cocoa. On Linux, it is written in Python and uses Qt5.
The Cocoa UI of dupeGuru is hosted in a separate repo: https://github.com/arsenetar/dupeguru-cocoa
a system. It is written mostly in Python 3 and uses [qt](https://www.qt.io/) for the UI.
## Current status
Still looking for additional help especially with regards to:
* OSX maintenance: reproducing bugs & cocoa version, building package with Cocoa UI.
* Linux maintenance: reproducing bugs, maintaining PPA repository, Debian package.
* OSX maintenance: reproducing bugs, packaging verification.
* Linux maintenance: reproducing bugs, maintaining PPA repository, Debian package, rpm package.
* Translations: updating missing strings, transifex project at https://www.transifex.com/voltaicideas/dupeguru-1
* Documentation: keeping it up-to-date.
@@ -26,7 +22,6 @@ This folder contains the source for dupeGuru. Its documentation is in `help`, bu
* help: Help document, written for Sphinx.
* locale: .po files for localization.
* hscommon: A collection of helpers used across HS applications.
* qtlib: A collection of helpers used across Qt UI codebases of HS applications.
## How to build dupeGuru from source
@@ -43,12 +38,10 @@ For macos instructions (qt version) see the [macOS Instructions](macos.md).
When running in a linux based environment the following system packages or equivalents are needed to build:
* python3-pyqt5
* pyqt5-dev-tools (on some systems, see note)
* python3-wheel (for hsaudiotag3k)
* python3-venv (only if using a virtual environment)
* python3-dev
* build-essential
Note: On some linux systems pyrcc5 is not put on the path when installing python3-pyqt5, this will cause some issues with the resource files (and icons). These systems should have a respective pyqt5-dev-tools package, which should also be installed. The presence of pyrcc5 can be checked with `which pyrcc5`. Debian based systems need the extra package, and Arch does not.
To create packages the following are also needed:

View File

@@ -61,7 +61,7 @@ def parse_args():
def build_one_help(language):
print("Generating Help in {}".format(language))
print(f"Generating Help in {language}")
current_path = Path(".").absolute()
changelog_path = current_path.joinpath("help", "changelog")
tixurl = "https://github.com/arsenetar/dupeguru/issues/{}"
@@ -88,14 +88,8 @@ def build_help():
p.map(build_one_help, languages)
def build_qt_localizations():
loc.compile_all_po(Path("qtlib", "locale"))
loc.merge_locale_dir(Path("qtlib", "locale"), "locale")
def build_localizations():
loc.compile_all_po("locale")
build_qt_localizations()
locale_dest = Path("build", "locale")
if locale_dest.exists():
shutil.rmtree(locale_dest)
@@ -109,25 +103,16 @@ def build_updatepot():
print("Building columns.pot")
loc.generate_pot(["core"], Path("locale", "columns.pot"), ["coltr"])
print("Building ui.pot")
# When we're not under OS X, we don't want to overwrite ui.pot because it contains Cocoa locs
# We want to merge the generated pot with the old pot in the most preserving way possible.
ui_packages = ["qt", Path("cocoa", "inter")]
loc.generate_pot(ui_packages, Path("locale", "ui.pot"), ["tr"], merge=True)
print("Building qtlib.pot")
loc.generate_pot(["qtlib"], Path("qtlib", "locale", "qtlib.pot"), ["tr"])
loc.generate_pot(["qt"], Path("locale", "ui.pot"), ["tr"], merge=True)
def build_mergepot():
print("Updating .po files using .pot files")
loc.merge_pots_into_pos("locale")
loc.merge_pots_into_pos(Path("qtlib", "locale"))
# loc.merge_pots_into_pos(Path("cocoalib", "locale"))
def build_normpo():
loc.normalize_all_pos("locale")
loc.normalize_all_pos(Path("qtlib", "locale"))
# loc.normalize_all_pos(Path("cocoalib", "locale"))
def build_pe_modules():
@@ -144,7 +129,7 @@ def build_normal():
print("Building localizations")
build_localizations()
print("Building Qt stuff")
print_and_do("pyrcc5 {0} > {1}".format(Path("qt", "dg.qrc"), Path("qt", "dg_rc.py")))
print_and_do("pyrcc5 {} > {}".format(Path("qt", "dg.qrc"), Path("qt", "dg_rc.py")))
fix_qt_resource_file(Path("qt", "dg_rc.py"))
build_help()

View File

@@ -1,2 +1,2 @@
__version__ = "4.2.1"
__version__ = "4.3.1"
__appname__ = "dupeGuru"

View File

@@ -4,37 +4,39 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import cProfile
import datetime
import os
import os.path as op
import logging
import subprocess
import re
import shutil
from pathlib import Path
from send2trash import send2trash
from hscommon.jobprogress import job
from hscommon.notify import Broadcaster
from hscommon.path import Path
from hscommon.conflict import smart_move, smart_copy
from hscommon.gui.progress_window import ProgressWindow
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"
@@ -132,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
@@ -248,7 +250,7 @@ class DupeGuru(Broadcaster):
ref = group.ref
linkfunc = os.link if use_hardlinks else os.symlink
linkfunc(str(ref.path), str_path)
self.clean_empty_dirs(dupe.path.parent())
self.clean_empty_dirs(dupe.path.parent)
def _create_file(self, path):
# We add fs.Folder to fileclasses in case the file we're loading contains folder paths.
@@ -262,7 +264,7 @@ class DupeGuru(Broadcaster):
try:
f._read_all_info(attrnames=self.METADATA_TO_READ)
return f
except EnvironmentError:
except OSError:
return None
def _get_export_data(self):
@@ -415,7 +417,7 @@ class DupeGuru(Broadcaster):
def clean_empty_dirs(self, path):
if self.options["clean_empty_dirs"]:
while delete_if_empty(path, [".DS_Store"]):
path = path.parent()
path = path.parent
def clear_picture_cache(self):
try:
@@ -428,25 +430,25 @@ class DupeGuru(Broadcaster):
def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType):
source_path = dupe.path
location_path = first(p for p in self.directories if dupe.path in p)
location_path = first(p for p in self.directories if p in dupe.path.parents)
dest_path = Path(destination)
if dest_type in {DestType.RELATIVE, DestType.ABSOLUTE}:
# no filename, no windows drive letter
source_base = source_path.remove_drive_letter().parent()
source_base = source_path.relative_to(source_path.anchor).parent
if dest_type == DestType.RELATIVE:
source_base = source_base[location_path:]
dest_path = dest_path[source_base]
source_base = source_base.relative_to(location_path.relative_to(location_path.anchor))
dest_path = dest_path.joinpath(source_base)
if not dest_path.exists():
dest_path.makedirs()
dest_path.mkdir(parents=True)
# Add filename to dest_path. For file move/copy, it's not required, but for folders, yes.
dest_path = dest_path[source_path.name]
dest_path = dest_path.joinpath(source_path.name)
logging.debug("Copy/Move operation from '%s' to '%s'", source_path, dest_path)
# Raises an EnvironmentError if there's a problem
if copy:
smart_copy(source_path, dest_path)
else:
smart_move(source_path, dest_path)
self.clean_empty_dirs(source_path.parent())
self.clean_empty_dirs(source_path.parent)
def copy_or_move_marked(self, copy):
"""Start an async move (or copy) job on marked duplicates.
@@ -553,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.
@@ -780,7 +786,7 @@ class DupeGuru(Broadcaster):
except OSError as e:
self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
def start_scanning(self):
def start_scanning(self, profile_scan=False):
"""Starts an async job to scan for duplicates.
Scans folders selected in :attr:`directories` and put the results in :attr:`results`
@@ -800,6 +806,9 @@ class DupeGuru(Broadcaster):
self._results_changed()
def do(j):
if profile_scan:
pr = cProfile.Profile()
pr.enable()
j.set_progress(0, tr("Collecting files to scan"))
if scanner.scan_type == ScanType.FOLDERS:
files = list(self.directories.get_folders(folderclass=se.fs.Folder, j=j))
@@ -810,6 +819,9 @@ class DupeGuru(Broadcaster):
logging.info("Scanning %d files" % len(files))
self.results.groups = scanner.get_dupe_groups(files, self.ignore_list, j)
self.discarded_file_count = scanner.discarded_file_count
if profile_scan:
pr.disable()
pr.dump_stats(op.join(self.appdata, f"{datetime.datetime.now():%Y-%m-%d_%H-%M-%S}.profile"))
self._start_job(JobType.SCAN, do)

View File

@@ -7,13 +7,13 @@
import os
from xml.etree import ElementTree as ET
import logging
from pathlib import Path
from hscommon.jobprogress import job
from hscommon.path import Path
from hscommon.util import FileOrPath
from hscommon.trans import tr
from . import fs
from core import fs
__all__ = [
"Directories",
@@ -63,7 +63,7 @@ class Directories:
def __contains__(self, path):
for p in self._dirs:
if path in p:
if path == p or p in path.parents:
return True
return False
@@ -90,58 +90,57 @@ class Directories:
return DirectoryState.EXCLUDED
def _get_files(self, from_path, fileclasses, j):
for root, dirs, files in os.walk(str(from_path)):
j.check_if_cancelled()
root_path = Path(root)
state = self.get_state(root_path)
if state == DirectoryState.EXCLUDED and not any(p[: len(root_path)] == root_path for p in self.states):
# Recursively get files from folders with lots of subfolder is expensive. However, there
# might be a subfolder in this path that is not excluded. What we want to do is to skim
# through self.states and see if we must continue, or we can stop right here to save time
del dirs[:]
try:
if state != DirectoryState.EXCLUDED:
# Old logic
if self._exclude_list is None or not self._exclude_list.mark_count:
found_files = [fs.get_file(root_path + f, fileclasses=fileclasses) for f in files]
else:
found_files = []
# print(f"len of files: {len(files)} {files}")
for f in files:
if not self._exclude_list.is_excluded(root, f):
found_files.append(fs.get_file(root_path + f, fileclasses=fileclasses))
found_files = [f for f in found_files if f is not None]
# In some cases, directories can be considered as files by dupeGuru, which is
# why we have this line below. In fact, there only one case: Bundle files under
# OS X... In other situations, this forloop will do nothing.
for d in dirs[:]:
f = fs.get_file(root_path + d, fileclasses=fileclasses)
if f is not None:
found_files.append(f)
dirs.remove(d)
logging.debug(
"Collected %d files in folder %s",
len(found_files),
str(root_path),
)
for file in found_files:
file.is_ref = state == DirectoryState.REFERENCE
yield file
except (EnvironmentError, fs.InvalidPath):
pass
try:
with os.scandir(from_path) as iter:
root_path = Path(from_path)
state = self.get_state(root_path)
# if we have no un-excluded dirs under this directory skip going deeper
skip_dirs = state == DirectoryState.EXCLUDED and not any(
p.parts[: len(root_path.parts)] == root_path.parts for p in self.states
)
count = 0
for item in iter:
j.check_if_cancelled()
try:
if item.is_dir():
if skip_dirs:
continue
yield from self._get_files(item.path, fileclasses, j)
continue
elif state == DirectoryState.EXCLUDED:
continue
# File excluding or not
if (
self._exclude_list is None
or not self._exclude_list.mark_count
or not self._exclude_list.is_excluded(str(from_path), item.name)
):
file = fs.get_file(item, fileclasses=fileclasses)
if file:
file.is_ref = state == DirectoryState.REFERENCE
count += 1
yield file
except (OSError, fs.InvalidPath):
pass
logging.debug(
"Collected %d files in folder %s",
count,
str(root_path),
)
except OSError:
pass
def _get_folders(self, from_folder, j):
j.check_if_cancelled()
try:
for subfolder in from_folder.subfolders:
for folder in self._get_folders(subfolder, j):
yield folder
yield from self._get_folders(subfolder, j)
state = self.get_state(from_folder.path)
if state != DirectoryState.EXCLUDED:
from_folder.is_ref = state == DirectoryState.REFERENCE
logging.debug("Yielding Folder %r state: %d", from_folder, state)
yield from_folder
except (EnvironmentError, fs.InvalidPath):
except (OSError, fs.InvalidPath):
pass
# ---Public
@@ -159,7 +158,7 @@ class Directories:
raise AlreadyThereError()
if not path.exists():
raise InvalidPathError()
self._dirs = [p for p in self._dirs if p not in path]
self._dirs = [p for p in self._dirs if path not in p.parents]
self._dirs.append(path)
@staticmethod
@@ -170,10 +169,10 @@ class Directories:
:rtype: list of Path
"""
try:
subpaths = [p for p in path.listdir() if p.isdir()]
subpaths = [p for p in path.glob("*") if p.is_dir()]
subpaths.sort(key=lambda x: x.name.lower())
return subpaths
except EnvironmentError:
except OSError:
return []
def get_files(self, fileclasses=None, j=job.nulljob):
@@ -220,14 +219,11 @@ class Directories:
if state != DirectoryState.NORMAL:
self.states[path] = state
return state
prevlen = 0
# we loop through the states to find the longest matching prefix
# if the parent has a state in cache, return that state
for p, s in self.states.items():
if p.is_parent_of(path) and len(p) > prevlen:
prevlen = len(p)
state = s
# find the longest parent path that is in states and return that state if found
# NOTE: path.parents is ordered longest to shortest
for parent_path in path.parents:
if parent_path in self.states:
return self.states[parent_path]
return state
def has_any_file(self):
@@ -296,6 +292,6 @@ class Directories:
if self.get_state(path) == state:
return
for iter_path in list(self.states.keys()):
if path.is_parent_of(iter_path):
if path in iter_path.parents:
del self.states[iter_path]
self.states[path] = state

View File

@@ -166,7 +166,7 @@ def reduce_common_words(word_dict, threshold):
The exception to this removal are the objects where all the words of the object are common.
Because if we remove them, we will miss some duplicates!
"""
uncommon_words = set(word for word, objects in word_dict.items() if len(objects) < threshold)
uncommon_words = {word for word, objects in word_dict.items() if len(objects) < threshold}
for word, objects in list(word_dict.items()):
if len(objects) < threshold:
continue
@@ -283,7 +283,7 @@ def getmatches_by_contents(files, bigsize=0, j=job.nulljob):
"""Returns a list of :class:`Match` within ``files`` if their contents is the same.
:param bigsize: The size in bytes over which we consider files big enough to
justify taking samples of md5. If 0, compute md5 as usual.
justify taking samples of the file for hashing. If 0, compute digest as usual.
:param j: A :ref:`job progress instance <jobs>`.
"""
size2files = defaultdict(set)
@@ -300,15 +300,16 @@ def getmatches_by_contents(files, bigsize=0, j=job.nulljob):
if first.is_ref and second.is_ref:
continue # Don't spend time comparing two ref pics together.
if first.size == 0 and second.size == 0:
# skip md5 for zero length files
# skip hashing for zero length files
result.append(Match(first, second, 100))
continue
if first.md5partial == second.md5partial:
# if digests are the same (and not None) then files match
if first.digest_partial == second.digest_partial and first.digest_partial is not None:
if bigsize > 0 and first.size > bigsize:
if first.md5samples == second.md5samples:
if first.digest_samples == second.digest_samples and first.digest_samples is not None:
result.append(Match(first, second, 100))
else:
if first.md5 == second.md5:
if first.digest == second.digest and first.digest is not None:
result.append(Match(first, second, 100))
group_count += 1
j.add_progress(desc=PROGRESS_MESSAGE % (len(result), group_count))
@@ -409,7 +410,7 @@ class Group:
You can call this after the duplicate scanning process to free a bit of memory.
"""
discarded = set(m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second]))
discarded = {m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second])}
self.matches -= discarded
self.candidates = defaultdict(set)
return discarded
@@ -456,7 +457,7 @@ class Group:
self._matches_for_ref = None
if (len(self) > 1) and any(not getattr(item, "is_ref", False) for item in self):
if discard_matches:
self.matches = set(m for m in self.matches if item not in m)
self.matches = {m for m in self.matches if item not in m}
else:
self._clear()
except ValueError:
@@ -529,7 +530,7 @@ def get_groups(matches):
del dupe2group
del matches
# should free enough memory to continue
logging.warning("Memory Overflow. Groups: {0}".format(len(groups)))
logging.warning(f"Memory Overflow. Groups: {len(groups)}")
# Now that we have a group, we have to discard groups' matches and see if there're any "orphan"
# matches, that is, matches that were candidate in a group but that none of their 2 files were
# accepted in the group. With these orphan groups, it's safe to build additional groups

View File

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

View File

@@ -11,16 +11,27 @@
# resulting needless complexity and memory usage. It's been a while since I wanted to do that fork,
# and I'm doing it now.
import hashlib
import os
from math import floor
import logging
import sqlite3
from threading import Lock
from typing import Any
from typing import Any, AnyStr, Union, Callable
from hscommon.path import Path
from pathlib import Path
from hscommon.util import nonone, get_file_ext
hasher: Callable
try:
import xxhash
hasher = xxhash.xxh128
except ImportError:
import hashlib
hasher = hashlib.md5
__all__ = [
"File",
"Folder",
@@ -40,7 +51,7 @@ NOT_SET = object()
# CPU.
CHUNK_SIZE = 1024 * 1024 # 1 MiB
# Minimum size below which partial hashes don't need to be computed
# Minimum size below which partial hashing is not used
MIN_FILE_SIZE = 3 * CHUNK_SIZE # 3MiB, because we take 3 samples
@@ -83,9 +94,11 @@ class OperationError(FSError):
class FilesDB:
schema_version = 1
schema_version_description = "Changed from md5 to xxhash if available."
create_table_query = "CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER, entry_dt DATETIME, md5 BLOB, md5partial BLOB)"
drop_table_query = "DROP TABLE files;"
create_table_query = "CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER, entry_dt DATETIME, digest BLOB, digest_partial BLOB, digest_samples BLOB)"
drop_table_query = "DROP TABLE IF EXISTS files;"
select_query = "SELECT {key} FROM files WHERE path=:path AND size=:size and mtime_ns=:mtime_ns"
insert_query = """
INSERT INTO files (path, size, mtime_ns, entry_dt, {key}) VALUES (:path, :size, :mtime_ns, datetime('now'), :value)
@@ -97,59 +110,72 @@ class FilesDB:
self.cur = None
self.lock = None
def connect(self, path):
# type: (str, ) -> None
def connect(self, path: Union[AnyStr, os.PathLike]) -> None:
self.conn = sqlite3.connect(path, check_same_thread=False)
self.cur = self.conn.cursor()
self.cur.execute(self.create_table_query)
self.lock = Lock()
self._check_upgrade()
def clear(self):
# type: () -> None
def _check_upgrade(self) -> None:
with self.lock:
has_schema = self.cur.execute(
"SELECT NAME FROM sqlite_master WHERE type='table' AND name='schema_version'"
).fetchall()
version = None
if has_schema:
version = self.cur.execute("SELECT version FROM schema_version ORDER BY version DESC").fetchone()[0]
else:
self.cur.execute("CREATE TABLE schema_version (version int PRIMARY KEY, description TEXT)")
if version != self.schema_version:
self.cur.execute(self.drop_table_query)
self.cur.execute(
"INSERT OR REPLACE INTO schema_version VALUES (:version, :description)",
{"version": self.schema_version, "description": self.schema_version_description},
)
self.cur.execute(self.create_table_query)
self.conn.commit()
def clear(self) -> None:
with self.lock:
self.cur.execute(self.drop_table_query)
self.cur.execute(self.create_table_query)
def get(self, path, key):
# type: (Path, str) -> bytes
def get(self, path: Path, key: str) -> Union[bytes, None]:
stat = path.stat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
try:
with self.lock:
self.cur.execute(
self.select_query.format(key=key), {"path": str(path), "size": size, "mtime_ns": mtime_ns}
)
result = self.cur.fetchone()
with self.lock:
self.cur.execute(self.select_query.format(key=key), {"path": str(path), "size": size, "mtime_ns": mtime_ns})
result = self.cur.fetchone()
if result:
return result[0]
if result:
return result[0]
except Exception as ex:
logging.warning(f"Couldn't get {key} for {path} w/{size}, {mtime_ns}: {ex}")
return None
def put(self, path, key, value):
# type: (Path, str, Any) -> None
def put(self, path: Path, key: str, value: Any) -> None:
stat = path.stat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
try:
with self.lock:
self.cur.execute(
self.insert_query.format(key=key),
{"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value},
)
except Exception as ex:
logging.warning(f"Couldn't put {key} for {path} w/{size}, {mtime_ns}: {ex}")
with self.lock:
self.cur.execute(
self.insert_query.format(key=key),
{"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value},
)
def commit(self):
# type: () -> None
def commit(self) -> None:
with self.lock:
self.conn.commit()
def close(self):
# type: () -> None
def close(self) -> None:
with self.lock:
self.cur.close()
self.conn.close()
@@ -161,19 +187,24 @@ filesdb = FilesDB() # Singleton
class File:
"""Represents a file and holds metadata to be used for scanning."""
INITIAL_INFO = {"size": 0, "mtime": 0, "md5": b"", "md5partial": b"", "md5samples": b""}
INITIAL_INFO = {"size": 0, "mtime": 0, "digest": b"", "digest_partial": b"", "digest_samples": b""}
# Slots for File make us save quite a bit of memory. In a memory test I've made with a lot of
# files, I saved 35% memory usage with "unread" files (no _read_info() call) and gains become
# even greater when we take into account read attributes (70%!). Yeah, it's worth it.
__slots__ = ("path", "is_ref", "words") + tuple(INITIAL_INFO.keys())
def __init__(self, path):
self.path = path
for attrname in self.INITIAL_INFO:
setattr(self, attrname, NOT_SET)
if type(path) is os.DirEntry:
self.path = Path(path.path)
self.size = nonone(path.stat().st_size, 0)
self.mtime = nonone(path.stat().st_mtime, 0)
else:
self.path = path
def __repr__(self):
return "<{} {}>".format(self.__class__.__name__, str(self.path))
return f"<{self.__class__.__name__} {str(self.path)}>"
def __getattribute__(self, attrname):
result = object.__getattribute__(self, attrname)
@@ -187,32 +218,51 @@ class File:
result = self.INITIAL_INFO[attrname]
return result
def _calc_md5(self):
def _calc_digest(self):
# type: () -> bytes
with self.path.open("rb") as fp:
md5 = hashlib.md5()
file_hash = hasher()
# The goal here is to not run out of memory on really big files. However, the chunk
# size has to be large enough so that the python loop isn't too costly in terms of
# CPU.
CHUNK_SIZE = 1024 * 1024 # 1 mb
filedata = fp.read(CHUNK_SIZE)
while filedata:
md5.update(filedata)
file_hash.update(filedata)
filedata = fp.read(CHUNK_SIZE)
return md5.digest()
return file_hash.digest()
def _calc_md5partial(self):
def _calc_digest_partial(self):
# type: () -> bytes
# This offset is where we should start reading the file to get a partial md5
# This offset is where we should start reading the file to get a partial hash
# For audio file, it should be where audio data starts
offset, size = (0x4000, 0x4000)
with self.path.open("rb") as fp:
fp.seek(offset)
partialdata = fp.read(size)
return hashlib.md5(partialdata).digest()
partial_data = fp.read(size)
return hasher(partial_data).digest()
def _calc_digest_samples(self) -> bytes:
size = self.size
with self.path.open("rb") as fp:
# Chunk at 25% of the file
fp.seek(floor(size * 25 / 100), 0)
file_data = fp.read(CHUNK_SIZE)
file_hash = hasher(file_data)
# Chunk at 60% of the file
fp.seek(floor(size * 60 / 100), 0)
file_data = fp.read(CHUNK_SIZE)
file_hash.update(file_data)
# Last chunk of the file
fp.seek(-CHUNK_SIZE, 2)
file_data = fp.read(CHUNK_SIZE)
file_hash.update(file_data)
return file_hash.digest()
def _read_info(self, field):
# print(f"_read_info({field}) for {self}")
@@ -220,48 +270,26 @@ class File:
stats = self.path.stat()
self.size = nonone(stats.st_size, 0)
self.mtime = nonone(stats.st_mtime, 0)
elif field == "md5partial":
try:
self.md5partial = filesdb.get(self.path, "md5partial")
if self.md5partial is None:
self.md5partial = self._calc_md5partial()
filesdb.put(self.path, "md5partial", self.md5partial)
except Exception as e:
logging.warning("Couldn't get md5partial for %s: %s", self.path, e)
elif field == "md5":
try:
self.md5 = filesdb.get(self.path, "md5")
if self.md5 is None:
self.md5 = self._calc_md5()
filesdb.put(self.path, "md5", self.md5)
except Exception as e:
logging.warning("Couldn't get md5 for %s: %s", self.path, e)
elif field == "md5samples":
try:
with self.path.open("rb") as fp:
size = self.size
# Might as well hash such small files entirely.
if size <= MIN_FILE_SIZE:
setattr(self, field, self.md5)
return
# Chunk at 25% of the file
fp.seek(floor(size * 25 / 100), 0)
filedata = fp.read(CHUNK_SIZE)
md5 = hashlib.md5(filedata)
# Chunk at 60% of the file
fp.seek(floor(size * 60 / 100), 0)
filedata = fp.read(CHUNK_SIZE)
md5.update(filedata)
# Last chunk of the file
fp.seek(-CHUNK_SIZE, 2)
filedata = fp.read(CHUNK_SIZE)
md5.update(filedata)
setattr(self, field, md5.digest())
except Exception as e:
logging.error(f"Error computing md5samples: {e}")
elif field == "digest_partial":
self.digest_partial = filesdb.get(self.path, "digest_partial")
if self.digest_partial is None:
self.digest_partial = self._calc_digest_partial()
filesdb.put(self.path, "digest_partial", self.digest_partial)
elif field == "digest":
self.digest = filesdb.get(self.path, "digest")
if self.digest is None:
self.digest = self._calc_digest()
filesdb.put(self.path, "digest", self.digest)
elif field == "digest_samples":
size = self.size
# Might as well hash such small files entirely.
if size <= MIN_FILE_SIZE:
setattr(self, field, self.digest)
return
self.digest_samples = filesdb.get(self.path, "digest_samples")
if self.digest_samples is None:
self.digest_samples = self._calc_digest_samples()
filesdb.put(self.path, "digest_samples", self.digest_samples)
def _read_all_info(self, attrnames=None):
"""Cache all possible info.
@@ -277,17 +305,17 @@ class File:
@classmethod
def can_handle(cls, path):
"""Returns whether this file wrapper class can handle ``path``."""
return not path.islink() and path.isfile()
return not path.is_symlink() and path.is_file()
def rename(self, newname):
if newname == self.name:
return
destpath = self.path.parent()[newname]
destpath = self.path.parent.joinpath(newname)
if destpath.exists():
raise AlreadyExistsError(newname, self.path.parent())
raise AlreadyExistsError(newname, self.path.parent)
try:
self.path.rename(destpath)
except EnvironmentError:
except OSError:
raise OperationError(self)
if not destpath.exists():
raise OperationError(self)
@@ -308,19 +336,20 @@ class File:
@property
def folder_path(self):
return self.path.parent()
return self.path.parent
class Folder(File):
"""A wrapper around a folder path.
It has the size/md5 info of a File, but its value is the sum of its subitems.
It has the size/digest info of a File, but its value is the sum of its subitems.
"""
__slots__ = File.__slots__ + ("_subfolders",)
def __init__(self, path):
File.__init__(self, path)
self.size = NOT_SET
self._subfolders = None
def _all_items(self):
@@ -335,31 +364,31 @@ class Folder(File):
self.size = size
stats = self.path.stat()
self.mtime = nonone(stats.st_mtime, 0)
elif field in {"md5", "md5partial", "md5samples"}:
elif field in {"digest", "digest_partial", "digest_samples"}:
# What's sensitive here is that we must make sure that subfiles'
# md5 are always added up in the same order, but we also want a
# different md5 if a file gets moved in a different subdirectory.
# digest are always added up in the same order, but we also want a
# different digest if a file gets moved in a different subdirectory.
def get_dir_md5_concat():
def get_dir_digest_concat():
items = self._all_items()
items.sort(key=lambda f: f.path)
md5s = [getattr(f, field) for f in items]
return b"".join(md5s)
digests = [getattr(f, field) for f in items]
return b"".join(digests)
md5 = hashlib.md5(get_dir_md5_concat())
digest = md5.digest()
digest = hasher(get_dir_digest_concat()).digest()
setattr(self, field, digest)
@property
def subfolders(self):
if self._subfolders is None:
subfolders = [p for p in self.path.listdir() if not p.islink() and p.isdir()]
with os.scandir(self.path) as iter:
subfolders = [p for p in iter if not p.is_symlink() and p.is_dir()]
self._subfolders = [self.__class__(p) for p in subfolders]
return self._subfolders
@classmethod
def can_handle(cls, path):
return not path.islink() and path.isdir()
return not path.is_symlink() and path.is_dir()
def get_file(path, fileclasses=[File]):
@@ -384,10 +413,11 @@ def get_files(path, fileclasses=[File]):
assert all(issubclass(fileclass, File) for fileclass in fileclasses)
try:
result = []
for path in path.listdir():
file = get_file(path, fileclasses=fileclasses)
if file is not None:
result.append(file)
with os.scandir(path) as iter:
for item in iter:
file = get_file(item, fileclasses=fileclasses)
if file is not None:
result.append(file)
return result
except EnvironmentError:
except OSError:
raise InvalidPath(path)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,11 +97,6 @@ class MusicFile(fs.File):
"dupe_count": format_dupe_count(dupe_count),
}
def _get_md5partial_offset_and_size(self):
# No longer calculating the offset and audio size, just whole file
size = self.path.stat().st_size
return (0, size)
def _read_info(self, field):
fs.File._read_info(self, field)
if field in TAG_FIELDS:

View File

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

View File

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

13
core/pe/block.pyi Normal file
View File

@@ -0,0 +1,13 @@
from typing import Tuple, List, Union, Sequence
_block = Tuple[int, int, int]
class NoBlocksError(Exception): ... # noqa: E302, E701
class DifferentBlockCountError(Exception): ... # noqa E701
def getblock(image: object) -> Union[_block, None]: ... # noqa: E302
def getblocks2(image: object, block_count_per_side: int) -> Union[List[_block], None]: ...
def diff(first: _block, second: _block) -> int: ...
def avgdiff( # noqa: E302
first: Sequence[_block], second: Sequence[_block], limit: int = 768, min_iterations: int = 1
) -> Union[int, None]: ...

View File

@@ -4,7 +4,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ._cache import string_to_colors # noqa
from core.pe._cache import string_to_colors # noqa
def colors_to_string(colors):
@@ -13,7 +13,7 @@ def colors_to_string(colors):
[(0,100,255)] --> 0064ff
[(1,2,3),(4,5,6)] --> 010203040506
"""
return "".join("%02x%02x%02x" % (r, g, b) for r, g, b in colors)
return "".join("{:02x}{:02x}{:02x}".format(r, g, b) for r, g, b in colors)
# This function is an important bottleneck of dupeGuru PE. It has been converted to C.

6
core/pe/cache.pyi Normal file
View File

@@ -0,0 +1,6 @@
from typing import Union, Tuple, List
_block = Tuple[int, int, int]
def colors_to_string(colors: List[_block]) -> str: ... # noqa: E302
def string_to_colors(s: str) -> Union[List[_block], None]: ...

View File

@@ -10,11 +10,11 @@ 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):
return "path:{}".format(path)
return f"path:{path}"
def unwrap_path(key):
@@ -22,7 +22,7 @@ def unwrap_path(key):
def wrap_id(path):
return "id:{}".format(path)
return f"id:{path}"
def unwrap_id(key):

View File

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

View File

@@ -1,33 +0,0 @@
# Created By: Virgil Dupras
# Created On: 2014-03-15
# 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 plistlib
class IPhotoPlistParser(plistlib._PlistParser):
"""A parser for iPhoto plists.
iPhoto plists tend to be malformed, so we have to subclass the built-in parser to be a bit more
lenient.
"""
def __init__(self):
plistlib._PlistParser.__init__(self, use_builtin_types=True, dict_type=dict)
# For debugging purposes, we remember the last bit of data to be analyzed so that we can
# log it in case of an exception
self.lastdata = ""
def get_data(self):
self.lastdata = plistlib._PlistParser.get_data(self)
return self.lastdata
def end_integer(self):
try:
self.add_object(int(self.get_data()))
except ValueError:
self.add_object(0)

View File

@@ -15,7 +15,7 @@ from hscommon.trans import tr
from hscommon.jobprogress import job
from core.engine import Match
from .block import avgdiff, DifferentBlockCountError, NoBlocksError
from core.pe.block import avgdiff, DifferentBlockCountError, NoBlocksError
# OPTIMIZATION NOTES:
# The bottleneck of the matching phase is CPU, which is why we use multiprocessing. However, another
@@ -51,11 +51,11 @@ except Exception:
def get_cache(cache_path, readonly=False):
if cache_path.endswith("shelve"):
from .cache_shelve import ShelveCache
from core.pe.cache_shelve import ShelveCache
return ShelveCache(cache_path, readonly=readonly)
else:
from .cache_sqlite import SqliteCache
from core.pe.cache_sqlite import SqliteCache
return SqliteCache(cache_path, readonly=readonly)
@@ -87,7 +87,7 @@ def prepare_pictures(pictures, cache_path, with_dimensions, j=job.nulljob):
blocks = picture.get_blocks(BLOCK_COUNT_PER_SIDE)
cache[picture.unicode_path] = blocks
prepared.append(picture)
except (IOError, ValueError) as e:
except (OSError, ValueError) as e:
logging.warning(str(e))
except MemoryError:
logging.warning(
@@ -238,7 +238,7 @@ def getmatches(pictures, cache_path, threshold, match_scaled=False, j=job.nulljo
for ref_id, other_id, percentage in myiter:
ref = id2picture[ref_id]
other = id2picture[other_id]
if percentage == 100 and ref.md5 != other.md5:
if percentage == 100 and ref.digest != other.digest:
percentage = 99
if percentage >= threshold:
ref.dimensions # pre-read dimensions for display in results

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ class Criterion:
@property
def display(self):
return "{} ({})".format(self.category.NAME, self.display_value)
return f"{self.category.NAME} ({self.display_value})"
class ValueListCategory(CriterionCategory):
@@ -82,10 +82,12 @@ class FolderCategory(ValueListCategory):
def sort_key(self, dupe, crit_value):
value = self.extract_value(dupe)
if value[: len(crit_value)] == crit_value:
return 0
else:
# This is instead of using is_relative_to() which was added in py 3.9
try:
value.relative_to(crit_value)
except ValueError:
return 1
return 0
class FilenameCategory(CriterionCategory):

View File

@@ -17,8 +17,8 @@ from hscommon.conflict import get_conflicted_name
from hscommon.util import flatten, nonone, FileOrPath, format_size
from hscommon.trans import tr
from . import engine
from .markable import Markable
from core import engine
from core.markable import Markable
class Results(Markable):
@@ -191,7 +191,7 @@ class Results(Markable):
self.__filters.append(filter_str)
if self.__filtered_dupes is None:
self.__filtered_dupes = flatten(g[:] for g in self.groups)
self.__filtered_dupes = set(dupe for dupe in self.__filtered_dupes if filter_re.search(str(dupe.path)))
self.__filtered_dupes = {dupe for dupe in self.__filtered_dupes if filter_re.search(str(dupe.path))}
filtered_groups = set()
for dupe in self.__filtered_dupes:
filtered_groups.add(self.get_group_of_duplicate(dupe))
@@ -301,7 +301,7 @@ class Results(Markable):
try:
func(dupe)
to_remove.append(dupe)
except (EnvironmentError, UnicodeEncodeError) as e:
except (OSError, UnicodeEncodeError) as e:
self.problems.append((dupe, str(e)))
if remove_from_results:
self.remove_duplicates(to_remove)
@@ -374,8 +374,8 @@ class Results(Markable):
try:
do_write(outfile)
except IOError as e:
# If our IOError is because dest is already a directory, we want to handle that. 21 is
except OSError as e:
# If our OSError is because dest is already a directory, we want to handle that. 21 is
# the code we get on OS X and Linux, 13 is what we get on Windows.
if e.errno in {21, 13}:
p = str(outfile)

View File

@@ -13,7 +13,7 @@ from hscommon.jobprogress import job
from hscommon.util import dedupe, rem_file_ext, get_file_ext
from hscommon.trans import tr
from . import engine
from core import engine
# It's quite ugly to have scan types from all editions all put in the same class, but because there's
# there will be some nasty bugs popping up (ScanType is used in core when in should exclusively be
@@ -134,7 +134,7 @@ class Scanner:
return False
if is_same_with_digit(refname, dupename):
return True
return len(dupe.path) > len(ref.path)
return len(dupe.path.parts) > len(ref.path.parts)
@staticmethod
def get_scan_options():
@@ -164,7 +164,7 @@ class Scanner:
toremove = set()
last_parent_path = sortedpaths[0]
for p in sortedpaths[1:]:
if p in last_parent_path:
if last_parent_path in p.parents:
toremove.add(p)
else:
last_parent_path = p

View File

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

View File

@@ -7,18 +7,19 @@
import os
import os.path as op
import logging
import tempfile
import pytest
from hscommon.path import Path
from pathlib import Path
import hscommon.conflict
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):
@@ -56,7 +57,7 @@ class TestCaseDupeGuru:
# for this unit is pathetic. What's done is done. My approach now is to add tests for
# every change I want to make. The blowup was caused by a missing import.
p = Path(str(tmpdir))
p["foo"].open("w").close()
p.joinpath("foo").touch()
monkeypatch.setattr(
hscommon.conflict,
"smart_copy",
@@ -68,22 +69,23 @@ 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"], op.join("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))
sourcepath = tmppath["source"]
sourcepath = tmppath.joinpath("source")
sourcepath.mkdir()
sourcepath["myfile"].open("w")
sourcepath.joinpath("myfile").touch()
app = TestApp().app
app.directories.add_path(tmppath)
[myfile] = app.directories.get_files()
monkeypatch.setattr(app, "clean_empty_dirs", log_calls(lambda path: None))
app.copy_or_move(myfile, False, tmppath["dest"], 0)
app.copy_or_move(myfile, False, tmppath.joinpath("dest"), 0)
calls = app.clean_empty_dirs.calls
eq_(1, len(calls))
eq_(sourcepath, calls[0]["path"])
@@ -95,7 +97,7 @@ class TestCaseDupeGuru:
# At some point, any() was used in a wrong way that made Scan() wrongly return 1
app = TestApp().app
f1, f2 = [FakeFile("foo") for _ in range(2)]
f1, f2 = (FakeFile("foo") for _ in range(2))
f1.is_ref, f2.is_ref = (False, False)
assert not (bool(f1) and bool(f2))
add_fake_files_to_directories(app.directories, [f1, f2])
@@ -106,8 +108,8 @@ class TestCaseDupeGuru:
# If the ignore_hardlink_matches option is set, don't match files hardlinking to the same
# inode.
tmppath = Path(str(tmpdir))
tmppath["myfile"].open("w").write("foo")
os.link(str(tmppath["myfile"]), str(tmppath["hardlink"]))
tmppath.joinpath("myfile").open("wt").write("foo")
os.link(str(tmppath.joinpath("myfile")), str(tmppath.joinpath("hardlink")))
app = TestApp().app
app.directories.add_path(tmppath)
app.options["scan_type"] = ScanType.CONTENTS
@@ -153,7 +155,7 @@ class TestCaseDupeGuruCleanEmptyDirs:
# delete_if_empty must be recursively called up in the path until it returns False
@log_calls
def mock_delete_if_empty(path, files_to_delete=[]):
return len(path) > 1
return len(path.parts) > 1
monkeypatch.setattr(hscommon.util, "delete_if_empty", mock_delete_if_empty)
# XXX This monkeypatch is temporary. will be fixed in a better monkeypatcher.
@@ -180,8 +182,8 @@ class TestCaseDupeGuruWithResults:
self.rtable.refresh()
tmpdir = request.getfixturevalue("tmpdir")
tmppath = Path(str(tmpdir))
tmppath["foo"].mkdir()
tmppath["bar"].mkdir()
tmppath.joinpath("foo").mkdir()
tmppath.joinpath("bar").mkdir()
self.app.directories.add_path(tmppath)
def test_get_objects(self, do_setup):
@@ -424,12 +426,9 @@ class TestCaseDupeGuruRenameSelected:
def do_setup(self, request):
tmpdir = request.getfixturevalue("tmpdir")
p = Path(str(tmpdir))
fp = open(str(p["foo bar 1"]), mode="w")
fp.close()
fp = open(str(p["foo bar 2"]), mode="w")
fp.close()
fp = open(str(p["foo bar 3"]), mode="w")
fp.close()
p.joinpath("foo bar 1").touch()
p.joinpath("foo bar 2").touch()
p.joinpath("foo bar 3").touch()
files = fs.get_files(p)
for f in files:
f.is_ref = False
@@ -451,7 +450,7 @@ class TestCaseDupeGuruRenameSelected:
g = self.groups[0]
self.rtable.select([1])
assert app.rename_selected("renamed")
names = [p.name for p in self.p.listdir()]
names = [p.name for p in self.p.glob("*")]
assert "renamed" in names
assert "foo bar 2" not in names
eq_(g.dupes[0].name, "renamed")
@@ -464,7 +463,7 @@ class TestCaseDupeGuruRenameSelected:
assert not app.rename_selected("renamed")
msg = logging.warning.calls[0]["msg"]
eq_("dupeGuru Warning: list index out of range", msg)
names = [p.name for p in self.p.listdir()]
names = [p.name for p in self.p.glob("*")]
assert "renamed" not in names
assert "foo bar 2" in names
eq_(g.dupes[0].name, "foo bar 2")
@@ -477,7 +476,7 @@ class TestCaseDupeGuruRenameSelected:
assert not app.rename_selected("foo bar 1")
msg = logging.warning.calls[0]["msg"]
assert msg.startswith("dupeGuru Warning: 'foo bar 1' already exists in")
names = [p.name for p in self.p.listdir()]
names = [p.name for p in self.p.glob("*")]
assert "foo bar 1" in names
assert "foo bar 2" in names
eq_(g.dupes[0].name, "foo bar 2")
@@ -488,9 +487,9 @@ class TestAppWithDirectoriesInTree:
def do_setup(self, request):
tmpdir = request.getfixturevalue("tmpdir")
p = Path(str(tmpdir))
p["sub1"].mkdir()
p["sub2"].mkdir()
p["sub3"].mkdir()
p.joinpath("sub1").mkdir()
p.joinpath("sub2").mkdir()
p.joinpath("sub3").mkdir()
app = TestApp()
self.app = app.app
self.dtree = app.dtree

View File

@@ -5,17 +5,16 @@
# http://www.gnu.org/licenses/gpl-3.0.html
from hscommon.testutil import TestApp as TestAppBase, CallLogger, eq_, with_app # noqa
from hscommon.path import Path
from pathlib import Path
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:
@@ -86,9 +85,9 @@ class NamedObject:
folder = "basepath"
self._folder = Path(folder)
self.size = size
self.md5partial = name
self.md5 = name
self.md5samples = name
self.digest_partial = name
self.digest = name
self.digest_samples = name
if with_words:
self.words = getwords(name)
self.is_ref = False
@@ -111,11 +110,11 @@ class NamedObject:
@property
def path(self):
return self._folder[self.name]
return self._folder.joinpath(self.name)
@property
def folder_path(self):
return self.path.parent()
return self.path.parent
@property
def extension(self):

View File

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

View File

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

View File

@@ -10,45 +10,39 @@ import tempfile
import shutil
from pytest import raises
from hscommon.path import Path
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):
# We have it as a separate function because other units are using it.
rootpath = rootpath["fs"]
rootpath = rootpath.joinpath("fs")
rootpath.mkdir()
rootpath["dir1"].mkdir()
rootpath["dir2"].mkdir()
rootpath["dir3"].mkdir()
fp = rootpath["file1.test"].open("w")
fp.write("1")
fp.close()
fp = rootpath["file2.test"].open("w")
fp.write("12")
fp.close()
fp = rootpath["file3.test"].open("w")
fp.write("123")
fp.close()
fp = rootpath["dir1"]["file1.test"].open("w")
fp.write("1")
fp.close()
fp = rootpath["dir2"]["file2.test"].open("w")
fp.write("12")
fp.close()
fp = rootpath["dir3"]["file3.test"].open("w")
fp.write("123")
fp.close()
rootpath.joinpath("dir1").mkdir()
rootpath.joinpath("dir2").mkdir()
rootpath.joinpath("dir3").mkdir()
with rootpath.joinpath("file1.test").open("wt") as fp:
fp.write("1")
with rootpath.joinpath("file2.test").open("wt") as fp:
fp.write("12")
with rootpath.joinpath("file3.test").open("wt") as fp:
fp.write("123")
with rootpath.joinpath("dir1", "file1.test").open("wt") as fp:
fp.write("1")
with rootpath.joinpath("dir2", "file2.test").open("wt") as fp:
fp.write("12")
with rootpath.joinpath("dir3", "file3.test").open("wt") as fp:
fp.write("123")
return rootpath
@@ -60,11 +54,10 @@ def setup_module(module):
# and another with a more complex structure.
testpath = Path(tempfile.mkdtemp())
module.testpath = testpath
rootpath = testpath["onefile"]
rootpath = testpath.joinpath("onefile")
rootpath.mkdir()
fp = rootpath["test.txt"].open("w")
fp.write("test_data")
fp.close()
with rootpath.joinpath("test.txt").open("wt") as fp:
fp.write("test_data")
create_fake_fs(testpath)
@@ -80,13 +73,13 @@ def test_empty():
def test_add_path():
d = Directories()
p = testpath["onefile"]
p = testpath.joinpath("onefile")
d.add_path(p)
eq_(1, len(d))
assert p in d
assert (p["foobar"]) in d
assert p.parent() not in d
p = testpath["fs"]
assert (p.joinpath("foobar")) in d
assert p.parent not in d
p = testpath.joinpath("fs")
d.add_path(p)
eq_(2, len(d))
assert p in d
@@ -94,18 +87,18 @@ def test_add_path():
def test_add_path_when_path_is_already_there():
d = Directories()
p = testpath["onefile"]
p = testpath.joinpath("onefile")
d.add_path(p)
with raises(AlreadyThereError):
d.add_path(p)
with raises(AlreadyThereError):
d.add_path(p["foobar"])
d.add_path(p.joinpath("foobar"))
eq_(1, len(d))
def test_add_path_containing_paths_already_there():
d = Directories()
d.add_path(testpath["onefile"])
d.add_path(testpath.joinpath("onefile"))
eq_(1, len(d))
d.add_path(testpath)
eq_(len(d), 1)
@@ -114,7 +107,7 @@ def test_add_path_containing_paths_already_there():
def test_add_path_non_latin(tmpdir):
p = Path(str(tmpdir))
to_add = p["unicode\u201a"]
to_add = p.joinpath("unicode\u201a")
os.mkdir(str(to_add))
d = Directories()
try:
@@ -125,25 +118,25 @@ def test_add_path_non_latin(tmpdir):
def test_del():
d = Directories()
d.add_path(testpath["onefile"])
d.add_path(testpath.joinpath("onefile"))
try:
del d[1]
assert False
except IndexError:
pass
d.add_path(testpath["fs"])
d.add_path(testpath.joinpath("fs"))
del d[1]
eq_(1, len(d))
def test_states():
d = Directories()
p = testpath["onefile"]
p = testpath.joinpath("onefile")
d.add_path(p)
eq_(DirectoryState.NORMAL, d.get_state(p))
d.set_state(p, DirectoryState.REFERENCE)
eq_(DirectoryState.REFERENCE, d.get_state(p))
eq_(DirectoryState.REFERENCE, d.get_state(p["dir1"]))
eq_(DirectoryState.REFERENCE, d.get_state(p.joinpath("dir1")))
eq_(1, len(d.states))
eq_(p, list(d.states.keys())[0])
eq_(DirectoryState.REFERENCE, d.states[p])
@@ -152,7 +145,7 @@ def test_states():
def test_get_state_with_path_not_there():
# When the path's not there, just return DirectoryState.Normal
d = Directories()
d.add_path(testpath["onefile"])
d.add_path(testpath.joinpath("onefile"))
eq_(d.get_state(testpath), DirectoryState.NORMAL)
@@ -160,26 +153,26 @@ def test_states_overwritten_when_larger_directory_eat_smaller_ones():
# ref #248
# When setting the state of a folder, we overwrite previously set states for subfolders.
d = Directories()
p = testpath["onefile"]
p = testpath.joinpath("onefile")
d.add_path(p)
d.set_state(p, DirectoryState.EXCLUDED)
d.add_path(testpath)
d.set_state(testpath, DirectoryState.REFERENCE)
eq_(d.get_state(p), DirectoryState.REFERENCE)
eq_(d.get_state(p["dir1"]), DirectoryState.REFERENCE)
eq_(d.get_state(p.joinpath("dir1")), DirectoryState.REFERENCE)
eq_(d.get_state(testpath), DirectoryState.REFERENCE)
def test_get_files():
d = Directories()
p = testpath["fs"]
p = testpath.joinpath("fs")
d.add_path(p)
d.set_state(p["dir1"], DirectoryState.REFERENCE)
d.set_state(p["dir2"], DirectoryState.EXCLUDED)
d.set_state(p.joinpath("dir1"), DirectoryState.REFERENCE)
d.set_state(p.joinpath("dir2"), DirectoryState.EXCLUDED)
files = list(d.get_files())
eq_(5, len(files))
for f in files:
if f.path.parent() == p["dir1"]:
if f.path.parent == p.joinpath("dir1"):
assert f.is_ref
else:
assert not f.is_ref
@@ -193,7 +186,7 @@ def test_get_files_with_folders():
return True
d = Directories()
p = testpath["fs"]
p = testpath.joinpath("fs")
d.add_path(p)
files = list(d.get_files(fileclasses=[FakeFile]))
# We have the 3 root files and the 3 root dirs
@@ -202,23 +195,23 @@ def test_get_files_with_folders():
def test_get_folders():
d = Directories()
p = testpath["fs"]
p = testpath.joinpath("fs")
d.add_path(p)
d.set_state(p["dir1"], DirectoryState.REFERENCE)
d.set_state(p["dir2"], DirectoryState.EXCLUDED)
d.set_state(p.joinpath("dir1"), DirectoryState.REFERENCE)
d.set_state(p.joinpath("dir2"), DirectoryState.EXCLUDED)
folders = list(d.get_folders())
eq_(len(folders), 3)
ref = [f for f in folders if f.is_ref]
not_ref = [f for f in folders if not f.is_ref]
eq_(len(ref), 1)
eq_(ref[0].path, p["dir1"])
eq_(ref[0].path, p.joinpath("dir1"))
eq_(len(not_ref), 2)
eq_(ref[0].size, 1)
def test_get_files_with_inherited_exclusion():
d = Directories()
p = testpath["onefile"]
p = testpath.joinpath("onefile")
d.add_path(p)
d.set_state(p, DirectoryState.EXCLUDED)
eq_([], list(d.get_files()))
@@ -234,13 +227,13 @@ def test_save_and_load(tmpdir):
d1.add_path(p1)
d1.add_path(p2)
d1.set_state(p1, DirectoryState.REFERENCE)
d1.set_state(p1["dir1"], DirectoryState.EXCLUDED)
d1.set_state(p1.joinpath("dir1"), DirectoryState.EXCLUDED)
tmpxml = str(tmpdir.join("directories_testunit.xml"))
d1.save_to_file(tmpxml)
d2.load_from_file(tmpxml)
eq_(2, len(d2))
eq_(DirectoryState.REFERENCE, d2.get_state(p1))
eq_(DirectoryState.EXCLUDED, d2.get_state(p1["dir1"]))
eq_(DirectoryState.EXCLUDED, d2.get_state(p1.joinpath("dir1")))
def test_invalid_path():
@@ -268,7 +261,7 @@ def test_load_from_file_with_invalid_path(tmpdir):
# This test simulates a load from file resulting in a
# InvalidPath raise. Other directories must be loaded.
d1 = Directories()
d1.add_path(testpath["onefile"])
d1.add_path(testpath.joinpath("onefile"))
# Will raise InvalidPath upon loading
p = Path(str(tmpdir.join("toremove")))
p.mkdir()
@@ -283,11 +276,11 @@ def test_load_from_file_with_invalid_path(tmpdir):
def test_unicode_save(tmpdir):
d = Directories()
p1 = Path(str(tmpdir))["hello\xe9"]
p1 = Path(str(tmpdir), "hello\xe9")
p1.mkdir()
p1["foo\xe9"].mkdir()
p1.joinpath("foo\xe9").mkdir()
d.add_path(p1)
d.set_state(p1["foo\xe9"], DirectoryState.EXCLUDED)
d.set_state(p1.joinpath("foo\xe9"), DirectoryState.EXCLUDED)
tmpxml = str(tmpdir.join("directories_testunit.xml"))
try:
d.save_to_file(tmpxml)
@@ -297,12 +290,12 @@ def test_unicode_save(tmpdir):
def test_get_files_refreshes_its_directories():
d = Directories()
p = testpath["fs"]
p = testpath.joinpath("fs")
d.add_path(p)
files = d.get_files()
eq_(6, len(list(files)))
time.sleep(1)
os.remove(str(p["dir1"]["file1.test"]))
os.remove(str(p.joinpath("dir1", "file1.test")))
files = d.get_files()
eq_(5, len(list(files)))
@@ -311,15 +304,15 @@ def test_get_files_does_not_choke_on_non_existing_directories(tmpdir):
d = Directories()
p = Path(str(tmpdir))
d.add_path(p)
p.rmtree()
shutil.rmtree(str(p))
eq_([], list(d.get_files()))
def test_get_state_returns_excluded_by_default_for_hidden_directories(tmpdir):
d = Directories()
p = Path(str(tmpdir))
hidden_dir_path = p[".foo"]
p[".foo"].mkdir()
hidden_dir_path = p.joinpath(".foo")
p.joinpath(".foo").mkdir()
d.add_path(p)
eq_(d.get_state(hidden_dir_path), DirectoryState.EXCLUDED)
# But it can be overriden
@@ -331,22 +324,22 @@ def test_default_path_state_override(tmpdir):
# It's possible for a subclass to override the default state of a path
class MyDirectories(Directories):
def _default_state_for_path(self, path):
if "foobar" in path:
if "foobar" in path.parts:
return DirectoryState.EXCLUDED
d = MyDirectories()
p1 = Path(str(tmpdir))
p1["foobar"].mkdir()
p1["foobar/somefile"].open("w").close()
p1["foobaz"].mkdir()
p1["foobaz/somefile"].open("w").close()
p1.joinpath("foobar").mkdir()
p1.joinpath("foobar/somefile").touch()
p1.joinpath("foobaz").mkdir()
p1.joinpath("foobaz/somefile").touch()
d.add_path(p1)
eq_(d.get_state(p1["foobaz"]), DirectoryState.NORMAL)
eq_(d.get_state(p1["foobar"]), DirectoryState.EXCLUDED)
eq_(d.get_state(p1.joinpath("foobaz")), DirectoryState.NORMAL)
eq_(d.get_state(p1.joinpath("foobar")), DirectoryState.EXCLUDED)
eq_(len(list(d.get_files())), 1) # only the 'foobaz' file is there
# However, the default state can be changed
d.set_state(p1["foobar"], DirectoryState.NORMAL)
eq_(d.get_state(p1["foobar"]), DirectoryState.NORMAL)
d.set_state(p1.joinpath("foobar"), DirectoryState.NORMAL)
eq_(d.get_state(p1.joinpath("foobar")), DirectoryState.NORMAL)
eq_(len(list(d.get_files())), 2)
@@ -372,42 +365,42 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
self.d._exclude_list.add(regex)
self.d._exclude_list.mark(regex)
p1 = Path(str(tmpdir))
p1["$Recycle.Bin"].mkdir()
p1["$Recycle.Bin"]["subdir"].mkdir()
p1.joinpath("$Recycle.Bin").mkdir()
p1.joinpath("$Recycle.Bin", "subdir").mkdir()
self.d.add_path(p1)
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.EXCLUDED)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin")), DirectoryState.EXCLUDED)
# By default, subdirs should be excluded too, but this can be overridden separately
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.EXCLUDED)
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.NORMAL)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdir")), DirectoryState.EXCLUDED)
self.d.set_state(p1.joinpath("$Recycle.Bin", "subdir"), DirectoryState.NORMAL)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdir")), DirectoryState.NORMAL)
def test_exclude_refined(self, tmpdir):
regex1 = r"^\$Recycle\.Bin$"
self.d._exclude_list.add(regex1)
self.d._exclude_list.mark(regex1)
p1 = Path(str(tmpdir))
p1["$Recycle.Bin"].mkdir()
p1["$Recycle.Bin"]["somefile.png"].open("w").close()
p1["$Recycle.Bin"]["some_unwanted_file.jpg"].open("w").close()
p1["$Recycle.Bin"]["subdir"].mkdir()
p1["$Recycle.Bin"]["subdir"]["somesubdirfile.png"].open("w").close()
p1["$Recycle.Bin"]["subdir"]["unwanted_subdirfile.gif"].open("w").close()
p1["$Recycle.Bin"]["subdar"].mkdir()
p1["$Recycle.Bin"]["subdar"]["somesubdarfile.jpeg"].open("w").close()
p1["$Recycle.Bin"]["subdar"]["unwanted_subdarfile.png"].open("w").close()
self.d.add_path(p1["$Recycle.Bin"])
p1.joinpath("$Recycle.Bin").mkdir()
p1.joinpath("$Recycle.Bin", "somefile.png").touch()
p1.joinpath("$Recycle.Bin", "some_unwanted_file.jpg").touch()
p1.joinpath("$Recycle.Bin", "subdir").mkdir()
p1.joinpath("$Recycle.Bin", "subdir", "somesubdirfile.png").touch()
p1.joinpath("$Recycle.Bin", "subdir", "unwanted_subdirfile.gif").touch()
p1.joinpath("$Recycle.Bin", "subdar").mkdir()
p1.joinpath("$Recycle.Bin", "subdar", "somesubdarfile.jpeg").touch()
p1.joinpath("$Recycle.Bin", "subdar", "unwanted_subdarfile.png").touch()
self.d.add_path(p1.joinpath("$Recycle.Bin"))
# Filter should set the default state to Excluded
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.EXCLUDED)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin")), DirectoryState.EXCLUDED)
# The subdir should inherit its parent state
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.EXCLUDED)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.EXCLUDED)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdir")), DirectoryState.EXCLUDED)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdar")), DirectoryState.EXCLUDED)
# Override a child path's state
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.NORMAL)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
self.d.set_state(p1.joinpath("$Recycle.Bin", "subdir"), DirectoryState.NORMAL)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdir")), DirectoryState.NORMAL)
# Parent should keep its default state, and the other child too
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.EXCLUDED)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.EXCLUDED)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin")), DirectoryState.EXCLUDED)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdar")), DirectoryState.EXCLUDED)
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
# only the 2 files directly under the Normal directory
@@ -419,8 +412,8 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
assert "somesubdirfile.png" in files
assert "unwanted_subdirfile.gif" in files
# Overriding the parent should enable all children
self.d.set_state(p1["$Recycle.Bin"], DirectoryState.NORMAL)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.NORMAL)
self.d.set_state(p1.joinpath("$Recycle.Bin"), DirectoryState.NORMAL)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdar")), DirectoryState.NORMAL)
# all files there
files = self.get_files_and_expect_num_result(6)
assert "somefile.png" in files
@@ -444,7 +437,7 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
assert self.d._exclude_list.error(regex3) is None
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
# Directory shouldn't change its state here, unless explicitely done by user
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdir")), DirectoryState.NORMAL)
files = self.get_files_and_expect_num_result(5)
assert "unwanted_subdirfile.gif" not in files
assert "unwanted_subdarfile.png" in files
@@ -453,15 +446,15 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
regex4 = r".*subdir$"
self.d._exclude_list.rename(regex3, regex4)
assert self.d._exclude_list.error(regex4) is None
p1["$Recycle.Bin"]["subdar"]["file_ending_with_subdir"].open("w").close()
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.EXCLUDED)
p1.joinpath("$Recycle.Bin", "subdar", "file_ending_with_subdir").touch()
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdir")), DirectoryState.EXCLUDED)
files = self.get_files_and_expect_num_result(4)
assert "file_ending_with_subdir" not in files
assert "somesubdarfile.jpeg" in files
assert "somesubdirfile.png" not in files
assert "unwanted_subdirfile.gif" not in files
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.NORMAL)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
self.d.set_state(p1.joinpath("$Recycle.Bin", "subdir"), DirectoryState.NORMAL)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdir")), DirectoryState.NORMAL)
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
files = self.get_files_and_expect_num_result(6)
assert "file_ending_with_subdir" not in files
@@ -471,9 +464,9 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
regex5 = r".*subdir.*"
self.d._exclude_list.rename(regex4, regex5)
# Files containing substring should be filtered
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdir")), DirectoryState.NORMAL)
# The path should not match, only the filename, the "subdir" in the directory name shouldn't matter
p1["$Recycle.Bin"]["subdir"]["file_which_shouldnt_match"].open("w").close()
p1.joinpath("$Recycle.Bin", "subdir", "file_which_shouldnt_match").touch()
files = self.get_files_and_expect_num_result(5)
assert "somesubdirfile.png" not in files
assert "unwanted_subdirfile.gif" not in files
@@ -493,7 +486,7 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
assert self.d._exclude_list.error(regex6) is None
assert regex6 in self.d._exclude_list
# This still should not be affected
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "subdir")), DirectoryState.NORMAL)
files = self.get_files_and_expect_num_result(5)
# These files are under the "/subdir" directory
assert "somesubdirfile.png" not in files
@@ -505,20 +498,20 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
def test_japanese_unicode(self, tmpdir):
p1 = Path(str(tmpdir))
p1["$Recycle.Bin"].mkdir()
p1["$Recycle.Bin"]["somerecycledfile.png"].open("w").close()
p1["$Recycle.Bin"]["some_unwanted_file.jpg"].open("w").close()
p1["$Recycle.Bin"]["subdir"].mkdir()
p1["$Recycle.Bin"]["subdir"]["過去白濁物語~]_カラー.jpg"].open("w").close()
p1["$Recycle.Bin"]["思叫物語"].mkdir()
p1["$Recycle.Bin"]["思叫物語"]["なししろ会う前"].open("w").close()
p1["$Recycle.Bin"]["思叫物語"]["堂~ロ"].open("w").close()
self.d.add_path(p1["$Recycle.Bin"])
p1.joinpath("$Recycle.Bin").mkdir()
p1.joinpath("$Recycle.Bin", "somerecycledfile.png").touch()
p1.joinpath("$Recycle.Bin", "some_unwanted_file.jpg").touch()
p1.joinpath("$Recycle.Bin", "subdir").mkdir()
p1.joinpath("$Recycle.Bin", "subdir", "過去白濁物語~]_カラー.jpg").touch()
p1.joinpath("$Recycle.Bin", "思叫物語").mkdir()
p1.joinpath("$Recycle.Bin", "思叫物語", "なししろ会う前").touch()
p1.joinpath("$Recycle.Bin", "思叫物語", "堂~ロ").touch()
self.d.add_path(p1.joinpath("$Recycle.Bin"))
regex3 = r".*物語.*"
self.d._exclude_list.add(regex3)
self.d._exclude_list.mark(regex3)
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
eq_(self.d.get_state(p1["$Recycle.Bin"]["思叫物語"]), DirectoryState.EXCLUDED)
eq_(self.d.get_state(p1.joinpath("$Recycle.Bin", "思叫物語")), DirectoryState.EXCLUDED)
files = self.get_files_and_expect_num_result(2)
assert "過去白濁物語~]_カラー.jpg" not in files
assert "なししろ会う前" not in files
@@ -527,7 +520,7 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
regex4 = r".*物語$"
self.d._exclude_list.rename(regex3, regex4)
assert self.d._exclude_list.error(regex4) is None
self.d.set_state(p1["$Recycle.Bin"]["思叫物語"], DirectoryState.NORMAL)
self.d.set_state(p1.joinpath("$Recycle.Bin", "思叫物語"), DirectoryState.NORMAL)
files = self.get_files_and_expect_num_result(5)
assert "過去白濁物語~]_カラー.jpg" in files
assert "なししろ会う前" in files
@@ -539,15 +532,15 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
self.d._exclude_list.add(regex)
self.d._exclude_list.mark(regex)
p1 = Path(str(tmpdir))
p1["foobar"].mkdir()
p1["foobar"][".hidden_file.txt"].open("w").close()
p1["foobar"][".hidden_dir"].mkdir()
p1["foobar"][".hidden_dir"]["foobar.jpg"].open("w").close()
p1["foobar"][".hidden_dir"][".hidden_subfile.png"].open("w").close()
self.d.add_path(p1["foobar"])
p1.joinpath("foobar").mkdir()
p1.joinpath("foobar", ".hidden_file.txt").touch()
p1.joinpath("foobar", ".hidden_dir").mkdir()
p1.joinpath("foobar", ".hidden_dir", "foobar.jpg").touch()
p1.joinpath("foobar", ".hidden_dir", ".hidden_subfile.png").touch()
self.d.add_path(p1.joinpath("foobar"))
# It should not inherit its parent's state originally
eq_(self.d.get_state(p1["foobar"][".hidden_dir"]), DirectoryState.EXCLUDED)
self.d.set_state(p1["foobar"][".hidden_dir"], DirectoryState.NORMAL)
eq_(self.d.get_state(p1.joinpath("foobar", ".hidden_dir")), DirectoryState.EXCLUDED)
self.d.set_state(p1.joinpath("foobar", ".hidden_dir"), DirectoryState.NORMAL)
# The files should still be filtered
files = self.get_files_and_expect_num_result(1)
eq_(len(self.d._exclude_list.compiled_paths), 0)

View File

@@ -10,9 +10,9 @@ from hscommon.jobprogress import job
from hscommon.util import first
from hscommon.testutil import eq_, log_calls
from .base import NamedObject
from .. import engine
from ..engine import (
from core.tests.base import NamedObject
from core import engine
from core.engine import (
get_match,
getwords,
Group,
@@ -271,9 +271,9 @@ class TestCaseBuildWordDict:
class TestCaseMergeSimilarWords:
def test_some_similar_words(self):
d = {
"foobar": set([1]),
"foobar1": set([2]),
"foobar2": set([3]),
"foobar": {1},
"foobar1": {2},
"foobar2": {3},
}
merge_similar_words(d)
eq_(1, len(d))
@@ -283,8 +283,8 @@ class TestCaseMergeSimilarWords:
class TestCaseReduceCommonWords:
def test_typical(self):
d = {
"foo": set([NamedObject("foo bar", True) for _ in range(50)]),
"bar": set([NamedObject("foo bar", True) for _ in range(49)]),
"foo": {NamedObject("foo bar", True) for _ in range(50)},
"bar": {NamedObject("foo bar", True) for _ in range(49)},
}
reduce_common_words(d, 50)
assert "foo" not in d
@@ -293,7 +293,7 @@ class TestCaseReduceCommonWords:
def test_dont_remove_objects_with_only_common_words(self):
d = {
"common": set([NamedObject("common uncommon", True) for _ in range(50)] + [NamedObject("common", True)]),
"uncommon": set([NamedObject("common uncommon", True)]),
"uncommon": {NamedObject("common uncommon", True)},
}
reduce_common_words(d, 50)
eq_(1, len(d["common"]))
@@ -302,7 +302,7 @@ class TestCaseReduceCommonWords:
def test_values_still_are_set_instances(self):
d = {
"common": set([NamedObject("common uncommon", True) for _ in range(50)] + [NamedObject("common", True)]),
"uncommon": set([NamedObject("common uncommon", True)]),
"uncommon": {NamedObject("common uncommon", True)},
}
reduce_common_words(d, 50)
assert isinstance(d["common"], set)
@@ -312,9 +312,9 @@ class TestCaseReduceCommonWords:
# If a word has been removed by the reduce, an object in a subsequent common word that
# contains the word that has been removed would cause a KeyError.
d = {
"foo": set([NamedObject("foo bar baz", True) for _ in range(50)]),
"bar": set([NamedObject("foo bar baz", True) for _ in range(50)]),
"baz": set([NamedObject("foo bar baz", True) for _ in range(49)]),
"foo": {NamedObject("foo bar baz", True) for _ in range(50)},
"bar": {NamedObject("foo bar baz", True) for _ in range(50)},
"baz": {NamedObject("foo bar baz", True) for _ in range(49)},
}
try:
reduce_common_words(d, 50)
@@ -328,7 +328,7 @@ class TestCaseReduceCommonWords:
o.words = [["foo", "bar"], ["baz"]]
return o
d = {"foo": set([create_it() for _ in range(50)])}
d = {"foo": {create_it() for _ in range(50)}}
try:
reduce_common_words(d, 50)
except TypeError:
@@ -343,7 +343,7 @@ class TestCaseReduceCommonWords:
d = {
"foo": set([NamedObject("foo bar baz", True) for _ in range(49)] + [only_common]),
"bar": set([NamedObject("foo bar baz", True) for _ in range(49)] + [only_common]),
"baz": set([NamedObject("foo bar baz", True) for _ in range(49)]),
"baz": {NamedObject("foo bar baz", True) for _ in range(49)},
}
reduce_common_words(d, 50)
eq_(1, len(d["foo"]))
@@ -530,7 +530,7 @@ class TestCaseGetMatches:
class TestCaseGetMatchesByContents:
def test_big_file_partial_hashes(self):
def test_big_file_partial_hashing(self):
smallsize = 1
bigsize = 100 * 1024 * 1024 # 100MB
f = [
@@ -539,17 +539,17 @@ class TestCaseGetMatchesByContents:
no("smallfoo", size=smallsize),
no("smallbar", size=smallsize),
]
f[0].md5 = f[0].md5partial = f[0].md5samples = "foobar"
f[1].md5 = f[1].md5partial = f[1].md5samples = "foobar"
f[2].md5 = f[2].md5partial = "bleh"
f[3].md5 = f[3].md5partial = "bleh"
f[0].digest = f[0].digest_partial = f[0].digest_samples = "foobar"
f[1].digest = f[1].digest_partial = f[1].digest_samples = "foobar"
f[2].digest = f[2].digest_partial = "bleh"
f[3].digest = f[3].digest_partial = "bleh"
r = getmatches_by_contents(f, bigsize=bigsize)
eq_(len(r), 2)
# User disabled optimization for big files, compute hashes as usual
# User disabled optimization for big files, compute digests as usual
r = getmatches_by_contents(f, bigsize=0)
eq_(len(r), 2)
# Other file is now slightly different, md5partial is still the same
f[1].md5 = f[1].md5samples = "foobardiff"
# Other file is now slightly different, digest_partial is still the same
f[1].digest = f[1].digest_samples = "foobardiff"
r = getmatches_by_contents(f, bigsize=bigsize)
# Successfully filter it out
eq_(len(r), 1)
@@ -884,7 +884,7 @@ class TestCaseGetGroups:
# If, with a (A, B, C, D) set, all match with A, but C and D don't match with B and that the
# (A, B) match is the highest (thus resulting in an (A, B) group), still match C and D
# in a separate group instead of discarding them.
A, B, C, D = [NamedObject() for _ in range(4)]
A, B, C, D = (NamedObject() for _ in range(4))
m1 = Match(A, B, 90) # This is the strongest "A" match
m2 = Match(A, C, 80) # Because C doesn't match with B, it won't be in the group
m3 = Match(A, D, 80) # Same thing for D

View File

@@ -10,8 +10,8 @@ from xml.etree import ElementTree as ET
from hscommon.testutil import eq_
from hscommon.plat import ISWINDOWS
from .base import DupeGuru
from ..exclude import ExcludeList, ExcludeDict, default_regexes, AlreadyThereException
from core.tests.base import DupeGuru
from core.exclude import ExcludeList, ExcludeDict, default_regexes, AlreadyThereException
from re import error
@@ -289,8 +289,8 @@ class TestCaseListEmptyUnion(TestCaseListEmpty):
compiled = [x for x in self.exclude_list.compiled]
assert regex not in compiled
# Need to escape both to get the same strings after compilation
compiled_escaped = set([x.encode("unicode-escape").decode() for x in compiled[0].pattern.split("|")])
default_escaped = set([x.encode("unicode-escape").decode() for x in default_regexes])
compiled_escaped = {x.encode("unicode-escape").decode() for x in compiled[0].pattern.split("|")}
default_escaped = {x.encode("unicode-escape").decode() for x in default_regexes}
assert compiled_escaped == default_escaped
eq_(len(default_regexes), len(compiled[0].pattern.split("|")))
@@ -366,8 +366,8 @@ class TestCaseDictEmptyUnion(TestCaseDictEmpty):
compiled = [x for x in self.exclude_list.compiled]
assert regex not in compiled
# Need to escape both to get the same strings after compilation
compiled_escaped = set([x.encode("unicode-escape").decode() for x in compiled[0].pattern.split("|")])
default_escaped = set([x.encode("unicode-escape").decode() for x in default_regexes])
compiled_escaped = {x.encode("unicode-escape").decode() for x in compiled[0].pattern.split("|")}
default_escaped = {x.encode("unicode-escape").decode() for x in default_regexes}
assert compiled_escaped == default_escaped
eq_(len(default_regexes), len(compiled[0].pattern.split("|")))

View File

@@ -6,43 +6,47 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import hashlib
import typing
from os import urandom
from hscommon.path import Path
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:
import xxhash
hasher = xxhash.xxh128
except ImportError:
import hashlib
hasher = hashlib.md5
def create_fake_fs_with_random_data(rootpath):
rootpath = rootpath["fs"]
rootpath = rootpath.joinpath("fs")
rootpath.mkdir()
rootpath["dir1"].mkdir()
rootpath["dir2"].mkdir()
rootpath["dir3"].mkdir()
fp = rootpath["file1.test"].open("wb")
rootpath.joinpath("dir1").mkdir()
rootpath.joinpath("dir2").mkdir()
rootpath.joinpath("dir3").mkdir()
data1 = urandom(200 * 1024) # 200KiB
data2 = urandom(1024 * 1024) # 1MiB
data3 = urandom(10 * 1024 * 1024) # 10MiB
fp.write(data1)
fp.close()
fp = rootpath["file2.test"].open("wb")
fp.write(data2)
fp.close()
fp = rootpath["file3.test"].open("wb")
fp.write(data3)
fp.close()
fp = rootpath["dir1"]["file1.test"].open("wb")
fp.write(data1)
fp.close()
fp = rootpath["dir2"]["file2.test"].open("wb")
fp.write(data2)
fp.close()
fp = rootpath["dir3"]["file3.test"].open("wb")
fp.write(data3)
fp.close()
with rootpath.joinpath("file1.test").open("wb") as fp:
fp.write(data1)
with rootpath.joinpath("file2.test").open("wb") as fp:
fp.write(data2)
with rootpath.joinpath("file3.test").open("wb") as fp:
fp.write(data3)
with rootpath.joinpath("dir1", "file1.test").open("wb") as fp:
fp.write(data1)
with rootpath.joinpath("dir2", "file2.test").open("wb") as fp:
fp.write(data2)
with rootpath.joinpath("dir3", "file3.test").open("wb") as fp:
fp.write(data3)
return rootpath
@@ -52,54 +56,54 @@ def test_size_aggregates_subfiles(tmpdir):
eq_(b.size, 12)
def test_md5_aggregate_subfiles_sorted(tmpdir):
# dir.allfiles can return child in any order. Thus, bundle.md5 must aggregate
# all files' md5 it contains, but it must make sure that it does so in the
def test_digest_aggregate_subfiles_sorted(tmpdir):
# dir.allfiles can return child in any order. Thus, bundle.digest must aggregate
# all files' digests it contains, but it must make sure that it does so in the
# same order everytime.
p = create_fake_fs_with_random_data(Path(str(tmpdir)))
b = fs.Folder(p)
md51 = fs.File(p["dir1"]["file1.test"]).md5
md52 = fs.File(p["dir2"]["file2.test"]).md5
md53 = fs.File(p["dir3"]["file3.test"]).md5
md54 = fs.File(p["file1.test"]).md5
md55 = fs.File(p["file2.test"]).md5
md56 = fs.File(p["file3.test"]).md5
# The expected md5 is the md5 of md5s for folders and the direct md5 for files
folder_md51 = hashlib.md5(md51).digest()
folder_md52 = hashlib.md5(md52).digest()
folder_md53 = hashlib.md5(md53).digest()
md5 = hashlib.md5(folder_md51 + folder_md52 + folder_md53 + md54 + md55 + md56)
eq_(b.md5, md5.digest())
digest1 = fs.File(p.joinpath("dir1", "file1.test")).digest
digest2 = fs.File(p.joinpath("dir2", "file2.test")).digest
digest3 = fs.File(p.joinpath("dir3", "file3.test")).digest
digest4 = fs.File(p.joinpath("file1.test")).digest
digest5 = fs.File(p.joinpath("file2.test")).digest
digest6 = fs.File(p.joinpath("file3.test")).digest
# The expected digest is the hash of digests for folders and the direct digest for files
folder_digest1 = hasher(digest1).digest()
folder_digest2 = hasher(digest2).digest()
folder_digest3 = hasher(digest3).digest()
digest = hasher(folder_digest1 + folder_digest2 + folder_digest3 + digest4 + digest5 + digest6).digest()
eq_(b.digest, digest)
def test_partial_md5_aggregate_subfile_sorted(tmpdir):
def test_partial_digest_aggregate_subfile_sorted(tmpdir):
p = create_fake_fs_with_random_data(Path(str(tmpdir)))
b = fs.Folder(p)
md51 = fs.File(p["dir1"]["file1.test"]).md5partial
md52 = fs.File(p["dir2"]["file2.test"]).md5partial
md53 = fs.File(p["dir3"]["file3.test"]).md5partial
md54 = fs.File(p["file1.test"]).md5partial
md55 = fs.File(p["file2.test"]).md5partial
md56 = fs.File(p["file3.test"]).md5partial
# The expected md5 is the md5 of md5s for folders and the direct md5 for files
folder_md51 = hashlib.md5(md51).digest()
folder_md52 = hashlib.md5(md52).digest()
folder_md53 = hashlib.md5(md53).digest()
md5 = hashlib.md5(folder_md51 + folder_md52 + folder_md53 + md54 + md55 + md56)
eq_(b.md5partial, md5.digest())
digest1 = fs.File(p.joinpath("dir1", "file1.test")).digest_partial
digest2 = fs.File(p.joinpath("dir2", "file2.test")).digest_partial
digest3 = fs.File(p.joinpath("dir3", "file3.test")).digest_partial
digest4 = fs.File(p.joinpath("file1.test")).digest_partial
digest5 = fs.File(p.joinpath("file2.test")).digest_partial
digest6 = fs.File(p.joinpath("file3.test")).digest_partial
# The expected digest is the hash of digests for folders and the direct digest for files
folder_digest1 = hasher(digest1).digest()
folder_digest2 = hasher(digest2).digest()
folder_digest3 = hasher(digest3).digest()
digest = hasher(folder_digest1 + folder_digest2 + folder_digest3 + digest4 + digest5 + digest6).digest()
eq_(b.digest_partial, digest)
md51 = fs.File(p["dir1"]["file1.test"]).md5samples
md52 = fs.File(p["dir2"]["file2.test"]).md5samples
md53 = fs.File(p["dir3"]["file3.test"]).md5samples
md54 = fs.File(p["file1.test"]).md5samples
md55 = fs.File(p["file2.test"]).md5samples
md56 = fs.File(p["file3.test"]).md5samples
# The expected md5 is the md5 of md5s for folders and the direct md5 for files
folder_md51 = hashlib.md5(md51).digest()
folder_md52 = hashlib.md5(md52).digest()
folder_md53 = hashlib.md5(md53).digest()
md5 = hashlib.md5(folder_md51 + folder_md52 + folder_md53 + md54 + md55 + md56)
eq_(b.md5samples, md5.digest())
digest1 = fs.File(p.joinpath("dir1", "file1.test")).digest_samples
digest2 = fs.File(p.joinpath("dir2", "file2.test")).digest_samples
digest3 = fs.File(p.joinpath("dir3", "file3.test")).digest_samples
digest4 = fs.File(p.joinpath("file1.test")).digest_samples
digest5 = fs.File(p.joinpath("file2.test")).digest_samples
digest6 = fs.File(p.joinpath("file3.test")).digest_samples
# The expected digest is the digest of digests for folders and the direct digest for files
folder_digest1 = hasher(digest1).digest()
folder_digest2 = hasher(digest2).digest()
folder_digest3 = hasher(digest3).digest()
digest = hasher(folder_digest1 + folder_digest2 + folder_digest3 + digest4 + digest5 + digest6).digest()
eq_(b.digest_samples, digest)
def test_has_file_attrs(tmpdir):

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,9 @@ from xml.etree import ElementTree as ET
from pytest import raises
from hscommon.testutil import eq_
from hscommon.util import first
from .. import engine
from .base import NamedObject, GetTestGroups, DupeGuru
from ..results import Results
from core import engine
from core.tests.base import NamedObject, GetTestGroups, DupeGuru
from core.results import Results
class TestCaseResultsEmpty:
@@ -337,7 +336,7 @@ class TestCaseResultsMarkings:
def log_object(o):
log.append(o)
if o is self.objects[1]:
raise EnvironmentError("foobar")
raise OSError("foobar")
log = []
self.results.mark_all()
@@ -447,7 +446,7 @@ class TestCaseResultsXML:
self.results.groups = self.groups
def get_file(self, path): # use this as a callback for load_from_xml
return [o for o in self.objects if o.path == path][0]
return [o for o in self.objects if str(o.path) == path][0]
def test_save_to_xml(self):
self.objects[0].is_ref = True
@@ -464,7 +463,7 @@ class TestCaseResultsXML:
eq_(6, len(g1))
eq_(3, len([c for c in g1 if c.tag == "file"]))
eq_(3, len([c for c in g1 if c.tag == "match"]))
d1, d2, d3 = [c for c in g1 if c.tag == "file"]
d1, d2, d3 = (c for c in g1 if c.tag == "file")
eq_(op.join("basepath", "foo bar"), d1.get("path"))
eq_(op.join("basepath", "bar bleh"), d2.get("path"))
eq_(op.join("basepath", "foo bleh"), d3.get("path"))
@@ -477,7 +476,7 @@ class TestCaseResultsXML:
eq_(3, len(g2))
eq_(2, len([c for c in g2 if c.tag == "file"]))
eq_(1, len([c for c in g2 if c.tag == "match"]))
d1, d2 = [c for c in g2 if c.tag == "file"]
d1, d2 = (c for c in g2 if c.tag == "file")
eq_(op.join("basepath", "ibabtu"), d1.get("path"))
eq_(op.join("basepath", "ibabtu"), d2.get("path"))
eq_("n", d1.get("is_ref"))

View File

@@ -7,14 +7,14 @@
import pytest
from hscommon.jobprogress import job
from hscommon.path import Path
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:
@@ -22,14 +22,14 @@ class NamedObject:
if path is None:
path = Path(name)
else:
path = Path(path)[name]
path = Path(path, name)
self.name = name
self.size = size
self.path = path
self.words = getwords(name)
def __repr__(self):
return "<NamedObject %r %r>" % (self.name, self.path)
return "<NamedObject {!r} {!r}>".format(self.name, self.path)
no = NamedObject
@@ -123,19 +123,19 @@ def test_content_scan(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.CONTENTS
f = [no("foo"), no("bar"), no("bleh")]
f[0].md5 = f[0].md5partial = f[0].md5samples = "foobar"
f[1].md5 = f[1].md5partial = f[1].md5samples = "foobar"
f[2].md5 = f[2].md5partial = f[1].md5samples = "bleh"
f[0].digest = f[0].digest_partial = f[0].digest_samples = "foobar"
f[1].digest = f[1].digest_partial = f[1].digest_samples = "foobar"
f[2].digest = f[2].digest_partial = f[1].digest_samples = "bleh"
r = s.get_dupe_groups(f)
eq_(len(r), 1)
eq_(len(r[0]), 2)
eq_(s.discarded_file_count, 0) # don't count the different md5 as discarded!
eq_(s.discarded_file_count, 0) # don't count the different digest as discarded!
def test_content_scan_compare_sizes_first(fake_fileexists):
class MyFile(no):
@property
def md5(self):
def digest(self):
raise AssertionError()
s = Scanner()
@@ -161,14 +161,14 @@ def test_ignore_file_size(fake_fileexists):
no("largeignore1", large_size + 1),
no("largeignore2", large_size + 1),
]
f[0].md5 = f[0].md5partial = f[0].md5samples = "smallignore"
f[1].md5 = f[1].md5partial = f[1].md5samples = "smallignore"
f[2].md5 = f[2].md5partial = f[2].md5samples = "small"
f[3].md5 = f[3].md5partial = f[3].md5samples = "small"
f[4].md5 = f[4].md5partial = f[4].md5samples = "large"
f[5].md5 = f[5].md5partial = f[5].md5samples = "large"
f[6].md5 = f[6].md5partial = f[6].md5samples = "largeignore"
f[7].md5 = f[7].md5partial = f[7].md5samples = "largeignore"
f[0].digest = f[0].digest_partial = f[0].digest_samples = "smallignore"
f[1].digest = f[1].digest_partial = f[1].digest_samples = "smallignore"
f[2].digest = f[2].digest_partial = f[2].digest_samples = "small"
f[3].digest = f[3].digest_partial = f[3].digest_samples = "small"
f[4].digest = f[4].digest_partial = f[4].digest_samples = "large"
f[5].digest = f[5].digest_partial = f[5].digest_samples = "large"
f[6].digest = f[6].digest_partial = f[6].digest_samples = "largeignore"
f[7].digest = f[7].digest_partial = f[7].digest_samples = "largeignore"
r = s.get_dupe_groups(f)
# No ignores
@@ -197,21 +197,21 @@ def test_big_file_partial_hashes(fake_fileexists):
s.big_file_size_threshold = bigsize
f = [no("bigfoo", bigsize), no("bigbar", bigsize), no("smallfoo", smallsize), no("smallbar", smallsize)]
f[0].md5 = f[0].md5partial = f[0].md5samples = "foobar"
f[1].md5 = f[1].md5partial = f[1].md5samples = "foobar"
f[2].md5 = f[2].md5partial = "bleh"
f[3].md5 = f[3].md5partial = "bleh"
f[0].digest = f[0].digest_partial = f[0].digest_samples = "foobar"
f[1].digest = f[1].digest_partial = f[1].digest_samples = "foobar"
f[2].digest = f[2].digest_partial = "bleh"
f[3].digest = f[3].digest_partial = "bleh"
r = s.get_dupe_groups(f)
eq_(len(r), 2)
# md5partial is still the same, but the file is actually different
f[1].md5 = f[1].md5samples = "difffoobar"
# here we compare the full md5s, as the user disabled the optimization
# digest_partial is still the same, but the file is actually different
f[1].digest = f[1].digest_samples = "difffoobar"
# here we compare the full digests, as the user disabled the optimization
s.big_file_size_threshold = 0
r = s.get_dupe_groups(f)
eq_(len(r), 1)
# here we should compare the md5samples, and see they are different
# here we should compare the digest_samples, and see they are different
s.big_file_size_threshold = bigsize
r = s.get_dupe_groups(f)
eq_(len(r), 1)
@@ -221,9 +221,9 @@ def test_min_match_perc_doesnt_matter_for_content_scan(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.CONTENTS
f = [no("foo"), no("bar"), no("bleh")]
f[0].md5 = f[0].md5partial = f[0].md5samples = "foobar"
f[1].md5 = f[1].md5partial = f[1].md5samples = "foobar"
f[2].md5 = f[2].md5partial = f[2].md5samples = "bleh"
f[0].digest = f[0].digest_partial = f[0].digest_samples = "foobar"
f[1].digest = f[1].digest_partial = f[1].digest_samples = "foobar"
f[2].digest = f[2].digest_partial = f[2].digest_samples = "bleh"
s.min_match_percentage = 101
r = s.get_dupe_groups(f)
eq_(len(r), 1)
@@ -234,12 +234,16 @@ def test_min_match_perc_doesnt_matter_for_content_scan(fake_fileexists):
eq_(len(r[0]), 2)
def test_content_scan_doesnt_put_md5_in_words_at_the_end(fake_fileexists):
def test_content_scan_doesnt_put_digest_in_words_at_the_end(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.CONTENTS
f = [no("foo"), no("bar")]
f[0].md5 = f[0].md5partial = f[0].md5samples = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
f[1].md5 = f[1].md5partial = f[1].md5samples = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
f[0].digest = f[0].digest_partial = f[
0
].digest_samples = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
f[1].digest = f[1].digest_partial = f[
1
].digest_samples = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
r = s.get_dupe_groups(f)
# FIXME looks like we are missing something here?
r[0]
@@ -332,7 +336,7 @@ def test_tag_scan(fake_fileexists):
def test_tag_with_album_scan(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.TAG
s.scanned_tags = set(["artist", "album", "title"])
s.scanned_tags = {"artist", "album", "title"}
o1 = no("foo")
o2 = no("bar")
o3 = no("bleh")
@@ -352,7 +356,7 @@ def test_tag_with_album_scan(fake_fileexists):
def test_that_dash_in_tags_dont_create_new_fields(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.TAG
s.scanned_tags = set(["artist", "album", "title"])
s.scanned_tags = {"artist", "album", "title"}
s.min_match_percentage = 50
o1 = no("foo")
o2 = no("bar")
@@ -369,7 +373,7 @@ def test_that_dash_in_tags_dont_create_new_fields(fake_fileexists):
def test_tag_scan_with_different_scanned(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.TAG
s.scanned_tags = set(["track", "year"])
s.scanned_tags = {"track", "year"}
o1 = no("foo")
o2 = no("bar")
o1.artist = "The White Stripes"
@@ -387,7 +391,7 @@ def test_tag_scan_with_different_scanned(fake_fileexists):
def test_tag_scan_only_scans_existing_tags(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.TAG
s.scanned_tags = set(["artist", "foo"])
s.scanned_tags = {"artist", "foo"}
o1 = no("foo")
o2 = no("bar")
o1.artist = "The White Stripes"
@@ -401,7 +405,7 @@ def test_tag_scan_only_scans_existing_tags(fake_fileexists):
def test_tag_scan_converts_to_str(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.TAG
s.scanned_tags = set(["track"])
s.scanned_tags = {"track"}
o1 = no("foo")
o2 = no("bar")
o1.track = 42
@@ -416,7 +420,7 @@ def test_tag_scan_converts_to_str(fake_fileexists):
def test_tag_scan_non_ascii(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.TAG
s.scanned_tags = set(["title"])
s.scanned_tags = {"title"}
o1 = no("foo")
o2 = no("bar")
o1.title = "foobar\u00e9"
@@ -568,12 +572,14 @@ def test_dont_group_files_that_dont_exist(tmpdir):
s = Scanner()
s.scan_type = ScanType.CONTENTS
p = Path(str(tmpdir))
p["file1"].open("w").write("foo")
p["file2"].open("w").write("foo")
with p.joinpath("file1").open("w") as fp:
fp.write("foo")
with p.joinpath("file2").open("w") as fp:
fp.write("foo")
file1, file2 = fs.get_files(p)
def getmatches(*args, **kw):
file2.path.remove()
file2.path.unlink()
return [Match(file1, file2, 100)]
s._getmatches = getmatches
@@ -587,21 +593,21 @@ def test_folder_scan_exclude_subfolder_matches(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.FOLDERS
topf1 = no("top folder 1", size=42)
topf1.md5 = topf1.md5partial = topf1.md5samples = b"some_md5_1"
topf1.digest = topf1.digest_partial = topf1.digest_samples = b"some_digest__1"
topf1.path = Path("/topf1")
topf2 = no("top folder 2", size=42)
topf2.md5 = topf2.md5partial = topf2.md5samples = b"some_md5_1"
topf2.digest = topf2.digest_partial = topf2.digest_samples = b"some_digest__1"
topf2.path = Path("/topf2")
subf1 = no("sub folder 1", size=41)
subf1.md5 = subf1.md5partial = subf1.md5samples = b"some_md5_2"
subf1.digest = subf1.digest_partial = subf1.digest_samples = b"some_digest__2"
subf1.path = Path("/topf1/sub")
subf2 = no("sub folder 2", size=41)
subf2.md5 = subf2.md5partial = subf2.md5samples = b"some_md5_2"
subf2.digest = subf2.digest_partial = subf2.digest_samples = b"some_digest__2"
subf2.path = Path("/topf2/sub")
eq_(len(s.get_dupe_groups([topf1, topf2, subf1, subf2])), 1) # only top folders
# however, if another folder matches a subfolder, keep in in the matches
otherf = no("other folder", size=41)
otherf.md5 = otherf.md5partial = otherf.md5samples = b"some_md5_2"
otherf.digest = otherf.digest_partial = otherf.digest_samples = b"some_digest__2"
otherf.path = Path("/otherfolder")
eq_(len(s.get_dupe_groups([topf1, topf2, subf1, subf2, otherf])), 2)
@@ -624,9 +630,9 @@ def test_dont_count_ref_files_as_discarded(fake_fileexists):
o1 = no("foo", path="p1")
o2 = no("foo", path="p2")
o3 = no("foo", path="p3")
o1.md5 = o1.md5partial = o1.md5samples = "foobar"
o2.md5 = o2.md5partial = o2.md5samples = "foobar"
o3.md5 = o3.md5partial = o3.md5samples = "foobar"
o1.digest = o1.digest_partial = o1.digest_samples = "foobar"
o2.digest = o2.digest_partial = o2.digest_samples = "foobar"
o3.digest = o3.digest_partial = o3.digest_samples = "foobar"
o1.is_ref = True
o2.is_ref = True
eq_(len(s.get_dupe_groups([o1, o2, o3])), 1)

View File

@@ -7,6 +7,12 @@
import time
import sys
import os
import urllib.request
import urllib.error
import json
import semantic_version
import logging
from typing import Union
from hscommon.util import format_time_decimal
@@ -64,3 +70,34 @@ def fix_surrogate_encoding(s, encoding="utf-8"):
def executable_folder():
return os.path.dirname(os.path.abspath(sys.argv[0]))
def check_for_update(current_version: str, include_prerelease: bool = False) -> Union[None, dict]:
request = urllib.request.Request(
"https://api.github.com/repos/arsenetar/dupeguru/releases",
headers={"Accept": "application/vnd.github.v3+json"},
)
try:
with urllib.request.urlopen(request) as response:
if response.status != 200:
logging.warn(f"Error retriving updates. Status: {response.status}")
return None
try:
response_json = json.loads(response.read())
except json.JSONDecodeError as ex:
logging.warn(f"Error parsing updates. {ex.msg}")
return None
except urllib.error.URLError as ex:
logging.warn(f"Error retriving updates. {ex.reason}")
return None
new_version = semantic_version.Version(current_version)
new_url = None
for release in response_json:
release_version = semantic_version.Version(release["name"])
if new_version < release_version and (include_prerelease or not release_version.prerelease):
new_version = release_version
new_url = release["html_url"]
if new_url is not None:
return {"version": new_version, "url": new_url}
else:
return None

View File

@@ -1,3 +1,21 @@
=== 4.3.1 (2022-07-08)
* Fix issue where cache db exceptions could prevent files being hashed (#1015)
* Add extra guard for non-zero length files without digests to prevent false duplicates
* Update Italian translations
=== 4.3.0 (2022-07-01)
* Redirect stdout from custom command to the log files (#1008)
* Update translations
* Fix typo in debian control file (#989)
* Add option to profile scans
* Update fs.py to optimize stat() calls
* Fix Error when delete after scan (#988)
* Update directory scanning to use os.scandir() and DirEntry objects
* Improve performance of Directories.get_state()
* Migrate from hscommon.path to pathlib
* Switch file hashing to xxhash with fallback to md5
* Add update check feature to about box
=== 4.2.1 (2022-03-25)
* Default to English on unsupported system language (#976)
* Fix image viewer zoom datatype issue (#978)

5
hscommon/.gitignore vendored
View File

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

View File

@@ -9,6 +9,7 @@
"""This module is a collection of function to help in HS apps build process.
"""
from argparse import ArgumentParser
import os
import sys
import os.path as op
@@ -20,23 +21,19 @@ import re
import importlib
from datetime import datetime
import glob
import sysconfig
import modulefinder
from typing import Any, AnyStr, Callable, Dict, List, Union
from setuptools import setup, Extension
from .plat import ISWINDOWS
from .util import ensure_folder, delete_files_with_pattern
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
@@ -45,34 +42,26 @@ def _perform(src, dst, action, actionname):
shutil.rmtree(dst)
else:
os.remove(dst)
print("%s %s --> %s" % (actionname, src, dst))
print("{} {} --> {}".format(actionname, src, dst))
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)
@@ -81,42 +70,35 @@ 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
fp = open(filename, "rt", encoding="utf-8")
fp = open(filename, encoding="utf-8")
contents = fp.read()
fp.close()
# We can't use str.format() because in some files, there might be {} characters that mess with it.
for key, item in kwargs.items():
contents = contents.replace("{{{}}}".format(key), item)
contents = contents.replace(f"{{{key}}}", item)
fp = open(outfilename, "wt", encoding="utf-8")
fp.write(contents)
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",
@@ -143,13 +125,13 @@ 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.
if args.sign_identity:
sign_identity = "Developer ID Application: {}".format(args.sign_identity)
result = print_and_do('codesign --force --deep --sign "{}" "{}"'.format(sign_identity, app_path))
sign_identity = f"Developer ID Application: {args.sign_identity}"
result = print_and_do(f'codesign --force --deep --sign "{sign_identity}" "{app_path}"')
if result != 0:
print("ERROR: Signing failed. Aborting packaging.")
return
@@ -159,46 +141,32 @@ 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)
print_and_do('cp -R "%s" "%s"' % (app_path, dmgpath))
print_and_do('cp -R "{}" "{}"'.format(app_path, dmgpath))
print_and_do('ln -s /Applications "%s"' % op.join(dmgpath, "Applications"))
dmgname = "%s_osx_%s.dmg" % (
dmgname = "{}_osx_{}.dmg".format(
plist["CFBundleName"].lower().replace(" ", "_"),
plist["CFBundleVersion"].replace(".", "_"),
)
print("Building %s" % dmgname)
# UDBZ = bzip compression. UDZO (zip compression) was used before, but it compresses much less.
print_and_do('hdiutil create "%s" -format UDBZ -nocrossdev -srcdir "%s"' % (op.join(destfolder, dmgname), dmgpath))
print_and_do(
'hdiutil create "{}" -format UDBZ -nocrossdev -srcdir "{}"'.format(op.join(destfolder, dmgname), dmgpath)
)
print("Build Complete")
def copy_sysconfig_files_for_embed(destpath):
# This normally shouldn't be needed for Python 3.3+.
makefile = sysconfig.get_makefile_filename()
configh = sysconfig.get_config_h_filename()
shutil.copy(makefile, destpath)
shutil.copy(configh, destpath)
with open(op.join(destpath, "site.py"), "w") as fp:
fp.write(
"""
import os.path as op
from distutils import sysconfig
sysconfig.get_makefile_filename = lambda: op.join(op.dirname(__file__), 'Makefile')
sysconfig.get_config_h_filename = lambda: op.join(op.dirname(__file__), 'pyconfig.h')
"""
)
def add_to_pythonpath(path):
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", "")
@@ -211,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.
@@ -238,7 +211,7 @@ def copy_packages(packages_names, dest, create_links=False, extra_ignores=None):
os.unlink(dest_path)
else:
shutil.rmtree(dest_path)
print("Copying package at {0} to {1}".format(source_path, dest_path))
print(f"Copying package at {source_path} to {dest_path}")
if create_links:
os.symlink(op.abspath(source_path), dest_path)
else:
@@ -248,28 +221,14 @@ def copy_packages(packages_names, dest, create_links=False, extra_ignores=None):
shutil.copy(source_path, dest_path)
def copy_qt_plugins(folder_names, dest): # This is only for Windows
from PyQt5.QtCore import QLibraryInfo
qt_plugin_dir = QLibraryInfo.location(QLibraryInfo.PluginsPath)
def ignore(path, names):
if path == qt_plugin_dir:
return [n for n in names if n not in folder_names]
else:
return [n for n in names if not n.endswith(".dll")]
shutil.copytree(qt_plugin_dir, dest, ignore=ignore)
def build_debian_changelog(
changelogpath,
destfile,
pkgname,
from_version=None,
distribution="precise",
fix_version=None,
):
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
@@ -322,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:
@@ -333,7 +292,7 @@ def read_changelog_file(filename):
return
yield version, date, description
with open(filename, "rt", encoding="utf-8") as fp:
with open(filename, encoding="utf-8") as fp:
contents = fp.read()
splitted = re_changelog_header.split(contents)[1:] # the first item is empty
result = []
@@ -349,184 +308,7 @@ def read_changelog_file(filename):
return result
class OSXAppStructure:
def __init__(self, dest):
self.dest = dest
self.contents = op.join(dest, "Contents")
self.macos = op.join(self.contents, "MacOS")
self.resources = op.join(self.contents, "Resources")
self.frameworks = op.join(self.contents, "Frameworks")
self.infoplist = op.join(self.contents, "Info.plist")
def create(self, infoplist):
ensure_empty_folder(self.dest)
os.makedirs(self.macos)
os.mkdir(self.resources)
os.mkdir(self.frameworks)
copy(infoplist, self.infoplist)
open(op.join(self.contents, "PkgInfo"), "wt").write("APPLxxxx")
def copy_executable(self, executable):
info = plistlib.readPlist(self.infoplist)
self.executablename = info["CFBundleExecutable"]
self.executablepath = op.join(self.macos, self.executablename)
copy(executable, self.executablepath)
def copy_resources(self, *resources, use_symlinks=False):
for path in resources:
resource_dest = op.join(self.resources, op.basename(path))
action = symlink if use_symlinks else copy
action(op.abspath(path), resource_dest)
def copy_frameworks(self, *frameworks):
for path in frameworks:
framework_dest = op.join(self.frameworks, op.basename(path))
copy(path, framework_dest)
def create_osx_app_structure(
dest,
executable,
infoplist,
resources=None,
frameworks=None,
symlink_resources=False,
):
# `dest`: A path to the destination .app folder
# `executable`: the path of the executable file that goes in "MacOS"
# `infoplist`: The path to your Info.plist file.
# `resources`: A list of paths of files or folders going in the "Resources" folder.
# `frameworks`: Same as above for "Frameworks".
# `symlink_resources`: If True, will symlink resources into the structure instead of copying them.
app = OSXAppStructure(dest)
app.create(infoplist)
app.copy_executable(executable)
app.copy_resources(*resources, use_symlinks=symlink_resources)
app.copy_frameworks(*frameworks)
class OSXFrameworkStructure:
def __init__(self, dest):
self.dest = dest
self.contents = op.join(dest, "Versions", "A")
self.resources = op.join(self.contents, "Resources")
self.headers = op.join(self.contents, "Headers")
self.infoplist = op.join(self.resources, "Info.plist")
self._update_executable_path()
def _update_executable_path(self):
if not op.exists(self.infoplist):
self.executablename = self.executablepath = None
return
info = plistlib.readPlist(self.infoplist)
self.executablename = info["CFBundleExecutable"]
self.executablepath = op.join(self.contents, self.executablename)
def create(self, infoplist):
ensure_empty_folder(self.dest)
os.makedirs(self.contents)
os.mkdir(self.resources)
os.mkdir(self.headers)
copy(infoplist, self.infoplist)
self._update_executable_path()
def create_symlinks(self):
# Only call this after create() and copy_executable()
os.symlink("A", op.join(self.dest, "Versions", "Current"))
os.symlink(op.relpath(self.executablepath, self.dest), op.join(self.dest, self.executablename))
os.symlink(op.relpath(self.headers, self.dest), op.join(self.dest, "Headers"))
os.symlink(op.relpath(self.resources, self.dest), op.join(self.dest, "Resources"))
def copy_executable(self, executable):
copy(executable, self.executablepath)
def copy_resources(self, *resources, use_symlinks=False):
for path in resources:
resource_dest = op.join(self.resources, op.basename(path))
action = symlink if use_symlinks else copy
action(op.abspath(path), resource_dest)
def copy_headers(self, *headers, use_symlinks=False):
for path in headers:
header_dest = op.join(self.headers, op.basename(path))
action = symlink if use_symlinks else copy
action(op.abspath(path), header_dest)
def copy_embeddable_python_dylib(dst):
runtime = op.join(
sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX"),
sysconfig.get_config_var("LDLIBRARY"),
)
filedest = op.join(dst, "Python")
shutil.copy(runtime, filedest)
os.chmod(filedest, 0o774) # We need write permission to use install_name_tool
cmd = "install_name_tool -id @rpath/Python %s" % filedest
print_and_do(cmd)
def collect_stdlib_dependencies(script, dest_folder, extra_deps=None):
sysprefix = sys.prefix # could be a virtualenv
basesysprefix = sys.base_prefix # seems to be path to non-virtual sys
real_lib_prefix = sysconfig.get_config_var("LIBDEST") # leaving this in case it is neede
def is_stdlib_path(path):
# A module path is only a stdlib path if it's in either sys.prefix or
# sysconfig.get_config_var('prefix') (the 2 are different if we are in a virtualenv) and if
# there's no "site-package in the path.
if not path:
return False
if "site-package" in path:
return False
if not (path.startswith(sysprefix) or path.startswith(basesysprefix) or path.startswith(real_lib_prefix)):
return False
return True
ensure_folder(dest_folder)
mf = modulefinder.ModuleFinder()
mf.run_script(script)
modpaths = [mod.__file__ for mod in mf.modules.values()]
modpaths = filter(is_stdlib_path, modpaths)
for p in modpaths:
if p.startswith(real_lib_prefix):
relpath = op.relpath(p, real_lib_prefix)
elif p.startswith(sysprefix):
relpath = op.relpath(p, sysprefix)
assert relpath.startswith("lib/python3.") # we want to get rid of that lib/python3.x part
relpath = relpath[len("lib/python3.X/") :]
elif p.startswith(basesysprefix):
relpath = op.relpath(p, basesysprefix)
assert relpath.startswith("lib/python3.")
relpath = relpath[len("lib/python3.X/") :]
else:
raise AssertionError()
if relpath.startswith("lib-dynload"): # We copy .so files in lib-dynload directly in our dest
relpath = relpath[len("lib-dynload/") :]
if relpath.startswith("encodings") or relpath.startswith("distutils"):
# We force their inclusion later.
continue
dest_path = op.join(dest_folder, relpath)
ensure_folder(op.dirname(dest_path))
copy(p, dest_path)
# stringprep is used by encodings.
# We use real_lib_prefix with distutils because virtualenv messes with it and we need to refer
# to the original distutils folder.
FORCED_INCLUSION = [
"encodings",
"stringprep",
op.join(real_lib_prefix, "distutils"),
]
if extra_deps:
FORCED_INCLUSION += extra_deps
copy_packages(FORCED_INCLUSION, dest_folder)
# There's a couple of rather big exe files in the distutils folder that we absolutely don't
# need. Remove them.
delete_files_with_pattern(op.join(dest_folder, "distutils"), "*.exe")
# And, finally, create an empty "site.py" that Python needs around on startup.
open(op.join(dest_folder, "site.py"), "w").close()
def fix_qt_resource_file(path):
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
@@ -537,21 +319,3 @@ def fix_qt_resource_file(path):
lines = [line for line in lines if not line.startswith(b"#")]
with open(path, "wb") as fp:
fp.write(b"\n".join(lines))
def build_cocoa_ext(extname, dest, source_files, extra_frameworks=(), extra_includes=()):
extra_link_args = ["-framework", "CoreFoundation", "-framework", "Foundation"]
for extra in extra_frameworks:
extra_link_args += ["-framework", extra]
ext = Extension(
extname,
source_files,
extra_link_args=extra_link_args,
include_dirs=extra_includes,
)
setup(script_args=["build_ext", "--inplace"], ext_modules=[ext])
# Our problem here is to get the fully qualified filename of the resulting .so but I couldn't
# find a documented way to do so. The only thing I could find is this below :(
fn = ext._file_name
assert op.exists(fn)
move(fn, op.join(dest, fn))

View File

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

View File

@@ -14,7 +14,8 @@ import re
import os
import shutil
from .path import Path, pathify
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 .path import Path, pathify
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,34 +48,33 @@ 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
@pathify
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.isdir() and not source_path.isdir():
dest_path = dest_path[source_path.name]
if dest_path.is_dir() and not source_path.is_dir():
dest_path = dest_path.joinpath(source_path.name)
if dest_path.exists():
filename = dest_path.name
dest_dir_path = dest_path.parent()
dest_dir_path = dest_path.parent
newname = get_conflicted_name(os.listdir(str(dest_dir_path)), filename)
dest_path = dest_dir_path[newname]
dest_path = dest_dir_path.joinpath(newname)
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)
except IOError as e:
except OSError as e:
if e.errno in {
21,
13,

View File

@@ -1,23 +0,0 @@
# Created By: Virgil Dupras
# Created On: 2011-04-19
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import sys
import traceback
# Taken from http://bzimmer.ziclix.com/2008/12/17/python-thread-dumps/
def stacktraces():
code = []
for thread_id, stack in sys._current_frames().items():
code.append("\n# ThreadID: %s" % thread_id)
for filename, lineno, name, line in traceback.extract_stack(stack):
code.append('File: "%s", line %d, in %s' % (filename, lineno, name))
if line:
code.append(" %s" % (line.strip()))
return "\n".join(code)

View File

@@ -6,31 +6,33 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from enum import Enum
from os import PathLike
import os.path as op
import logging
class SpecialFolder:
class SpecialFolder(Enum):
APPDATA = 1
CACHE = 2
def open_url(url):
def open_url(url: str) -> None:
"""Open ``url`` with the default browser."""
_open_url(url)
def open_path(path):
def open_path(path: PathLike) -> None:
"""Open ``path`` with its associated application."""
_open_path(str(path))
def reveal_path(path):
def reveal_path(path: PathLike) -> None:
"""Open the folder containing ``path`` with the default file browser."""
_reveal_path(str(path))
def special_folder_path(special_folder, appname=None, portable=False):
def special_folder_path(special_folder: SpecialFolder, portable: bool = False) -> str:
"""Returns the path of ``special_folder``.
``special_folder`` is a SpecialFolder.* const. The result is the special folder for the current
@@ -38,77 +40,58 @@ 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:
# Normally, we would simply do "from cocoa import proxy", but due to a bug in pytest (currently
# at v2.4.2), our test suite is broken when we do that. This below is a workaround until that
# bug is fixed.
import cocoa
from PyQt5.QtCore import QUrl, QStandardPaths
from PyQt5.QtGui import QDesktopServices
from qt.util import get_appdata
from core.util import executable_folder
from hscommon.plat import ISWINDOWS, ISOSX
import subprocess
if not hasattr(cocoa, "proxy"):
raise ImportError()
proxy = cocoa.proxy
_open_url = proxy.openURL_
_open_path = proxy.openPath_
_reveal_path = proxy.revealPath_
def _open_url(url: str) -> None:
QDesktopServices.openUrl(QUrl(url))
def _special_folder_path(special_folder, appname=None, portable=False):
if special_folder == SpecialFolder.CACHE:
base = proxy.getCachePath()
def _open_path(path: str) -> None:
url = QUrl.fromLocalFile(str(path))
QDesktopServices.openUrl(url)
def _reveal_path(path: str) -> None:
if ISWINDOWS:
subprocess.run(["explorer", "/select,", op.abspath(path)])
elif ISOSX:
subprocess.run(["open", "-R", op.abspath(path)])
else:
base = proxy.getAppdataPath()
if not appname:
appname = proxy.bundleInfo_("CFBundleName")
return op.join(base, appname)
_open_path(op.dirname(str(path)))
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")
else:
folder = QStandardPaths.standardLocations(QStandardPaths.CacheLocation)[0]
else:
folder = get_appdata(portable)
return folder
except ImportError:
try:
from PyQt5.QtCore import QUrl, QStandardPaths
from PyQt5.QtGui import QDesktopServices
from qtlib.util import get_appdata
from core.util import executable_folder
from hscommon.plat import ISWINDOWS, ISOSX
import subprocess
# We're either running tests, and these functions don't matter much or we're in a really
# weird situation. Let's just have dummy fallbacks.
logging.warning("Can't setup desktop functions!")
def _open_url(url):
QDesktopServices.openUrl(QUrl(url))
def _open_url(url: str) -> None:
# Dummy for tests
pass
def _open_path(path):
url = QUrl.fromLocalFile(str(path))
QDesktopServices.openUrl(url)
def _open_path(path: str) -> None:
# Dummy for tests
pass
def _reveal_path(path):
if ISWINDOWS:
subprocess.run(["explorer", "/select,", op.abspath(path)])
elif ISOSX:
subprocess.run(["open", "-R", op.abspath(path)])
else:
_open_path(op.dirname(str(path)))
def _reveal_path(path: str) -> None:
# Dummy for tests
pass
def _special_folder_path(special_folder, appname=None, portable=False):
if special_folder == SpecialFolder.CACHE:
if ISWINDOWS and portable:
folder = op.join(executable_folder(), "cache")
else:
folder = QStandardPaths.standardLocations(QStandardPaths.CacheLocation)[0]
else:
folder = get_appdata(portable)
return folder
except ImportError:
# We're either running tests, and these functions don't matter much or we're in a really
# weird situation. Let's just have dummy fallbacks.
logging.warning("Can't setup desktop functions!")
def _open_path(path):
# Dummy for tests
pass
def _reveal_path(path):
# Dummy for tests
pass
def _special_folder_path(special_folder, appname=None, portable=False):
return "/tmp"
def _special_folder_path(special_folder: SpecialFolder, portable: bool = False) -> str:
return "/tmp"

View File

@@ -1,216 +0,0 @@
# Created By: Virgil Dupras
# Created On: 2011-08-05
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from sys import maxsize as INF
from math import sqrt
VERY_SMALL = 0.0000001
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return "<Point {:2.2f}, {:2.2f}>".format(*self)
def __iter__(self):
yield self.x
yield self.y
def distance_to(self, other):
return Line(self, other).length()
class Line:
def __init__(self, p1, p2):
self.p1 = p1
self.p2 = p2
def __repr__(self):
return "<Line {}, {}>".format(*self)
def __iter__(self):
yield self.p1
yield self.p2
def dx(self):
return self.p2.x - self.p1.x
def dy(self):
return self.p2.y - self.p1.y
def length(self):
return sqrt(self.dx() ** 2 + self.dy() ** 2)
def slope(self):
if self.dx() == 0:
return INF if self.dy() > 0 else -INF
else:
return self.dy() / self.dx()
def intersection_point(self, other):
# with help from http://paulbourke.net/geometry/lineline2d/
if abs(self.slope() - other.slope()) < VERY_SMALL:
# parallel. Even if coincident, we return nothing
return None
A, B = self
C, D = other
denom = (D.y - C.y) * (B.x - A.x) - (D.x - C.x) * (B.y - A.y)
if denom == 0:
return None
numera = (D.x - C.x) * (A.y - C.y) - (D.y - C.y) * (A.x - C.x)
numerb = (B.x - A.x) * (A.y - C.y) - (B.y - A.y) * (A.x - C.x)
mua = numera / denom
mub = numerb / denom
if (0 <= mua <= 1) and (0 <= mub <= 1):
x = A.x + mua * (B.x - A.x)
y = A.y + mua * (B.y - A.y)
return Point(x, y)
else:
return None
class Rect:
def __init__(self, x, y, w, h):
self.x = x
self.y = y
self.w = w
self.h = h
def __iter__(self):
yield self.x
yield self.y
yield self.w
yield self.h
def __repr__(self):
return "<Rect {:2.2f}, {:2.2f}, {:2.2f}, {:2.2f}>".format(*self)
@classmethod
def from_center(cls, center, width, height):
x = center.x - width / 2
y = center.y - height / 2
return cls(x, y, width, height)
@classmethod
def from_corners(cls, pt1, pt2):
x1, y1 = pt1
x2, y2 = pt2
return cls(min(x1, x2), min(y1, y2), abs(x1 - x2), abs(y1 - y2))
def center(self):
return Point(self.x + self.w / 2, self.y + self.h / 2)
def contains_point(self, point):
x, y = point
(x1, y1), (x2, y2) = self.corners()
return (x1 <= x <= x2) and (y1 <= y <= y2)
def contains_rect(self, rect):
pt1, pt2 = rect.corners()
return self.contains_point(pt1) and self.contains_point(pt2)
def corners(self):
return Point(self.x, self.y), Point(self.x + self.w, self.y + self.h)
def intersects(self, other):
r1pt1, r1pt2 = self.corners()
r2pt1, r2pt2 = other.corners()
if r1pt1.x < r2pt1.x:
xinter = r1pt2.x >= r2pt1.x
else:
xinter = r2pt2.x >= r1pt1.x
if not xinter:
return False
if r1pt1.y < r2pt1.y:
yinter = r1pt2.y >= r2pt1.y
else:
yinter = r2pt2.y >= r1pt1.y
return yinter
def lines(self):
pt1, pt4 = self.corners()
pt2 = Point(pt4.x, pt1.y)
pt3 = Point(pt1.x, pt4.y)
l1 = Line(pt1, pt2)
l2 = Line(pt2, pt4)
l3 = Line(pt3, pt4)
l4 = Line(pt1, pt3)
return l1, l2, l3, l4
def scaled_rect(self, dx, dy):
"""Returns a rect that has the same borders at self, but grown/shrunk by dx/dy on each side."""
x, y, w, h = self
x -= dx
y -= dy
w += dx * 2
h += dy * 2
return Rect(x, y, w, h)
def united(self, other):
"""Returns the bounding rectangle of this rectangle and `other`."""
# ul=upper left lr=lower right
ulcorner1, lrcorner1 = self.corners()
ulcorner2, lrcorner2 = other.corners()
corner1 = Point(min(ulcorner1.x, ulcorner2.x), min(ulcorner1.y, ulcorner2.y))
corner2 = Point(max(lrcorner1.x, lrcorner2.x), max(lrcorner1.y, lrcorner2.y))
return Rect.from_corners(corner1, corner2)
# --- Properties
@property
def top(self):
return self.y
@top.setter
def top(self, value):
self.y = value
@property
def bottom(self):
return self.y + self.h
@bottom.setter
def bottom(self, value):
self.y = value - self.h
@property
def left(self):
return self.x
@left.setter
def left(self, value):
self.x = value
@property
def right(self):
return self.x + self.w
@right.setter
def right(self, value):
self.x = value - self.w
@property
def width(self):
return self.w
@width.setter
def width(self, value):
self.w = value
@property
def height(self):
return self.h
@height.setter
def height(self, value):
self.h = value

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
from collections.abc import MutableSequence
from .base import GUIObject
from hscommon.gui.base import GUIObject
class Node(MutableSequence):
@@ -77,8 +77,7 @@ class Node(MutableSequence):
if include_self and predicate(self):
yield self
for child in self:
for found in child.findall(predicate, include_self=True):
yield found
yield from child.findall(predicate, include_self=True)
def get_node(self, index_path):
"""Returns the node at ``index_path``.

View File

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

View File

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

View File

@@ -1,38 +1,25 @@
import os
import os.path as op
import shutil
import re
import tempfile
from typing import Any, List
import polib
from . import pygettext
from .util import modified_after, dedupe, ensure_folder
from .build import print_and_do, ensure_empty_folder
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:
@@ -53,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)
@@ -63,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)):
@@ -74,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")
@@ -87,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)
@@ -99,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
@@ -116,118 +103,3 @@ def normalize_all_pos(base_folder):
for pofile in pofiles:
p = polib.pofile(pofile)
p.save()
# --- Cocoa
def all_lproj_paths(folder):
return files_with_ext(folder, ".lproj")
def escape_cocoa_strings(s):
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def unescape_cocoa_strings(s):
return s.replace("\\\\", "\\").replace('\\"', '"').replace("\\n", "\n")
def strings2pot(target, dest):
with open(target, "rt", encoding="utf-8") as fp:
contents = fp.read()
# We're reading an en.lproj file. We only care about the righthand part of the translation.
re_trans = re.compile(r'".*" = "(.*)";')
strings = re_trans.findall(contents)
if op.exists(dest):
po = polib.pofile(dest)
else:
po = polib.POFile()
for s in dedupe(strings):
s = unescape_cocoa_strings(s)
entry = po.find(s)
if entry is None:
entry = polib.POEntry(msgid=s)
po.append(entry)
# we don't know or care about a line number so we put 0
entry.occurrences.append((target, "0"))
entry.occurrences = dedupe(entry.occurrences)
po.save(dest)
def allstrings2pot(lprojpath, dest, excludes=None):
allstrings = files_with_ext(lprojpath, STRING_EXT)
if excludes:
allstrings = [p for p in allstrings if op.splitext(op.basename(p))[0] not in excludes]
for strings_path in allstrings:
strings2pot(strings_path, dest)
def po2strings(pofile, en_strings, dest):
# Takes en_strings and replace all righthand parts of "foo" = "bar"; entries with translations
# in pofile, then puts the result in dest.
po = polib.pofile(pofile)
if not modified_after(pofile, dest):
return
ensure_folder(op.dirname(dest))
print("Creating {} from {}".format(dest, pofile))
with open(en_strings, "rt", encoding="utf-8") as fp:
contents = fp.read()
re_trans = re.compile(r'(?<= = ").*(?=";\n)')
def repl(match):
s = match.group(0)
unescaped = unescape_cocoa_strings(s)
entry = po.find(unescaped)
if entry is None:
print("WARNING: Could not find entry '{}' in .po file".format(s))
return s
trans = entry.msgstr
return escape_cocoa_strings(trans) if trans else s
contents = re_trans.sub(repl, contents)
with open(dest, "wt", encoding="utf-8") as fp:
fp.write(contents)
def generate_cocoa_strings_from_code(code_folder, dest_folder):
# Uses the "genstrings" command to generate strings file from all .m files in "code_folder".
# The strings file (their name depends on the localization table used in the source) will be
# placed in "dest_folder".
# genstrings produces utf-16 files with comments. After having generated the files, we convert
# them to utf-8 and remove the comments.
ensure_empty_folder(dest_folder)
print_and_do('genstrings -o "{}" `find "{}" -name *.m | xargs`'.format(dest_folder, code_folder))
for stringsfile in os.listdir(dest_folder):
stringspath = op.join(dest_folder, stringsfile)
with open(stringspath, "rt", encoding="utf-16") as fp:
content = fp.read()
content = re.sub(r"/\*.*?\*/", "", content)
content = re.sub(r"\n{2,}", "\n", content)
# I have no idea why, but genstrings seems to have problems with "%" character in strings
# and inserts (number)$ after it. Find these bogus inserts and remove them.
content = re.sub(r"%\d\$", "%", content)
with open(stringspath, "wt", encoding="utf-8") as fp:
fp.write(content)
def generate_cocoa_strings_from_xib(xib_folder):
xibs = [op.join(xib_folder, fn) for fn in os.listdir(xib_folder) if fn.endswith(".xib")]
for xib in xibs:
dest = xib.replace(".xib", STRING_EXT)
print_and_do("ibtool {} --generate-strings-file {}".format(xib, dest))
print_and_do("iconv -f utf-16 -t utf-8 {0} | tee {0}".format(dest))
def localize_stringsfile(stringsfile, dest_root_folder):
stringsfile_name = op.basename(stringsfile)
for lang in get_langs("locale"):
pofile = op.join("locale", lang, "LC_MESSAGES", "ui.po")
cocoa_lang = PO2COCOA.get(lang, lang)
dest_lproj = op.join(dest_root_folder, cocoa_lang + ".lproj")
ensure_folder(dest_lproj)
po2strings(pofile, stringsfile, op.join(dest_lproj, stringsfile_name))
def localize_all_stringsfiles(src_folder, dest_root_folder):
stringsfiles = [op.join(src_folder, fn) for fn in os.listdir(src_folder) if fn.endswith(STRING_EXT)]
for path in stringsfiles:
localize_stringsfile(path, dest_root_folder)

View File

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

View File

@@ -7,208 +7,9 @@
# http://www.gnu.org/licenses/gpl-3.0.html
import logging
import os
import os.path as op
import shutil
import sys
from itertools import takewhile
from functools import wraps
from inspect import signature
class Path(tuple):
"""A handy class to work with paths.
We subclass ``tuple``, each element of the tuple represents an element of the path.
* ``Path('/foo/bar/baz')[1]`` --> ``'bar'``
* ``Path('/foo/bar/baz')[1:2]`` --> ``Path('bar/baz')``
* ``Path('/foo/bar')['baz']`` --> ``Path('/foo/bar/baz')``
* ``str(Path('/foo/bar/baz'))`` --> ``'/foo/bar/baz'``
"""
# Saves a little bit of memory usage
__slots__ = ()
def __new__(cls, value, separator=None):
def unicode_if_needed(s):
if isinstance(s, str):
return s
else:
try:
return str(s, sys.getfilesystemencoding())
except UnicodeDecodeError:
logging.warning("Could not decode %r", s)
raise
if isinstance(value, Path):
return value
if not separator:
separator = os.sep
if isinstance(value, bytes):
value = unicode_if_needed(value)
if isinstance(value, str):
if value:
if (separator not in value) and ("/" in value):
separator = "/"
value = value.split(separator)
else:
value = ()
else:
if any(isinstance(x, bytes) for x in value):
value = [unicode_if_needed(x) for x in value]
# value is a tuple/list
if any(separator in x for x in value):
# We have a component with a separator in it. Let's rejoin it, and generate another path.
return Path(separator.join(value), separator)
if (len(value) > 1) and (not value[-1]):
value = value[
:-1
] # We never want a path to end with a '' (because Path() can be called with a trailing slash ending path)
return tuple.__new__(cls, value)
def __add__(self, other):
other = Path(other)
if other and (not other[0]):
other = other[1:]
return Path(tuple.__add__(self, other))
def __contains__(self, item):
if isinstance(item, Path):
return item[: len(self)] == self
else:
return tuple.__contains__(self, item)
def __eq__(self, other):
return tuple.__eq__(self, Path(other))
def __getitem__(self, key):
if isinstance(key, slice):
if isinstance(key.start, Path):
equal_elems = list(takewhile(lambda pair: pair[0] == pair[1], zip(self, key.start)))
key = slice(len(equal_elems), key.stop, key.step)
if isinstance(key.stop, Path):
equal_elems = list(
takewhile(
lambda pair: pair[0] == pair[1],
zip(reversed(self), reversed(key.stop)),
)
)
stop = -len(equal_elems) if equal_elems else None
key = slice(key.start, stop, key.step)
return Path(tuple.__getitem__(self, key))
elif isinstance(key, (str, Path)):
return self + key
else:
return tuple.__getitem__(self, key)
def __hash__(self):
return tuple.__hash__(self)
def __ne__(self, other):
return not self.__eq__(other)
def __radd__(self, other):
return Path(other) + self
def __str__(self):
if len(self) == 1:
first = self[0]
if (len(first) == 2) and (first[1] == ":"): # Windows drive letter
return first + "\\"
elif not len(first): # root directory
return "/"
return os.sep.join(self)
def has_drive_letter(self):
if not self:
return False
first = self[0]
return (len(first) == 2) and (first[1] == ":")
def is_parent_of(self, other):
"""Whether ``other`` is a subpath of ``self``.
Almost the same as ``other in self``, but it's a bit more self-explicative and when
``other == self``, returns False.
"""
if other == self:
return False
else:
return other in self
def remove_drive_letter(self):
if self.has_drive_letter():
return self[1:]
else:
return self
def tobytes(self):
return str(self).encode(sys.getfilesystemencoding())
def parent(self):
"""Returns the parent path.
``Path('/foo/bar/baz').parent()`` --> ``Path('/foo/bar')``
"""
return self[:-1]
@property
def name(self):
"""Last element of the path (filename), with extension.
``Path('/foo/bar/baz').name`` --> ``'baz'``
"""
return self[-1]
# OS method wrappers
def exists(self):
return op.exists(str(self))
def copy(self, dest_path):
return shutil.copy(str(self), str(dest_path))
def copytree(self, dest_path, *args, **kwargs):
return shutil.copytree(str(self), str(dest_path), *args, **kwargs)
def isdir(self):
return op.isdir(str(self))
def isfile(self):
return op.isfile(str(self))
def islink(self):
return op.islink(str(self))
def listdir(self):
return [self[name] for name in os.listdir(str(self))]
def mkdir(self, *args, **kwargs):
return os.mkdir(str(self), *args, **kwargs)
def makedirs(self, *args, **kwargs):
return os.makedirs(str(self), *args, **kwargs)
def move(self, dest_path):
return shutil.move(str(self), str(dest_path))
def open(self, *args, **kwargs):
return open(str(self), *args, **kwargs)
def remove(self):
return os.remove(str(self))
def rename(self, dest_path):
return os.rename(str(self), str(dest_path))
def rmdir(self):
return os.rmdir(str(self))
def rmtree(self):
return shutil.rmtree(str(self))
def stat(self):
return os.stat(str(self))
from pathlib import Path
def pathify(f):
@@ -246,7 +47,7 @@ def log_io_error(func):
def wrapper(path, *args, **kwargs):
try:
return func(path, *args, **kwargs)
except (IOError, OSError) as e:
except OSError as e:
msg = 'Error "{0}" during operation "{1}" on "{2}": "{3}"'
classname = e.__class__.__name__
funcname = func.__name__

View File

@@ -374,7 +374,7 @@ def main(source_files, outpath, keywords=None):
fp = open(options.excludefilename, encoding="utf-8")
options.toexclude = fp.readlines()
fp.close()
except IOError:
except OSError:
print(
"Can't read --exclude-file: %s" % options.excludefilename,
file=sys.stderr,

View File

@@ -6,8 +6,9 @@
from pathlib import Path
import re
from typing import Callable, Dict, Union
from .build import read_changelog_file, filereplace
from hscommon.build import read_changelog_file, filereplace
from sphinx.cmd.build import build_main as sphinx_build
CHANGELOG_FORMAT = """
@@ -18,25 +19,25 @@ 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 #
"""
urlpattern = tixurl.format("\\1") # will be replaced buy the content of the first group in re
R = re.compile(r"#(\d+)")
repl = "`#\\1 <{}>`__".format(urlpattern)
repl = f"`#\\1 <{urlpattern}>`__"
return lambda text: R.sub(repl, text)
def gen(
basepath,
destpath,
changelogpath,
tixurl,
confrepl=None,
confpath=None,
changelogtmpl=None,
):
basepath: Path,
destpath: Path,
changelogpath: Path,
tixurl: str,
confrepl: Union[Dict[str, str], None] = None,
confpath: Union[Path, None] = None,
changelogtmpl: Union[Path, None] = None,
) -> None:
"""Generate sphinx docs with all bells and whistles.
basepath: The base sphinx source path.

View File

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

View File

@@ -8,15 +8,15 @@
import pytest
from ..conflict import (
from hscommon.conflict import (
get_conflicted_name,
get_unconflicted_name,
is_conflicted,
smart_copy,
smart_move,
)
from ..path import Path
from ..testutil import eq_
from pathlib import Path
from hscommon.testutil import eq_
class TestCaseGetConflictedName:
@@ -71,43 +71,43 @@ class TestCaseMoveCopy:
def do_setup(self, request):
tmpdir = request.getfixturevalue("tmpdir")
self.path = Path(str(tmpdir))
self.path["foo"].open("w").close()
self.path["bar"].open("w").close()
self.path["dir"].mkdir()
self.path.joinpath("foo").touch()
self.path.joinpath("bar").touch()
self.path.joinpath("dir").mkdir()
def test_move_no_conflict(self, do_setup):
smart_move(self.path + "foo", self.path + "baz")
assert self.path["baz"].exists()
assert not self.path["foo"].exists()
smart_move(self.path.joinpath("foo"), self.path.joinpath("baz"))
assert self.path.joinpath("baz").exists()
assert not self.path.joinpath("foo").exists()
def test_copy_no_conflict(self, do_setup): # No need to duplicate the rest of the tests... Let's just test on move
smart_copy(self.path + "foo", self.path + "baz")
assert self.path["baz"].exists()
assert self.path["foo"].exists()
smart_copy(self.path.joinpath("foo"), self.path.joinpath("baz"))
assert self.path.joinpath("baz").exists()
assert self.path.joinpath("foo").exists()
def test_move_no_conflict_dest_is_dir(self, do_setup):
smart_move(self.path + "foo", self.path + "dir")
assert self.path["dir"]["foo"].exists()
assert not self.path["foo"].exists()
smart_move(self.path.joinpath("foo"), self.path.joinpath("dir"))
assert self.path.joinpath("dir", "foo").exists()
assert not self.path.joinpath("foo").exists()
def test_move_conflict(self, do_setup):
smart_move(self.path + "foo", self.path + "bar")
assert self.path["[000] bar"].exists()
assert not self.path["foo"].exists()
smart_move(self.path.joinpath("foo"), self.path.joinpath("bar"))
assert self.path.joinpath("[000] bar").exists()
assert not self.path.joinpath("foo").exists()
def test_move_conflict_dest_is_dir(self, do_setup):
smart_move(self.path["foo"], self.path["dir"])
smart_move(self.path["bar"], self.path["foo"])
smart_move(self.path["foo"], self.path["dir"])
assert self.path["dir"]["foo"].exists()
assert self.path["dir"]["[000] foo"].exists()
assert not self.path["foo"].exists()
assert not self.path["bar"].exists()
smart_move(self.path.joinpath("foo"), self.path.joinpath("dir"))
smart_move(self.path.joinpath("bar"), self.path.joinpath("foo"))
smart_move(self.path.joinpath("foo"), self.path.joinpath("dir"))
assert self.path.joinpath("dir", "foo").exists()
assert self.path.joinpath("dir", "[000] foo").exists()
assert not self.path.joinpath("foo").exists()
assert not self.path.joinpath("bar").exists()
def test_copy_folder(self, tmpdir):
# smart_copy also works on folders
path = Path(str(tmpdir))
path["foo"].mkdir()
path["bar"].mkdir()
smart_copy(path["foo"], path["bar"]) # no crash
assert path["[000] bar"].exists()
path.joinpath("foo").mkdir()
path.joinpath("bar").mkdir()
smart_copy(path.joinpath("foo"), path.joinpath("bar")) # no crash
assert path.joinpath("[000] bar").exists()

View File

@@ -4,8 +4,8 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ..testutil import eq_
from ..notify import Broadcaster, Listener, Repeater
from hscommon.testutil import eq_
from hscommon.notify import Broadcaster, Listener, Repeater
class HelloListener(Listener):
@@ -113,7 +113,7 @@ def test_repeater_with_repeated_notifications():
# If REPEATED_NOTIFICATIONS is not empty, only notifs in this set are repeated (but they're
# still dispatched locally).
class MyRepeater(HelloRepeater):
REPEATED_NOTIFICATIONS = set(["hello"])
REPEATED_NOTIFICATIONS = {"hello"}
def __init__(self, broadcaster):
HelloRepeater.__init__(self, broadcaster)

View File

@@ -6,261 +6,8 @@
# 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 pytest
from ..path import Path, pathify
from ..testutil import eq_
@pytest.fixture
def force_ossep(request):
monkeypatch = request.getfixturevalue("monkeypatch")
monkeypatch.setattr(os, "sep", "/")
def test_empty(force_ossep):
path = Path("")
eq_("", str(path))
eq_(0, len(path))
path = Path(())
eq_("", str(path))
eq_(0, len(path))
def test_single(force_ossep):
path = Path("foobar")
eq_("foobar", path)
eq_(1, len(path))
def test_multiple(force_ossep):
path = Path("foo/bar")
eq_("foo/bar", path)
eq_(2, len(path))
def test_init_with_tuple_and_list(force_ossep):
path = Path(("foo", "bar"))
eq_("foo/bar", path)
path = Path(["foo", "bar"])
eq_("foo/bar", path)
def test_init_with_invalid_value(force_ossep):
try:
Path(42)
assert False
except TypeError:
pass
def test_access(force_ossep):
path = Path("foo/bar/bleh")
eq_("foo", path[0])
eq_("foo", path[-3])
eq_("bar", path[1])
eq_("bar", path[-2])
eq_("bleh", path[2])
eq_("bleh", path[-1])
def test_slicing(force_ossep):
path = Path("foo/bar/bleh")
subpath = path[:2]
eq_("foo/bar", subpath)
assert isinstance(subpath, Path)
def test_parent(force_ossep):
path = Path("foo/bar/bleh")
subpath = path.parent()
eq_("foo/bar", subpath)
assert isinstance(subpath, Path)
def test_filename(force_ossep):
path = Path("foo/bar/bleh.ext")
eq_(path.name, "bleh.ext")
def test_deal_with_empty_components(force_ossep):
"""Keep ONLY a leading space, which means we want a leading slash."""
eq_("foo//bar", str(Path(("foo", "", "bar"))))
eq_("/foo/bar", str(Path(("", "foo", "bar"))))
eq_("foo/bar", str(Path("foo/bar/")))
def test_old_compare_paths(force_ossep):
eq_(Path("foobar"), Path("foobar"))
eq_(Path("foobar/"), Path("foobar\\", "\\"))
eq_(Path("/foobar/"), Path("\\foobar\\", "\\"))
eq_(Path("/foo/bar"), Path("\\foo\\bar", "\\"))
eq_(Path("/foo/bar"), Path("\\foo\\bar\\", "\\"))
assert Path("/foo/bar") != Path("\\foo\\foo", "\\")
# We also have to test __ne__
assert not (Path("foobar") != Path("foobar"))
assert Path("/a/b/c.x") != Path("/a/b/c.y")
def test_old_split_path(force_ossep):
eq_(Path("foobar"), ("foobar",))
eq_(Path("foo/bar"), ("foo", "bar"))
eq_(Path("/foo/bar/"), ("", "foo", "bar"))
eq_(Path("\\foo\\bar", "\\"), ("", "foo", "bar"))
def test_representation(force_ossep):
eq_("('foo', 'bar')", repr(Path(("foo", "bar"))))
def test_add(force_ossep):
eq_("foo/bar/bar/foo", Path(("foo", "bar")) + Path("bar/foo"))
eq_("foo/bar/bar/foo", Path("foo/bar") + "bar/foo")
eq_("foo/bar/bar/foo", Path("foo/bar") + ("bar", "foo"))
eq_("foo/bar/bar/foo", ("foo", "bar") + Path("bar/foo"))
eq_("foo/bar/bar/foo", "foo/bar" + Path("bar/foo"))
# Invalid concatenation
try:
Path(("foo", "bar")) + 1
assert False
except TypeError:
pass
def test_path_slice(force_ossep):
foo = Path("foo")
bar = Path("bar")
foobar = Path("foo/bar")
eq_("bar", foobar[foo:])
eq_("foo", foobar[:bar])
eq_("foo/bar", foobar[bar:])
eq_("foo/bar", foobar[:foo])
eq_((), foobar[foobar:])
eq_((), foobar[:foobar])
abcd = Path("a/b/c/d")
a = Path("a")
d = Path("d")
z = Path("z")
eq_("b/c", abcd[a:d])
eq_("b/c/d", abcd[a : d + z])
eq_("b/c", abcd[a : z + d])
eq_("a/b/c/d", abcd[:z])
def test_add_with_root_path(force_ossep):
"""if I perform /a/b/c + /d/e/f, I want /a/b/c/d/e/f, not /a/b/c//d/e/f"""
eq_("/foo/bar", str(Path("/foo") + Path("/bar")))
def test_create_with_tuple_that_have_slash_inside(force_ossep, monkeypatch):
eq_(("", "foo", "bar"), Path(("/foo", "bar")))
monkeypatch.setattr(os, "sep", "\\")
eq_(("", "foo", "bar"), Path(("\\foo", "bar")))
def test_auto_decode_os_sep(force_ossep, monkeypatch):
"""Path should decode any either / or os.sep, but always encode in os.sep."""
eq_(("foo\\bar", "bleh"), Path("foo\\bar/bleh"))
monkeypatch.setattr(os, "sep", "\\")
eq_(("foo", "bar/bleh"), Path("foo\\bar/bleh"))
path = Path("foo/bar")
eq_(("foo", "bar"), path)
eq_("foo\\bar", str(path))
def test_contains(force_ossep):
p = Path(("foo", "bar"))
assert Path(("foo", "bar", "bleh")) in p
assert Path(("foo", "bar")) in p
assert "foo" in p
assert "bleh" not in p
assert Path("foo") not in p
def test_is_parent_of(force_ossep):
assert Path(("foo", "bar")).is_parent_of(Path(("foo", "bar", "bleh")))
assert not Path(("foo", "bar")).is_parent_of(Path(("foo", "baz")))
assert not Path(("foo", "bar")).is_parent_of(Path(("foo", "bar")))
def test_windows_drive_letter(force_ossep):
p = Path(("c:",))
eq_("c:\\", str(p))
def test_root_path(force_ossep):
p = Path("/")
eq_("/", str(p))
def test_str_encodes_unicode_to_getfilesystemencoding(force_ossep):
p = Path(("foo", "bar\u00e9"))
eq_("foo/bar\u00e9".encode(sys.getfilesystemencoding()), p.tobytes())
def test_unicode(force_ossep):
p = Path(("foo", "bar\u00e9"))
eq_("foo/bar\u00e9", str(p))
def test_str_repr_of_mix_between_non_ascii_str_and_unicode(force_ossep):
u = "foo\u00e9"
encoded = u.encode(sys.getfilesystemencoding())
p = Path((encoded, "bar"))
print(repr(tuple(p)))
eq_("foo\u00e9/bar".encode(sys.getfilesystemencoding()), p.tobytes())
def test_path_of_a_path_returns_self(force_ossep):
# if Path() is called with a path as value, just return value.
p = Path("foo/bar")
assert Path(p) is p
def test_getitem_str(force_ossep):
# path['something'] returns the child path corresponding to the name
p = Path("/foo/bar")
eq_(p["baz"], Path("/foo/bar/baz"))
def test_getitem_path(force_ossep):
# path[Path('something')] returns the child path corresponding to the name (or subpath)
p = Path("/foo/bar")
eq_(p[Path("baz/bleh")], Path("/foo/bar/baz/bleh"))
@pytest.mark.xfail(reason="pytest's capture mechanism is flaky, I have to investigate")
def test_log_unicode_errors(force_ossep, monkeypatch, capsys):
# When an there's a UnicodeDecodeError on path creation, log it so it can be possible
# to debug the cause of it.
monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "ascii")
with pytest.raises(UnicodeDecodeError):
Path(["", b"foo\xe9"])
out, err = capsys.readouterr()
assert repr(b"foo\xe9") in err
def test_has_drive_letter(monkeypatch):
monkeypatch.setattr(os, "sep", "\\")
p = Path("foo\\bar")
assert not p.has_drive_letter()
p = Path("C:\\")
assert p.has_drive_letter()
p = Path("z:\\foo")
assert p.has_drive_letter()
def test_remove_drive_letter(monkeypatch):
monkeypatch.setattr(os, "sep", "\\")
p = Path("foo\\bar")
eq_(p.remove_drive_letter(), Path("foo\\bar"))
p = Path("C:\\")
eq_(p.remove_drive_letter(), Path(""))
p = Path("z:\\foo")
eq_(p.remove_drive_letter(), Path("foo"))
from hscommon.path import pathify
from pathlib import Path
def test_pathify():

View File

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

View File

@@ -1,137 +0,0 @@
# Created By: Virgil Dupras
# Created On: 2007/05/19
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import time
import threading
import os
import sqlite3 as sqlite
from pytest import raises
from ..testutil import eq_
from ..sqlite import ThreadedConn
# Threading is hard to test. In a lot of those tests, a failure means that the test run will
# hang forever. Well... I don't know a better alternative.
def test_can_access_from_multiple_threads():
def run():
con.execute("insert into foo(bar) values('baz')")
con = ThreadedConn(":memory:", True)
con.execute("create table foo(bar TEXT)")
t = threading.Thread(target=run)
t.start()
t.join()
result = con.execute("select * from foo")
eq_(1, len(result))
eq_("baz", result[0][0])
def test_exception_during_query():
con = ThreadedConn(":memory:", True)
con.execute("create table foo(bar TEXT)")
with raises(sqlite.OperationalError):
con.execute("select * from bleh")
def test_not_autocommit(tmpdir):
dbpath = str(tmpdir.join("foo.db"))
con = ThreadedConn(dbpath, False)
con.execute("create table foo(bar TEXT)")
con.execute("insert into foo(bar) values('baz')")
del con
# The data shouldn't have been inserted
con = ThreadedConn(dbpath, False)
result = con.execute("select * from foo")
eq_(0, len(result))
con.execute("insert into foo(bar) values('baz')")
con.commit()
del con
# Now the data should be there
con = ThreadedConn(dbpath, False)
result = con.execute("select * from foo")
eq_(1, len(result))
def test_rollback():
con = ThreadedConn(":memory:", False)
con.execute("create table foo(bar TEXT)")
con.execute("insert into foo(bar) values('baz')")
con.rollback()
result = con.execute("select * from foo")
eq_(0, len(result))
def test_query_palceholders():
con = ThreadedConn(":memory:", True)
con.execute("create table foo(bar TEXT)")
con.execute("insert into foo(bar) values(?)", ["baz"])
result = con.execute("select * from foo")
eq_(1, len(result))
eq_("baz", result[0][0])
def test_make_sure_theres_no_messup_between_queries():
def run(expected_rowid):
time.sleep(0.1)
result = con.execute("select rowid from foo where rowid = ?", [expected_rowid])
assert expected_rowid == result[0][0]
con = ThreadedConn(":memory:", True)
con.execute("create table foo(bar TEXT)")
for i in range(100):
con.execute("insert into foo(bar) values('baz')")
threads = []
for i in range(1, 101):
t = threading.Thread(target=run, args=(i,))
t.start()
threads.append(t)
while threads:
time.sleep(0.1)
threads = [t for t in threads if t.is_alive()]
def test_query_after_close():
con = ThreadedConn(":memory:", True)
con.close()
con.execute("select 1")
def test_lastrowid():
# It's not possible to return a cursor because of the threading, but lastrowid should be
# fetchable from the connection itself
con = ThreadedConn(":memory:", True)
con.execute("create table foo(bar TEXT)")
con.execute("insert into foo(bar) values('baz')")
eq_(1, con.lastrowid)
def test_add_fetchone_fetchall_interface_to_results():
con = ThreadedConn(":memory:", True)
con.execute("create table foo(bar TEXT)")
con.execute("insert into foo(bar) values('baz1')")
con.execute("insert into foo(bar) values('baz2')")
result = con.execute("select * from foo")
ref = result[:]
eq_(ref, result.fetchall())
eq_(ref[0], result.fetchone())
eq_(ref[1], result.fetchone())
assert result.fetchone() is None
def test_non_ascii_dbname(tmpdir):
ThreadedConn(str(tmpdir.join("foo\u00e9.db")), True)
def test_non_ascii_dbdir(tmpdir):
# when this test fails, it doesn't fail gracefully, it brings the whole test suite with it.
dbdir = tmpdir.join("foo\u00e9")
os.mkdir(str(dbdir))
ThreadedConn(str(dbdir.join("foo.db")), True)

View File

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

View File

@@ -6,8 +6,8 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from ..testutil import eq_
from ..gui.tree import Tree, Node
from hscommon.testutil import eq_
from hscommon.gui.tree import Tree, Node
def tree_with_some_nodes():
@@ -98,7 +98,7 @@ def test_selection_override():
def test_findall():
t = tree_with_some_nodes()
r = t.findall(lambda n: n.name.startswith("sub"))
eq_(set(r), set([t[0][0], t[0][1]]))
eq_(set(r), {t[0][0], t[0][1]})
def test_findall_dont_include_self():
@@ -106,7 +106,7 @@ def test_findall_dont_include_self():
t = tree_with_some_nodes()
del t._name # so that if the predicate is called on `t`, we crash
r = t.findall(lambda n: not n.name.startswith("sub"), include_self=False) # no crash
eq_(set(r), set([t[0], t[1], t[2]]))
eq_(set(r), {t[0], t[1], t[2]})
def test_find_dont_include_self():

View File

@@ -10,23 +10,19 @@ from io import StringIO
from pytest import raises
from ..testutil import eq_
from ..path import Path
from ..util import (
from hscommon.testutil import eq_
from pathlib import Path
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"), " "))
@@ -245,30 +215,30 @@ class TestCaseDeleteIfEmpty:
def test_not_empty(self, tmpdir):
testpath = Path(str(tmpdir))
testpath["foo"].mkdir()
testpath.joinpath("foo").mkdir()
assert not delete_if_empty(testpath)
assert testpath.exists()
def test_with_files_to_delete(self, tmpdir):
testpath = Path(str(tmpdir))
testpath["foo"].open("w")
testpath["bar"].open("w")
testpath.joinpath("foo").touch()
testpath.joinpath("bar").touch()
assert delete_if_empty(testpath, ["foo", "bar"])
assert not testpath.exists()
def test_directory_in_files_to_delete(self, tmpdir):
testpath = Path(str(tmpdir))
testpath["foo"].mkdir()
testpath.joinpath("foo").mkdir()
assert not delete_if_empty(testpath, ["foo"])
assert testpath.exists()
def test_delete_files_to_delete_only_if_dir_is_empty(self, tmpdir):
testpath = Path(str(tmpdir))
testpath["foo"].open("w")
testpath["bar"].open("w")
testpath.joinpath("foo").touch()
testpath.joinpath("bar").touch()
assert not delete_if_empty(testpath, ["foo"])
assert testpath.exists()
assert testpath["foo"].exists()
assert testpath.joinpath("foo").exists()
def test_doesnt_exist(self):
# When the 'path' doesn't exist, just do nothing.
@@ -276,8 +246,8 @@ class TestCaseDeleteIfEmpty:
def test_is_file(self, tmpdir):
# When 'path' is a file, do nothing.
p = Path(str(tmpdir)) + "filename"
p.open("w").close()
p = Path(str(tmpdir)).joinpath("filename")
p.touch()
delete_if_empty(p) # no crash
def test_ioerror(self, tmpdir, monkeypatch):

View File

@@ -8,26 +8,10 @@
import pytest
import threading
import py.path
def eq_(a, b, msg=None):
__tracebackhide__ = True
assert a == b, msg or "%r != %r" % (a, b)
def eq_sorted(a, b, msg=None):
"""If both a and b are iterable sort them and compare using eq_, otherwise just pass them through to eq_ anyway."""
try:
eq_(sorted(a), sorted(b), msg)
except TypeError:
eq_(a, b, msg)
def assert_almost_equal(a, b, places=7):
__tracebackhide__ = True
assert round(a, ndigits=places) == round(b, ndigits=places)
assert a == b, msg or "{!r} != {!r}".format(a, b)
def callcounter():
@@ -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.
@@ -97,17 +64,17 @@ class CallLogger:
__tracebackhide__ = True
if expected is not None:
not_called = set(expected) - set(self.calls)
assert not not_called, "These calls haven't been made: {0}".format(not_called)
assert not not_called, f"These calls haven't been made: {not_called}"
if verify_order:
max_index = 0
for call in expected:
index = self.calls.index(call)
if index < max_index:
raise AssertionError("The call {0} hasn't been made in the correct order".format(call))
raise AssertionError(f"The call {call} hasn't been made in the correct order")
max_index = index
if not_expected is not None:
called = set(not_expected) & set(self.calls)
assert not called, "These calls shouldn't have been made: {0}".format(called)
assert not called, f"These calls shouldn't have been made: {called}"
self.clear_calls()
@@ -133,7 +100,7 @@ class TestApp:
parent = self.default_parent
if holder is None:
holder = self
setattr(holder, "{0}_gui".format(name), view)
setattr(holder, f"{name}_gui", view)
gui = class_(parent)
gui.view = view
setattr(holder, name, gui)
@@ -168,20 +135,6 @@ def app(request):
return app
def jointhreads():
"""Join all threads to the main thread"""
for thread in threading.enumerate():
if hasattr(thread, "BUGGY"):
continue
if thread.getName() != "MainThread" and thread.isAlive():
if hasattr(thread, "close"):
thread.close()
thread.join(1)
if thread.isAlive():
print("Thread problem. Some thread doesn't want to stop.")
thread.BUGGY = True
def _unify_args(func, args, kwargs, args_to_ignore=None):
"""Unify args and kwargs in the same dictionary.

View File

@@ -11,16 +11,18 @@
import locale
import logging
import os
import os.path as op
from typing import Callable, Union
from .plat import ISLINUX
from hscommon.plat import ISLINUX
_trfunc = None
_trget = None
installed_lang = None
def tr(s, context=None):
def tr(s: str, context: Union[str, None] = None) -> str:
if _trfunc is None:
return s
else:
@@ -30,7 +32,7 @@ def tr(s, context=None):
return _trfunc(s)
def trget(domain):
def trget(domain: str) -> Callable[[str], str]:
# Returns a tr() function for the specified domain.
if _trget is None:
return lambda s: tr(s, domain)
@@ -38,14 +40,16 @@ def trget(domain):
return _trget(domain)
def set_tr(new_tr, new_trget=None):
def set_tr(
new_tr: Callable[[str, Union[str, None]], str], new_trget: Union[Callable[[str], Callable[[str], str]], None] = None
) -> None:
global _trfunc, _trget
_trfunc = new_tr
if new_trget is not None:
_trget = new_trget
def get_locale_name(lang):
def get_locale_name(lang: str) -> Union[str, None]:
# Removed old conversion code as windows seems to support these
LANG2LOCALENAME = {
"cs": "cs_CZ",
@@ -77,7 +81,7 @@ def get_locale_name(lang):
# --- Qt
def install_qt_trans(lang=None):
def install_qt_trans(lang: str = None) -> None:
from PyQt5.QtCore import QCoreApplication, QTranslator, QLocale
if not lang:
@@ -97,27 +101,29 @@ 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:
return gettext.translation(domain, localedir=base_folder, languages=[lang]).gettext
except IOError:
except OSError:
return lambda s: s
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,19 +135,7 @@ def install_gettext_trans(base_folder, lang):
installed_lang = lang
def install_gettext_trans_under_cocoa():
from cocoa import proxy
res_folder = proxy.getResourcePath()
base_folder = op.join(res_folder, "locale")
current_lang = proxy.systemLang()
install_gettext_trans(base_folder, current_lang)
localename = get_locale_name(current_lang)
if localename is not None:
locale.setlocale(locale.LC_ALL, localename)
def install_gettext_trans_under_qt(base_folder, lang=None):
def install_gettext_trans_under_qt(base_folder: os.PathLike, lang: str = None) -> None:
# So, we install the gettext locale, great, but we also should try to install qt_*.qm if
# available so that strings that are inside Qt itself over which I have no control are in the
# right language.

View File

@@ -6,19 +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 hscommon.path import pathify, log_io_error
from .path import Path, 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
@@ -26,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)
@@ -34,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.
@@ -57,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:
@@ -71,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))
@@ -79,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 = []
@@ -96,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:
@@ -106,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
@@ -144,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:
@@ -158,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:
@@ -167,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.
@@ -186,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.
@@ -206,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:
@@ -229,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.
@@ -267,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 += "%s-%s" % (chr(0x10000), chr(min(sys.maxunicode, 0x10FFFF)))
RE_INVALID_XML_SUB = re.compile("[^%s]" % _valid_xml_range, re.U).sub
def remove_invalid_xml(s, replace_with=" "):
return RE_INVALID_XML_SUB(replace_with, s)
def multi_replace(s, replace_from, replace_to=""):
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']
@@ -301,71 +258,28 @@ 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 (EnvironmentError, AttributeError):
return False
try:
second_mtime = second_path.stat().st_mtime
except (EnvironmentError, AttributeError):
return True
return first_mtime > second_mtime
def find_in_path(name, paths=None):
"""Search for `name` in all directories of `paths` and return the absolute path of the first
occurrence. If `paths` is None, $PATH is used.
"""
if paths is None:
paths = os.environ["PATH"]
if isinstance(paths, str): # if it's not a string, it's already a list
paths = paths.split(os.pathsep)
for path in paths:
if op.exists(op.join(path, name)):
return op.join(path, name)
return None
@log_io_error
@pathify
def delete_if_empty(path: Path, files_to_delete=[]):
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.isdir():
return
contents = path.listdir()
if any(p for p in contents if (p.name not in files_to_delete) or p.isdir()):
if not path.exists() or not path.is_dir():
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
for p in contents:
p.remove()
p.unlink()
path.rmdir()
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
@@ -385,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.
@@ -421,16 +308,16 @@ class FileOrPath:
dostuff()
"""
def __init__(self, file_or_path, mode="rb"):
def __init__(self, file_or_path: Union[Path, str], mode: str = "rb") -> None:
self.file_or_path = file_or_path
self.mode = mode
self.mustclose = False
self.fp = None
self.fp: Union[IO, None] = None
def __enter__(self):
def __enter__(self) -> IO:
self.fp, self.mustclose = open_if_filename(self.file_or_path, self.mode)
return self.fp
def __exit__(self, exc_type, exc_value, traceback):
def __exit__(self, exc_type, exc_value, traceback) -> None:
if self.fp and self.mustclose:
self.fp.close()

View File

Before

Width:  |  Height:  |  Size: 398 B

After

Width:  |  Height:  |  Size: 398 B

View File

@@ -25,7 +25,7 @@ msgstr ""
msgid "Samplerate"
msgstr ""
#: core\me\result_table.py:19 core\pe\result_table.py:19 core\prioritize.py:92
#: core\me\result_table.py:19 core\pe\result_table.py:19 core\prioritize.py:94
#: core\se\result_table.py:19
msgid "Filename"
msgstr ""
@@ -53,7 +53,7 @@ msgid "Kind"
msgstr ""
#: core\me\result_table.py:26 core\pe\result_table.py:25
#: core\prioritize.py:163 core\se\result_table.py:23
#: core\prioritize.py:165 core\se\result_table.py:23
msgid "Modification"
msgstr ""
@@ -111,7 +111,7 @@ msgstr ""
msgid "EXIF Timestamp"
msgstr ""
#: core\prioritize.py:156
#: core\prioritize.py:158
msgid "Size"
msgstr ""

View File

@@ -4,123 +4,123 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: utf-8\n"
#: core\app.py:42
#: core\app.py:44
msgid "There are no marked duplicates. Nothing has been done."
msgstr ""
#: core\app.py:43
#: core\app.py:45
msgid "There are no selected duplicates. Nothing has been done."
msgstr ""
#: core\app.py:44
#: core\app.py:46
msgid "You're about to open many files at once. Depending on what those files are opened with, doing so can create quite a mess. Continue?"
msgstr ""
#: core\app.py:71
#: core\app.py:73
msgid "Scanning for duplicates"
msgstr ""
#: core\app.py:72
#: core\app.py:74
msgid "Loading"
msgstr ""
#: core\app.py:73
#: core\app.py:75
msgid "Moving"
msgstr ""
#: core\app.py:74
#: core\app.py:76
msgid "Copying"
msgstr ""
#: core\app.py:75
#: core\app.py:77
msgid "Sending to Trash"
msgstr ""
#: core\app.py:289
#: core\app.py:291
msgid "A previous action is still hanging in there. You can't start a new one yet. Wait a few seconds, then try again."
msgstr ""
#: core\app.py:300
#: core\app.py:302
msgid "No duplicates found."
msgstr ""
#: core\app.py:315
#: core\app.py:317
msgid "All marked files were copied successfully."
msgstr ""
#: core\app.py:317
#: core\app.py:319
msgid "All marked files were moved successfully."
msgstr ""
#: core\app.py:319
#: core\app.py:321
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:321
#: core\app.py:323
msgid "All marked files were successfully sent to Trash."
msgstr ""
#: core\app.py:326
#: core\app.py:328
msgid "Could not load file: {}"
msgstr ""
#: core\app.py:382
#: core\app.py:384
msgid "'{}' already is in the list."
msgstr ""
#: core\app.py:384
#: core\app.py:386
msgid "'{}' does not exist."
msgstr ""
#: core\app.py:392
#: core\app.py:394
msgid "All selected %d matches are going to be ignored in all subsequent scans. Continue?"
msgstr ""
#: core\app.py:469
#: core\app.py:471
msgid "Select a directory to copy marked files to"
msgstr ""
#: core\app.py:471
#: core\app.py:473
msgid "Select a directory to move marked files to"
msgstr ""
#: core\app.py:510
#: core\app.py:512
msgid "Select a destination for your exported CSV"
msgstr ""
#: core\app.py:516 core\app.py:771 core\app.py:781
#: core\app.py:518 core\app.py:773 core\app.py:783
msgid "Couldn't write to file: {}"
msgstr ""
#: core\app.py:539
#: core\app.py:541
msgid "You have no custom command set up. Set it up in your preferences."
msgstr ""
#: core\app.py:695 core\app.py:707
#: core\app.py:697 core\app.py:709
msgid "You are about to remove %d files from results. Continue?"
msgstr ""
#: core\app.py:743
#: core\app.py:745
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr ""
#: core\app.py:790
#: core\app.py:792
msgid "The selected directories contain no scannable file."
msgstr ""
#: core\app.py:803
#: core\app.py:808
msgid "Collecting files to scan"
msgstr ""
#: core\app.py:850
#: core\app.py:858
msgid "%s (%d discarded)"
msgstr ""
#: core\directories.py:191
#: core\directories.py:190
msgid "Collected {} files to scan"
msgstr ""
#: core\directories.py:207
#: core\directories.py:206
msgid "Collected {} folders to scan"
msgstr ""
@@ -188,35 +188,35 @@ msgstr ""
msgid "None"
msgstr ""
#: core\prioritize.py:100
#: core\prioritize.py:102
msgid "Ends with number"
msgstr ""
#: core\prioritize.py:101
#: core\prioritize.py:103
msgid "Doesn't end with number"
msgstr ""
#: core\prioritize.py:102
#: core\prioritize.py:104
msgid "Longest"
msgstr ""
#: core\prioritize.py:103
#: core\prioritize.py:105
msgid "Shortest"
msgstr ""
#: core\prioritize.py:140
#: core\prioritize.py:142
msgid "Highest"
msgstr ""
#: core\prioritize.py:140
#: core\prioritize.py:142
msgid "Lowest"
msgstr ""
#: core\prioritize.py:169
#: core\prioritize.py:171
msgid "Newest"
msgstr ""
#: core\prioritize.py:169
#: core\prioritize.py:171
msgid "Oldest"
msgstr ""

View File

@@ -1,10 +1,10 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Andrew Senetar <arsenetar@gmail.com>, 2022
# Fuan <jcfrt@posteo.net>, 2022
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: Fuan <jcfrt@posteo.net>, 2022\n"
"Language-Team: Czech (https://www.transifex.com/voltaicideas/teams/116153/cs/)\n"
"Language: cs\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -977,3 +977,157 @@ msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""
#: qt\preferences_dialog.py:241
msgid "Profile scan operation"
msgstr ""
#: qt\preferences_dialog.py:242
msgid "Profile the scan operation and save logs for optimization."
msgstr ""
#: qt\preferences_dialog.py:246
msgid "Logs located in: <a href=\"{}\">{}</a>"
msgstr ""
#: qt\preferences_dialog.py:291
msgid "Debug"
msgstr ""
#: qt\about_box.py:31
msgid "About {}"
msgstr "O {}"
#: qt\about_box.py:47
msgid "Version {}"
msgstr "Verze {}"
#: qt\about_box.py:49 qt\about_box.py:75
msgid "Checking for updates..."
msgstr ""
#: qt\about_box.py:54
msgid "Licensed under GPLv3"
msgstr "Licencován pod GPLv3"
#: qt\about_box.py:68
msgid "No update available."
msgstr ""
#: qt\about_box.py:71
msgid "New version {} available, download <a href=\"{}\">here</a>."
msgstr ""
#: qt\error_report_dialog.py:50
msgid "Error Report"
msgstr "Chybové hlášení"
#: qt\error_report_dialog.py:54
msgid "Something went wrong. How about reporting the error?"
msgstr "Něco se pokazilo. Co takhle nahlásit chybu?"
#: qt\error_report_dialog.py:60
msgid ""
"Error reports should be reported as Github issues. You can copy the error traceback above and paste it in a new issue.\n"
"\n"
"Please make sure to run a search for any already existing issues beforehand. Also make sure to test the very latest version available from the repository, since the bug you are experiencing might have already been patched.\n"
"\n"
"What usually really helps is if you add a description of how you got the error. Thanks!\n"
"\n"
"Although the application should continue to run after this error, it may be in an unstable state, so it is recommended that you restart the application."
msgstr ""
"Chybové zprávy by měly být vykázáno jako emise GitHub. Můžete zkopírovat chybovou TraceBack výše a vložit ji do nové emise.\n"
"\n"
"Prosím, ujistěte se, že ke spuštění hledání jakýchkoli již existujících otázek předem. Také se ujistěte, vyzkoušet nejnovější dostupnou verzi z úložiště, protože chyba jste se setkali již mohla být oprava.\n"
"\n"
"To, co obvykle opravdu pomáhá, je přidat popis toho, jak jste se dostali k chybě. Dík!\n"
"\n"
"Přestože by aplikace měla po této chybě pokračovat, může být v nestabilním stavu, proto se doporučuje aplikaci restartovat."
#: qt\error_report_dialog.py:80
msgid "Go to Github"
msgstr "Přejít na Github"
#: qt\preferences.py:24
msgid "Czech"
msgstr "česky"
#: qt\preferences.py:25
msgid "German"
msgstr "Německy"
#: qt\preferences.py:26
msgid "Greek"
msgstr "řecky"
#: qt\preferences.py:27
msgid "English"
msgstr "Anglicky."
#: qt\preferences.py:28
msgid "Spanish"
msgstr "španělsky"
#: qt\preferences.py:29
msgid "French"
msgstr "Francouzsky"
#: qt\preferences.py:30
msgid "Armenian"
msgstr "arménsky"
#: qt\preferences.py:31
msgid "Italian"
msgstr "italsky"
#: qt\preferences.py:32
msgid "Japanese"
msgstr "Japonština"
#: qt\preferences.py:33
msgid "Korean"
msgstr "korejsky"
#: qt\preferences.py:34
msgid "Malay"
msgstr "Malajština"
#: qt\preferences.py:35
msgid "Dutch"
msgstr "holandsky"
#: qt\preferences.py:36
msgid "Polish"
msgstr "polsky"
#: qt\preferences.py:37
msgid "Brazilian"
msgstr "brazilsky"
#: qt\preferences.py:38
msgid "Russian"
msgstr "rusky"
#: qt\preferences.py:39
msgid "Turkish"
msgstr "Turečtina"
#: qt\preferences.py:40
msgid "Ukrainian"
msgstr "ukrajinsky"
#: qt\preferences.py:41
msgid "Vietnamese"
msgstr "vietnamsky"
#: qt\preferences.py:42
msgid "Chinese (Simplified)"
msgstr "čínsky (zjednodušeně)"
#: qt\recent.py:54
msgid "Clear List"
msgstr "Vymazání seznamu"
#: qt\search_edit.py:78
msgid "Search..."
msgstr "Hledat..."

View File

@@ -1,11 +1,11 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Robert M, 2021
# Robert M, 2022
# Andrew Senetar <arsenetar@gmail.com>, 2022
# Fuan <jcfrt@posteo.net>, 2022
#
msgid ""
msgstr ""
"Last-Translator: Robert M, 2021\n"
"Last-Translator: Fuan <jcfrt@posteo.net>, 2022\n"
"Language-Team: German (https://www.transifex.com/voltaicideas/teams/116153/de/)\n"
"Language: de\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -989,3 +989,157 @@ msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""
#: qt\preferences_dialog.py:241
msgid "Profile scan operation"
msgstr ""
#: qt\preferences_dialog.py:242
msgid "Profile the scan operation and save logs for optimization."
msgstr ""
#: qt\preferences_dialog.py:246
msgid "Logs located in: <a href=\"{}\">{}</a>"
msgstr ""
#: qt\preferences_dialog.py:291
msgid "Debug"
msgstr ""
#: qt\about_box.py:31
msgid "About {}"
msgstr "Über {}"
#: qt\about_box.py:47
msgid "Version {}"
msgstr "Version {}"
#: qt\about_box.py:49 qt\about_box.py:75
msgid "Checking for updates..."
msgstr ""
#: qt\about_box.py:54
msgid "Licensed under GPLv3"
msgstr "Lizenziert unter GPLv3"
#: qt\about_box.py:68
msgid "No update available."
msgstr ""
#: qt\about_box.py:71
msgid "New version {} available, download <a href=\"{}\">here</a>."
msgstr ""
#: qt\error_report_dialog.py:50
msgid "Error Report"
msgstr "Fehlermeldung"
#: qt\error_report_dialog.py:54
msgid "Something went wrong. How about reporting the error?"
msgstr "Etwas ist schief gelaufen. Wie wäre es, den Fehler zu melden?"
#: qt\error_report_dialog.py:60
msgid ""
"Error reports should be reported as Github issues. You can copy the error traceback above and paste it in a new issue.\n"
"\n"
"Please make sure to run a search for any already existing issues beforehand. Also make sure to test the very latest version available from the repository, since the bug you are experiencing might have already been patched.\n"
"\n"
"What usually really helps is if you add a description of how you got the error. Thanks!\n"
"\n"
"Although the application should continue to run after this error, it may be in an unstable state, so it is recommended that you restart the application."
msgstr ""
"Fehlerberichte sollten als Github-Probleme gemeldet werden. Sie können den obigen Fehler-Traceback kopieren und in eine neue Ausgabe einfügen.\n"
"\n"
"Bitte stellen Sie sicher, dass Sie vorher nach bereits vorhandenen Problemen suchen. Stellen Sie außerdem sicher, dass Sie die neueste Version testen, die im Repository verfügbar ist, da der aufgetretene Fehler möglicherweise bereits behoben wurde.\n"
"\n"
"Was normalerweise wirklich hilft, ist, wenn Sie eine Beschreibung hinzufügen, wie Sie den Fehler erhalten haben. Vielen Dank!\n"
"\n"
"Obwohl die Anwendung nach diesem Fehler weiterhin ausgeführt werden sollte, befindet sie sich möglicherweise in einem instabilen Zustand. Es wird daher empfohlen, die Anwendung neu zu starten."
#: qt\error_report_dialog.py:80
msgid "Go to Github"
msgstr "Geh zu Github"
#: qt\preferences.py:24
msgid "Czech"
msgstr "Tschechisch"
#: qt\preferences.py:25
msgid "German"
msgstr "Deutsch"
#: qt\preferences.py:26
msgid "Greek"
msgstr "Griechisch"
#: qt\preferences.py:27
msgid "English"
msgstr "Englisch"
#: qt\preferences.py:28
msgid "Spanish"
msgstr "Spanisch"
#: qt\preferences.py:29
msgid "French"
msgstr "Französisch"
#: qt\preferences.py:30
msgid "Armenian"
msgstr "Armenisch"
#: qt\preferences.py:31
msgid "Italian"
msgstr "Italienisch"
#: qt\preferences.py:32
msgid "Japanese"
msgstr "Japanisch"
#: qt\preferences.py:33
msgid "Korean"
msgstr "Koreanisch"
#: qt\preferences.py:34
msgid "Malay"
msgstr "Malaiisch"
#: qt\preferences.py:35
msgid "Dutch"
msgstr "Niederländisch"
#: qt\preferences.py:36
msgid "Polish"
msgstr "Polnisch"
#: qt\preferences.py:37
msgid "Brazilian"
msgstr "Brasilianisch"
#: qt\preferences.py:38
msgid "Russian"
msgstr "Russisch"
#: qt\preferences.py:39
msgid "Turkish"
msgstr "Türkisch"
#: qt\preferences.py:40
msgid "Ukrainian"
msgstr "Ukrainisch"
#: qt\preferences.py:41
msgid "Vietnamese"
msgstr "Vietnamesisch"
#: qt\preferences.py:42
msgid "Chinese (Simplified)"
msgstr "Chinesisch (Vereinfachtes)"
#: qt\recent.py:54
msgid "Clear List"
msgstr "Liste löschen"
#: qt\search_edit.py:78
msgid "Search..."
msgstr "Suche..."

View File

@@ -1,10 +1,10 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Fuan <jcfrt@posteo.net>, 2022
# Andrew Senetar <arsenetar@gmail.com>, 2022
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: Andrew Senetar <arsenetar@gmail.com>, 2022\n"
"Language-Team: Greek (https://www.transifex.com/voltaicideas/teams/116153/el/)\n"
"Language: el\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -994,3 +994,157 @@ msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""
#: qt\preferences_dialog.py:241
msgid "Profile scan operation"
msgstr ""
#: qt\preferences_dialog.py:242
msgid "Profile the scan operation and save logs for optimization."
msgstr ""
#: qt\preferences_dialog.py:246
msgid "Logs located in: <a href=\"{}\">{}</a>"
msgstr ""
#: qt\preferences_dialog.py:291
msgid "Debug"
msgstr ""
#: qt\about_box.py:31
msgid "About {}"
msgstr "Σχετικά {}"
#: qt\about_box.py:47
msgid "Version {}"
msgstr "Έκδοση {}"
#: qt\about_box.py:49 qt\about_box.py:75
msgid "Checking for updates..."
msgstr ""
#: qt\about_box.py:54
msgid "Licensed under GPLv3"
msgstr "Άδεια χρήσης βάσει GPLv3"
#: qt\about_box.py:68
msgid "No update available."
msgstr ""
#: qt\about_box.py:71
msgid "New version {} available, download <a href=\"{}\">here</a>."
msgstr ""
#: qt\error_report_dialog.py:50
msgid "Error Report"
msgstr "Αναφορά σφάλματος"
#: qt\error_report_dialog.py:54
msgid "Something went wrong. How about reporting the error?"
msgstr "Κάτι πήγε στραβά. Μήπως να αναφερθεί το σφάλμα;"
#: qt\error_report_dialog.py:60
msgid ""
"Error reports should be reported as Github issues. You can copy the error traceback above and paste it in a new issue.\n"
"\n"
"Please make sure to run a search for any already existing issues beforehand. Also make sure to test the very latest version available from the repository, since the bug you are experiencing might have already been patched.\n"
"\n"
"What usually really helps is if you add a description of how you got the error. Thanks!\n"
"\n"
"Although the application should continue to run after this error, it may be in an unstable state, so it is recommended that you restart the application."
msgstr ""
"Οι αναφορές σφαλμάτων πρέπει να αναφέρονται ως ζητήματα Github. Μπορείτε να αντιγράψετε την ανίχνευση σφαλμάτων παραπάνω και να την επικολλήσετε σε ένα νέο ζήτημα.\n"
"\n"
"Βεβαιωθείτε ότι έχετε πραγματοποιήσει αναζήτηση για τυχόν υπάρχοντα ζητήματα εκ των προτέρων. Επίσης, φροντίστε να δοκιμάσετε την πιο πρόσφατη διαθέσιμη έκδοση από το αποθετήριο, καθώς το σφάλμα που αντιμετωπίζετε ενδέχεται να έχει ήδη διορθωθεί.\n"
"\n"
"Αυτό που συνήθως βοηθάει συνήθως είναι εάν προσθέσετε μια περιγραφή για το πώς λάβατε το σφάλμα. Ευχαριστώ!\n"
"\n"
"Παρόλο που η εφαρμογή θα πρέπει να συνεχίσει να εκτελείται μετά από αυτό το σφάλμα, ενδέχεται να βρίσκεται σε ασταθή κατάσταση, επομένως συνιστάται να κάνετε επανεκκίνηση της εφαρμογής."
#: qt\error_report_dialog.py:80
msgid "Go to Github"
msgstr "Επίσκεψη Github"
#: qt\preferences.py:24
msgid "Czech"
msgstr "Τσέχικα"
#: qt\preferences.py:25
msgid "German"
msgstr "Γερμανικά"
#: qt\preferences.py:26
msgid "Greek"
msgstr "Ελληνικά"
#: qt\preferences.py:27
msgid "English"
msgstr "Αγγλικά"
#: qt\preferences.py:28
msgid "Spanish"
msgstr "Ισπανικά"
#: qt\preferences.py:29
msgid "French"
msgstr "Γαλλικά"
#: qt\preferences.py:30
msgid "Armenian"
msgstr "Αρμένικα"
#: qt\preferences.py:31
msgid "Italian"
msgstr "Ιταλικά"
#: qt\preferences.py:32
msgid "Japanese"
msgstr "Ιαπωνικά"
#: qt\preferences.py:33
msgid "Korean"
msgstr "Κορεάτικα"
#: qt\preferences.py:34
msgid "Malay"
msgstr "Μαλαϊκά"
#: qt\preferences.py:35
msgid "Dutch"
msgstr "Γερμανικά"
#: qt\preferences.py:36
msgid "Polish"
msgstr "Πολωνικά"
#: qt\preferences.py:37
msgid "Brazilian"
msgstr "Βραζιλιάνικα"
#: qt\preferences.py:38
msgid "Russian"
msgstr "Ρώσικα"
#: qt\preferences.py:39
msgid "Turkish"
msgstr "Τουρκικά"
#: qt\preferences.py:40
msgid "Ukrainian"
msgstr "Ουκρανέζικα"
#: qt\preferences.py:41
msgid "Vietnamese"
msgstr "Βιετναμέζικα"
#: qt\preferences.py:42
msgid "Chinese (Simplified)"
msgstr "Κινέζικα (Απλοποιημένα)"
#: qt\recent.py:54
msgid "Clear List"
msgstr "Εκκαθάριση λίστας"
#: qt\search_edit.py:78
msgid "Search..."
msgstr "Αναζήτηση..."

View File

@@ -1,25 +1,26 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# IlluminatiWave, 2022
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: IlluminatiWave, 2022\n"
"Language-Team: Spanish (https://www.transifex.com/voltaicideas/teams/116153/es/)\n"
"Language: es\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: utf-8\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: core\app.py:42
#: core\app.py:44
msgid "There are no marked duplicates. Nothing has been done."
msgstr "No hay duplicados marcados. No se ha hecho nada."
#: core\app.py:43
#: core\app.py:45
msgid "There are no selected duplicates. Nothing has been done."
msgstr "No hay duplicados seleccionados. No se ha hecho nada."
#: core\app.py:44
#: core\app.py:46
msgid ""
"You're about to open many files at once. Depending on what those files are "
"opened with, doing so can create quite a mess. Continue?"
@@ -27,27 +28,27 @@ msgstr ""
"Está a punto de abrir muchas imágenes. Dependiendo de los ficheros que se "
"abran, abrirlos puede colgar la máquina. ¿Continuar?"
#: core\app.py:71
#: core\app.py:73
msgid "Scanning for duplicates"
msgstr "Buscando duplicados"
#: core\app.py:72
#: core\app.py:74
msgid "Loading"
msgstr "Cargando"
#: core\app.py:73
#: core\app.py:75
msgid "Moving"
msgstr "Moviendo"
#: core\app.py:74
#: core\app.py:76
msgid "Copying"
msgstr "Copiando"
#: core\app.py:75
#: core\app.py:77
msgid "Sending to Trash"
msgstr "Enviando a la Papelera"
#: core\app.py:289
#: core\app.py:291
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
@@ -55,40 +56,40 @@ msgstr ""
"Una acción previa sigue ejecutándose. No puede abrir una nueva todavía. "
"Espere unos segundos y vuelva a intentarlo."
#: core\app.py:300
#: core\app.py:302
msgid "No duplicates found."
msgstr "No se han encontrado duplicados."
#: core\app.py:315
#: core\app.py:317
msgid "All marked files were copied successfully."
msgstr ""
"Todos los ficheros seleccionados han sido copiados satisfactoriamente."
#: core\app.py:317
#: core\app.py:319
msgid "All marked files were moved successfully."
msgstr "Todos los ficheros seleccionados se han movidos satisfactoriamente."
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:321
msgid "All marked files were deleted successfully."
msgstr "Todos los ficheros seleccionados se han eliminado satisfactoriamente."
#: core\app.py:323
msgid "All marked files were successfully sent to Trash."
msgstr "Todo los ficheros marcados se han enviado a la papelera exitosamente."
#: core\app.py:326
#: core\app.py:328
msgid "Could not load file: {}"
msgstr "No se pudo cargar el archivo: {}"
#: core\app.py:382
#: core\app.py:384
msgid "'{}' already is in the list."
msgstr "'{}' ya está en la lista."
#: core\app.py:384
#: core\app.py:386
msgid "'{}' does not exist."
msgstr "'{}' no existe."
#: core\app.py:392
#: core\app.py:394
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
@@ -96,57 +97,57 @@ msgstr ""
"Todas las %d coincidencias seleccionadas van a ser ignoradas en las "
"subsiguientes exploraciones. ¿Continuar?"
#: core\app.py:469
#: core\app.py:471
msgid "Select a directory to copy marked files to"
msgstr "Seleccione un directorio donde desee copiar los archivos marcados"
#: core\app.py:471
#: core\app.py:473
msgid "Select a directory to move marked files to"
msgstr "Seleccione un directorio al que desee mover los archivos marcados"
#: core\app.py:510
#: core\app.py:512
msgid "Select a destination for your exported CSV"
msgstr "Seleccionar un destino para el CSV seleccionado"
#: core\app.py:516 core\app.py:771 core\app.py:781
#: core\app.py:518 core\app.py:773 core\app.py:783
msgid "Couldn't write to file: {}"
msgstr "No se pudo escribir en el archivo: {}"
#: core\app.py:539
#: core\app.py:541
msgid "You have no custom command set up. Set it up in your preferences."
msgstr "No hay comandos configurados. Establézcalos en sus preferencias."
#: core\app.py:695 core\app.py:707
#: core\app.py:697 core\app.py:709
msgid "You are about to remove %d files from results. Continue?"
msgstr "Está a punto de eliminar %d ficheros de resultados. ¿Continuar?"
#: core\app.py:743
#: core\app.py:745
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{} grupos de duplicados han sido cambiados por la re-priorización"
msgstr "{} grupos de duplicados han sido cambiados por la re-priorización."
#: core\app.py:790
#: core\app.py:792
msgid "The selected directories contain no scannable file."
msgstr "Las carpetas seleccionadas no contienen ficheros para explorar."
#: core\app.py:803
#: core\app.py:808
msgid "Collecting files to scan"
msgstr "Recopilando ficheros a explorar"
#: core\app.py:850
#: core\app.py:858
msgid "%s (%d discarded)"
msgstr "%s (%d descartados)"
#: core\directories.py:191
#: core\directories.py:190
msgid "Collected {} files to scan"
msgstr ""
msgstr "{} ficheros recopilados para explorar"
#: core\directories.py:207
#: core\directories.py:206
msgid "Collected {} folders to scan"
msgstr ""
msgstr "{} carpetas recopiladas para explorar"
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr ""
msgstr "%d coincidencias encontradas en %d grupos"
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
@@ -209,35 +210,35 @@ msgstr "Marca horaria EXIF"
msgid "None"
msgstr "Ninguno"
#: core\prioritize.py:100
#: core\prioritize.py:102
msgid "Ends with number"
msgstr "Termina con un número"
#: core\prioritize.py:101
#: core\prioritize.py:103
msgid "Doesn't end with number"
msgstr "No termina con un número"
#: core\prioritize.py:102
#: core\prioritize.py:104
msgid "Longest"
msgstr "El más largo"
#: core\prioritize.py:103
#: core\prioritize.py:105
msgid "Shortest"
msgstr "El más corto"
#: core\prioritize.py:140
#: core\prioritize.py:142
msgid "Highest"
msgstr "El más alto"
#: core\prioritize.py:140
#: core\prioritize.py:142
msgid "Lowest"
msgstr "El más bajo"
#: core\prioritize.py:169
#: core\prioritize.py:171
msgid "Newest"
msgstr "El más nuevo"
#: core\prioritize.py:169
#: core\prioritize.py:171
msgid "Oldest"
msgstr "El más antiguo"

View File

@@ -1,10 +1,11 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Fuan <jcfrt@posteo.net>, 2022
# Andrew Senetar <arsenetar@gmail.com>, 2022
# IlluminatiWave, 2022
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: IlluminatiWave, 2022\n"
"Language-Team: Spanish (https://www.transifex.com/voltaicideas/teams/116153/es/)\n"
"Language: es\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -333,7 +334,7 @@ msgstr "Tamaño de fuente:"
#: qt/preferences_dialog.py:85
msgid "Language:"
msgstr "Lenguaje:"
msgstr "Idioma:"
#: qt/preferences_dialog.py:91 cocoa/en.lproj/Localizable.strings:0
msgid "Copy and Move:"
@@ -950,40 +951,200 @@ msgstr "Visualización"
#: qt\se\preferences_dialog.py:70
msgid "Partially hash files bigger than"
msgstr ""
msgstr "Archivos de hash parcialmente mayores a"
#: qt\se\preferences_dialog.py:80
msgid "MB"
msgstr ""
msgstr "MB"
#: qt\preferences_dialog.py:163
msgid "Use native OS dialogs"
msgstr ""
msgstr "Usar diálogos nativos del SO"
#: qt\preferences_dialog.py:166
msgid ""
"For actions such as file/folder selection use the OS native dialogs.\n"
"Some native dialogs have limited functionality."
msgstr ""
"Para acciones como la selección de archivos/carpetas, utilice los diálogos nativos del SO\n"
"Algunos diálogos nativos tienen una funcionalidad limitada."
#: qt\se\preferences_dialog.py:68
msgid "Ignore files larger than"
msgstr ""
msgstr "Ignorar los ficheros mayores a"
#: qt\app.py:135 qt\app.py:293
msgid "Clear Cache"
msgstr ""
msgstr "Borrar caché"
#: qt\app.py:294
msgid ""
"Do you really want to clear the cache? This will remove all cached file "
"hashes and picture analysis."
msgstr ""
"¿Seguro que quieres borrar la caché? Esto eliminará todos los hashes de "
"ficheros y análisis de imágenes almacenados en la caché."
#: qt\app.py:299
msgid "Cache cleared."
msgstr ""
msgstr "Caché eliminada."
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr "Usar tema oscuro"
#: qt\preferences_dialog.py:241
msgid "Profile scan operation"
msgstr "Perfilar operación de análisis"
#: qt\preferences_dialog.py:242
msgid "Profile the scan operation and save logs for optimization."
msgstr ""
"Perfilar la operación de análisis y guardar los registros para su "
"optimización."
#: qt\preferences_dialog.py:246
msgid "Logs located in: <a href=\"{}\">{}</a>"
msgstr "Registro guardado en: <a href=\"{}\">{}</a>"
#: qt\preferences_dialog.py:291
msgid "Debug"
msgstr "Depurar"
#: qt\about_box.py:31
msgid "About {}"
msgstr "Acerca de {}"
#: qt\about_box.py:47
msgid "Version {}"
msgstr "Versión {}"
#: qt\about_box.py:49 qt\about_box.py:75
msgid "Checking for updates..."
msgstr "Buscando actualizaciones..."
#: qt\about_box.py:54
msgid "Licensed under GPLv3"
msgstr "Licenciado en GPLv3"
#: qt\about_box.py:68
msgid "No update available."
msgstr "Sin actualizaciones disponibles."
#: qt\about_box.py:71
msgid "New version {} available, download <a href=\"{}\">here</a>."
msgstr "Nueva versión disponible {}, descargar <a href=\"{}\">aquí</a> "
#: qt\error_report_dialog.py:50
msgid "Error Report"
msgstr "Informe de error"
#: qt\error_report_dialog.py:54
msgid "Something went wrong. How about reporting the error?"
msgstr "Algo salió mal. ¿Qué tal informar el error?"
#: qt\error_report_dialog.py:60
msgid ""
"Error reports should be reported as Github issues. You can copy the error traceback above and paste it in a new issue.\n"
"\n"
"Please make sure to run a search for any already existing issues beforehand. Also make sure to test the very latest version available from the repository, since the bug you are experiencing might have already been patched.\n"
"\n"
"What usually really helps is if you add a description of how you got the error. Thanks!\n"
"\n"
"Although the application should continue to run after this error, it may be in an unstable state, so it is recommended that you restart the application."
msgstr ""
"Los informes de errores deben notificarse en Problemas de Github. Puede copiar el seguimiento del error anterior y pegarlo en un nuevo número.\n"
"\n"
"Asegúrese de realizar una búsqueda de los problemas ya existentes de antemano. También asegúrese de probar la última versión disponible en el repositorio, ya que es posible que el error que está experimentando ya se haya corregido.\n"
"\n"
"Lo que generalmente ayuda es agregar una descripción de cómo obtuvo el error. ¡Gracias!\n"
"\n"
"Aunque la aplicación debería continuar ejecutándose después de este error, puede estar en un estado inestable, por lo que se recomienda que reinicie la aplicación."
#: qt\error_report_dialog.py:80
msgid "Go to Github"
msgstr "Ir a Github"
#: qt\preferences.py:24
msgid "Czech"
msgstr "Checo"
#: qt\preferences.py:25
msgid "German"
msgstr "Alemán"
#: qt\preferences.py:26
msgid "Greek"
msgstr "Griego"
#: qt\preferences.py:27
msgid "English"
msgstr "Inglés"
#: qt\preferences.py:28
msgid "Spanish"
msgstr "Español"
#: qt\preferences.py:29
msgid "French"
msgstr "Francés"
#: qt\preferences.py:30
msgid "Armenian"
msgstr "Armenio"
#: qt\preferences.py:31
msgid "Italian"
msgstr "Italiano"
#: qt\preferences.py:32
msgid "Japanese"
msgstr "Japonés"
#: qt\preferences.py:33
msgid "Korean"
msgstr "Coreano"
#: qt\preferences.py:34
msgid "Malay"
msgstr "Malayo"
#: qt\preferences.py:35
msgid "Dutch"
msgstr "Holandés"
#: qt\preferences.py:36
msgid "Polish"
msgstr "Polaco"
#: qt\preferences.py:37
msgid "Brazilian"
msgstr "Brasileño"
#: qt\preferences.py:38
msgid "Russian"
msgstr "Ruso"
#: qt\preferences.py:39
msgid "Turkish"
msgstr "Turco"
#: qt\preferences.py:40
msgid "Ukrainian"
msgstr "Ucraniano"
#: qt\preferences.py:41
msgid "Vietnamese"
msgstr "Vietnamita"
#: qt\preferences.py:42
msgid "Chinese (Simplified)"
msgstr "Chino (simplificado)"
#: qt\recent.py:54
msgid "Clear List"
msgstr "Limpiar lista"
#: qt\search_edit.py:78
msgid "Search..."
msgstr "Búsqueda..."

View File

@@ -1,10 +1,10 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Andrew Senetar <arsenetar@gmail.com>, 2022
# Fuan <jcfrt@posteo.net>, 2022
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: Fuan <jcfrt@posteo.net>, 2022\n"
"Language-Team: French (https://www.transifex.com/voltaicideas/teams/116153/fr/)\n"
"Language: fr\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -982,3 +982,157 @@ msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""
#: qt\preferences_dialog.py:241
msgid "Profile scan operation"
msgstr ""
#: qt\preferences_dialog.py:242
msgid "Profile the scan operation and save logs for optimization."
msgstr ""
#: qt\preferences_dialog.py:246
msgid "Logs located in: <a href=\"{}\">{}</a>"
msgstr ""
#: qt\preferences_dialog.py:291
msgid "Debug"
msgstr ""
#: qt\about_box.py:31
msgid "About {}"
msgstr "A propos de {}"
#: qt\about_box.py:47
msgid "Version {}"
msgstr "Version {}"
#: qt\about_box.py:49 qt\about_box.py:75
msgid "Checking for updates..."
msgstr ""
#: qt\about_box.py:54
msgid "Licensed under GPLv3"
msgstr "Sous licence GPLv3"
#: qt\about_box.py:68
msgid "No update available."
msgstr ""
#: qt\about_box.py:71
msgid "New version {} available, download <a href=\"{}\">here</a>."
msgstr ""
#: qt\error_report_dialog.py:50
msgid "Error Report"
msgstr "Rapport d'erreur"
#: qt\error_report_dialog.py:54
msgid "Something went wrong. How about reporting the error?"
msgstr "Un problème est survenu. Rapporter l'erreur?"
#: qt\error_report_dialog.py:60
msgid ""
"Error reports should be reported as Github issues. You can copy the error traceback above and paste it in a new issue.\n"
"\n"
"Please make sure to run a search for any already existing issues beforehand. Also make sure to test the very latest version available from the repository, since the bug you are experiencing might have already been patched.\n"
"\n"
"What usually really helps is if you add a description of how you got the error. Thanks!\n"
"\n"
"Although the application should continue to run after this error, it may be in an unstable state, so it is recommended that you restart the application."
msgstr ""
"Les rapports d'erreur doivent être envoyé via les tickets Github. Vous pouvez copier l'historique d'erreur ci-dessus et le coller dans un nouveau ticket.\n"
"\n"
"Veuillez vous assurer auparavant d'avoir fait une recherche pour un ticket similaire. Assurez-vous aussi d'avoir testé la toute dernière version disponible depuis le dépôt car le bug que vous avez rencontré a peut-être déjà été corrigé. \n"
"\n"
"Décrire comment vous avez rencontré cette erreur est aussi très précieux. Merci!\n"
"\n"
" Même si cette application continue de fonctionner après cette erreur, elle peut être dans un état instable, et il est donc recommandé de relancer l'application."
#: qt\error_report_dialog.py:80
msgid "Go to Github"
msgstr "Aller sur Github"
#: qt\preferences.py:24
msgid "Czech"
msgstr "Tchèque"
#: qt\preferences.py:25
msgid "German"
msgstr "Allemand"
#: qt\preferences.py:26
msgid "Greek"
msgstr "Grecque"
#: qt\preferences.py:27
msgid "English"
msgstr "Anglais"
#: qt\preferences.py:28
msgid "Spanish"
msgstr "Espagnol"
#: qt\preferences.py:29
msgid "French"
msgstr "Français"
#: qt\preferences.py:30
msgid "Armenian"
msgstr "Arménien"
#: qt\preferences.py:31
msgid "Italian"
msgstr "Italien"
#: qt\preferences.py:32
msgid "Japanese"
msgstr "Japonais"
#: qt\preferences.py:33
msgid "Korean"
msgstr "Coréen"
#: qt\preferences.py:34
msgid "Malay"
msgstr "Malaisien"
#: qt\preferences.py:35
msgid "Dutch"
msgstr "Néerlandais"
#: qt\preferences.py:36
msgid "Polish"
msgstr "Polonais"
#: qt\preferences.py:37
msgid "Brazilian"
msgstr "Brésilien"
#: qt\preferences.py:38
msgid "Russian"
msgstr "Russe"
#: qt\preferences.py:39
msgid "Turkish"
msgstr "Turc"
#: qt\preferences.py:40
msgid "Ukrainian"
msgstr "Ukrainien"
#: qt\preferences.py:41
msgid "Vietnamese"
msgstr "Vietnamien"
#: qt\preferences.py:42
msgid "Chinese (Simplified)"
msgstr "Chinois (Simplifié)"
#: qt\recent.py:54
msgid "Clear List"
msgstr "Vider la liste"
#: qt\search_edit.py:78
msgid "Search..."
msgstr "Recherche..."

View File

@@ -1,10 +1,10 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Andrew Senetar <arsenetar@gmail.com>, 2022
# Fuan <jcfrt@posteo.net>, 2022
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: Fuan <jcfrt@posteo.net>, 2022\n"
"Language-Team: Armenian (https://www.transifex.com/voltaicideas/teams/116153/hy/)\n"
"Language: hy\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -922,3 +922,197 @@ msgstr "Գեներալ"
#: qt\preferences_dialog.py:286
msgid "Display"
msgstr "Ցուցադրման"
#: qt\se\preferences_dialog.py:70
msgid "Partially hash files bigger than"
msgstr ""
#: qt\se\preferences_dialog.py:80
msgid "MB"
msgstr ""
#: qt\preferences_dialog.py:163
msgid "Use native OS dialogs"
msgstr ""
#: qt\preferences_dialog.py:166
msgid ""
"For actions such as file/folder selection use the OS native dialogs.\n"
"Some native dialogs have limited functionality."
msgstr ""
#: qt\se\preferences_dialog.py:68
msgid "Ignore files larger than"
msgstr ""
#: qt\app.py:135 qt\app.py:293
msgid "Clear Cache"
msgstr ""
#: qt\app.py:294
msgid ""
"Do you really want to clear the cache? This will remove all cached file "
"hashes and picture analysis."
msgstr ""
#: qt\app.py:299
msgid "Cache cleared."
msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""
#: qt\preferences_dialog.py:241
msgid "Profile scan operation"
msgstr ""
#: qt\preferences_dialog.py:242
msgid "Profile the scan operation and save logs for optimization."
msgstr ""
#: qt\preferences_dialog.py:246
msgid "Logs located in: <a href=\"{}\">{}</a>"
msgstr ""
#: qt\preferences_dialog.py:291
msgid "Debug"
msgstr ""
#: qt\about_box.py:31
msgid "About {}"
msgstr "{}- ի մասին"
#: qt\about_box.py:47
msgid "Version {}"
msgstr "{}-րդ տարբերակ"
#: qt\about_box.py:49 qt\about_box.py:75
msgid "Checking for updates..."
msgstr ""
#: qt\about_box.py:54
msgid "Licensed under GPLv3"
msgstr "իցենզավորված տակ GPLv3"
#: qt\about_box.py:68
msgid "No update available."
msgstr ""
#: qt\about_box.py:71
msgid "New version {} available, download <a href=\"{}\">here</a>."
msgstr ""
#: qt\error_report_dialog.py:50
msgid "Error Report"
msgstr "Սխալների մասին հաղորդագրություն"
#: qt\error_report_dialog.py:54
msgid "Something went wrong. How about reporting the error?"
msgstr "Ինչ որ բան այնպես չգնաց. Հաղորդել սխալը?"
#: qt\error_report_dialog.py:60
msgid ""
"Error reports should be reported as Github issues. You can copy the error traceback above and paste it in a new issue.\n"
"\n"
"Please make sure to run a search for any already existing issues beforehand. Also make sure to test the very latest version available from the repository, since the bug you are experiencing might have already been patched.\n"
"\n"
"What usually really helps is if you add a description of how you got the error. Thanks!\n"
"\n"
"Although the application should continue to run after this error, it may be in an unstable state, so it is recommended that you restart the application."
msgstr ""
"Error հաշվետվությունները պետք է հրապարակվեն որպես Github հարցերի շուրջ: Կարող եք վերևում պատճենել սխալի հետևումը և տեղադրել այն նոր համարում:\n"
"\n"
"Խնդրում ենք համոզվեք, որ նախապես փնտրեք արդեն գոյություն ունեցող ցանկացած խնդիր: Նաեւ համոզվեք, որ ստուգել են հենց վերջին տարբերակը մատչելի շտեմարան, քանի որ Bug դուք ապրում գուցե արդեն patched.\n"
"\n"
"Սովորաբար այն, ինչ օգնում է իրականում, այն է, եթե ավելացնեք նկարագրությունը, թե ինչպես եք ստացել սխալը: Շնորհակալություն\n"
"\n"
"Չնայած այս սխալից հետո ծրագիրը պետք է շարունակի գործել, այն կարող է լինել անկայուն վիճակում, ուստի խորհուրդ է տրվում վերագործարկել ծրագիրը:"
#: qt\error_report_dialog.py:80
msgid "Go to Github"
msgstr "Գնացեք Գիթուբ"
#: qt\preferences.py:24
msgid "Czech"
msgstr "Չեխերեն"
#: qt\preferences.py:25
msgid "German"
msgstr "Գերմաներեն"
#: qt\preferences.py:26
msgid "Greek"
msgstr "հունարեն"
#: qt\preferences.py:27
msgid "English"
msgstr "Անգլերեն"
#: qt\preferences.py:28
msgid "Spanish"
msgstr "Իսպաներեն"
#: qt\preferences.py:29
msgid "French"
msgstr "Ֆրանսերեն"
#: qt\preferences.py:30
msgid "Armenian"
msgstr "հայերեն"
#: qt\preferences.py:31
msgid "Italian"
msgstr "Իտալերեն"
#: qt\preferences.py:32
msgid "Japanese"
msgstr "ճապոներեն"
#: qt\preferences.py:33
msgid "Korean"
msgstr "կորեերեն"
#: qt\preferences.py:34
msgid "Malay"
msgstr "Մալայերեն"
#: qt\preferences.py:35
msgid "Dutch"
msgstr "հոլանդերեն"
#: qt\preferences.py:36
msgid "Polish"
msgstr "լեհերեն"
#: qt\preferences.py:37
msgid "Brazilian"
msgstr "բրազիլական"
#: qt\preferences.py:38
msgid "Russian"
msgstr "ռուսերեն"
#: qt\preferences.py:39
msgid "Turkish"
msgstr "Թուրքերեն"
#: qt\preferences.py:40
msgid "Ukrainian"
msgstr "ուկրաիներեն"
#: qt\preferences.py:41
msgid "Vietnamese"
msgstr "վիետնամերեն"
#: qt\preferences.py:42
msgid "Chinese (Simplified)"
msgstr "Չինարեն (Պարզեցված)"
#: qt\recent.py:54
msgid "Clear List"
msgstr "Մաքրել ցանկը"
#: qt\search_edit.py:78
msgid "Search..."
msgstr "Որոնել..."

View File

@@ -1,16 +1,17 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Emanuele, 2021
# Andrew Senetar <arsenetar@gmail.com>, 2022
# Emanuele, 2022
# Fuan <jcfrt@posteo.net>, 2022
# Giovanni, 2022
#
msgid ""
msgstr ""
"Last-Translator: Emanuele, 2021\n"
"Last-Translator: Giovanni, 2022\n"
"Language-Team: Italian (https://www.transifex.com/voltaicideas/teams/116153/it/)\n"
"Language: it\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: utf-8\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#: qt/app.py:81
msgid "Quit"
@@ -979,18 +980,175 @@ msgstr "Ignora file più grandi di"
#: qt\app.py:135 qt\app.py:293
msgid "Clear Cache"
msgstr ""
msgstr "Svuota cache"
#: qt\app.py:294
msgid ""
"Do you really want to clear the cache? This will remove all cached file "
"hashes and picture analysis."
msgstr ""
"Vuoi davvero svuotare la cache? Ciò rimuoverà tutti gli hash dei file "
"memorizzati nella cache e le analisi delle immagini."
#: qt\app.py:299
msgid "Cache cleared."
msgstr ""
msgstr "Cache svuotata"
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr "Usa stile scuro"
#: qt\preferences_dialog.py:241
msgid "Profile scan operation"
msgstr "Profila l'operazione di scansione"
#: qt\preferences_dialog.py:242
msgid "Profile the scan operation and save logs for optimization."
msgstr ""
"Profila l'operazione di scansione e salva i registri per l'ottimizzazione."
#: qt\preferences_dialog.py:246
msgid "Logs located in: <a href=\"{}\">{}</a>"
msgstr "I log si trovano in: <a href=\"{}\">{}</a>"
#: qt\preferences_dialog.py:291
msgid "Debug"
msgstr "Debug"
#: qt\about_box.py:31
msgid "About {}"
msgstr "A proposito di {}"
#: qt\about_box.py:47
msgid "Version {}"
msgstr "Versione {}"
#: qt\about_box.py:49 qt\about_box.py:75
msgid "Checking for updates..."
msgstr "Controllo degli aggiornamenti..."
#: qt\about_box.py:54
msgid "Licensed under GPLv3"
msgstr "Distribuito sotto licenza GPLv3"
#: qt\about_box.py:68
msgid "No update available."
msgstr "Nessun aggiornamento disponibile."
#: qt\about_box.py:71
msgid "New version {} available, download <a href=\"{}\">here</a>."
msgstr "È disponibile la nuova versione {}, scaricabile <a href=\"{}\">qui</a>."
#: qt\error_report_dialog.py:50
msgid "Error Report"
msgstr "Rapporto di errore"
#: qt\error_report_dialog.py:54
msgid "Something went wrong. How about reporting the error?"
msgstr "Qualcosa è andato storto. Che ne dici di segnalare l'errore?"
#: qt\error_report_dialog.py:60
msgid ""
"Error reports should be reported as Github issues. You can copy the error traceback above and paste it in a new issue.\n"
"\n"
"Please make sure to run a search for any already existing issues beforehand. Also make sure to test the very latest version available from the repository, since the bug you are experiencing might have already been patched.\n"
"\n"
"What usually really helps is if you add a description of how you got the error. Thanks!\n"
"\n"
"Although the application should continue to run after this error, it may be in an unstable state, so it is recommended that you restart the application."
msgstr ""
"I rapporti di errore dovrebbero essere segnalati come problemi di Github. Puoi copiare il traceback degli errori sopra e incollarlo in un nuovo numero.\n"
"\n"
"Assicurati di eseguire prima una ricerca per eventuali problemi già esistenti. Assicurati anche di testare l'ultima versione disponibile dal repository, poiché il bug che stai riscontrando potrebbe essere già stato corretto.\n"
"\n"
"Ciò che di solito aiuta davvero è aggiungere una descrizione di come hai ottenuto l'errore. Grazie!\n"
"\n"
"Sebbene l'applicazione debba continuare a essere eseguita dopo questo errore, potrebbe essere in uno stato instabile, quindi si consiglia di riavviare l'applicazione."
#: qt\error_report_dialog.py:80
msgid "Go to Github"
msgstr "Apri in Github"
#: qt\preferences.py:24
msgid "Czech"
msgstr "Ceco"
#: qt\preferences.py:25
msgid "German"
msgstr "Tedesco"
#: qt\preferences.py:26
msgid "Greek"
msgstr "Greco"
#: qt\preferences.py:27
msgid "English"
msgstr "Inglese"
#: qt\preferences.py:28
msgid "Spanish"
msgstr "Spagnolo"
#: qt\preferences.py:29
msgid "French"
msgstr "Francese"
#: qt\preferences.py:30
msgid "Armenian"
msgstr "Armeno"
#: qt\preferences.py:31
msgid "Italian"
msgstr "Italiano"
#: qt\preferences.py:32
msgid "Japanese"
msgstr "Giapponese"
#: qt\preferences.py:33
msgid "Korean"
msgstr "Coreano"
#: qt\preferences.py:34
msgid "Malay"
msgstr "Malese"
#: qt\preferences.py:35
msgid "Dutch"
msgstr "Olandese"
#: qt\preferences.py:36
msgid "Polish"
msgstr "Polacco"
#: qt\preferences.py:37
msgid "Brazilian"
msgstr "Brasiliano"
#: qt\preferences.py:38
msgid "Russian"
msgstr "Russo"
#: qt\preferences.py:39
msgid "Turkish"
msgstr "Turco"
#: qt\preferences.py:40
msgid "Ukrainian"
msgstr "Ucraino"
#: qt\preferences.py:41
msgid "Vietnamese"
msgstr "Vietnamita"
#: qt\preferences.py:42
msgid "Chinese (Simplified)"
msgstr "Cinese (semplificato)"
#: qt\recent.py:54
msgid "Clear List"
msgstr "Cancellare l'elenco"
#: qt\search_edit.py:78
msgid "Search..."
msgstr "Ricerca..."

View File

@@ -1,138 +1,139 @@
# Translators:
# Fuan <jcfrt@posteo.net>, 2021
# Yuji Sasaki, 2022
# Fuan <jcfrt@posteo.net>, 2022
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: Fuan <jcfrt@posteo.net>, 2022\n"
"Language-Team: Japanese (https://www.transifex.com/voltaicideas/teams/116153/ja/)\n"
"Language: ja\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: utf-8\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: core\app.py:42
#: core\app.py:44
msgid "There are no marked duplicates. Nothing has been done."
msgstr "マークされた重複はありません。 何も行われていません。"
msgstr "チェックを入れた重複はありません。 何も行われませんでした。"
#: core\app.py:43
#: core\app.py:45
msgid "There are no selected duplicates. Nothing has been done."
msgstr "選択された重複はありません。 何も行われていません。"
#: core\app.py:44
#: core\app.py:46
msgid ""
"You're about to open many files at once. Depending on what those files are "
"opened with, doing so can create quite a mess. Continue?"
msgstr "一度に多くのファイルを開こうとしています。 これらのファイルを開く対象によっては、これを行うとかなり混乱する可能性があります。 継続する?"
#: core\app.py:71
#: core\app.py:73
msgid "Scanning for duplicates"
msgstr "重複のスキャン"
#: core\app.py:72
#: core\app.py:74
msgid "Loading"
msgstr "読み込み中"
#: core\app.py:73
#: core\app.py:75
msgid "Moving"
msgstr "移動します"
#: core\app.py:74
#: core\app.py:76
msgid "Copying"
msgstr "コピー中"
#: core\app.py:75
#: core\app.py:77
msgid "Sending to Trash"
msgstr "ごみ箱に送信します"
#: core\app.py:289
#: core\app.py:291
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
msgstr "前のアクションはまだそこにぶら下がっています。 まだ新しいものを始めることはできません。 数秒待ってから、再試行してください。"
#: core\app.py:300
#: core\app.py:302
msgid "No duplicates found."
msgstr "重複は見つかりませんでした。"
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr "マークされたファイルはすべて正常にコピーされました。"
#: core\app.py:317
msgid "All marked files were moved successfully."
msgstr "マークされたファイルすべて正常に移動されました。"
msgid "All marked files were copied successfully."
msgstr "チェックを入れたファイルすべてコピーしました。"
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr ""
msgid "All marked files were moved successfully."
msgstr "チェックを入れたファイルをすべて移動しました。"
#: core\app.py:321
msgid "All marked files were successfully sent to Trash."
msgstr "マークされたファイルすべてごみ箱に正常に送信されました。"
msgid "All marked files were deleted successfully."
msgstr "チェックを入れたファイルすべて削除しました。"
#: core\app.py:326
#: core\app.py:323
msgid "All marked files were successfully sent to Trash."
msgstr "チェックを入れたファイルをすべてごみ箱に移動しました。"
#: core\app.py:328
msgid "Could not load file: {}"
msgstr "ファイルを読み込めませんでした:{}"
#: core\app.py:382
#: core\app.py:384
msgid "'{}' already is in the list."
msgstr "「{}」既にリストに含まれています。"
#: core\app.py:384
#: core\app.py:386
msgid "'{}' does not exist."
msgstr "'{}' 存在しません。"
#: core\app.py:392
#: core\app.py:394
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
msgstr "選択した%d個の一致は、以降のすべてのスキャンで無視されます。 継続する?"
#: core\app.py:469
#: core\app.py:471
msgid "Select a directory to copy marked files to"
msgstr "マークされたファイルをコピーするディレクトリを選択してください"
#: core\app.py:471
#: core\app.py:473
msgid "Select a directory to move marked files to"
msgstr "マークされたファイルを移動するディレクトリを選択してください"
#: core\app.py:510
#: core\app.py:512
msgid "Select a destination for your exported CSV"
msgstr "エクスポートしたCSVの宛先を選択します。"
#: core\app.py:516 core\app.py:771 core\app.py:781
#: core\app.py:518 core\app.py:773 core\app.py:783
msgid "Couldn't write to file: {}"
msgstr "ファイルに書き込めませんでした:{}"
#: core\app.py:539
#: core\app.py:541
msgid "You have no custom command set up. Set it up in your preferences."
msgstr "カスタムコマンドは設定されていません。 お好みで設定してください。"
#: core\app.py:695 core\app.py:707
#: core\app.py:697 core\app.py:709
msgid "You are about to remove %d files from results. Continue?"
msgstr "結果から%d個のファイルを削除しようとしています。 継続する?"
#: core\app.py:743
#: core\app.py:745
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{}重複するグループは、再優先順位付けによって変更されました。"
#: core\app.py:790
#: core\app.py:792
msgid "The selected directories contain no scannable file."
msgstr "選択したディレクトリにはスキャン可能なファイルが含まれていません。"
#: core\app.py:803
#: core\app.py:808
msgid "Collecting files to scan"
msgstr "スキャンするファイルを収集しています"
#: core\app.py:850
#: core\app.py:858
msgid "%s (%d discarded)"
msgstr "%s (%d 廃棄)"
#: core\directories.py:191
#: core\directories.py:190
msgid "Collected {} files to scan"
msgstr ""
#: core\directories.py:207
#: core\directories.py:206
msgid "Collected {} folders to scan"
msgstr ""
@@ -200,35 +201,35 @@ msgstr "EXIFタイムスタンプ"
msgid "None"
msgstr "無し"
#: core\prioritize.py:100
#: core\prioritize.py:102
msgid "Ends with number"
msgstr "番号で終わっている"
#: core\prioritize.py:101
#: core\prioritize.py:103
msgid "Doesn't end with number"
msgstr "数字で終わっていない"
#: core\prioritize.py:102
#: core\prioritize.py:104
msgid "Longest"
msgstr "最長"
#: core\prioritize.py:103
#: core\prioritize.py:105
msgid "Shortest"
msgstr "最短"
#: core\prioritize.py:140
#: core\prioritize.py:142
msgid "Highest"
msgstr "最高"
#: core\prioritize.py:140
#: core\prioritize.py:142
msgid "Lowest"
msgstr "最低"
#: core\prioritize.py:169
#: core\prioritize.py:171
msgid "Newest"
msgstr "最新"
#: core\prioritize.py:169
#: core\prioritize.py:171
msgid "Oldest"
msgstr "最古"

View File

@@ -1,9 +1,10 @@
# Translators:
# Fuan <jcfrt@posteo.net>, 2021
# Andrew Senetar <arsenetar@gmail.com>, 2022
# Fuan <jcfrt@posteo.net>, 2022
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: Fuan <jcfrt@posteo.net>, 2022\n"
"Language-Team: Japanese (https://www.transifex.com/voltaicideas/teams/116153/ja/)\n"
"Language: ja\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -80,7 +81,7 @@ msgstr "(非対応)"
#: qt/deletion_options.py:49 cocoa/en.lproj/Localizable.strings:0
msgid "Directly delete files"
msgstr "ファイルを直接削除する"
msgstr "ファイルを完全に削除"
#: qt/deletion_options.py:51 cocoa/en.lproj/Localizable.strings:0
msgid ""
@@ -99,7 +100,7 @@ msgstr "キャンセル"
#: qt/details_table.py:16 cocoa/en.lproj/Localizable.strings:0
msgid "Attribute"
msgstr "アトリビュート"
msgstr "属性"
#: qt/details_table.py:16 cocoa/en.lproj/Localizable.strings:0
msgid "Selected"
@@ -162,7 +163,7 @@ msgstr "スキャンの種類:"
#: qt/directories_dialog.py:135
msgid "More Options"
msgstr "もっとオプション"
msgstr "詳細設定"
#: qt/directories_dialog.py:139 cocoa/en.lproj/Localizable.strings:0
msgid "Select folders to scan and press \"Scan\"."
@@ -178,7 +179,7 @@ msgstr "スキャン"
#: qt/directories_dialog.py:230
msgid "Unsaved results"
msgstr "保存されていない結果"
msgstr "保存結果"
#: qt/directories_dialog.py:231 cocoa/en.lproj/Localizable.strings:0
msgid "You have unsaved results, do you really want to quit?"
@@ -279,27 +280,27 @@ msgstr "単語の重み付け"
#: qt/me/preferences_dialog.py:52 qt/se/preferences_dialog.py:32
#: cocoa/en.lproj/Localizable.strings:0
msgid "Match similar words"
msgstr "類似の単語一致する"
msgstr "類似の単語一致"
#: qt/me/preferences_dialog.py:54 qt/pe/preferences_dialog.py:21
#: qt/se/preferences_dialog.py:34 cocoa/en.lproj/Localizable.strings:0
msgid "Can mix file kind"
msgstr "ファイルの種類を混在させることができる"
msgstr "ファイルの種類を混在"
#: qt/me/preferences_dialog.py:56 qt/pe/preferences_dialog.py:23
#: qt/se/preferences_dialog.py:36 cocoa/en.lproj/Localizable.strings:0
msgid "Use regular expressions when filtering"
msgstr "フィルタリング時に正規表現を使用する"
msgstr "フィルタに正規表現を使用"
#: qt/me/preferences_dialog.py:58 qt/pe/preferences_dialog.py:25
#: qt/se/preferences_dialog.py:38 cocoa/en.lproj/Localizable.strings:0
msgid "Remove empty folders on delete or move"
msgstr "削除または移動時に空のフォルダを削除する"
msgstr "削除や移動で空になったフォルダを削除"
#: qt/me/preferences_dialog.py:60 qt/pe/preferences_dialog.py:27
#: qt/se/preferences_dialog.py:59 cocoa/en.lproj/Localizable.strings:0
msgid "Ignore duplicates hardlinking to the same file"
msgstr "同じファイルへの重複ハードリンクを無視する"
msgstr "同じファイルへの重複ハードリンクを無視"
#: qt/me/preferences_dialog.py:62 qt/pe/preferences_dialog.py:29
#: qt/se/preferences_dialog.py:62 cocoa/en.lproj/Localizable.strings:0
@@ -308,11 +309,11 @@ msgstr "デバッグモード(再起動が必要)"
#: qt/pe/preferences_dialog.py:19 cocoa/en.lproj/Localizable.strings:0
msgid "Match pictures of different dimensions"
msgstr "異なる寸法の写真を一致させる"
msgstr "異なるサイズの写真を一致"
#: qt/preferences_dialog.py:43
msgid "Filter Hardness:"
msgstr "フィルター硬度:"
msgstr "フィルタの強さ:"
#: qt/preferences_dialog.py:69
msgid "More Results"
@@ -324,7 +325,7 @@ msgstr "より少ない結果"
#: qt/preferences_dialog.py:81
msgid "Font size:"
msgstr "フォントサイズ:"
msgstr "文字サイズ:"
#: qt/preferences_dialog.py:85
msgid "Language:"
@@ -332,7 +333,7 @@ msgstr "言語:"
#: qt/preferences_dialog.py:91 cocoa/en.lproj/Localizable.strings:0
msgid "Copy and Move:"
msgstr "コピーと移動"
msgstr "コピーと移動:"
#: qt/preferences_dialog.py:94 cocoa/en.lproj/Localizable.strings:0
msgid "Right in destination"
@@ -348,7 +349,7 @@ msgstr "絶対パスを再作成"
#: qt/preferences_dialog.py:99
msgid "Custom Command (arguments: %d for dupe, %r for ref):"
msgstr "カスタムコマンド (引数重複の場合はd、参照の場合はr:"
msgstr "カスタムコマンド (引数: dは重複・rは参照:"
#: qt/preferences_dialog.py:174
msgid "dupeGuru has to restart for language changes to take effect."
@@ -718,7 +719,7 @@ msgstr "ウィンドウ"
#: cocoa/en.lproj/Localizable.strings:0
msgid "Zoom"
msgstr "ズーム"
msgstr "拡大"
#: qt\app.py:158
msgid "Exclusion Filters"
@@ -908,15 +909,15 @@ msgstr "結果"
#: qt\preferences_dialog.py:150
msgid "General Interface"
msgstr "一般的なインターフェイス"
msgstr "一般"
#: qt\preferences_dialog.py:176
msgid "Result Table"
msgstr "結果"
msgstr "結果"
#: qt\preferences_dialog.py:205
msgid "Details Window"
msgstr "詳細ウィンドウ"
msgstr "詳細画面"
#: qt\preferences_dialog.py:285
msgid "General"
@@ -965,3 +966,157 @@ msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""
#: qt\preferences_dialog.py:241
msgid "Profile scan operation"
msgstr ""
#: qt\preferences_dialog.py:242
msgid "Profile the scan operation and save logs for optimization."
msgstr ""
#: qt\preferences_dialog.py:246
msgid "Logs located in: <a href=\"{}\">{}</a>"
msgstr ""
#: qt\preferences_dialog.py:291
msgid "Debug"
msgstr ""
#: qt\about_box.py:31
msgid "About {}"
msgstr "{}について"
#: qt\about_box.py:47
msgid "Version {}"
msgstr "バージョン {}"
#: qt\about_box.py:49 qt\about_box.py:75
msgid "Checking for updates..."
msgstr ""
#: qt\about_box.py:54
msgid "Licensed under GPLv3"
msgstr "GPLv3のもとでライセンスされています"
#: qt\about_box.py:68
msgid "No update available."
msgstr ""
#: qt\about_box.py:71
msgid "New version {} available, download <a href=\"{}\">here</a>."
msgstr ""
#: qt\error_report_dialog.py:50
msgid "Error Report"
msgstr "エラーレポート"
#: qt\error_report_dialog.py:54
msgid "Something went wrong. How about reporting the error?"
msgstr "不明な理由により失敗しました。問題を報告しませんか?"
#: qt\error_report_dialog.py:60
msgid ""
"Error reports should be reported as Github issues. You can copy the error traceback above and paste it in a new issue.\n"
"\n"
"Please make sure to run a search for any already existing issues beforehand. Also make sure to test the very latest version available from the repository, since the bug you are experiencing might have already been patched.\n"
"\n"
"What usually really helps is if you add a description of how you got the error. Thanks!\n"
"\n"
"Although the application should continue to run after this error, it may be in an unstable state, so it is recommended that you restart the application."
msgstr ""
"エラーレポートはGithubの問題として報告する必要があります。 上記のエラートレースバックをコピーして、新しい問題に貼り付けることができます。\n"
"\n"
"事前に既存の問題を検索してください。 また、発生しているバグにはすでにパッチが適用されている可能性があるため、リポジトリから入手できる最新バージョンをテストしてください。\n"
"\n"
"通常本当に役立つのは、エラーが発生した方法の説明を追加することです。 ありがとう!\n"
"\n"
"このエラーの後もアプリケーションは実行を継続するはずですが、不安定な状態になっている可能性があるため、アプリケーションを再起動することをお勧めします。"
#: qt\error_report_dialog.py:80
msgid "Go to Github"
msgstr "Githubに移動"
#: qt\preferences.py:24
msgid "Czech"
msgstr "チェコ語"
#: qt\preferences.py:25
msgid "German"
msgstr "ドイツ語"
#: qt\preferences.py:26
msgid "Greek"
msgstr "ギリシャ語"
#: qt\preferences.py:27
msgid "English"
msgstr "英語"
#: qt\preferences.py:28
msgid "Spanish"
msgstr "スペイン語"
#: qt\preferences.py:29
msgid "French"
msgstr "フランス語"
#: qt\preferences.py:30
msgid "Armenian"
msgstr "アルメニア語"
#: qt\preferences.py:31
msgid "Italian"
msgstr "イタリア語"
#: qt\preferences.py:32
msgid "Japanese"
msgstr "日本語"
#: qt\preferences.py:33
msgid "Korean"
msgstr "韓国語"
#: qt\preferences.py:34
msgid "Malay"
msgstr "マレー語"
#: qt\preferences.py:35
msgid "Dutch"
msgstr "オランダ語"
#: qt\preferences.py:36
msgid "Polish"
msgstr "ポーランド語"
#: qt\preferences.py:37
msgid "Brazilian"
msgstr "ブラジル語"
#: qt\preferences.py:38
msgid "Russian"
msgstr "ロシア語"
#: qt\preferences.py:39
msgid "Turkish"
msgstr "トルコ語"
#: qt\preferences.py:40
msgid "Ukrainian"
msgstr "ウクライナ語"
#: qt\preferences.py:41
msgid "Vietnamese"
msgstr "ベトナム語"
#: qt\preferences.py:42
msgid "Chinese (Simplified)"
msgstr "中国語(簡体字)"
#: qt\recent.py:54
msgid "Clear List"
msgstr "リストをクリア"
#: qt\search_edit.py:78
msgid "Search..."
msgstr "探索..."

View File

@@ -1,9 +1,10 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Sangdon Lim, 2022
#
msgid ""
msgstr ""
"Last-Translator: Andrew Senetar <arsenetar@gmail.com>, 2021\n"
"Last-Translator: Sangdon Lim, 2022\n"
"Language-Team: Korean (https://www.transifex.com/voltaicideas/teams/116153/ko/)\n"
"Language: ko\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -31,7 +32,7 @@ msgstr "비트레이트"
msgid "Samplerate"
msgstr "샘플레이트"
#: core\me\result_table.py:19 core\pe\result_table.py:19 core\prioritize.py:92
#: core\me\result_table.py:19 core\pe\result_table.py:19 core\prioritize.py:94
#: core\se\result_table.py:19
msgid "Filename"
msgstr "폴더명"
@@ -59,7 +60,7 @@ msgid "Kind"
msgstr "종류"
#: core\me\result_table.py:26 core\pe\result_table.py:25
#: core\prioritize.py:163 core\se\result_table.py:23
#: core\prioritize.py:165 core\se\result_table.py:23
msgid "Modification"
msgstr "수정날짜"
@@ -115,8 +116,8 @@ msgstr "크기 (KB)"
#: core\pe\result_table.py:24
msgid "EXIF Timestamp"
msgstr "EXIF 타임스프"
msgstr "EXIF 타임스프"
#: core\prioritize.py:156
#: core\prioritize.py:158
msgid "Size"
msgstr "크기"

View File

@@ -1,149 +1,150 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Sangdon Lim, 2022
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: Sangdon Lim, 2022\n"
"Language-Team: Korean (https://www.transifex.com/voltaicideas/teams/116153/ko/)\n"
"Language: ko\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: utf-8\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: core\app.py:42
#: core\app.py:44
msgid "There are no marked duplicates. Nothing has been done."
msgstr "표시된 중복 항목이 없습니다. 아무것도하지 않았습니다."
#: core\app.py:43
#: core\app.py:45
msgid "There are no selected duplicates. Nothing has been done."
msgstr "선택한 중복 항목이 없습니다. 아무것도하지 않았습니다."
#: core\app.py:44
#: core\app.py:46
msgid ""
"You're about to open many files at once. Depending on what those files are "
"opened with, doing so can create quite a mess. Continue?"
msgstr "한 번에 많은 파일을 열려고합니다. 그 파일을 여는 것에 따라 그렇게하면 꽤 엉망이 될 수 있습니다. 계속하다?"
msgstr "한 번에 많은 파일을 열려고 합니다. 시스템 설정에 따라 너무 많은 프로그램이 실행될 수 있습니다. 진행할까요?"
#: core\app.py:71
#: core\app.py:73
msgid "Scanning for duplicates"
msgstr "중복 검색"
#: core\app.py:72
#: core\app.py:74
msgid "Loading"
msgstr "불러오는중"
#: core\app.py:73
#: core\app.py:75
msgid "Moving"
msgstr "이동중"
#: core\app.py:74
#: core\app.py:76
msgid "Copying"
msgstr "복사중"
#: core\app.py:75
#: core\app.py:77
msgid "Sending to Trash"
msgstr "휴지통으로 보내기"
#: core\app.py:289
#: core\app.py:291
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
msgstr "이전 작업이 여전히 거기에 걸려 있습니다. 아직 새로운 것을 시작할 수 없습니다. 몇 초 후에 다시 시도하십시오."
msgstr "이전 작업이 아직 진행 중이어서 새 작업을 시작할 수 없습니다. 몇 초 후에 다시 시도해 보세요."
#: core\app.py:300
#: core\app.py:302
msgid "No duplicates found."
msgstr "중복 파일이 없습니다."
#: core\app.py:315
#: core\app.py:317
msgid "All marked files were copied successfully."
msgstr "표시된 모든 파일이 성공적으로 복사되었습니다."
#: core\app.py:317
#: core\app.py:319
msgid "All marked files were moved successfully."
msgstr "표시된 모든 파일이 성공적으로 이동되었습니다."
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:321
msgid "All marked files were deleted successfully."
msgstr "표시된 모든 파일이 성공적으로 제거되었습니다."
#: core\app.py:323
msgid "All marked files were successfully sent to Trash."
msgstr "표시된 모든 파일이 성공적으로 휴지통으로 전송되었습니다."
#: core\app.py:326
#: core\app.py:328
msgid "Could not load file: {}"
msgstr "파일을로드 할 수 없습니다 : {}"
#: core\app.py:382
#: core\app.py:384
msgid "'{}' already is in the list."
msgstr "'{}' 는 이미 목록에 있습니다."
#: core\app.py:384
#: core\app.py:386
msgid "'{}' does not exist."
msgstr "'{}' 가 존재하지 않습니다."
#: core\app.py:392
#: core\app.py:394
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
msgstr "선택된 %d개의 일치 항목은 모든 후속 검색에서 무시됩니다. 계속하다?"
#: core\app.py:469
#: core\app.py:471
msgid "Select a directory to copy marked files to"
msgstr "표시된 파일을 복사 할 디렉토리를 선택하십시오"
#: core\app.py:471
#: core\app.py:473
msgid "Select a directory to move marked files to"
msgstr "표시된 파일을 이동할 디렉토리를 선택하십시오"
#: core\app.py:510
#: core\app.py:512
msgid "Select a destination for your exported CSV"
msgstr "내 보낸 CSV의 대상을 선택하십시오"
#: core\app.py:516 core\app.py:771 core\app.py:781
#: core\app.py:518 core\app.py:773 core\app.py:783
msgid "Couldn't write to file: {}"
msgstr "파일에 쓸 수 없습니다 : {}"
#: core\app.py:539
#: core\app.py:541
msgid "You have no custom command set up. Set it up in your preferences."
msgstr "사용자 지정 명령을 설정하지 않았습니다. 기본 설정에서 설정하십시오."
#: core\app.py:695 core\app.py:707
#: core\app.py:697 core\app.py:709
msgid "You are about to remove %d files from results. Continue?"
msgstr "결과에서 %d 개의 파일을 제거하려고합니다. 계속하다?"
msgstr "결과에서 %d 개의 파일을 제거하려고합니다. 실행할까요?"
#: core\app.py:743
#: core\app.py:745
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{} 개의 중복 그룹이 우선 순위 재 지정으로 변경되었습니다."
#: core\app.py:790
#: core\app.py:792
msgid "The selected directories contain no scannable file."
msgstr "선택한 디렉토리에 스캔 가능한 파일이 없습니다."
#: core\app.py:803
#: core\app.py:808
msgid "Collecting files to scan"
msgstr "스캔 할 파일 수집"
#: core\app.py:850
#: core\app.py:858
msgid "%s (%d discarded)"
msgstr "%s (%d 폐기)"
#: core\directories.py:191
#: core\directories.py:190
msgid "Collected {} files to scan"
msgstr ""
msgstr "파일 목록 생성 중: {}개 파일"
#: core\directories.py:207
#: core\directories.py:206
msgid "Collected {} folders to scan"
msgstr ""
msgstr "폴더 목록 생성 중: {}개 폴더"
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr ""
msgstr "중복 파일 %d개 확인됨: %d개 그룹"
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
msgstr "{}개 파일을 휴지통으로 보내고 있습니다."
msgstr "{}개 파일을 휴지통으로 보내니다."
#: core\gui\exclude_list_table.py:14
msgid "Regular Expressions"
@@ -163,7 +164,7 @@ msgstr "파일 이름 - 필드"
#: core\me\scanner.py:22
msgid "Filename - Fields (No Order)"
msgstr "파일 이름 - 필드 (주문 없음)"
msgstr "파일 이름 - 필드 (순서 없음)"
#: core\me\scanner.py:23
msgid "Tags"
@@ -191,7 +192,7 @@ msgstr "%d/%d 일치 확인"
#: core\pe\matchexif.py:19
msgid "Read EXIF of %d/%d pictures"
msgstr "%d/%d 사진 EXIF 읽"
msgstr "사진 EXIF 읽는 중: %d/%d"
#: core\pe\scanner.py:22
msgid "EXIF Timestamp"
@@ -201,35 +202,35 @@ msgstr "EXIF 타임 스탬프"
msgid "None"
msgstr "없음"
#: core\prioritize.py:100
#: core\prioritize.py:102
msgid "Ends with number"
msgstr "숫자로 끝남"
#: core\prioritize.py:101
#: core\prioritize.py:103
msgid "Doesn't end with number"
msgstr "숫자로 끝나지 않음"
#: core\prioritize.py:102
#: core\prioritize.py:104
msgid "Longest"
msgstr "최장"
#: core\prioritize.py:103
#: core\prioritize.py:105
msgid "Shortest"
msgstr "최단"
#: core\prioritize.py:140
#: core\prioritize.py:142
msgid "Highest"
msgstr "제일 높은"
#: core\prioritize.py:140
#: core\prioritize.py:142
msgid "Lowest"
msgstr "최저"
#: core\prioritize.py:169
#: core\prioritize.py:171
msgid "Newest"
msgstr "최신"
#: core\prioritize.py:169
#: core\prioritize.py:171
msgid "Oldest"
msgstr "가장 오래된"
@@ -243,15 +244,15 @@ msgstr "필터: %s"
#: core\scanner.py:90
msgid "Read size of %d/%d files"
msgstr "%d/%d 개의 파일을 읽을 수 있습니다."
msgstr "파일 크기 읽는 중: %d/%d"
#: core\scanner.py:116
msgid "Read metadata of %d/%d files"
msgstr "%d/%d 개 파일 메타 데이터었습니다."
msgstr "파일 메타데이터 읽는 중: %d/%d"
#: core\scanner.py:154
msgid "Almost done! Fiddling with results..."
msgstr "거의 완료되었습니다! 결과를 만지작 거리는 중..."
msgstr "거의 완료되었습니다! 결과를 취합하고 있습니다."
#: core\se\scanner.py:18
msgid "Folders"

Some files were not shown because too many files have changed in this diff Show More