From b5451305a0ffd08e5de103e68e5363efbf533fa8 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Tue, 11 Jun 2024 11:24:58 +0200 Subject: [PATCH 01/20] cli:identify_file - use separate function to identify files --- digiarch/cli.py | 125 ++++++++++++++++++++++++++++++------------------ 1 file changed, 79 insertions(+), 46 deletions(-) diff --git a/digiarch/cli.py b/digiarch/cli.py index 8cd0b6e6..9e626aad 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -12,6 +12,8 @@ from typing import Callable from typing import Optional from typing import Union +from uuid import UUID +from uuid import uuid4 import yaml from acacore.__version__ import __version__ as __acacore_version__ @@ -104,6 +106,82 @@ def handle_end(ctx: Context, database: FileDB, exception: ExceptionManager, *log database.commit() +def identify_file( + ctx: Context, + root: Path, + path: Path, + database: FileDB, + siegfried: Siegfried, + actions: dict[str, Action], + custom_signatures: list[CustomSignature], + *, + update: bool = False, +) -> tuple[File | None, list[HistoryEntry]]: + uuid: UUID + existing_file: Optional[File] = database.files.select( + where="relative_path = ?", + limit=1, + parameters=[str(path.relative_to(root))], + ).fetchone() + + if existing_file and update: + uuid = existing_file.uuid + elif existing_file: + return None, [] + else: + uuid = uuid4() + update = False + + file_history: list[HistoryEntry] = [] + + with ExceptionManager( + Exception, + UnidentifiedImageError, + DecompressionBombError, + allow=[OSError, IOError], + ) as identify_error: + file = File.from_file(path, root, siegfried, actions, custom_signatures, uuid=uuid) + + if identify_error.exception: + file = File.from_file(path, root, siegfried) + file.action = "manual" + file.action_data = ActionData( + manual=ManualAction( + reason=identify_error.exception.__class__.__name__, + process="Identify and fix error.", + ), + ) + file_history.append( + HistoryEntry.command_history( + ctx, + "file:identify:error", + file.uuid, + repr(identify_error.exception), + "".join(format_tb(identify_error.traceback)) if identify_error.traceback else None, + ), + ) + + if file.action_data and file.action_data.rename: + old_path, new_path = handle_rename(file, file.action_data.rename) + if new_path: + file = File.from_file(new_path, root, siegfried, actions, custom_signatures, uuid=file.uuid) + file_history.append( + HistoryEntry.command_history( + ctx, + "file:action:rename", + file.uuid, + [old_path.relative_to(root), new_path.relative_to(root)], + ), + ) + + if update: + database.files.update(file, {"uuid": file.uuid}) + else: + database.files.insert(file, exist_ok=True) + + return file, file_history + + def regex_callback(pattern: str, flags: Union[int, RegexFlag] = 0) -> Callable[[Context, Parameter, str], str]: def _callback(ctx: Context, param: Parameter, value: str): if not match(pattern, value, flags): @@ -221,52 +299,7 @@ def app_identify( with ExceptionManager(BaseException) as exception: for path in find_files(root, exclude=[database_path.parent]): - if database.file_exists(path, root): - continue - - file_history: list[HistoryEntry] = [] - - with ExceptionManager( - Exception, - UnidentifiedImageError, - DecompressionBombError, - allow=[OSError, IOError], - ) as identify_error: - file = File.from_file(path, root, siegfried, actions, custom_signatures) - - if identify_error.exception: - file = File.from_file(path, root, siegfried) - file.action = "manual" - file.action_data = ActionData( - manual=ManualAction( - reason=identify_error.exception.__class__.__name__, - process="Identify and fix error.", - ), - ) - file_history.append( - HistoryEntry.command_history( - ctx, - "file:identify:error", - file.uuid, - repr(identify_error.exception), - "".join(format_tb(identify_error.traceback)) if identify_error.traceback else None, - ), - ) - - if file.action_data and file.action_data.rename: - old_path, new_path = handle_rename(file, file.action_data.rename) - if new_path: - file = File.from_file(new_path, root, siegfried, actions, custom_signatures) - file_history.append( - HistoryEntry.command_history( - ctx, - "file:action:rename", - file.uuid, - [old_path.relative_to(root), new_path.relative_to(root)], - ), - ) - - database.files.insert(file, exist_ok=True) + file, file_history = identify_file(ctx, root, path, database, siegfried, actions, custom_signatures) logger_stdout.info( f"{HistoryEntry.command_history(ctx, ':file:new').operation} " From 92472f04168f96a9c6083746996ec78b4e548d1a Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Tue, 11 Jun 2024 11:27:15 +0200 Subject: [PATCH 02/20] cli:app_reidentify - add command to re-identify specific files --- digiarch/cli.py | 135 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 3 deletions(-) diff --git a/digiarch/cli.py b/digiarch/cli.py index 9e626aad..1fcdd34f 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -10,7 +10,9 @@ from sys import stdout from traceback import format_tb from typing import Callable +from typing import Generator from typing import Optional +from typing import Sequence from typing import Union from uuid import UUID from uuid import uuid4 @@ -253,6 +255,9 @@ def app_identify( update_siegfried_signature: bool, actions_file: Optional[str], custom_signatures_file: Optional[str], + *, + update_where: Optional[str] = None, + update_where_ids: Optional[Sequence[str]] = None, ): """ Process a folder (ROOT) recursively and populate a files' database. @@ -298,11 +303,31 @@ def app_identify( handle_start(ctx, database, logger) with ExceptionManager(BaseException) as exception: - for path in find_files(root, exclude=[database_path.parent]): - file, file_history = identify_file(ctx, root, path, database, siegfried, actions, custom_signatures) + files: Generator[Path, None, None] + + if update_where: + files = ( + f.get_absolute_path(root) + for i in update_where_ids + for f in database.files.select(where=update_where, parameters=[i]) + ) + else: + files = find_files(root, exclude=[database_path.parent]) + + for path in files: + file, file_history = identify_file( + ctx, + root, + path, + database, + siegfried, + actions, + custom_signatures, + update=update_where is not None, + ) logger_stdout.info( - f"{HistoryEntry.command_history(ctx, ':file:new').operation} " + f"{HistoryEntry.command_history(ctx, ':file:' + ('update' if update_where else 'new')).operation} " f"{file.relative_path} {file.puid} {file.action}", ) @@ -313,6 +338,110 @@ def app_identify( handle_end(ctx, database, exception, logger) +@app.command("reidentify", no_args_is_help=True, short_help="Reidentify files.") +@argument("root", type=ClickPath(exists=True, file_okay=False, writable=True, resolve_path=True)) +@argument( + "ids", + metavar="ID...", + nargs=-1, + type=str, + required=True, + callback=lambda _c, _p, v: tuple(sorted(set(v), key=v.index)), +) +@option("--uuid", "id_type", flag_value="uuid", default=True, help="Use UUID's as identifiers. [default]") +@option("--puid", "id_type", flag_value="puid", help="Use PUID's as identifiers.") +@option("--path", "id_type", flag_value="relative_path", help="Use relative paths as identifiers.") +@option( + "--path-like", + "id_type", + flag_value="relative_path-like", + help="Use relative paths as identifiers, match with LIKE.", +) +@option("--checksum", "id_type", flag_value="checksum", help="Use checksums as identifiers.") +@option("--warning", "id_type", flag_value="warnings", help="Use warnings as identifiers.") +@option("--id-files", is_flag=True, default=False, help="Interpret IDs as files from which to read the IDs.") +@option( + "--siegfried-path", + type=ClickPath(dir_okay=False, resolve_path=True), + envvar="SIEGFRIED_PATH", + default=None, + show_envvar=True, + help="The path to the Siegfried executable.", +) +@option( + "--siegfried-home", + type=ClickPath(file_okay=False, resolve_path=True), + envvar="SIEGFRIED_HOME", + default=None, + show_envvar=True, + help="The path to the Siegfried home folder.", +) +@option( + "--siegfried-signature", + type=Choice(("pronom", "loc", "tika", "freedesktop", "pronom-tika-loc", "deluxe", "archivematica")), + default="pronom", + show_default=True, + help="The signature file to use with Siegfried.", +) +@option( + "--update-siegfried-signature/--no-update-siegfried-signature", + is_flag=True, + default=False, + show_default=True, + help="Control whether Siegfried should update its signature.", +) +@option( + "--actions", + "actions_file", + type=ClickPath(exists=True, dir_okay=False, file_okay=True, resolve_path=True), + default=None, + help="Path to a YAML file containing file format actions.", +) +@option( + "--custom-signatures", + "custom_signatures_file", + type=ClickPath(exists=True, dir_okay=False, file_okay=True, resolve_path=True), + default=None, + help="Path to a YAML file containing custom signature specifications.", +) +@pass_context +def app_reidentify( + ctx: Context, + root: Union[str, Path], + ids: tuple[str], + id_type: str, + id_files: bool, + siegfried_path: Optional[str], + siegfried_home: Optional[str], + siegfried_signature: TSignature, + update_siegfried_signature: bool, + actions_file: Optional[str], + custom_signatures_file: Optional[str], +): + if id_files: + ids = tuple(i.strip("\n\r\t") for f in ids for i in Path(f).read_text().splitlines() if i.strip()) + + if id_type in ("warnings",): + where: str = f"{id_type} like '%\"' || ? || '\"%'" + elif id_type.endswith("-like"): + id_type = id_type.removesuffix("-like") + where: str = f"{id_type} like ?" + else: + where: str = f"{id_type} = ?" + + app_identify.callback( + root, + siegfried_path, + siegfried_home, + siegfried_signature, + update_siegfried_signature, + actions_file, + custom_signatures_file, + update_where=where, + update_where_ids=ids, + ) + + @app.group("edit", no_args_is_help=True) def app_edit(): """Edit a files' database.""" From 6f10f3ceecf22fe9953f69fbc71eb8fa76b5b286 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Tue, 11 Jun 2024 14:59:37 +0200 Subject: [PATCH 03/20] cli:app_edit_rename - allow empty extension, stricter extension regex --- digiarch/cli.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/digiarch/cli.py b/digiarch/cli.py index 1fcdd34f..5f1b2acb 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -683,7 +683,7 @@ def app_edit_action( nargs=1, type=str, required=True, - callback=regex_callback(r'^(\.[^/<>:"\\|?*.\x7F\x00-\x20]+)+$'), + callback=regex_callback(r"^((\.[a-zA-Z0-9]+)+| +)$"), ) @argument("reason", nargs=1, type=str, required=True) @option("--replace", "replace_mode", flag_value="last", default=True, help="Replace the last extension. [default]") @@ -728,6 +728,7 @@ def app_edit_rename( The --append option will not add the new extension if it is already present. """ + extension = extension.strip() database_path: Path = Path(root) / "_metadata" / "files.db" if not database_path.is_file(): @@ -759,10 +760,7 @@ def app_edit_rename( old_name: str = file.relative_path.name new_name: str = old_name - if replace_mode == "last" and not match( - r'^\.[^/<>:"\\|?*.\x7F\x00-\x20]+$', - file.relative_path.suffix, - ): + if replace_mode == "last" and not match(r"^\.[a-zA-Z0-9]+$", file.relative_path.suffix): new_name = file.relative_path.name + extension elif replace_mode == "last": new_name = file.relative_path.with_suffix(extension) @@ -773,7 +771,7 @@ def app_edit_rename( elif replace_mode == "all": suffixes: str = "" for suffix in file.relative_path.suffixes[::-1]: - if match(r'^\.[^/<>:"\\|?*.\x7F\x00-\x20]+$', suffix): + if match(r"^\.[a-zA-Z0-9]+$", suffix): suffixes = suffix + suffixes else: break From 7a3f74ca6227df109b79d50323b9e9a16f9fd5fd Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Tue, 11 Jun 2024 15:38:40 +0200 Subject: [PATCH 04/20] tests:custom_signatures - use YAML file --- tests/custom_signatures.json | 154 ----------------------------------- tests/custom_signatures.yml | 103 +++++++++++++++++++++++ tests/test_cli.py | 2 +- 3 files changed, 104 insertions(+), 155 deletions(-) delete mode 100644 tests/custom_signatures.json create mode 100644 tests/custom_signatures.yml diff --git a/tests/custom_signatures.json b/tests/custom_signatures.json deleted file mode 100644 index 2beb443d..00000000 --- a/tests/custom_signatures.json +++ /dev/null @@ -1,154 +0,0 @@ -[ - { - "bof": "(?i)^576F726450726F0DFB000000000000000005985C8172030040CCC1BFFFBDF970", - "puid": "x-fmt/340", - "signature": "Lotus WordPro Document", - "extension": ".lwp" - }, - { - "bof": "(?i)^00001A000(3|4|5)10040000000000", - "puid": "aca-fmt/1", - "signature": "Lotus 1-2-3 Spreadsheet", - "extension": ".123" - }, - { - "bof": "(?i)(50|70)726F67(49|69)64[0-9A-F]{2,20}576f72642e446f63756d656e74", - "puid": "aca-fmt/2", - "signature": "Microsoft Word Markup", - "extension": ".doc" - }, - { - "bof": "(?i)(50|70)726F67(49|69)64[0-9A-F]{2,18}457863656C2E5368656574", - "puid": "aca-fmt/3", - "signature": "Microsoft Excel Markup", - "extension": ".xls" - }, - { - "bof": "(?i)75726e3a736368656d61732d6d6963726f736f66742d636f6d3a6f66666963653a657863656c", - "puid": "aca-fmt/3", - "signature": "Microsoft Excel Markup", - "extension": ".xls" - }, - { - "bof": "(?i)^1a000003000014000000", - "puid": "aca-fmt/5", - "signature": "Lotus Notes Template", - "extension": ".ntf" - }, - { - "bof": "(?i)^0300000041505052", - "puid": "aca-fmt/6", - "signature": "Lotus Approach Index File", - "extension": ".adx" - }, - { - "bof": "(?i)^1a000004000029000000", - "eof": "(?i)bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "puid": "aca-fmt/8", - "operator": "AND", - "signature": "Lotus Notes Database", - "extension": ".nsf" - }, - { - "bof": "(?i)4D696E644d616E61676572", - "puid": "aca-fmt/4", - "signature": "MindManager Mind Map", - "extension": ".mmap" - }, - { - "bof": "(?i)010000002E010000(43|03)000000", - "puid": "aca-fmt/7", - "signature": "ID File", - "extension": ".id" - }, - { - "bof": "(?i)474946383961", - "puid": "fmt/4", - "signature": "GIF 1989a", - "extension": ".gif" - }, - { - "bof": "(?i)010690080004000000000001000100010790060008000000E404000000000000E80001088007001800000049504D2E4D6963726F736F6674204D61696C2E4E6F746500310801", - "puid": "aca-fmt/9", - "signature": "Microsoft email attachments archive (winmail)", - "extension": ".dat" - }, - { - "bof": "(?i)010690080004000000000001000100010790060008000000E404000000000000E800010", - "puid": "aca-fmt/9", - "signature": "Microsoft email attachments archive (winmail)", - "extension": ".dat" - }, - { - "bof": "(?i)^789F3E22", - "puid": "aca-fmt/9", - "signature": "Microsoft email attachments archive (winmail)", - "extension": ".dat" - }, - { - "bof": "(?i)41636365737356657273696F6E.{0,1024}30362E", - "puid": "aca-fmt/10", - "signature": "MS Access 95", - "extension": ".mdb" - }, - { - "bof": "(?i)41636365737356657273696F6E.{0,1024}30372E", - "puid": "aca-fmt/11", - "signature": "MS Access 97", - "extension": ".mdb" - }, - { - "bof": "(?i)410063006300650073007300560065007200730069006F006E.{0,2048}300038002E00", - "puid": "aca-fmt/12", - "signature": "MS Access 2000", - "extension": ".mdb" - }, - { - "bof": "(?i)410063006300650073007300560065007200730069006F006E.{0,2048}300039002E00", - "puid": "aca-fmt/13", - "signature": "MS Access 2002/3", - "extension": ".mdb" - }, - { - "bof": "(?i)^000100005374616E64617264204A65742044420000000000", - "puid": "aca-fmt/14", - "signature": "MS Access database unspecified version (Jet 3 DB)", - "extension": ".mdb" - }, - { - "bof": "(?i)^000100005374616E64617264204A65742044420001000000", - "puid": "aca-fmt/15", - "signature": "MS Access database unspecified version (Jet 4 DB)", - "extension": ".mdb" - }, - { - "bof": "(?i)000100005374616E646172642041434520444200", - "puid": "aca-fmt/16", - "signature": "MS Access database unspecified version (ACE DB)", - "extension": ".mdb" - }, - { - "bof": "(?i)00A0E150E161BA2A6AB1A2A2FA5A9B5A7B5D90F1723131F2B0F17234F57639CA4A9A0A8ADA4A8AD161A390D1137A5A9B5A4A8ADB2B6DCBDBDF42B2F55C8CCD8C7CBD0D3D7FDC2C2F90", - "puid": "aca-fmt/17", - "signature": "MapInfo MAP file", - "extension": ".map" - }, - { - "bof": "(?i)^504B0304{26}6D696D65747970656170706C69636174696F6E2F766E642E6F617369732E6F70656E646F63756D656E742E74657874", - "puid": "aca-fmt/18", - "signature": "OpenDocument Text (unspecified version)", - "extension": ".odt" - }, - { - "bof": "(?i)41007000700072006F0061006300680044006F006300", - "puid": "aca-fmt/19", - "signature": "Lotus Approach View File", - "extension": ".apr" - }, - { - "bof": "(?i)^217461626C650D0A2176657273696F6E.{20,512}446566696E6974696F6E205461626C65", - "puid": "aca-fmt/20", - "signature": "MapInfo TAB file", - "extension": ".tab" - } -] diff --git a/tests/custom_signatures.yml b/tests/custom_signatures.yml new file mode 100644 index 00000000..33802df6 --- /dev/null +++ b/tests/custom_signatures.yml @@ -0,0 +1,103 @@ +--- +- bof: "(?i)^576F726450726F0DFB000000000000000005985C8172030040CCC1BFFFBDF970" + puid: x-fmt/340 + signature: Lotus WordPro Document + extension: ".lwp" +- bof: "(?i)^00001A000(3|4|5)10040000000000" + puid: aca-fmt/1 + signature: Lotus 1-2-3 Spreadsheet + extension: ".123" +- bof: "(?i)(50|70)726F67(49|69)64[0-9A-F]{2,20}576f72642e446f63756d656e74" + puid: aca-fmt/2 + signature: Microsoft Word Markup + extension: ".doc" +- bof: "(?i)(50|70)726F67(49|69)64[0-9A-F]{2,18}457863656C2E5368656574" + puid: aca-fmt/3 + signature: Microsoft Excel Markup + extension: ".xls" +- bof: "(?i)75726e3a736368656d61732d6d6963726f736f66742d636f6d3a6f66666963653a657863656c" + puid: aca-fmt/3 + signature: Microsoft Excel Markup + extension: ".xls" +- bof: "(?i)^1a000003000014000000" + puid: aca-fmt/5 + signature: Lotus Notes Template + extension: ".ntf" +- bof: "(?i)^0300000041505052" + puid: aca-fmt/6 + signature: Lotus Approach Index File + extension: ".adx" +- bof: "(?i)^1a000004000029000000" + eof: "(?i)bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + puid: aca-fmt/8 + operator: AND + signature: Lotus Notes Database + extension: ".nsf" +- bof: "(?i)4D696E644d616E61676572" + puid: aca-fmt/4 + signature: MindManager Mind Map + extension: ".mmap" +- bof: "(?i)010000002E010000(43|03)000000" + puid: aca-fmt/7 + signature: ID File + extension: ".id" +- bof: "(?i)474946383961" + puid: fmt/4 + signature: GIF 1989a + extension: ".gif" +- bof: "(?i)010690080004000000000001000100010790060008000000E404000000000000E80001088007001800000049504D2E4D6963726F736F6674204D61696C2E4E6F746500310801" + puid: aca-fmt/9 + signature: Microsoft email attachments archive (winmail) + extension: ".dat" +- bof: "(?i)010690080004000000000001000100010790060008000000E404000000000000E800010" + puid: aca-fmt/9 + signature: Microsoft email attachments archive (winmail) + extension: ".dat" +- bof: "(?i)^789F3E22" + puid: aca-fmt/9 + signature: Microsoft email attachments archive (winmail) + extension: ".dat" +- bof: "(?i)41636365737356657273696F6E.{0,1024}30362E" + puid: aca-fmt/10 + signature: MS Access 95 + extension: ".mdb" +- bof: "(?i)41636365737356657273696F6E.{0,1024}30372E" + puid: aca-fmt/11 + signature: MS Access 97 + extension: ".mdb" +- bof: "(?i)410063006300650073007300560065007200730069006F006E.{0,2048}300038002E00" + puid: aca-fmt/12 + signature: MS Access 2000 + extension: ".mdb" +- bof: "(?i)410063006300650073007300560065007200730069006F006E.{0,2048}300039002E00" + puid: aca-fmt/13 + signature: MS Access 2002/3 + extension: ".mdb" +- bof: "(?i)^000100005374616E64617264204A65742044420000000000" + puid: aca-fmt/14 + signature: MS Access database unspecified version (Jet 3 DB) + extension: ".mdb" +- bof: "(?i)^000100005374616E64617264204A65742044420001000000" + puid: aca-fmt/15 + signature: MS Access database unspecified version (Jet 4 DB) + extension: ".mdb" +- bof: "(?i)000100005374616E646172642041434520444200" + puid: aca-fmt/16 + signature: MS Access database unspecified version (ACE DB) + extension: ".mdb" +- bof: "(?i)00A0E150E161BA2A6AB1A2A2FA5A9B5A7B5D90F1723131F2B0F17234F57639CA4A9A0A8ADA4A8AD161A390D1137A5A9B5A4A8ADB2B6DCBDBDF42B2F55C8CCD8C7CBD0D3D7FDC2C2F90" + puid: aca-fmt/17 + signature: MapInfo MAP file + extension: ".map" +- bof: "(?i)^504B0304{26}6D696D65747970656170706C69636174696F6E2F766E642E6F617369732E6F70656E646F63756D656E742E74657874" + puid: aca-fmt/18 + signature: OpenDocument Text (unspecified version) + extension: ".odt" +- bof: "(?i)41007000700072006F0061006300680044006F006300" + puid: aca-fmt/19 + signature: Lotus Approach View File + extension: ".apr" +- bof: "(?i)^217461626C650D0A2176657273696F6E.{20,512}446566696E6974696F6E205461626C65" + puid: aca-fmt/20 + signature: MapInfo TAB file + extension: ".tab" diff --git a/tests/test_cli.py b/tests/test_cli.py index 18e33acf..14f2facb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -63,7 +63,7 @@ def test_identify(tests_folder: Path, files_folder: Path, files_folder_copy: Pat "--actions", str(tests_folder / "fileformats.yml"), "--custom-signatures", - str(tests_folder / "custom_signatures.json"), + str(tests_folder / "custom_signatures.yml"), "--no-update-siegfried-signature", "--siegfried-home", str(tests_folder), From 35db0fe7abef883c467abbd0aefc112362679ba3 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Tue, 11 Jun 2024 15:47:18 +0200 Subject: [PATCH 05/20] cli:app_reidentify - add tests for reidentify command --- tests/test_cli.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 14f2facb..78c535b3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,6 +25,7 @@ from digiarch.cli import app_edit_rename from digiarch.cli import app_edit_rollback from digiarch.cli import app_identify +from digiarch.cli import app_reidentify from digiarch.database import FileDB @@ -112,6 +113,52 @@ def test_identify(tests_folder: Path, files_folder: Path, files_folder_copy: Pat assert last_history.reason is not None +def test_reidentify(tests_folder: Path, files_folder: Path, files_folder_copy: Path): + database_path: Path = files_folder / "_metadata" / "files.db" + database_path_copy: Path = files_folder_copy / database_path.relative_to(files_folder) + database_path_copy.parent.mkdir(parents=True, exist_ok=True) + copy(database_path, database_path_copy) + + with FileDB(database_path_copy) as database: + file: File = database.files.select( + where="puid = ? and warning like '%' || ? || '%'", + parameters=["fmt/11", '"extension mismatch"'], + limit=1, + ).fetchone() + assert isinstance(file, File) + file.root = files_folder_copy + file.get_absolute_path().rename(file.get_absolute_path().with_suffix(".png")) + file.relative_path = file.relative_path.with_suffix(".png") + database.files.update(file, {"uuid": file.uuid}) + database.commit() + + app.main( + [ + app_reidentify.name, + str(files_folder_copy), + "--uuid", + str(file.uuid), + "--actions", + str(tests_folder / "fileformats.yml"), + "--custom-signatures", + str(tests_folder / "custom_signatures.yml"), + "--no-update-siegfried-signature", + "--siegfried-home", + str(tests_folder), + ], + standalone_mode=False, + ) + + with FileDB(database_path_copy) as database: + file_new: File = database.files.select( + where="uuid = ? and relative_path = ?", + parameters=[str(file.uuid), str(file.relative_path)], + limit=1, + ).fetchone() + assert isinstance(file, File) + assert "extension mismatch" not in (file_new.warning or []) + + # noinspection DuplicatedCode def test_edit_action(tests_folder: Path, files_folder: Path, files_folder_copy: Path): database_path: Path = files_folder / "_metadata" / "files.db" From f0b033f15afc65a7a13e9aae520dcef59dbd9625 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Tue, 11 Jun 2024 15:54:27 +0200 Subject: [PATCH 06/20] cli:identify_file - remove type union pipe operator --- digiarch/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/digiarch/cli.py b/digiarch/cli.py index 5f1b2acb..e192f0b5 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -118,7 +118,7 @@ def identify_file( custom_signatures: list[CustomSignature], *, update: bool = False, -) -> tuple[File | None, list[HistoryEntry]]: +) -> tuple[Optional[File], list[HistoryEntry]]: uuid: UUID existing_file: Optional[File] = database.files.select( where="relative_path = ?", From ee2c68296c10dce91544b1fd29f2b8f170dfe526 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Tue, 11 Jun 2024 16:00:37 +0200 Subject: [PATCH 07/20] tests:reidentify - add noinspection DuplicatedCode --- tests/test_cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 78c535b3..2dc1acde 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -113,6 +113,7 @@ def test_identify(tests_folder: Path, files_folder: Path, files_folder_copy: Pat assert last_history.reason is not None +# noinspection DuplicatedCode def test_reidentify(tests_folder: Path, files_folder: Path, files_folder_copy: Path): database_path: Path = files_folder / "_metadata" / "files.db" database_path_copy: Path = files_folder_copy / database_path.relative_to(files_folder) From 508c357d37cea67ffcff3ca81ac8f2c60adde30f Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Tue, 11 Jun 2024 16:11:03 +0200 Subject: [PATCH 08/20] cli:app_edit_rename - fix attribute error --- digiarch/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/digiarch/cli.py b/digiarch/cli.py index e192f0b5..89d0ada8 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -763,7 +763,7 @@ def app_edit_rename( if replace_mode == "last" and not match(r"^\.[a-zA-Z0-9]+$", file.relative_path.suffix): new_name = file.relative_path.name + extension elif replace_mode == "last": - new_name = file.relative_path.with_suffix(extension) + new_name = file.relative_path.with_suffix(extension).name elif replace_mode == "append" and old_name.lower().endswith(extension.lower()): continue elif replace_mode == "append": From ad21cc84a986a33e19459ce98ff43094bf965cd2 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Tue, 11 Jun 2024 16:11:22 +0200 Subject: [PATCH 09/20] cli:app_edit_rename - update extension format in docstring --- digiarch/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/digiarch/cli.py b/digiarch/cli.py index 89d0ada8..6a2f6018 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -724,7 +724,7 @@ def app_edit_rename( To see the changes without committing them, use the --dry-run option. The --replace and --replace-all options will only replace valid suffixes (i.e., matching the expression - \.[^/<>:"\\|?*\x7F\x00-\x20]+). + \.[a-zA-Z0-9]+). The --append option will not add the new extension if it is already present. """ From 4857d5dd4de4fa5854ee8362b531c7534b4d6902 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Tue, 11 Jun 2024 16:12:05 +0200 Subject: [PATCH 10/20] tests:edit_rename_empty - add tests for edit rename command with empty extension --- tests/test_cli.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2dc1acde..b4174c60 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -396,6 +396,47 @@ def test_edit_rename_same(files_folder: Path, files_folder_copy: Path): assert file_old.get_absolute_path().is_file() +# noinspection DuplicatedCode +def test_edit_rename_empty(files_folder: Path, files_folder_copy: Path): + database_path: Path = files_folder / "_metadata" / "files.db" + database_path_copy: Path = files_folder_copy / database_path.relative_to(files_folder) + database_path_copy.parent.mkdir(parents=True, exist_ok=True) + copy(database_path, database_path_copy) + + # Ensure the selected file exists and is not one that is renamed by identify + with FileDB(database_path_copy) as database: + file_old: File = next( + f + for f in database.files.select(order_by=[("random()", "asc")]) + if files_folder.joinpath(f.relative_path).is_file() and f.relative_path.suffix + ) + assert isinstance(file_old, File) + + test_reason: str = "edit extension empty" + + args: list[str] = [ + app_edit.name, + app_edit_rename.name, + "--uuid", + str(files_folder_copy), + str(file_old.uuid), + "--replace", + " ", + test_reason, + ] + + app.main(args, standalone_mode=False) + + with FileDB(database_path_copy) as database: + file_new: Optional[File] = database.files.select(where="uuid = ?", parameters=[str(file_old.uuid)]).fetchone() + assert isinstance(file_new, File) + file_old.root = files_folder_copy + file_new.root = files_folder_copy + assert file_new.relative_path == file_old.relative_path.with_suffix("") + assert file_new.get_absolute_path().is_file() + assert not file_old.get_absolute_path().is_file() + + # noinspection DuplicatedCode def test_edit_remove(files_folder: Path, files_folder_copy: Path): database_path: Path = files_folder / "_metadata" / "files.db" From 7aa848ffa6a4c607bb97be4f127cdef6ae992997 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Tue, 11 Jun 2024 16:20:06 +0200 Subject: [PATCH 11/20] version - minor 1.4.0 > 1.5.0 --- digiarch/__version__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/digiarch/__version__.py b/digiarch/__version__.py index 3e8d9f94..5b601886 100644 --- a/digiarch/__version__.py +++ b/digiarch/__version__.py @@ -1 +1 @@ -__version__ = "1.4.0" +__version__ = "1.5.0" diff --git a/pyproject.toml b/pyproject.toml index 7fb48b31..0662ec36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "digiarch" -version = "1.4.0" +version = "1.5.0" description = "Tools for the Digital Archive Project at Aarhus Stadsarkiv" authors = ["Aryan Muhammadi Landi ", "Nina Jensen ", "Aarhus Stadsarkiv "] license = "GPL-3.0" From af1f0910039b7ae007f3abaff02d5c6b034fbf61 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Wed, 12 Jun 2024 09:20:49 +0200 Subject: [PATCH 12/20] cli:app_reidentify - add docstring --- digiarch/cli.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/digiarch/cli.py b/digiarch/cli.py index 6a2f6018..f2d31a75 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -406,7 +406,7 @@ def app_identify( ) @pass_context def app_reidentify( - ctx: Context, + _ctx: Context, root: Union[str, Path], ids: tuple[str], id_type: str, @@ -418,6 +418,16 @@ def app_reidentify( actions_file: Optional[str], custom_signatures_file: Optional[str], ): + """ + Re-indentify specific files. + + Each file is re-identified with Siegfried and an action is assigned to it. + Files that need re-identification with custom signatures, renaming, or ignoring are processed accordingly. + + The ID arguments are interpreted as a list of UUID's by default. The behaviour can be changed with the + --puid, --path, --path-like, --checksum, and --warning options. If the --id-files option is used, each ID argument + is interpreted as the path to a file containing a list of IDs (one per line, empty lines are ignored). + """ if id_files: ids = tuple(i.strip("\n\r\t") for f in ids for i in Path(f).read_text().splitlines() if i.strip()) From e0b2993e8a95c231d58050f030f83f57f1e1803f Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Wed, 12 Jun 2024 10:51:31 +0200 Subject: [PATCH 13/20] cli:regex_callback - support tuple values --- digiarch/cli.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/digiarch/cli.py b/digiarch/cli.py index f2d31a75..a550a939 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -185,8 +185,11 @@ def identify_file( def regex_callback(pattern: str, flags: Union[int, RegexFlag] = 0) -> Callable[[Context, Parameter, str], str]: - def _callback(ctx: Context, param: Parameter, value: str): - if not match(pattern, value, flags): + def _callback(ctx: Context, param: Parameter, value: Union[str, Sequence[str]]): + if isinstance(value, (list, tuple)): + if any(not match(pattern, v, flags) for v in value): + raise BadParameter(f"{value!r} does not match pattern {pattern}", ctx, param) + elif not match(pattern, value, flags): raise BadParameter(f"{value!r} does not match pattern {pattern}", ctx, param) return value From fba69dbd2c1b2421ac3fe65b221b433933fc6e4e Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Wed, 12 Jun 2024 10:51:44 +0200 Subject: [PATCH 14/20] cli:app_history - add history command to show events in the log --- digiarch/cli.py | 112 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/digiarch/cli.py b/digiarch/cli.py index a550a939..b7cdf6d9 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -3,6 +3,7 @@ from logging import Logger from os import environ from pathlib import Path +from re import IGNORECASE from re import match from re import RegexFlag from sqlite3 import DatabaseError @@ -455,6 +456,117 @@ def app_reidentify( ) +@app.command("history", short_help="View and search events log.") +@argument("root", type=ClickPath(exists=True, file_okay=False, writable=True, resolve_path=True)) +@option( + "--from", + "time_from", + type=DateTime(["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"]), + default=None, + help="Minimum date of events.", +) +@option( + "--to", + "time_to", + type=DateTime(["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"]), + default=None, + help="Maximum date of events.", +) +@option( + "--operation", + type=str, + default=None, + multiple=True, + callback=regex_callback(r"[a-z%-]+(\.[a-z%-]+)*(:[a-z%-]+([.:][a-z%-]+)*)?", IGNORECASE), + help="Operation and sub-operation.", +) +@option( + "--uuid", + type=str, + default=None, + multiple=True, + callback=regex_callback(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", IGNORECASE), + help="File UUID.", +) +@option( + "--ascending/--descending", + "ascending", + is_flag=True, + default=True, + show_default=True, + help="Sort by ascending or descending order.", +) +@pass_context +def app_history( + ctx: Context, + root: str, + time_from: Optional[datetime], + time_to: Optional[datetime], + operation: Optional[tuple[str, ...]], + uuid: Optional[tuple[str, ...]], + ascending: bool, +): + """ + View and search events log. + + The --operation option supports LIKE syntax with the % operator. + + If multiple --uuid or --operation options are used, the query will match any of them. + + If no query option is given, only the first 100 results will be shown. + """ + operation = tuple(o.strip() for o in operation if o.strip(" %:.")) if operation else None + database_path: Path = Path(root) / "_metadata" / "files.db" + + if not database_path.is_file(): + raise FileNotFoundError(database_path) + + program_name: str = ctx.find_root().command.name + logger_stdout: Logger = setup_logger(program_name + "_std_out", streams=[stdout]) + + where: list[str] = [] + parameters: list[str | int] = [] + + if time_from: + where.append("time <= ?") + parameters.append(time_from.isoformat()) + + if time_to: + where.append("time <= ?") + parameters.append(time_to.isoformat()) + + if uuid: + where.append("(" + " or ".join("uuid = ?" for _ in uuid) + ")") + parameters.extend(uuid) + + if operation: + where.append("(" + " or ".join("operation like ?" for _ in operation) + ")") + parameters.extend(operation) + + if not where: + logger_stdout.warning(f"No selectors given. Showing {'first' if ascending else 'last'} 10000 events.") + + with FileDB(database_path) as database: + yaml.add_representer(UUID, lambda dumper, data: dumper.represent_str(str(data))) + yaml.add_representer( + str, + lambda dumper, data: ( + dumper.represent_str(str(data)) + if len(data) < 200 + else dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style="|") + ), + ) + + for event in database.history.select( + where=" and ".join(where) or None, + parameters=parameters or None, + order_by=[("time", "asc" if ascending else "desc")], + limit=None if where else 100, + ): + yaml.dump(event.model_dump(), stdout, yaml.Dumper, sort_keys=False) + print() + + @app.group("edit", no_args_is_help=True) def app_edit(): """Edit a files' database.""" From 142a72889225e5fe318cfc410ea45768b28546e1 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Wed, 12 Jun 2024 10:57:52 +0200 Subject: [PATCH 15/20] pyproject - ignore DTZ006 datetime.datetime.fromtimestamp() called without a tz argument --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0662ec36..ccbd0a3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,7 @@ lint.ignore = [ "D213", # multi-line docstring summary should start at the second line, "D300", # use triple docstring "DTZ005", # datetime.datetime.now() called without a tz argument + "DTZ006", # datetime.datetime.fromtimestamp() called without a tz argument "E712", # comparison to True/False, we ignore because we use sqlalchemy "FBT001", # boolean arguement in function definition "N802", # name of function should be lower case From cc6c86332e8d8c39b2cbda48ddfd224d3d6fa58c Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Wed, 12 Jun 2024 10:58:22 +0200 Subject: [PATCH 16/20] tests:history - test history command --- tests/test_cli.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index b4174c60..51ee628d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from pathlib import Path from shutil import copy from typing import Optional +from uuid import uuid4 import pytest from acacore.models.file import File @@ -17,6 +18,7 @@ from acacore.models.reference_files import ReplaceAction from acacore.utils.functions import find_files from acacore.utils.functions import rm_tree +from click import BadParameter from digiarch.cli import app from digiarch.cli import app_edit @@ -24,6 +26,7 @@ from digiarch.cli import app_edit_remove from digiarch.cli import app_edit_rename from digiarch.cli import app_edit_rollback +from digiarch.cli import app_history from digiarch.cli import app_identify from digiarch.cli import app_reidentify from digiarch.database import FileDB @@ -160,6 +163,57 @@ def test_reidentify(tests_folder: Path, files_folder: Path, files_folder_copy: P assert "extension mismatch" not in (file_new.warning or []) +# noinspection DuplicatedCode +def test_history(tests_folder: Path, files_folder: Path): + app.main( + [ + app_history.name, + str(files_folder), + ], + standalone_mode=False, + ) + + with pytest.raises(BadParameter): + app.main( + [app_history.name, str(files_folder), "--from", "test"], + standalone_mode=False, + ) + + with pytest.raises(BadParameter): + app.main( + [app_history.name, str(files_folder), "--to", "test"], + standalone_mode=False, + ) + + with pytest.raises(BadParameter): + app.main( + [app_history.name, str(files_folder), "--uuid", "test"], + standalone_mode=False, + ) + + with pytest.raises(BadParameter): + app.main( + [app_history.name, str(files_folder), "--operation", "&test"], + standalone_mode=False, + ) + + app.main( + [ + app_history.name, + str(files_folder), + "--from", + datetime.fromtimestamp(0).isoformat(), + "--to", + datetime.now().isoformat(), + "--operation", + f"{app.name}%", + "--uuid", + str(uuid4()), + ], + standalone_mode=False, + ) + + # noinspection DuplicatedCode def test_edit_action(tests_folder: Path, files_folder: Path, files_folder_copy: Path): database_path: Path = files_folder / "_metadata" / "files.db" From 1715690c860283d03b62ae4a61167a58504d5544 Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Wed, 12 Jun 2024 11:00:32 +0200 Subject: [PATCH 17/20] database - remove unused wrapper --- digiarch/cli.py | 2 +- digiarch/database.py | 16 ---------------- tests/test_cli.py | 2 +- 3 files changed, 2 insertions(+), 18 deletions(-) delete mode 100644 digiarch/database.py diff --git a/digiarch/cli.py b/digiarch/cli.py index b7cdf6d9..647a7b66 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -20,6 +20,7 @@ import yaml from acacore.__version__ import __version__ as __acacore_version__ +from acacore.database import FileDB from acacore.models.file import File from acacore.models.history import HistoryEntry from acacore.models.reference_files import Action @@ -57,7 +58,6 @@ from pydantic import TypeAdapter from .__version__ import __version__ -from .database import FileDB Image.MAX_IMAGE_PIXELS = int(50e3**2) diff --git a/digiarch/database.py b/digiarch/database.py deleted file mode 100644 index 5ca6820e..00000000 --- a/digiarch/database.py +++ /dev/null @@ -1,16 +0,0 @@ -from pathlib import Path -from sqlite3 import Cursor -from typing import Optional - -from acacore.database import FileDB as FileDBBase - - -class FileDB(FileDBBase): - # noinspection SqlResolve - def file_exists(self, path: Path, root: Optional[Path] = None) -> bool: - path = path.relative_to(root) if root else path - cursor: Cursor = self.execute( - f'select "{self.files.keys[0].name}" from "{self.files.name}" where relative_path = ? limit 1', - [str(path)], - ) - return cursor.fetchone() is not None diff --git a/tests/test_cli.py b/tests/test_cli.py index 51ee628d..bebbe032 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,7 @@ from uuid import uuid4 import pytest +from acacore.database import FileDB from acacore.models.file import File from acacore.models.history import HistoryEntry from acacore.models.reference_files import ActionData @@ -29,7 +30,6 @@ from digiarch.cli import app_history from digiarch.cli import app_identify from digiarch.cli import app_reidentify -from digiarch.database import FileDB @pytest.fixture() From 329676bbb371b2bd3b2206be7ff40254eefea5db Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Wed, 12 Jun 2024 11:07:47 +0200 Subject: [PATCH 18/20] cli:app_history - add reason query option --- digiarch/cli.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/digiarch/cli.py b/digiarch/cli.py index 647a7b66..d04444d1 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -488,6 +488,7 @@ def app_reidentify( callback=regex_callback(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", IGNORECASE), help="File UUID.", ) +@option("--reason", type=str, default=None, multiple=True, help="Event reason.") @option( "--ascending/--descending", "ascending", @@ -504,18 +505,20 @@ def app_history( time_to: Optional[datetime], operation: Optional[tuple[str, ...]], uuid: Optional[tuple[str, ...]], + reason: Optional[tuple[str, ...]], ascending: bool, ): """ View and search events log. - The --operation option supports LIKE syntax with the % operator. + The --operation and --reason options supports LIKE syntax with the % operator. If multiple --uuid or --operation options are used, the query will match any of them. If no query option is given, only the first 100 results will be shown. """ operation = tuple(o.strip() for o in operation if o.strip(" %:.")) if operation else None + reason = tuple(r.strip(" %") for r in reason if r.strip(" %")) if reason else None database_path: Path = Path(root) / "_metadata" / "files.db" if not database_path.is_file(): @@ -543,6 +546,10 @@ def app_history( where.append("(" + " or ".join("operation like ?" for _ in operation) + ")") parameters.extend(operation) + if reason: + where.append("(" + " or ".join("reason like '%' || ? || '%'" for _ in reason) + ")") + parameters.extend(reason) + if not where: logger_stdout.warning(f"No selectors given. Showing {'first' if ascending else 'last'} 10000 events.") From 5845599fa5d87e330b4af43ca2b8b91d8f6b061d Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Wed, 12 Jun 2024 11:08:44 +0200 Subject: [PATCH 19/20] tests:history - add --reason option --- tests/test_cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index bebbe032..6292d6c4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -209,6 +209,8 @@ def test_history(tests_folder: Path, files_folder: Path): f"{app.name}%", "--uuid", str(uuid4()), + "--reason", + "_", ], standalone_mode=False, ) From 2addbd797a4b02b0ba7feb3a90fcb5bbf9fb872a Mon Sep 17 00:00:00 2001 From: Matteo Campinoti Date: Wed, 12 Jun 2024 11:09:22 +0200 Subject: [PATCH 20/20] tests:history - fix missing mention in help --- digiarch/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/digiarch/cli.py b/digiarch/cli.py index d04444d1..76334fee 100644 --- a/digiarch/cli.py +++ b/digiarch/cli.py @@ -513,7 +513,7 @@ def app_history( The --operation and --reason options supports LIKE syntax with the % operator. - If multiple --uuid or --operation options are used, the query will match any of them. + If multiple --uuid, --operation, or --reason options are used, the query will match any of them. If no query option is given, only the first 100 results will be shown. """