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

Compare commits

...

4 Commits

Author SHA1 Message Date
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
12 changed files with 223 additions and 216 deletions

View File

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

View File

@@ -555,9 +555,13 @@ class DupeGuru(Broadcaster):
# a workaround to make the damn thing work.
exepath, args = match.groups()
path, exename = op.split(exepath)
subprocess.Popen(exename + args, shell=True, cwd=path)
p = subprocess.Popen(exename + args, shell=True, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
output = p.stdout.read()
logging.info("Custom command %s %s: %s", exename, args, output)
else:
subprocess.Popen(dupe_cmd, shell=True)
p = subprocess.Popen(dupe_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
output = p.stdout.read()
logging.info("Custom command %s: %s", dupe_cmd, output)
def load(self):
"""Load directory selection and ignore list from files in appdata.

View File

@@ -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

@@ -1,3 +1,16 @@
=== 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)

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

View File

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

View File

@@ -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,6 +8,7 @@
from threading import Thread
import sys
from typing import Callable, Tuple, Union
from hscommon.jobprogress.job import Job, JobInProgressError, JobCancelled
@@ -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,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

@@ -81,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 ""
@@ -100,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"
@@ -163,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\"."
@@ -179,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?"
@@ -280,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
@@ -309,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"
@@ -325,7 +325,7 @@ msgstr "より少ない結果"
#: qt/preferences_dialog.py:81
msgid "Font size:"
msgstr "フォントサイズ:"
msgstr "文字サイズ:"
#: qt/preferences_dialog.py:85
msgid "Language:"
@@ -333,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"
@@ -349,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."
@@ -719,7 +719,7 @@ msgstr "ウィンドウ"
#: cocoa/en.lproj/Localizable.strings:0
msgid "Zoom"
msgstr "ズーム"
msgstr "拡大"
#: qt\app.py:158
msgid "Exclusion Filters"
@@ -909,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"
@@ -985,7 +985,7 @@ msgstr ""
#: qt\about_box.py:31
msgid "About {}"
msgstr "{}について"
msgstr "{}について"
#: qt\about_box.py:47
msgid "Version {}"
@@ -997,7 +997,7 @@ msgstr ""
#: qt\about_box.py:54
msgid "Licensed under GPLv3"
msgstr "GPLv3としてライセンス供与。"
msgstr "GPLv3のもとでライセンスされています"
#: qt\about_box.py:68
msgid "No update available."
@@ -1013,7 +1013,7 @@ msgstr "エラーレポート"
#: qt\error_report_dialog.py:54
msgid "Something went wrong. How about reporting the error?"
msgstr "何かがうまくいかなかった。 エラーを報告するのはどうですか?"
msgstr "不明な理由により失敗しました。問題を報告しませんか?"
#: qt\error_report_dialog.py:60
msgid ""
@@ -1035,7 +1035,7 @@ msgstr ""
#: qt\error_report_dialog.py:80
msgid "Go to Github"
msgstr "Githubに移動する"
msgstr "Githubに移動"
#: qt\preferences.py:24
msgid "Czech"