iphoto-sizer 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.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.3
2
+ Name: iphoto-sizer
3
+ Version: 0.1.0
4
+ Summary: Export and sort Apple Photos library metadata by file size
5
+ Requires-Dist: osxphotos~=0.75.6
6
+ Requires-Dist: pydantic~=2.12.5
7
+ Requires-Dist: flask>=3.0 ; extra == 'web'
8
+ Requires-Python: >=3.11
9
+ Provides-Extra: web
10
+ Description-Content-Type: text/markdown
11
+
12
+ # iphoto-sizer
13
+
14
+ Exports metadata from your macOS Apple Photos library to CSV or JSON, sorted by file size (largest first). Useful for finding what's eating your iCloud storage.
15
+
16
+ Each record includes: filename, extension, media type (photo/video), size in bytes, human-readable size, creation date, UUID, and iCloud sync status (local vs cloud-only).
17
+
18
+ ## Prerequisites
19
+
20
+ - macOS with the Photos app and a library present
21
+ - Python 3.11+
22
+ - Full Disk Access granted to your terminal (System Settings > Privacy & Security > Full Disk Access)
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ # Clone and install with uv
28
+ git clone https://github.com/spencerpresley/iPhotoSizer.git && cd iPhotoSizer
29
+ uv sync
30
+ ```
31
+
32
+ Or install with pip:
33
+
34
+ ```bash
35
+ pip install .
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ After installing, an `iphoto-sizer` CLI command is available:
41
+
42
+ ```bash
43
+ # Export everything to photos_report.csv in the current directory
44
+ iphoto-sizer
45
+
46
+ # Only items larger than 100 MB
47
+ iphoto-sizer --min-size-mb 100
48
+
49
+ # Write to a specific path
50
+ iphoto-sizer -o ~/Desktop/large_files.csv
51
+
52
+ # Export as JSON instead of CSV
53
+ iphoto-sizer -f json -o ~/Desktop/photos.json
54
+
55
+ # Combine options
56
+ iphoto-sizer --min-size-mb 500 -f json -o ~/Desktop/big_ones.json
57
+ ```
58
+
59
+ You can also run it as a module:
60
+
61
+ ```bash
62
+ python -m iphoto_sizer
63
+ ```
64
+
65
+ ## CLI Options
66
+
67
+ | Flag | Description | Default |
68
+ |------|-------------|---------|
69
+ | `--min-size-mb` | Only include items at or above this size (MB) | `0` (all items) |
70
+ | `-o`, `--output` | Output file path | `photos_report.csv` |
71
+ | `-f`, `--format` | Output format: `csv` or `json` | `csv` |
72
+ | `--web` | Launch the web UI in a browser | off |
73
+
74
+ ## Web UI
75
+
76
+ An optional browser-based interface for browsing and filtering your library.
77
+
78
+ ### Install
79
+
80
+ ```bash
81
+ pip install iphoto-sizer[web]
82
+ ```
83
+
84
+ Or with uv:
85
+
86
+ ```bash
87
+ uv sync --extra web
88
+ ```
89
+
90
+ ### Usage
91
+
92
+ ```bash
93
+ iphoto-sizer --web
94
+ ```
95
+
96
+ Opens a local web server in your browser. From there you can run a new scan, open an existing report, export results, and open individual photos in Photos.app.
97
+
98
+ The web UI provides:
99
+ - Summary stats (total items, total size, video/photo breakdown)
100
+ - Sortable, filterable browsing of all library items
101
+ - Export to CSV, JSON, or both from the browser
102
+ - Open individual photos directly in Photos.app (experimental)
103
+
104
+ ## Output
105
+
106
+ **CSV columns / JSON fields:**
107
+
108
+ `filename`, `extension`, `media_type`, `size_bytes`, `size`, `creation_date`, `uuid`, `icloud_status`
109
+
110
+ - `size` is human-readable (e.g. `"150.23 MB"`, `"1.50 GB"`)
111
+ - `icloud_status` is `"local"` if the original file is on disk, `"cloud-only"` if it only exists in iCloud
112
+ - Records are sorted by `size_bytes` descending
113
+
114
+ A summary of total items, total size, and the 10 largest files is printed to stderr after export.
115
+
116
+ ## Notes
117
+
118
+ - Initial library load takes 15-20 seconds on large libraries.
119
+ - Photos that fail to parse are skipped with a warning; they don't stop the export.
120
+ - The tool checks for at least 50 MB of free disk space before writing.
@@ -0,0 +1,109 @@
1
+ # iphoto-sizer
2
+
3
+ Exports metadata from your macOS Apple Photos library to CSV or JSON, sorted by file size (largest first). Useful for finding what's eating your iCloud storage.
4
+
5
+ Each record includes: filename, extension, media type (photo/video), size in bytes, human-readable size, creation date, UUID, and iCloud sync status (local vs cloud-only).
6
+
7
+ ## Prerequisites
8
+
9
+ - macOS with the Photos app and a library present
10
+ - Python 3.11+
11
+ - Full Disk Access granted to your terminal (System Settings > Privacy & Security > Full Disk Access)
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ # Clone and install with uv
17
+ git clone https://github.com/spencerpresley/iPhotoSizer.git && cd iPhotoSizer
18
+ uv sync
19
+ ```
20
+
21
+ Or install with pip:
22
+
23
+ ```bash
24
+ pip install .
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ After installing, an `iphoto-sizer` CLI command is available:
30
+
31
+ ```bash
32
+ # Export everything to photos_report.csv in the current directory
33
+ iphoto-sizer
34
+
35
+ # Only items larger than 100 MB
36
+ iphoto-sizer --min-size-mb 100
37
+
38
+ # Write to a specific path
39
+ iphoto-sizer -o ~/Desktop/large_files.csv
40
+
41
+ # Export as JSON instead of CSV
42
+ iphoto-sizer -f json -o ~/Desktop/photos.json
43
+
44
+ # Combine options
45
+ iphoto-sizer --min-size-mb 500 -f json -o ~/Desktop/big_ones.json
46
+ ```
47
+
48
+ You can also run it as a module:
49
+
50
+ ```bash
51
+ python -m iphoto_sizer
52
+ ```
53
+
54
+ ## CLI Options
55
+
56
+ | Flag | Description | Default |
57
+ |------|-------------|---------|
58
+ | `--min-size-mb` | Only include items at or above this size (MB) | `0` (all items) |
59
+ | `-o`, `--output` | Output file path | `photos_report.csv` |
60
+ | `-f`, `--format` | Output format: `csv` or `json` | `csv` |
61
+ | `--web` | Launch the web UI in a browser | off |
62
+
63
+ ## Web UI
64
+
65
+ An optional browser-based interface for browsing and filtering your library.
66
+
67
+ ### Install
68
+
69
+ ```bash
70
+ pip install iphoto-sizer[web]
71
+ ```
72
+
73
+ Or with uv:
74
+
75
+ ```bash
76
+ uv sync --extra web
77
+ ```
78
+
79
+ ### Usage
80
+
81
+ ```bash
82
+ iphoto-sizer --web
83
+ ```
84
+
85
+ Opens a local web server in your browser. From there you can run a new scan, open an existing report, export results, and open individual photos in Photos.app.
86
+
87
+ The web UI provides:
88
+ - Summary stats (total items, total size, video/photo breakdown)
89
+ - Sortable, filterable browsing of all library items
90
+ - Export to CSV, JSON, or both from the browser
91
+ - Open individual photos directly in Photos.app (experimental)
92
+
93
+ ## Output
94
+
95
+ **CSV columns / JSON fields:**
96
+
97
+ `filename`, `extension`, `media_type`, `size_bytes`, `size`, `creation_date`, `uuid`, `icloud_status`
98
+
99
+ - `size` is human-readable (e.g. `"150.23 MB"`, `"1.50 GB"`)
100
+ - `icloud_status` is `"local"` if the original file is on disk, `"cloud-only"` if it only exists in iCloud
101
+ - Records are sorted by `size_bytes` descending
102
+
103
+ A summary of total items, total size, and the 10 largest files is printed to stderr after export.
104
+
105
+ ## Notes
106
+
107
+ - Initial library load takes 15-20 seconds on large libraries.
108
+ - Photos that fail to parse are skipped with a warning; they don't stop the export.
109
+ - The tool checks for at least 50 MB of free disk space before writing.
@@ -0,0 +1,106 @@
1
+ [project]
2
+ name = "iphoto-sizer"
3
+ version = "0.1.0"
4
+ description = "Export and sort Apple Photos library metadata by file size"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "osxphotos~=0.75.6",
9
+ "pydantic~=2.12.5",
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ web = ["flask>=3.0"]
14
+
15
+ [project.scripts]
16
+ iphoto-sizer = "iphoto_sizer.cli:main"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.10.9,<0.11.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "pyright>=1.1.407",
25
+ "pytest>=9.0.2",
26
+ "pytest-cov>=7.0.0",
27
+ "ruff>=0.14.9",
28
+ ]
29
+
30
+ [tool.ruff]
31
+ line-length = 100
32
+ target-version = "py311"
33
+
34
+ [tool.ruff.format]
35
+ docstring-code-format = true
36
+ quote-style = "double"
37
+ indent-style = "space"
38
+
39
+ [tool.ruff.lint]
40
+ select = ["ALL"]
41
+ ignore = [
42
+ # Conflicts with formatter
43
+ "COM812",
44
+ "ISC001",
45
+
46
+ # Docstrings - too strict
47
+ "D100", # Missing docstring in public module
48
+ "D104", # Missing docstring in public package
49
+ "D107", # Missing docstring in __init__
50
+
51
+ # Annoying / not useful
52
+ "C90", # McCabe complexity
53
+ "CPY", # Copyright headers
54
+ "FIX002", # Line contains TODO
55
+ "PLR09", # Too many arguments/statements/etc
56
+ "TD002", # Missing author in TODO
57
+ "TD003", # Missing issue link for TODO
58
+ "FBT", # Boolean trap - internal APIs are fine
59
+ "PLW0603", # Global statement - config module pattern
60
+
61
+ # Doesn't play well with Pydantic
62
+ "RUF012", # Mutable class attrs
63
+ "TC001", # Move import into TYPE_CHECKING
64
+ "TC002", # Move import into TYPE_CHECKING
65
+ "TC003", # Move import into TYPE_CHECKING
66
+
67
+ # May want to revisit
68
+ "BLE", # Blind exceptions
69
+ ]
70
+ unfixable = [
71
+ "B028", # warnings.warn() stacklevel - think about it
72
+ "PLW1510", # subprocess.run() check param - be explicit
73
+ ]
74
+ fixable = ["ALL"]
75
+
76
+ # Pydantic-friendly settings
77
+ flake8-annotations.allow-star-arg-any = true
78
+ flake8-annotations.mypy-init-return = true
79
+ flake8-builtins.ignorelist = ["id", "type", "input"]
80
+ flake8-type-checking.runtime-evaluated-base-classes = ["pydantic.BaseModel"]
81
+
82
+ [tool.ruff.lint.pydocstyle]
83
+ convention = "google"
84
+ ignore-var-parameters = true
85
+
86
+ [tool.ruff.lint.per-file-ignores]
87
+ "tests/**" = ["ANN", "D", "S", "PLR2004", "ARG", "PT", "T201", "FBT", "SLF001", "PLC0415", "ERA001"]
88
+ "**/cli.py" = ["T201"] # CLI tool — print to stderr is intentional
89
+ "**/core.py" = ["T201"] # load_photos_db prints status to stderr
90
+ "**/web/*.py" = ["T201"]
91
+
92
+ [tool.pyright]
93
+ include = ["."]
94
+ exclude = ["**/__pycache__", ".venv", "**/.venv", "output"]
95
+ pythonVersion = "3.11"
96
+ typeCheckingMode = "strict"
97
+
98
+ venvPath = "."
99
+ venv = ".venv"
100
+
101
+ reportUnusedCallResult = "none"
102
+ reportMissingTypeStubs = "none"
103
+ reportPrivateUsage = "none"
104
+ reportUnknownMemberType = "none"
105
+ reportUnnecessaryTypeIgnoreComment = "warning"
106
+ reportMissingTypeArgument = "none"
@@ -0,0 +1,45 @@
1
+ """iphoto-sizer — export Apple Photos library metadata sorted by file size."""
2
+
3
+ from iphoto_sizer.core import apply_filters, load_photos_db, photo_to_record, scan_library
4
+ from iphoto_sizer.models import (
5
+ BYTES_PER_GB,
6
+ BYTES_PER_MB,
7
+ CSV_COLUMNS,
8
+ DEFAULT_FORMAT,
9
+ DEFAULT_OUTPUT_FILE,
10
+ DEFAULT_OUTPUT_STEM,
11
+ ICLOUD_STATUS_CLOUD_ONLY,
12
+ ICLOUD_STATUS_LOCAL,
13
+ MEDIA_TYPE_PHOTO,
14
+ MEDIA_TYPE_VIDEO,
15
+ SUPPORTED_FORMATS,
16
+ OutputFormat,
17
+ PhotoRecord,
18
+ RecordWriter,
19
+ format_bytes,
20
+ )
21
+ from iphoto_sizer.writers import write_csv, write_json
22
+
23
+ __all__ = [
24
+ "BYTES_PER_GB",
25
+ "BYTES_PER_MB",
26
+ "CSV_COLUMNS",
27
+ "DEFAULT_FORMAT",
28
+ "DEFAULT_OUTPUT_FILE",
29
+ "DEFAULT_OUTPUT_STEM",
30
+ "ICLOUD_STATUS_CLOUD_ONLY",
31
+ "ICLOUD_STATUS_LOCAL",
32
+ "MEDIA_TYPE_PHOTO",
33
+ "MEDIA_TYPE_VIDEO",
34
+ "SUPPORTED_FORMATS",
35
+ "OutputFormat",
36
+ "PhotoRecord",
37
+ "RecordWriter",
38
+ "apply_filters",
39
+ "format_bytes",
40
+ "load_photos_db",
41
+ "photo_to_record",
42
+ "scan_library",
43
+ "write_csv",
44
+ "write_json",
45
+ ]
@@ -0,0 +1,4 @@
1
+ from iphoto_sizer.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,222 @@
1
+ """CLI entry point for iphoto-sizer.
2
+
3
+ .. code-block:: bash
4
+
5
+ # Export all items to photos_report.csv in the current directory
6
+ iphoto-sizer
7
+
8
+ # Or run as a module
9
+ python -m iphoto_sizer
10
+
11
+ # Export only items larger than 100 MB
12
+ iphoto-sizer --min-size-mb 100
13
+
14
+ # Export to a custom path
15
+ iphoto-sizer -o ~/Desktop/large_files.csv
16
+
17
+ # Export as JSON
18
+ iphoto-sizer -f json -o ~/Desktop/photos.json
19
+
20
+ # Combine options
21
+ iphoto-sizer --min-size-mb 500 -o ~/Desktop/big_ones.csv
22
+ """
23
+
24
+ import argparse
25
+ import shutil
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ from iphoto_sizer.core import load_photos_db, scan_library
30
+ from iphoto_sizer.models import (
31
+ BYTES_PER_MB,
32
+ DEFAULT_FORMAT,
33
+ DEFAULT_OUTPUT_FILE,
34
+ SUPPORTED_FORMATS,
35
+ PhotoRecord,
36
+ format_bytes,
37
+ )
38
+ from iphoto_sizer.writers import FORMAT_WRITERS
39
+
40
+ _EXIT_CODE_ERROR = 1
41
+ _EXIT_CODE_INTERRUPTED = 130
42
+ _MIN_FREE_DISK_SPACE_MB = 50
43
+
44
+ # Summary table column widths
45
+ _COL_WIDTH_RANK = 3
46
+ _COL_WIDTH_FILENAME = 40
47
+ _COL_WIDTH_SIZE = 10
48
+ _COL_WIDTH_MEDIA_TYPE = 5
49
+
50
+
51
+ def build_arg_parser() -> argparse.ArgumentParser:
52
+ """Build the CLI argument parser.
53
+
54
+ Returns:
55
+ argparse.ArgumentParser: Configured parser with ``--min-size-mb``
56
+ and ``--output`` arguments.
57
+ """
58
+ parser = argparse.ArgumentParser(
59
+ description="Export Apple Photos library metadata to CSV, sorted by file size."
60
+ )
61
+ parser.add_argument(
62
+ "--min-size-mb",
63
+ type=float,
64
+ default=0.0,
65
+ help="Only include items larger than this size in MB (default: include all)",
66
+ )
67
+ parser.add_argument(
68
+ "--output",
69
+ "-o",
70
+ type=str,
71
+ default=DEFAULT_OUTPUT_FILE,
72
+ help=f"Output file path (default: {DEFAULT_OUTPUT_FILE})",
73
+ )
74
+ parser.add_argument(
75
+ "--format",
76
+ "-f",
77
+ type=str,
78
+ choices=SUPPORTED_FORMATS,
79
+ default=DEFAULT_FORMAT,
80
+ help=f"Output format (default: {DEFAULT_FORMAT})",
81
+ )
82
+ parser.add_argument(
83
+ "--web",
84
+ action="store_true",
85
+ default=False,
86
+ help="Launch the web UI instead of exporting to a file",
87
+ )
88
+ return parser
89
+
90
+
91
+ def validate_output_path(output_path: str) -> Path:
92
+ """Validate and prepare the output file path.
93
+
94
+ Creates parent directories if they don't exist. Warns if an existing
95
+ file will be overwritten. Checks that the destination has enough disk
96
+ space for a reasonable CSV output.
97
+
98
+ Args:
99
+ output_path (str): Destination file path for the CSV.
100
+
101
+ Returns:
102
+ Path: The validated output path.
103
+
104
+ Raises:
105
+ SystemExit: If the parent directory cannot be created or there
106
+ is insufficient disk space.
107
+ """
108
+ path = Path(output_path)
109
+
110
+ # Ensure parent directory exists
111
+ try:
112
+ path.parent.mkdir(parents=True, exist_ok=True)
113
+ except OSError as e:
114
+ print(f"Cannot create output directory {path.parent}: {e}", file=sys.stderr)
115
+ sys.exit(_EXIT_CODE_ERROR)
116
+
117
+ if path.exists():
118
+ print(f"Note: overwriting existing file {path}", file=sys.stderr)
119
+
120
+ # Rough disk space check — a large library (100k items) produces
121
+ # roughly 15-20 MB of CSV, so 50 MB is a comfortable minimum
122
+ min_free_bytes = _MIN_FREE_DISK_SPACE_MB * BYTES_PER_MB
123
+ try:
124
+ free_bytes = shutil.disk_usage(path.parent).free
125
+ except OSError as e:
126
+ print(
127
+ f"Warning: could not check disk space for {path.parent}: {e}",
128
+ file=sys.stderr,
129
+ )
130
+ return path
131
+ if free_bytes < min_free_bytes:
132
+ print(
133
+ f"Insufficient disk space: {format_bytes(free_bytes)} free, "
134
+ f"need at least {format_bytes(min_free_bytes)}",
135
+ file=sys.stderr,
136
+ )
137
+ sys.exit(_EXIT_CODE_ERROR)
138
+
139
+ return path
140
+
141
+
142
+ def print_summary(records: list[PhotoRecord], top_n: int = 10) -> None:
143
+ """Print a summary report to stderr.
144
+
145
+ Displays total item count, total size, and the largest items.
146
+
147
+ Args:
148
+ records (list[PhotoRecord]): Records to summarize, assumed pre-sorted
149
+ by size descending.
150
+ top_n (int): Number of largest items to display. Defaults to 10.
151
+ """
152
+ if not records:
153
+ print("No items found.", file=sys.stderr)
154
+ return
155
+
156
+ total_bytes = sum(r.size_bytes for r in records)
157
+
158
+ print(file=sys.stderr)
159
+ print("=== Photos Library Report ===", file=sys.stderr)
160
+ print(f"Total items: {len(records):,}", file=sys.stderr)
161
+ print(f"Total size: {format_bytes(total_bytes)}", file=sys.stderr)
162
+ display_count = min(top_n, len(records))
163
+
164
+ print(file=sys.stderr)
165
+ print(f"Top {display_count} Largest Items:", file=sys.stderr)
166
+
167
+ for i, record in enumerate(records[:display_count], start=1):
168
+ print(
169
+ f" {i:>{_COL_WIDTH_RANK}}. {record.filename:<{_COL_WIDTH_FILENAME}}"
170
+ f" {record.size:>{_COL_WIDTH_SIZE}}"
171
+ f" {record.media_type:<{_COL_WIDTH_MEDIA_TYPE}} {record.icloud_status}",
172
+ file=sys.stderr,
173
+ )
174
+
175
+
176
+ def _start_web() -> None:
177
+ """Import and start the web UI. Raises ImportError if Flask is not installed."""
178
+ from iphoto_sizer.web import serve_web # noqa: PLC0415
179
+
180
+ serve_web()
181
+
182
+
183
+ def main() -> None:
184
+ """Run the full export pipeline: load, extract, filter, sort, write, summarize."""
185
+ try:
186
+ _run()
187
+ except KeyboardInterrupt:
188
+ print("\nInterrupted.", file=sys.stderr)
189
+ sys.exit(_EXIT_CODE_INTERRUPTED)
190
+
191
+
192
+ def _run() -> None:
193
+ """Inner pipeline logic, separated so ``main()`` owns interrupt handling."""
194
+ args = build_arg_parser().parse_args()
195
+
196
+ if args.min_size_mb < 0:
197
+ print("Error: --min-size-mb cannot be negative", file=sys.stderr)
198
+ sys.exit(_EXIT_CODE_ERROR)
199
+
200
+ if args.web:
201
+ try:
202
+ _start_web()
203
+ except ImportError:
204
+ print(
205
+ "The --web flag requires the [web] extra.\n"
206
+ "Install it with: pip install iphoto-sizer[web]",
207
+ file=sys.stderr,
208
+ )
209
+ sys.exit(_EXIT_CODE_ERROR)
210
+ return
211
+
212
+ output_path = validate_output_path(args.output)
213
+ db = load_photos_db()
214
+
215
+ records, skipped = scan_library(db, min_size_mb=args.min_size_mb)
216
+ if skipped:
217
+ print(f"Skipped {skipped} item(s) due to errors.", file=sys.stderr)
218
+
219
+ writer = FORMAT_WRITERS[args.format]
220
+ writer(records, output_path)
221
+ print(f"{args.format.upper()} written to {output_path}", file=sys.stderr)
222
+ print_summary(records)