immich-export 0.0.2__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 (39) hide show
  1. immich_export-0.0.2/.env.example +4 -0
  2. immich_export-0.0.2/.github/CODEOWNERS +1 -0
  3. immich_export-0.0.2/.github/workflows/ci.yml +24 -0
  4. immich_export-0.0.2/.github/workflows/release.yml +48 -0
  5. immich_export-0.0.2/.gitignore +10 -0
  6. immich_export-0.0.2/CHANGELOG.md +66 -0
  7. immich_export-0.0.2/LICENSE +21 -0
  8. immich_export-0.0.2/PKG-INFO +143 -0
  9. immich_export-0.0.2/README.md +120 -0
  10. immich_export-0.0.2/pyproject.toml +80 -0
  11. immich_export-0.0.2/renovate.json +37 -0
  12. immich_export-0.0.2/scripts/refresh_api_spec.py +66 -0
  13. immich_export-0.0.2/src/immich_export/__init__.py +3 -0
  14. immich_export-0.0.2/src/immich_export/__main__.py +5 -0
  15. immich_export-0.0.2/src/immich_export/api_contract.py +72 -0
  16. immich_export-0.0.2/src/immich_export/cli.py +149 -0
  17. immich_export-0.0.2/src/immich_export/client.py +189 -0
  18. immich_export-0.0.2/src/immich_export/config.py +61 -0
  19. immich_export-0.0.2/src/immich_export/errors.py +38 -0
  20. immich_export-0.0.2/src/immich_export/exporter.py +279 -0
  21. immich_export-0.0.2/src/immich_export/layout.py +52 -0
  22. immich_export-0.0.2/src/immich_export/manifest.py +123 -0
  23. immich_export-0.0.2/src/immich_export/models.py +108 -0
  24. immich_export-0.0.2/src/immich_export/py.typed +0 -0
  25. immich_export-0.0.2/src/immich_export/report.py +54 -0
  26. immich_export-0.0.2/src/immich_export/sidecar.py +97 -0
  27. immich_export-0.0.2/src/immich_export/views.py +61 -0
  28. immich_export-0.0.2/tests/__init__.py +0 -0
  29. immich_export-0.0.2/tests/conftest.py +27 -0
  30. immich_export-0.0.2/tests/data/immich-openapi.pruned.json +2742 -0
  31. immich_export-0.0.2/tests/fake_immich.py +204 -0
  32. immich_export-0.0.2/tests/test_cli.py +58 -0
  33. immich_export-0.0.2/tests/test_contract.py +42 -0
  34. immich_export-0.0.2/tests/test_errors.py +70 -0
  35. immich_export-0.0.2/tests/test_exporter.py +196 -0
  36. immich_export-0.0.2/tests/test_layout.py +80 -0
  37. immich_export-0.0.2/tests/test_manifest.py +61 -0
  38. immich_export-0.0.2/tests/test_sidecar.py +75 -0
  39. immich_export-0.0.2/uv.lock +573 -0
@@ -0,0 +1,4 @@
1
+ # Copy to .env (never commit the real one) or export in your shell.
2
+ # Create the key in Immich → Account Settings → API Keys (read-only is enough).
3
+ IMMICH_SERVER=https://immich.local:2283
4
+ IMMICH_API_KEY=your-api-key-here
@@ -0,0 +1 @@
1
+ * @gykonik
@@ -0,0 +1,24 @@
1
+ name: ci
2
+ on: [push, pull_request, workflow_dispatch]
3
+
4
+ jobs:
5
+ quality:
6
+ runs-on: ubuntu-latest
7
+ steps:
8
+ - uses: actions/checkout@v4
9
+ - uses: astral-sh/setup-uv@v5
10
+ - run: uv sync --all-extras --dev
11
+
12
+ - name: lint
13
+ run: |
14
+ uv run ruff check .
15
+ uv run ruff format --check .
16
+
17
+ - name: typecheck
18
+ run: uv run mypy
19
+
20
+ - name: test
21
+ run: uv run pytest -q
22
+
23
+ - name: build
24
+ run: uv build
@@ -0,0 +1,48 @@
1
+ name: release
2
+ on:
3
+ push:
4
+ branches: [main]
5
+
6
+ permissions:
7
+ contents: write # tag + GitHub Release
8
+ id-token: write # PyPI Trusted Publishing (OIDC, no token)
9
+
10
+ jobs:
11
+ release:
12
+ runs-on: ubuntu-latest
13
+ concurrency: release
14
+ steps:
15
+ # `git push` authenticates with the credentials checkout persists into the
16
+ # remote's extraheader — NOT with the token handed to the semantic-release
17
+ # action below (that one only signs GitHub API calls). The version commit
18
+ # lands on protected `main`, so the pushing identity needs a ruleset bypass:
19
+ # SEMANTIC_RELEASE_TOKEN is a PAT acting as an org owner, who has one.
20
+ - uses: actions/checkout@v4
21
+ with:
22
+ fetch-depth: 0
23
+ persist-credentials: true
24
+ token: ${{ secrets.SEMANTIC_RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
25
+
26
+ - name: Semantic release (version, tag, changelog, GitHub Release)
27
+ id: semrel
28
+ uses: python-semantic-release/python-semantic-release@v9
29
+ with:
30
+ github_token: ${{ secrets.SEMANTIC_RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
31
+
32
+ # No build step here on purpose. python-semantic-release already builds the
33
+ # sdist + wheel into dist/ via its `build_command` in pyproject.toml. It is
34
+ # a Docker action running as root, so those files are root-owned; a second
35
+ # `uv build` as the unprivileged runner user cannot overwrite them and dies
36
+ # with PermissionError, taking publish and the brew bump down with it.
37
+ - name: Publish to PyPI (OIDC)
38
+ if: steps.semrel.outputs.released == 'true'
39
+ uses: pypa/gh-action-pypi-publish@release/v1
40
+
41
+ - name: Bump Homebrew formula
42
+ if: steps.semrel.outputs.released == 'true'
43
+ run: |
44
+ gh workflow run bump.yml -R fileworks/homebrew-tap \
45
+ -f formula=immich-export \
46
+ -f version=${{ steps.semrel.outputs.version }}
47
+ env:
48
+ GH_TOKEN: ${{ secrets.TAP_DISPATCH_TOKEN }}
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ *.egg-info/
9
+ .env
10
+ .DS_Store
@@ -0,0 +1,66 @@
1
+ # CHANGELOG
2
+
3
+
4
+ ## v0.0.2 (2026-07-13)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **release**: Drop the duplicate build that broke publishing
9
+ ([#3](https://github.com/fileworks/immich-export/pull/3),
10
+ [`2f8683b`](https://github.com/fileworks/immich-export/commit/2f8683b21f8b0b75ffa0e6805547e450af394896))
11
+
12
+ The release job died at 'Build sdist + wheel' with
13
+
14
+ PermissionError: [Errno 13] Permission denied: dist/<pkg>-0.0.1.tar.gz
15
+
16
+ python-semantic-release already builds the sdist and wheel into dist/ via the build_command in
17
+ pyproject.toml. It is a Docker action running as root, so those artefacts are root-owned. The
18
+ workflow then ran a second, redundant 'uv build' as the unprivileged runner user, which cannot
19
+ overwrite them.
20
+
21
+ Publish to PyPI and the Homebrew bump are both gated on that step, so a permission error on a build
22
+ that never needed to happen silently took out the entire release.
23
+
24
+ Remove the duplicate build and the setup-uv step that only served it, and let the publish action
25
+ consume the dist/ that semantic-release produced.
26
+
27
+
28
+ ## v0.0.1 (2026-07-12)
29
+
30
+ ### Bug Fixes
31
+
32
+ - **release**: Give checkout the release token so the version push is allowed
33
+ ([#2](https://github.com/fileworks/immich-export/pull/2),
34
+ [`6ae635e`](https://github.com/fileworks/immich-export/commit/6ae635e0f7d3a0a1e65a40e76d470d5bbd5122a3))
35
+
36
+ The release job fails with GH013 — the chore(release) version commit cannot be pushed to protected
37
+ main.
38
+
39
+ Handing SEMANTIC_RELEASE_TOKEN to the python-semantic-release action is not enough: that input only
40
+ authenticates GitHub API calls. The actual git push uses the credentials actions/checkout persists
41
+ into the remote's extraheader, which were still the default GITHUB_TOKEN — an identity with no
42
+ ruleset bypass.
43
+
44
+ Set the token on checkout as well, mirroring media-sorter's semantic-release workflow, so the push
45
+ authenticates as an org owner and the always-bypass applies.
46
+
47
+ ### Continuous Integration
48
+
49
+ - Allow CI to be triggered manually ([#1](https://github.com/fileworks/immich-export/pull/1),
50
+ [`0e64d69`](https://github.com/fileworks/immich-export/commit/0e64d694c92fcd85992101ce249aaab9dd45c63e))
51
+
52
+ * ci: allow CI to be triggered manually
53
+
54
+ workflow_dispatch makes it possible to re-run checks without pushing a commit — needed while
55
+ verifying the org's Actions allow-list, and useful any time a run fails for reasons outside the
56
+ code.
57
+
58
+ * fix(release): push the version commit with a token that can bypass the ruleset
59
+
60
+ semantic-release commits the version bump to main. The branch ruleset requires a PR, and the default
61
+ GITHUB_TOKEN has no bypass — GitHub rejects the Actions app as a bypass actor ("must be part of
62
+ the ruleset source or owner organization"), so the release job could never land its commit.
63
+
64
+ SEMANTIC_RELEASE_TOKEN is a fine-grained PAT that acts as an org owner, who already holds an
65
+ always-bypass on the ruleset. Falls back to GITHUB_TOKEN so nothing breaks where the secret is
66
+ absent.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niklas Büchel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: immich-export
3
+ Version: 0.0.2
4
+ Summary: Export everything out of Immich — files plus albums, people, tags, descriptions — into a redundant, human-readable local folder tree.
5
+ Project-URL: Repository, https://github.com/fileworks/immich-export
6
+ Project-URL: Issues, https://github.com/fileworks/immich-export/issues
7
+ Author: gykonik
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: backup,export,immich,photos,xmp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: System :: Archiving :: Backup
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: httpx>=0.28
20
+ Requires-Dist: pydantic>=2.9
21
+ Requires-Dist: typer>=0.15
22
+ Description-Content-Type: text/markdown
23
+
24
+ # immich-export
25
+
26
+ Pull **everything** out of [Immich](https://immich.app) — the files **and** the
27
+ metadata that only lives in its database (albums, people, tags, descriptions,
28
+ favorites, geo) — into a redundant, human-readable local folder tree.
29
+
30
+ This is the media escape hatch that makes "Immich = source of truth"
31
+ reversible: if Immich vanished tomorrow, the export is a plain tree you can
32
+ browse, grep, or import anywhere else.
33
+
34
+ ```
35
+ immich-export/
36
+ library/2024/03/IMG_1234.jpg # primary tree (self-contained mode)
37
+ library/2024/03/IMG_1234.jpg.xmp # sidecar: tags, people, albums, description, geo, favorite
38
+ albums/Japan-2019/IMG_1234.jpg # → symlink into library/
39
+ people/Anna/IMG_1234.jpg # → symlink into library/
40
+ manifest.jsonl # one line per asset: id, checksum, path, all metadata
41
+ manifest.csv # the same, human-readable
42
+ export-report.txt # counts, warnings, errors, timing
43
+ ```
44
+
45
+ ## Install
46
+
47
+ ```sh
48
+ pipx install immich-export
49
+ # or
50
+ brew install fileworks/tap/immich-export
51
+ ```
52
+
53
+ *(Not yet published — first release pending; until then: `uv run immich-export` from a checkout.)*
54
+
55
+ ## Usage
56
+
57
+ ```sh
58
+ export IMMICH_SERVER=https://immich.local:2283
59
+ export IMMICH_API_KEY=... # Immich → Account Settings → API Keys
60
+
61
+ # full portable export (copies originals)
62
+ immich-export --out ./immich-export
63
+
64
+ # incremental re-run: only new/changed assets are downloaded
65
+ immich-export --out ./immich-export
66
+
67
+ # sidecar mode: you already have the Storage-Template tree mounted —
68
+ # only write .xmp sidecars + album/people views next to it
69
+ immich-export --mode sidecar --library-root /volume1/photos --out /volume1/photos
70
+
71
+ # custom primary tree
72
+ immich-export --layout "{year}/{album}" --out ./export
73
+ ```
74
+
75
+ Key flags (see `immich-export --help` for all):
76
+
77
+ | Flag | Default | Meaning |
78
+ |---|---|---|
79
+ | `--mode` | `self-contained` | `self-contained` copies originals; `sidecar` only writes XMP + views next to an existing tree |
80
+ | `--layout` | `{year}/{month}` | primary tree; tokens `{year} {month} {day} {album} {type}`; `{album}` falls back to `Unsorted` |
81
+ | `--album-view` / `--people-view` | on | build `albums/` and `people/` symlink views |
82
+ | `--sidecars` | `xmp` | `xmp` or `none` |
83
+ | `--since` | — | only assets taken on/after this date |
84
+ | `--resume` | on | skip assets already exported with an unchanged checksum (from `manifest.jsonl`) |
85
+ | `--include-hidden` | off | also export hidden + locked-folder assets |
86
+
87
+ ## Guarantees
88
+
89
+ - **Read-only against Immich.** Never writes back.
90
+ - **Best-effort.** One bad asset logs an error and the run continues; the report lists every failure.
91
+ - **Idempotent / resumable.** The manifest records each asset's checksum; re-runs download only new or changed files, and refresh sidecars when only metadata (albums/tags/people) changed.
92
+ - **Verifiable.** Every download is checked against Immich's SHA-1; `manifest.jsonl` lets you diff export-vs-server (or vs. a restore) any time.
93
+ - **Streams.** Assets are paged and downloaded with bounded concurrency — a 100k-asset library never sits in memory.
94
+
95
+ ## Exit codes
96
+
97
+ | Code | Meaning |
98
+ |---|---|
99
+ | 0 | success (including an empty library) |
100
+ | 2 | bad configuration or authentication failure |
101
+ | 3 | server unreachable |
102
+ | 4 | output directory unwritable / out of space |
103
+ | 1 | unexpected error (re-run with `--verbose` for the traceback) |
104
+
105
+ ## Sidecar format
106
+
107
+ Standard XMP wherever a standard slot exists — `dc:subject` (tags),
108
+ `Iptc4xmpExt:PersonInImage` (people), `dc:description`, `photoshop:DateCreated`,
109
+ `exif:GPSLatitude/Longitude`, `xmp:Rating` (favorite → 5) — so digiKam,
110
+ Lightroom and exiftool can read them. Album membership and Immich ids live in a
111
+ custom `immich:` namespace in the same file.
112
+
113
+ ## Immich API compatibility
114
+
115
+ Built against the **Immich v3 API** (spec version 3.0.1). Instead of a
116
+ generated client, the exact API slice used is declared in
117
+ `src/immich_export/api_contract.py` and checked in CI against a vendored,
118
+ pruned copy of the official OpenAPI spec. To check a new Immich release:
119
+
120
+ ```sh
121
+ uv run python scripts/refresh_api_spec.py --ref v3.1.0
122
+ uv run pytest tests/test_contract.py
123
+ ```
124
+
125
+ A removed endpoint or field fails the tests *before* it breaks at runtime.
126
+
127
+ ## Development
128
+
129
+ ```sh
130
+ uv sync --all-extras --dev
131
+ uv run ruff check . && uv run ruff format --check . # lint
132
+ uv run mypy # strict types
133
+ uv run pytest # tests (mock Immich API)
134
+ uv build # sdist + wheel
135
+ ```
136
+
137
+ Conventional Commits drive releases (`python-semantic-release`): merge to
138
+ `main` → version bump + changelog + GitHub Release + PyPI publish (OIDC) +
139
+ Homebrew formula bump.
140
+
141
+ ## License
142
+
143
+ MIT
@@ -0,0 +1,120 @@
1
+ # immich-export
2
+
3
+ Pull **everything** out of [Immich](https://immich.app) — the files **and** the
4
+ metadata that only lives in its database (albums, people, tags, descriptions,
5
+ favorites, geo) — into a redundant, human-readable local folder tree.
6
+
7
+ This is the media escape hatch that makes "Immich = source of truth"
8
+ reversible: if Immich vanished tomorrow, the export is a plain tree you can
9
+ browse, grep, or import anywhere else.
10
+
11
+ ```
12
+ immich-export/
13
+ library/2024/03/IMG_1234.jpg # primary tree (self-contained mode)
14
+ library/2024/03/IMG_1234.jpg.xmp # sidecar: tags, people, albums, description, geo, favorite
15
+ albums/Japan-2019/IMG_1234.jpg # → symlink into library/
16
+ people/Anna/IMG_1234.jpg # → symlink into library/
17
+ manifest.jsonl # one line per asset: id, checksum, path, all metadata
18
+ manifest.csv # the same, human-readable
19
+ export-report.txt # counts, warnings, errors, timing
20
+ ```
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ pipx install immich-export
26
+ # or
27
+ brew install fileworks/tap/immich-export
28
+ ```
29
+
30
+ *(Not yet published — first release pending; until then: `uv run immich-export` from a checkout.)*
31
+
32
+ ## Usage
33
+
34
+ ```sh
35
+ export IMMICH_SERVER=https://immich.local:2283
36
+ export IMMICH_API_KEY=... # Immich → Account Settings → API Keys
37
+
38
+ # full portable export (copies originals)
39
+ immich-export --out ./immich-export
40
+
41
+ # incremental re-run: only new/changed assets are downloaded
42
+ immich-export --out ./immich-export
43
+
44
+ # sidecar mode: you already have the Storage-Template tree mounted —
45
+ # only write .xmp sidecars + album/people views next to it
46
+ immich-export --mode sidecar --library-root /volume1/photos --out /volume1/photos
47
+
48
+ # custom primary tree
49
+ immich-export --layout "{year}/{album}" --out ./export
50
+ ```
51
+
52
+ Key flags (see `immich-export --help` for all):
53
+
54
+ | Flag | Default | Meaning |
55
+ |---|---|---|
56
+ | `--mode` | `self-contained` | `self-contained` copies originals; `sidecar` only writes XMP + views next to an existing tree |
57
+ | `--layout` | `{year}/{month}` | primary tree; tokens `{year} {month} {day} {album} {type}`; `{album}` falls back to `Unsorted` |
58
+ | `--album-view` / `--people-view` | on | build `albums/` and `people/` symlink views |
59
+ | `--sidecars` | `xmp` | `xmp` or `none` |
60
+ | `--since` | — | only assets taken on/after this date |
61
+ | `--resume` | on | skip assets already exported with an unchanged checksum (from `manifest.jsonl`) |
62
+ | `--include-hidden` | off | also export hidden + locked-folder assets |
63
+
64
+ ## Guarantees
65
+
66
+ - **Read-only against Immich.** Never writes back.
67
+ - **Best-effort.** One bad asset logs an error and the run continues; the report lists every failure.
68
+ - **Idempotent / resumable.** The manifest records each asset's checksum; re-runs download only new or changed files, and refresh sidecars when only metadata (albums/tags/people) changed.
69
+ - **Verifiable.** Every download is checked against Immich's SHA-1; `manifest.jsonl` lets you diff export-vs-server (or vs. a restore) any time.
70
+ - **Streams.** Assets are paged and downloaded with bounded concurrency — a 100k-asset library never sits in memory.
71
+
72
+ ## Exit codes
73
+
74
+ | Code | Meaning |
75
+ |---|---|
76
+ | 0 | success (including an empty library) |
77
+ | 2 | bad configuration or authentication failure |
78
+ | 3 | server unreachable |
79
+ | 4 | output directory unwritable / out of space |
80
+ | 1 | unexpected error (re-run with `--verbose` for the traceback) |
81
+
82
+ ## Sidecar format
83
+
84
+ Standard XMP wherever a standard slot exists — `dc:subject` (tags),
85
+ `Iptc4xmpExt:PersonInImage` (people), `dc:description`, `photoshop:DateCreated`,
86
+ `exif:GPSLatitude/Longitude`, `xmp:Rating` (favorite → 5) — so digiKam,
87
+ Lightroom and exiftool can read them. Album membership and Immich ids live in a
88
+ custom `immich:` namespace in the same file.
89
+
90
+ ## Immich API compatibility
91
+
92
+ Built against the **Immich v3 API** (spec version 3.0.1). Instead of a
93
+ generated client, the exact API slice used is declared in
94
+ `src/immich_export/api_contract.py` and checked in CI against a vendored,
95
+ pruned copy of the official OpenAPI spec. To check a new Immich release:
96
+
97
+ ```sh
98
+ uv run python scripts/refresh_api_spec.py --ref v3.1.0
99
+ uv run pytest tests/test_contract.py
100
+ ```
101
+
102
+ A removed endpoint or field fails the tests *before* it breaks at runtime.
103
+
104
+ ## Development
105
+
106
+ ```sh
107
+ uv sync --all-extras --dev
108
+ uv run ruff check . && uv run ruff format --check . # lint
109
+ uv run mypy # strict types
110
+ uv run pytest # tests (mock Immich API)
111
+ uv build # sdist + wheel
112
+ ```
113
+
114
+ Conventional Commits drive releases (`python-semantic-release`): merge to
115
+ `main` → version bump + changelog + GitHub Release + PyPI publish (OIDC) +
116
+ Homebrew formula bump.
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,80 @@
1
+ [project]
2
+ name = "immich-export"
3
+ version = "0.0.2"
4
+ description = "Export everything out of Immich — files plus albums, people, tags, descriptions — into a redundant, human-readable local folder tree."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "gykonik" }]
9
+ requires-python = ">=3.12"
10
+ keywords = ["immich", "export", "backup", "photos", "xmp"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Intended Audience :: End Users/Desktop",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: System :: Archiving :: Backup",
19
+ ]
20
+ dependencies = [
21
+ "httpx>=0.28",
22
+ "pydantic>=2.9",
23
+ "typer>=0.15",
24
+ ]
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/fileworks/immich-export"
28
+ Issues = "https://github.com/fileworks/immich-export/issues"
29
+
30
+ [project.scripts]
31
+ immich-export = "immich_export.cli:app"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "mypy>=1.13",
36
+ "pytest>=8.3",
37
+ "pytest-asyncio>=0.24",
38
+ "respx>=0.22",
39
+ "ruff>=0.8",
40
+ ]
41
+
42
+ [build-system]
43
+ requires = ["hatchling"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/immich_export"]
48
+
49
+ [tool.ruff]
50
+ line-length = 100
51
+ target-version = "py312"
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "I", "N", "UP", "B", "A", "C4", "SIM", "RUF", "PTH", "RET", "ARG"]
55
+
56
+ [tool.ruff.lint.per-file-ignores]
57
+ # pytest fixtures are frequently used only for their side effect
58
+ "tests/**" = ["ARG001"]
59
+
60
+ [tool.mypy]
61
+ python_version = "3.12"
62
+ strict = true
63
+ files = ["src", "tests", "scripts"]
64
+
65
+ [tool.pytest.ini_options]
66
+ testpaths = ["tests"]
67
+ asyncio_mode = "auto"
68
+
69
+ [tool.semantic_release]
70
+ version_toml = ["pyproject.toml:project.version"]
71
+ version_variables = ["src/immich_export/__init__.py:__version__"]
72
+ commit_message = "chore(release): {version} [skip ci]"
73
+ build_command = "python -m pip install uv && uv build"
74
+ allow_zero_version = true
75
+
76
+ [tool.semantic_release.branches.main]
77
+ match = "main"
78
+
79
+ [tool.semantic_release.changelog.default_templates]
80
+ changelog_file = "CHANGELOG.md"
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "extends": ["config:recommended", ":semanticCommits", ":dependencyDashboard"],
4
+ "timezone": "Europe/Berlin",
5
+ "schedule": ["before 6am on monday"],
6
+ "prConcurrentLimit": 5,
7
+ "lockFileMaintenance": {
8
+ "enabled": true,
9
+ "schedule": ["before 6am on the first day of the month"]
10
+ },
11
+ "vulnerabilityAlerts": {
12
+ "labels": ["security"],
13
+ "schedule": ["at any time"],
14
+ "automerge": true
15
+ },
16
+ "packageRules": [
17
+ {
18
+ "description": "Auto-merge patch/minor once CI (lint/typecheck/test/build) is green — low risk, keeps the tree current without manual review.",
19
+ "matchUpdateTypes": ["patch", "minor", "digest"],
20
+ "automerge": true,
21
+ "automergeType": "pr",
22
+ "platformAutomerge": true
23
+ },
24
+ {
25
+ "description": "Major bumps always get a PR, never auto-merged — API/behaviour changes need a look.",
26
+ "matchUpdateTypes": ["major"],
27
+ "automerge": false,
28
+ "labels": ["major-update"]
29
+ },
30
+ {
31
+ "description": "Group all GitHub Actions bumps into one PR.",
32
+ "matchManagers": ["github-actions"],
33
+ "groupName": "GitHub Actions",
34
+ "automerge": true
35
+ }
36
+ ]
37
+ }
@@ -0,0 +1,66 @@
1
+ """Refresh the vendored, pruned Immich OpenAPI spec used by the contract tests.
2
+
3
+ Usage:
4
+ uv run python scripts/refresh_api_spec.py # fetch from GitHub main
5
+ uv run python scripts/refresh_api_spec.py --ref v3.0.1
6
+ uv run python scripts/refresh_api_spec.py --from-file /path/to/spec.json
7
+
8
+ The pruned file keeps only what the contract tests need: every path with its
9
+ methods, and every schema with its property names. Re-running this against a
10
+ new Immich release and then running `pytest tests/test_contract.py` tells you
11
+ whether this tool still matches the API.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import httpx
22
+
23
+ SPEC_URL = (
24
+ "https://raw.githubusercontent.com/immich-app/immich/{ref}/open-api/immich-openapi-specs.json"
25
+ )
26
+ OUTPUT = Path(__file__).resolve().parent.parent / "tests" / "data" / "immich-openapi.pruned.json"
27
+ HTTP_METHODS = {"get", "put", "post", "delete", "patch", "head", "options"}
28
+
29
+
30
+ def prune(spec: dict[str, Any]) -> dict[str, Any]:
31
+ paths = {
32
+ path: sorted(method for method in operations if method in HTTP_METHODS)
33
+ for path, operations in spec["paths"].items()
34
+ }
35
+ schemas = {
36
+ name: sorted(schema.get("properties", {}))
37
+ for name, schema in spec["components"]["schemas"].items()
38
+ }
39
+ return {
40
+ "immich_version": spec["info"]["version"],
41
+ "paths": paths,
42
+ "schemas": schemas,
43
+ }
44
+
45
+
46
+ def main() -> None:
47
+ parser = argparse.ArgumentParser(description=__doc__)
48
+ parser.add_argument("--ref", default="main", help="Immich git ref to fetch the spec from.")
49
+ parser.add_argument("--from-file", type=Path, help="Use a local spec file instead of fetching.")
50
+ args = parser.parse_args()
51
+
52
+ if args.from_file:
53
+ spec = json.loads(args.from_file.read_text(encoding="utf-8"))
54
+ else:
55
+ response = httpx.get(SPEC_URL.format(ref=args.ref), follow_redirects=True, timeout=60)
56
+ response.raise_for_status()
57
+ spec = response.json()
58
+
59
+ pruned = prune(spec)
60
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
61
+ OUTPUT.write_text(json.dumps(pruned, indent=1, sort_keys=True) + "\n", encoding="utf-8")
62
+ print(f"Wrote {OUTPUT} (Immich {pruned['immich_version']})")
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
@@ -0,0 +1,3 @@
1
+ """immich-export — pull files *and* metadata out of Immich into a plain local tree."""
2
+
3
+ __version__ = "0.0.2"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m immich_export`."""
2
+
3
+ from .cli import app
4
+
5
+ app()