photo-tools-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. photo_tools_cli-0.1.0/PKG-INFO +169 -0
  2. photo_tools_cli-0.1.0/README.md +159 -0
  3. photo_tools_cli-0.1.0/pyproject.toml +42 -0
  4. photo_tools_cli-0.1.0/setup.cfg +4 -0
  5. photo_tools_cli-0.1.0/src/photo_tools/__init__.py +0 -0
  6. photo_tools_cli-0.1.0/src/photo_tools/cli.py +164 -0
  7. photo_tools_cli-0.1.0/src/photo_tools/cli_support/__init__.py +0 -0
  8. photo_tools_cli-0.1.0/src/photo_tools/cli_support/cli_errors.py +22 -0
  9. photo_tools_cli-0.1.0/src/photo_tools/cli_support/cli_reporter.py +17 -0
  10. photo_tools_cli-0.1.0/src/photo_tools/commands/__init__.py +0 -0
  11. photo_tools_cli-0.1.0/src/photo_tools/commands/clean_unpaired_raws.py +89 -0
  12. photo_tools_cli-0.1.0/src/photo_tools/commands/optimise.py +90 -0
  13. photo_tools_cli-0.1.0/src/photo_tools/commands/organise_by_date.py +105 -0
  14. photo_tools_cli-0.1.0/src/photo_tools/commands/separate_raws.py +74 -0
  15. photo_tools_cli-0.1.0/src/photo_tools/core/__init__.py +0 -0
  16. photo_tools_cli-0.1.0/src/photo_tools/core/dependencies.py +19 -0
  17. photo_tools_cli-0.1.0/src/photo_tools/core/validation.py +9 -0
  18. photo_tools_cli-0.1.0/src/photo_tools/exceptions.py +26 -0
  19. photo_tools_cli-0.1.0/src/photo_tools/image/__init__.py +0 -0
  20. photo_tools_cli-0.1.0/src/photo_tools/image/metadata.py +32 -0
  21. photo_tools_cli-0.1.0/src/photo_tools/image/optimisation.py +62 -0
  22. photo_tools_cli-0.1.0/src/photo_tools/logging_config.py +15 -0
  23. photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/PKG-INFO +169 -0
  24. photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/SOURCES.txt +32 -0
  25. photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/dependency_links.txt +1 -0
  26. photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/entry_points.txt +2 -0
  27. photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/requires.txt +3 -0
  28. photo_tools_cli-0.1.0/src/photo_tools_cli.egg-info/top_level.txt +1 -0
  29. photo_tools_cli-0.1.0/tests/test_clean_unpaired_raws.py +218 -0
  30. photo_tools_cli-0.1.0/tests/test_cli.py +82 -0
  31. photo_tools_cli-0.1.0/tests/test_optimise.py +96 -0
  32. photo_tools_cli-0.1.0/tests/test_organise_by_date.py +198 -0
  33. photo_tools_cli-0.1.0/tests/test_separate_raws.py +141 -0
  34. photo_tools_cli-0.1.0/tests/test_validation.py +18 -0
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: photo-tools-cli
3
+ Version: 0.1.0
4
+ Summary: Python CLI tools for photography workflows
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: typer<1.0,>=0.24
8
+ Requires-Dist: pillow<13.0,>=12.1
9
+ Requires-Dist: python-dotenv<2.0,>=1.2
10
+
11
+ [![CI](https://github.com/aga87/python-photo-tools/actions/workflows/ci.yml/badge.svg)](https://github.com/aga87/python-photo-tools/actions)
12
+
13
+ # Python Photo Tools
14
+
15
+ Command-line tools for organising photos by date, managing RAW/JPG pairs, and optimising images.
16
+
17
+ ## Supported formats
18
+
19
+ - RAW: .raf
20
+ - JPG: .jpg, .jpeg
21
+
22
+ ## Prerequisites - System Tools
23
+
24
+ This project depends on external system tools in addition to Python.
25
+
26
+ ### ExifTool
27
+
28
+ Used for extracting image metadata (including RAW formats).
29
+ - Supports all major image formats, including RAW
30
+ - Provides consistent metadata fields across formats
31
+ - More reliable than Python-only EXIF libraries
32
+
33
+ If `exiftool` is not installed, the CLI will fail with a clear error message and exit code `1`.
34
+
35
+ Install (macOS)
36
+
37
+ ```shell
38
+ brew install exiftool
39
+ ```
40
+
41
+ On Linux, install via your package manager (e.g. `apt install exiftool`).
42
+
43
+ ## Installation
44
+
45
+ ### Using pipx (recommended)
46
+
47
+ ```shell
48
+ pipx install git+https://github.com/aga87/python-photo-tools.git
49
+ ```
50
+
51
+ ### Local development
52
+
53
+ Clone the repository and install:
54
+
55
+ ```shell
56
+ pip install -e .
57
+ pip install --group dev -e .
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ List available commands:
63
+
64
+ ```bash
65
+ photo-tools --help
66
+ ```
67
+ Each command provides detailed usage, including arguments and options:
68
+
69
+ ```shell
70
+ photo-tools <command> --help
71
+ ```
72
+
73
+ **Note: All commands currently process files in the top-level input directory only.**
74
+
75
+ ### Global flags
76
+
77
+ All commands support:
78
+
79
+ - `--dry-run` — preview changes without modifying files
80
+ - `--verbose` / `-v` — show per-file output
81
+
82
+ Flags can be combined:
83
+
84
+ ```shell
85
+ photo-tools <command> ... --dry-run --verbose
86
+ ```
87
+
88
+ ### `by-date`
89
+
90
+ - Organise images into date-based folders (`YYYY-MM-DD`, optional suffix)
91
+ - Files are moved (not copied) into the output directory
92
+ - If a destination file already exists, it is skipped (no overwrite)
93
+
94
+ ```shell
95
+ photo-tools by-date <INPUT_DIR> <OUTPUT_DIR>
96
+ ```
97
+ ```shell
98
+ photo-tools by-date <INPUT_DIR> <OUTPUT_DIR> --suffix <SUFFIX>
99
+ ```
100
+
101
+
102
+ ### `raws`
103
+
104
+ - Move RAW images into a `raws/` subfolder within the input directory
105
+ - Non-RAW files are left unchanged
106
+ - Files are moved (not copied) in place
107
+ - If a destination file already exists, it is skipped (no overwrite)
108
+
109
+ ```shell
110
+ photo-tools raws <INPUT_DIR>
111
+ ```
112
+
113
+ ### `clean-raws`
114
+
115
+ - Move RAW files to `raws-to-delete/` if no matching JPG (same prefix) exists
116
+ - Matching is based on filename prefix (e.g. `abcd.RAF` matches `abcd_edit.jpg`)
117
+ - Files are moved (not deleted), making the operation reversible
118
+
119
+ ```shell
120
+ photo-tools clean-raws <RAW_DIR> <JPG_DIR>
121
+ ```
122
+
123
+ ### `optimise`
124
+
125
+ - Resize images to a maximum width of `2500px`
126
+ - Choose the highest quality that results in a file size ≤ `500 KB` (never below `70%`)
127
+ - Saves optimised images with prefix `lq_` in the same directory (overwrites existing files)
128
+
129
+
130
+ ```shell
131
+ photo-tools optimise <INPUT_DIR>
132
+ ```
133
+
134
+
135
+ ## Local Development Setup
136
+
137
+ ### Data
138
+
139
+ For convenience during development, you can create a local structure:
140
+
141
+ ```bash
142
+ mkdir -p data/input data/output
143
+ ```
144
+
145
+ Place test photos in:
146
+
147
+ ```
148
+ data/input/
149
+ ```
150
+
151
+ ### Running the CLI
152
+
153
+ You can run the CLI module directly for testing:
154
+
155
+ ```shell
156
+ python -m photo_tools.cli by-date ./data/input ./data/output
157
+ ```
158
+
159
+ ### Running tests
160
+
161
+ This project uses `pytest`:
162
+
163
+ ```bash
164
+ pytest
165
+ ```
166
+
167
+ ### Makefile
168
+
169
+ Common development tasks are available via the Makefile.
@@ -0,0 +1,159 @@
1
+ [![CI](https://github.com/aga87/python-photo-tools/actions/workflows/ci.yml/badge.svg)](https://github.com/aga87/python-photo-tools/actions)
2
+
3
+ # Python Photo Tools
4
+
5
+ Command-line tools for organising photos by date, managing RAW/JPG pairs, and optimising images.
6
+
7
+ ## Supported formats
8
+
9
+ - RAW: .raf
10
+ - JPG: .jpg, .jpeg
11
+
12
+ ## Prerequisites - System Tools
13
+
14
+ This project depends on external system tools in addition to Python.
15
+
16
+ ### ExifTool
17
+
18
+ Used for extracting image metadata (including RAW formats).
19
+ - Supports all major image formats, including RAW
20
+ - Provides consistent metadata fields across formats
21
+ - More reliable than Python-only EXIF libraries
22
+
23
+ If `exiftool` is not installed, the CLI will fail with a clear error message and exit code `1`.
24
+
25
+ Install (macOS)
26
+
27
+ ```shell
28
+ brew install exiftool
29
+ ```
30
+
31
+ On Linux, install via your package manager (e.g. `apt install exiftool`).
32
+
33
+ ## Installation
34
+
35
+ ### Using pipx (recommended)
36
+
37
+ ```shell
38
+ pipx install git+https://github.com/aga87/python-photo-tools.git
39
+ ```
40
+
41
+ ### Local development
42
+
43
+ Clone the repository and install:
44
+
45
+ ```shell
46
+ pip install -e .
47
+ pip install --group dev -e .
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ List available commands:
53
+
54
+ ```bash
55
+ photo-tools --help
56
+ ```
57
+ Each command provides detailed usage, including arguments and options:
58
+
59
+ ```shell
60
+ photo-tools <command> --help
61
+ ```
62
+
63
+ **Note: All commands currently process files in the top-level input directory only.**
64
+
65
+ ### Global flags
66
+
67
+ All commands support:
68
+
69
+ - `--dry-run` — preview changes without modifying files
70
+ - `--verbose` / `-v` — show per-file output
71
+
72
+ Flags can be combined:
73
+
74
+ ```shell
75
+ photo-tools <command> ... --dry-run --verbose
76
+ ```
77
+
78
+ ### `by-date`
79
+
80
+ - Organise images into date-based folders (`YYYY-MM-DD`, optional suffix)
81
+ - Files are moved (not copied) into the output directory
82
+ - If a destination file already exists, it is skipped (no overwrite)
83
+
84
+ ```shell
85
+ photo-tools by-date <INPUT_DIR> <OUTPUT_DIR>
86
+ ```
87
+ ```shell
88
+ photo-tools by-date <INPUT_DIR> <OUTPUT_DIR> --suffix <SUFFIX>
89
+ ```
90
+
91
+
92
+ ### `raws`
93
+
94
+ - Move RAW images into a `raws/` subfolder within the input directory
95
+ - Non-RAW files are left unchanged
96
+ - Files are moved (not copied) in place
97
+ - If a destination file already exists, it is skipped (no overwrite)
98
+
99
+ ```shell
100
+ photo-tools raws <INPUT_DIR>
101
+ ```
102
+
103
+ ### `clean-raws`
104
+
105
+ - Move RAW files to `raws-to-delete/` if no matching JPG (same prefix) exists
106
+ - Matching is based on filename prefix (e.g. `abcd.RAF` matches `abcd_edit.jpg`)
107
+ - Files are moved (not deleted), making the operation reversible
108
+
109
+ ```shell
110
+ photo-tools clean-raws <RAW_DIR> <JPG_DIR>
111
+ ```
112
+
113
+ ### `optimise`
114
+
115
+ - Resize images to a maximum width of `2500px`
116
+ - Choose the highest quality that results in a file size ≤ `500 KB` (never below `70%`)
117
+ - Saves optimised images with prefix `lq_` in the same directory (overwrites existing files)
118
+
119
+
120
+ ```shell
121
+ photo-tools optimise <INPUT_DIR>
122
+ ```
123
+
124
+
125
+ ## Local Development Setup
126
+
127
+ ### Data
128
+
129
+ For convenience during development, you can create a local structure:
130
+
131
+ ```bash
132
+ mkdir -p data/input data/output
133
+ ```
134
+
135
+ Place test photos in:
136
+
137
+ ```
138
+ data/input/
139
+ ```
140
+
141
+ ### Running the CLI
142
+
143
+ You can run the CLI module directly for testing:
144
+
145
+ ```shell
146
+ python -m photo_tools.cli by-date ./data/input ./data/output
147
+ ```
148
+
149
+ ### Running tests
150
+
151
+ This project uses `pytest`:
152
+
153
+ ```bash
154
+ pytest
155
+ ```
156
+
157
+ ### Makefile
158
+
159
+ Common development tasks are available via the Makefile.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "photo-tools-cli"
7
+ version = "0.1.0"
8
+ description = "Python CLI tools for photography workflows"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "typer>=0.24,<1.0",
13
+ "pillow>=12.1,<13.0",
14
+ "python-dotenv>=1.2,<2.0",
15
+ ]
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest>=9,<10",
20
+ "ruff>=0.15,<1.0",
21
+ "mypy>=1.19,<2.0",
22
+ "build>=1.4,<2.0",
23
+ "twine>=6.2,<7.0",
24
+ ]
25
+
26
+ [project.scripts]
27
+ photo-tools = "photo_tools.cli:app"
28
+
29
+ [tool.setuptools]
30
+ package-dir = { "" = "src" }
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I"]
37
+
38
+ [tool.mypy]
39
+ python_version = "3.13"
40
+ strict = true
41
+ mypy_path = "src"
42
+ explicit_package_bases = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,164 @@
1
+ import typer
2
+ from dotenv import load_dotenv
3
+
4
+ from photo_tools.cli_support.cli_errors import handle_cli_errors
5
+ from photo_tools.cli_support.cli_reporter import make_reporter
6
+ from photo_tools.commands.clean_unpaired_raws import clean_unpaired_raws
7
+ from photo_tools.commands.optimise import optimise
8
+ from photo_tools.commands.organise_by_date import organise_by_date
9
+ from photo_tools.commands.separate_raws import separate_raws
10
+ from photo_tools.core.dependencies import validate_feature
11
+ from photo_tools.exceptions import MissingDependencyError
12
+ from photo_tools.logging_config import setup_logging
13
+
14
+ app = typer.Typer(help="CLI tools for organising and optimising photography workflows.")
15
+
16
+
17
+ load_dotenv()
18
+ setup_logging()
19
+
20
+
21
+ @app.callback()
22
+ def main() -> None:
23
+ try:
24
+ # validate dependencies needed globally (if any)
25
+ validate_feature("exif")
26
+ except MissingDependencyError as e:
27
+ typer.secho(f"Error: {e}", fg=typer.colors.RED, err=True)
28
+ raise typer.Exit(code=1)
29
+
30
+
31
+ @app.command("by-date")
32
+ @handle_cli_errors
33
+ def organise_by_date_cmd(
34
+ input_dir: str = typer.Argument(
35
+ ...,
36
+ help="Directory containing input images.",
37
+ ),
38
+ output_dir: str = typer.Argument(
39
+ ...,
40
+ help="Directory where organised images will be saved.",
41
+ ),
42
+ suffix: str | None = typer.Option(
43
+ None,
44
+ "--suffix",
45
+ help="Optional suffix appended to folder names (e.g. location).",
46
+ ),
47
+ dry_run: bool = typer.Option(
48
+ False,
49
+ "--dry-run",
50
+ help="Preview changes without moving files.",
51
+ ),
52
+ verbose: bool = typer.Option(
53
+ False,
54
+ "--verbose",
55
+ "-v",
56
+ help="Show per-file output.",
57
+ ),
58
+ ) -> None:
59
+ """Organise images into folders based on capture date."""
60
+ organise_by_date(
61
+ input_dir=input_dir,
62
+ output_dir=output_dir,
63
+ report=make_reporter(verbose),
64
+ suffix=suffix,
65
+ dry_run=dry_run,
66
+ )
67
+
68
+
69
+ @app.command(
70
+ "raws",
71
+ help="Move RAW images into a 'raws' folder",
72
+ )
73
+ @handle_cli_errors
74
+ def separate_raws_cmd(
75
+ input_dir: str = typer.Argument(
76
+ ...,
77
+ help="Directory containing images from which RAW files should be separated.",
78
+ ),
79
+ dry_run: bool = typer.Option(
80
+ False,
81
+ "--dry-run",
82
+ help="Preview changes without moving files.",
83
+ ),
84
+ verbose: bool = typer.Option(
85
+ False,
86
+ "--verbose",
87
+ "-v",
88
+ help="Show per-file output.",
89
+ ),
90
+ ) -> None:
91
+ """Move RAW images into a 'raws' folder."""
92
+ separate_raws(
93
+ input_dir=input_dir,
94
+ report=make_reporter(verbose),
95
+ dry_run=dry_run,
96
+ )
97
+
98
+
99
+ @app.command(
100
+ "clean-raws",
101
+ help="Move RAW files without matching JPGs to 'raws-to-delete'.",
102
+ )
103
+ @handle_cli_errors
104
+ def clean_unpaired_raws_cmd(
105
+ raw_dir: str = typer.Argument(
106
+ ...,
107
+ help="Directory containing RAW files.",
108
+ ),
109
+ jpg_dir: str = typer.Argument(
110
+ ...,
111
+ help="Directory containing JPG files used for matching.",
112
+ ),
113
+ dry_run: bool = typer.Option(
114
+ False,
115
+ "--dry-run",
116
+ help="Preview changes without moving files.",
117
+ ),
118
+ verbose: bool = typer.Option(
119
+ False,
120
+ "--verbose",
121
+ "-v",
122
+ help="Show per-file output.",
123
+ ),
124
+ ) -> None:
125
+ clean_unpaired_raws(
126
+ raw_dir=raw_dir,
127
+ jpg_dir=jpg_dir,
128
+ report=make_reporter(verbose),
129
+ dry_run=dry_run,
130
+ )
131
+
132
+
133
+ @app.command(
134
+ "optimise",
135
+ help="Resize JPG images to max 2500px width and compress to ≤500KB using quality "
136
+ "70-100, saving as prefixed copies.",
137
+ )
138
+ @handle_cli_errors
139
+ def optimise_cmd(
140
+ input_dir: str = typer.Argument(
141
+ ...,
142
+ help="Directory containing JPG images to optimise.",
143
+ ),
144
+ dry_run: bool = typer.Option(
145
+ False,
146
+ "--dry-run",
147
+ help="Show resulting size and quality without writing files.",
148
+ ),
149
+ verbose: bool = typer.Option(
150
+ False,
151
+ "--verbose",
152
+ "-v",
153
+ help="Show per-file output.",
154
+ ),
155
+ ) -> None:
156
+ optimise(
157
+ input_dir=input_dir,
158
+ report=make_reporter(verbose),
159
+ dry_run=dry_run,
160
+ )
161
+
162
+
163
+ if __name__ == "__main__":
164
+ app()
@@ -0,0 +1,22 @@
1
+ import functools
2
+ from typing import Callable, ParamSpec, TypeVar
3
+
4
+ import typer
5
+
6
+ P = ParamSpec("P")
7
+ R = TypeVar("R")
8
+
9
+
10
+ # Decorator to handle common CLI errors and present clean messages to the user.
11
+ # Keeps core logic free of CLI concerns
12
+ def handle_cli_errors(func: Callable[P, R]) -> Callable[P, R]:
13
+ # Preserve original function metadata so Typer can correctly parse arguments
14
+ @functools.wraps(func)
15
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
16
+ try:
17
+ return func(*args, **kwargs)
18
+ except (FileNotFoundError, NotADirectoryError) as e:
19
+ typer.echo(f"Error: {e}")
20
+ raise typer.Exit(code=1)
21
+
22
+ return wrapper
@@ -0,0 +1,17 @@
1
+ from collections.abc import Callable
2
+
3
+ import typer
4
+
5
+ Reporter = Callable[[str, str], None]
6
+
7
+
8
+ def make_reporter(verbose: bool) -> Reporter:
9
+ def report(level: str, message: str) -> None:
10
+ if level == "warning":
11
+ typer.secho(message, fg=typer.colors.YELLOW, err=True)
12
+ elif level == "summary":
13
+ typer.echo(message)
14
+ elif level == "info" and verbose:
15
+ typer.echo(message)
16
+
17
+ return report
@@ -0,0 +1,89 @@
1
+ import logging
2
+ import shutil
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+
6
+ from photo_tools.core.validation import validate_input_dir
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ RAW_EXTENSIONS = {".raf"}
11
+ JPG_EXTENSIONS = {".jpg", ".jpeg"}
12
+
13
+ Reporter = Callable[[str, str], None]
14
+
15
+
16
+ def clean_unpaired_raws(
17
+ raw_dir: str,
18
+ jpg_dir: str,
19
+ report: Reporter,
20
+ dry_run: bool = False,
21
+ ) -> None:
22
+ raw_path = Path(raw_dir)
23
+ jpg_path = Path(jpg_dir)
24
+ trash_dir = raw_path / "raws-to-delete"
25
+
26
+ validate_input_dir(raw_path)
27
+ validate_input_dir(jpg_path)
28
+
29
+ moved_count = 0
30
+ dry_run_count = 0
31
+ skipped_existing_count = 0
32
+
33
+ jpg_files = [
34
+ f
35
+ for f in jpg_path.iterdir()
36
+ if f.is_file() and f.suffix.lower() in JPG_EXTENSIONS
37
+ ]
38
+
39
+ for raw_file in raw_path.iterdir():
40
+ if not raw_file.is_file():
41
+ continue
42
+
43
+ if raw_file.suffix.lower() not in RAW_EXTENSIONS:
44
+ continue
45
+
46
+ raw_stem = raw_file.stem.lower()
47
+ has_match = any(jpg.name.lower().startswith(raw_stem) for jpg in jpg_files)
48
+
49
+ if has_match:
50
+ logger.debug("Keeping %s (matched JPG)", raw_file.name)
51
+ continue
52
+
53
+ target_file = trash_dir / raw_file.name
54
+
55
+ if target_file.exists():
56
+ skipped_existing_count += 1
57
+ report(
58
+ "warning",
59
+ f"Skipping {raw_file.name}: already in raws-to-delete",
60
+ )
61
+ continue
62
+
63
+ if dry_run:
64
+ dry_run_count += 1
65
+ report(
66
+ "info",
67
+ f"[DRY RUN] Would move {raw_file.name} -> {trash_dir}",
68
+ )
69
+ continue
70
+
71
+ trash_dir.mkdir(parents=True, exist_ok=True)
72
+ shutil.move(str(raw_file), str(target_file))
73
+ moved_count += 1
74
+
75
+ report("info", f"Moved {raw_file.name} -> {trash_dir}")
76
+
77
+ # Summary
78
+
79
+ if dry_run:
80
+ report("summary", f"Dry run complete: would move {dry_run_count} file(s)")
81
+ else:
82
+ report("summary", f"Moved {moved_count} file(s)")
83
+
84
+ if skipped_existing_count:
85
+ report(
86
+ "warning",
87
+ f"Skipped {skipped_existing_count} file(s): "
88
+ "already exist in raws-to-delete",
89
+ )