argus-cortex 0.2.0__tar.gz → 0.3.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 (38) hide show
  1. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/.github/workflows/release.yml +9 -1
  2. argus_cortex-0.3.0/CHANGELOG.md +77 -0
  3. argus_cortex-0.3.0/CLAUDE.md +48 -0
  4. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/PKG-INFO +24 -2
  5. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/README.md +17 -0
  6. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/pyproject.toml +5 -3
  7. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/__init__.py +23 -0
  8. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/_version.py +2 -2
  9. argus_cortex-0.3.0/src/argus_cortex/xmp.py +409 -0
  10. argus_cortex-0.3.0/tests/test_xmp.py +157 -0
  11. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/.copier-answers.yml +0 -0
  12. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/.env.example +0 -0
  13. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/.github/workflows/ci.yml +0 -0
  14. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/.gitignore +0 -0
  15. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/LICENSE +0 -0
  16. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/Makefile +0 -0
  17. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/backends.py +0 -0
  18. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/py.typed +0 -0
  19. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/server.py +0 -0
  20. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/__init__.py +0 -0
  21. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/blob.py +0 -0
  22. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/config.py +0 -0
  23. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/errors.py +0 -0
  24. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/lineage.py +0 -0
  25. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/models.py +0 -0
  26. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/schema.py +0 -0
  27. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/vector.py +0 -0
  28. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/taxonomy.py +0 -0
  29. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/wire.py +0 -0
  30. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_backends.py +0 -0
  31. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_blob.py +0 -0
  32. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_server.py +0 -0
  33. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_smoke.py +0 -0
  34. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_store.py +0 -0
  35. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_store_pg_integration.py +0 -0
  36. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_taxonomy.py +0 -0
  37. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_vector.py +0 -0
  38. {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_wire.py +0 -0
@@ -62,7 +62,15 @@ jobs:
62
62
 
63
63
  publish-pypi:
64
64
  runs-on: ubuntu-latest
65
- needs: publish-testpypi
65
+ # Ordered *after* the TestPyPI rehearsal so its outcome is visible first, but
66
+ # deliberately not gated on it. A TestPyPI-only fault — a missing trusted
67
+ # publisher, an index outage — must not silently withhold the real release:
68
+ # that failure mode shipped argus-proof images for two tags with no wheel on
69
+ # PyPI, and nothing surfaced it because publish-image is independent.
70
+ # Only the build + install gate is required; a TestPyPI failure still marks
71
+ # the whole run failed, so the problem stays loud.
72
+ needs: [test-install, publish-testpypi]
73
+ if: "!cancelled() && needs.test-install.result == 'success'"
66
74
  environment: pypi
67
75
  steps:
68
76
  - uses: actions/download-artifact@v4
@@ -0,0 +1,77 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-10-25
11
+
12
+ ### Added
13
+ - **`argus_cortex.xmp`** — the suite's one XMP sidecar implementation, hoisted
14
+ from argus-lens so lens (keywords/caption) and argus-curator (face regions,
15
+ content rating) share a writer. `write_xmp()` / `read_xmp()` /
16
+ `merge_xmp()` / `parse_xmp()` / `render_xmp()` / `sidecar_path()` over an
17
+ `XmpData` model, all exported from the top-level package.
18
+ - Namespaces: `dc` (subject, description), `xmp` (Rating, Label), `lr`
19
+ (hierarchicalSubject), `Iptc4xmpExt` (PersonInImage), `mwg-rs` regions
20
+ (`XmpRegions` / `XmpRegion`, MWG centre-based normalised coordinates), and a
21
+ new `argus` namespace for `ContentRating`. The `argus` namespace URI
22
+ (`https://github.com/smk762/argus-cortex/ns/xmp/1.0/`) is now a wire
23
+ contract.
24
+ - **Writes merge**: each property set in `XmpData` replaces that property
25
+ (latest write wins, element or attribute form, across every
26
+ `rdf:Description`); everything else in the sidecar — Lightroom develop
27
+ settings, GPS, another stage's fields, foreign namespace prefixes — is
28
+ preserved. `None` leaves a property untouched, an empty value removes it;
29
+ `replace=True` discards the existing sidecar.
30
+ - Writes are atomic (temp file + rename) and keep the existing file mode
31
+ (`0644` for a new sidecar). An unparseable existing sidecar raises
32
+ `XmpError` and is never clobbered; a DTD / entity declaration is refused.
33
+ - Stdlib `xml.etree` + pydantic only, so the top-level import stays
34
+ pydantic-only.
35
+ - `CLAUDE.md` agent guide.
36
+
37
+ ### Security
38
+ - **eMudhra/emSign CA incident.** The extras that make outbound TLS
39
+ connections (`[remote]` httpx, `[qdrant]`, `[s3]`) now require
40
+ `certifi>=2026.10.19`, which no longer trusts the compromised emSign
41
+ intermediates. See https://discuss.python.org/t/109576.
42
+
43
+ ## [0.2.0] - 2026-07-21
44
+
45
+ ### Added
46
+ - **Optional stateful-services layer** (`argus_cortex.store`), each store opt-in
47
+ via `CORTEX_*` env vars and a `Null*` no-op when unset:
48
+ - Phase 1 — Postgres lineage store (`source_asset → caption → human_edit →
49
+ dataset_membership → training_run`), backed by a psycopg connection pool
50
+ with an atomic `transaction()` boundary (`[postgres]` extra).
51
+ - Phase 2 — Qdrant vector store for image / tag-set embeddings
52
+ (`[qdrant]` extra).
53
+ - Phase 3 — MinIO/S3 content-addressed blob store, keyed on sha256
54
+ (`content_key()` == `source_asset.sha256`) (`[s3]` extra).
55
+ - **`argus_cortex.server`** — shared pure-ASGI `WriteGuard`, the
56
+ `constant_refuse()` / `cross_site_refuse()` predicates, `env_flag()` and the
57
+ `UNSAFE_METHODS` / `TRUTHY` / `FALSY` constants (`[server]` extra).
58
+
59
+ ### Fixed
60
+ - Release: a TestPyPI failure no longer gates the real PyPI release.
61
+
62
+ ## [0.1.0] - 2026-07-08
63
+
64
+ ### Added
65
+ - Initial release (renamed from `argus-core`; that PyPI name was taken):
66
+ - `argus_cortex.taxonomy` — `TargetProfile`, `TargetStyle`, `TargetCategory`.
67
+ - `argus_cortex.wire` — versioned wire-schema toolkit (`make_versioned_base`,
68
+ `check_version` with an injectable error type, `render_schema` /
69
+ `wire_schema`, `VersionError`).
70
+ - `argus_cortex.backends` — the local/remote AI-backend contract
71
+ (`LocalBackend`, `RemoteBackend`, `RemoteProvider`, `with_retries`
72
+ retrying only transient errors, `resolve_device`).
73
+
74
+ [Unreleased]: https://github.com/smk762/argus-cortex/compare/v0.3.0...HEAD
75
+ [0.3.0]: https://github.com/smk762/argus-cortex/compare/v0.2.0...v0.3.0
76
+ [0.2.0]: https://github.com/smk762/argus-cortex/compare/v0.1.0...v0.2.0
77
+ [0.1.0]: https://github.com/smk762/argus-cortex/releases/tag/v0.1.0
@@ -0,0 +1,48 @@
1
+ # CLAUDE.md — argus-cortex
2
+
3
+ Guidance for AI agents working in this repo. Human-facing usage lives in [README.md](README.md); this file is the orientation an agent needs to change code safely.
4
+
5
+ ## What this is
6
+
7
+ The **shared foundation library** of the Argus suite (quarry → curator → lens → forge → proof). It holds the code the stages *share* so it lives in one place instead of copy-pasted per repo: the target taxonomy, the wire-schema versioning discipline, the local/remote AI-backend contract, and the optional stateful-services layer.
8
+
9
+ Naming: the working directory is `argus-core`, but the GitHub repo, the PyPI package, and the import name are all **`argus-cortex`** (`src/argus_cortex/`). Don't "fix" the mismatch.
10
+
11
+ Because everything here is imported by the downstream stages, **a change ripples across the whole suite** — treat this repo as an API, not a leaf. Read [README.md](README.md) for the *why* and worked examples; it is the source of truth for behaviour.
12
+
13
+ ## Layout
14
+
15
+ `src/argus_cortex/` (top level is pydantic-only; everything heavier is behind an extra and lazily imported):
16
+
17
+ - `taxonomy.py` — the "moat" types every stage inherits verbatim: `TargetProfile`, `TargetStyle`/`TargetCategory` literals. Keep it dependency-light; a change here retrains everyone's expectations.
18
+ - `wire.py` — the versioned wire-schema toolkit consumers build their contracts on: `make_versioned_base(field_name, current, majors)` (a Pydantic base that stamps + version-checks a field like `proof_version`/`manifest_version`), `check_version()` (the major-compatibility gate), `render_schema()`/`wire_schema()` (the canonical committed-schema string behind each consumer's `schema --check` CLI), and `VersionError`.
19
+ - `backends.py` — the AI-backend contract: `Backend`/`LocalBackend`/`RemoteBackend` (point at a hosted service via `from_host(host, port)` or `from_provider(RemoteProvider.…)`), plus `with_retries()` and `resolve_device()`. httpx is lazy, behind `[remote]`.
20
+ - `server.py` — the shared FastAPI/ASGI scaffolding other repos' micro-servers mount: `WriteGuard` (pure-ASGI method gate), the `constant_refuse()` (read-only/replay) and `cross_site_refuse()` (cross-origin write) predicates, `env_flag()`, and the `UNSAFE_METHODS`/`TRUTHY`/`FALSY` constants. Starlette is behind `[server]`.
21
+ - `xmp.py` — the suite's XMP sidecar read/merge/write (`write_xmp`/`read_xmp`/`merge_xmp`/`parse_xmp`, `XmpData`/`XmpRegions`, `XmpError`), used by lens (keywords/caption) and curator (faces, content rating). Stdlib `xml.etree` only. **Writes merge — latest wins per property, foreign properties preserved; keep that.** `None` = leave untouched, empty = remove. A DTD is refused and an unparseable sidecar raises rather than being overwritten. The `argus` namespace URI is a wire contract — don't change it.
22
+ - `store/` — the optional stateful-services layer, opt-in per `CORTEX_*` env var: `lineage.py` (Phase 1 Postgres DAG, `open_lineage_store()`), `vector.py` (Phase 2 Qdrant, `open_vector_store()`), `blob.py` (Phase 3 MinIO/S3, `open_blob_store()`), over `config.py`/`models.py`/`errors.py`. Each `open_*()` returns a `Null*` no-op when its URL is unset. Drivers are behind `[postgres]`/`[qdrant]`/`[s3]`.
23
+
24
+ There is **no CLI and no committed schema in this repo** — the `schema`/`schema --check` pattern is something `wire.py` *enables* for downstream repos, not something cortex runs on itself.
25
+
26
+ ## Commands
27
+
28
+ ```bash
29
+ make install # uv venv + editable install with [dev]
30
+ make test # uv run --no-sync pytest --tb=short -q
31
+ make lint # ruff check + format --check (pinned ruff 0.15.16)
32
+ make format # ruff format + check --fix
33
+ ```
34
+
35
+ Run one test: `uv run --no-sync pytest tests/test_wire.py::test_name -q`.
36
+
37
+ ## Conventions & gotchas
38
+
39
+ - **This is shared code — changes ripple.** A `taxonomy.py` edit or a `wire.py`/`backends.py` semantics change lands in every stage on their next bump. Prefer additive, backward-compatible changes; if you break the wire contract, that is a **major** version (`check_version` is what refuses the old major downstream).
40
+ - **Keep the top-level import pydantic-only.** `import argus_cortex` must not pull in starlette/httpx/psycopg/qdrant/minio — those are optional extras, imported lazily inside the module that needs them (see the `try/except ImportError` → "install `argus-cortex[extra]`" pattern). Don't hoist an optional import to module top level.
41
+ - **Stores fail *open* to a no-op, not an error.** `open_*()` returns a `Null*` store when the `CORTEX_*` URL is unset, so a stage with no DB configured still runs. Preserve that; blob identity is content-addressed on sha256 (`content_key()` == `source_asset.sha256`) — storage, dedup, and lineage join share one identity.
42
+ - **`WriteGuard` is pure ASGI on purpose** (not `BaseHTTPMiddleware`), so it never buffers the SSE/NDJSON streams downstream servers return. Register it *before* `CORSMiddleware`. `env_flag()` warns on a mistyped protection flag rather than silently disabling it — keep that fail-safe behaviour.
43
+ - **Versioning is git-tag-derived** (`hatch-vcs`). Never hand-edit a version; `src/argus_cortex/_version.py` is generated (gitignored). Tag `vX.Y.Z` to release.
44
+ - Pydantic v2 everywhere; async tests via `asyncio_mode = auto`. Ruff pinned **0.15.16**, line-length 120, target py311.
45
+
46
+ ## CI / release
47
+
48
+ CI runs via the shared [`argus-ci`](https://github.com/smk762/argus-ci) reusable workflow (ruff + pytest with the `dev` extra — which installs the real psycopg/qdrant/minio/starlette clients so each store's live driver path is exercised, not just fakes). Release publishes to PyPI (OIDC trusted publishing) on `v*` tags — **no container image, this is a library**. Scaffolded from [`argus-pkg-template`](https://github.com/smk762/argus-pkg-template); run `copier update` to pull tooling changes.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: argus-cortex
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Shared foundation for the Argus suite: taxonomy, versioned wire-schema tooling, and the local/remote AI backend contract
5
5
  Project-URL: Repository, https://github.com/smk762/argus-cortex
6
6
  Author: smk762
@@ -16,6 +16,8 @@ Classifier: Typing :: Typed
16
16
  Requires-Python: >=3.11
17
17
  Requires-Dist: pydantic>=2.7
18
18
  Provides-Extra: dev
19
+ Requires-Dist: certifi>=2026.10.19; extra == 'dev'
20
+ Requires-Dist: httpx2>=2.7; extra == 'dev'
19
21
  Requires-Dist: httpx>=0.27; extra == 'dev'
20
22
  Requires-Dist: minio>=7.2; extra == 'dev'
21
23
  Requires-Dist: psycopg[binary,pool]>=3.1; extra == 'dev'
@@ -27,10 +29,13 @@ Requires-Dist: starlette>=0.37; extra == 'dev'
27
29
  Provides-Extra: postgres
28
30
  Requires-Dist: psycopg[binary,pool]>=3.1; extra == 'postgres'
29
31
  Provides-Extra: qdrant
32
+ Requires-Dist: certifi>=2026.10.19; extra == 'qdrant'
30
33
  Requires-Dist: qdrant-client>=1.10; extra == 'qdrant'
31
34
  Provides-Extra: remote
35
+ Requires-Dist: certifi>=2026.10.19; extra == 'remote'
32
36
  Requires-Dist: httpx>=0.27; extra == 'remote'
33
37
  Provides-Extra: s3
38
+ Requires-Dist: certifi>=2026.10.19; extra == 's3'
34
39
  Requires-Dist: minio>=7.2; extra == 's3'
35
40
  Provides-Extra: server
36
41
  Requires-Dist: starlette>=0.37; extra == 'server'
@@ -48,6 +53,7 @@ Part of the [Argus suite](https://github.com/smk762?tab=repositories&q=argus) (q
48
53
  - **`argus_cortex.wire`** — the versioned wire-schema toolkit: `check_version()` (major-compatibility gate), `make_versioned_base()` (a Pydantic base that stamps + checks a version field like `proof_version` / `manifest_version`), and `wire_schema()` / `render_schema()` for the committed-schema `schema --check` CLI pattern.
49
54
  - **`argus_cortex.backends`** — the AI-backend contract generalised from argus-lens: `Backend` / `LocalBackend` / `RemoteBackend` (point at a hosted service by `base_url`, `host`/`port`, or a known `RemoteProvider`), plus `with_retries()` and `resolve_device()`. httpx lives behind the `[remote]` extra.
50
55
  - **`argus_cortex.server`** — shared write-guard + env-flag scaffolding for the suite's FastAPI micro-servers: `WriteGuard` (a pure-ASGI method-gate that never buffers streaming responses), the `constant_refuse()` / `cross_site_refuse()` predicates (read-only/replay mode and cross-site-write protection are two configs of the one guard), the `UNSAFE_METHODS` / `TRUTHY` / `FALSY` constants, and `env_flag()` (warns on a mistyped protection flag rather than silently disabling it). Starlette lives behind the `[server]` extra.
56
+ - **`argus_cortex.xmp`** — the suite's one XMP sidecar implementation (hoisted from argus-lens): `write_xmp()` / `read_xmp()` / `merge_xmp()` / `parse_xmp()` over an `XmpData` model covering `dc` (keywords, description), `xmp` (Rating, Label), `lr` (hierarchical keywords), `Iptc4xmpExt` (PersonInImage), `mwg-rs` face/pet regions, and `argus:ContentRating`. Writes **merge**: each property you set replaces that property (latest write wins), everything else in the sidecar — Lightroom develop settings, GPS, another stage's fields — is preserved. `None` leaves a property alone, an empty value removes it. Writes are atomic; an unparseable existing sidecar raises `XmpError` instead of being clobbered. Stdlib only.
51
57
  - **`argus_cortex.store`** — the optional **stateful services layer** (the suite's persistent "memory"). External and opt-in — every store is configured by `CORTEX_*` env vars and no-ops when its URL is unset.
52
58
  - **Phase 1 — Postgres lineage store.** Persists the `source_asset → caption → human_edit → dataset_membership → training_run` DAG for LoRA reproducibility, edit capture, and the feedback loop. Backed by a psycopg connection **pool** (concurrent `asyncio.to_thread` calls are safe; a dropped connection is replaced), with a `transaction()` boundary for atomic multi-step writes. `open_lineage_store()` returns a no-op store when `CORTEX_PG_URL` is unset; psycopg lives behind the `[postgres]` extra.
53
59
  - **Phase 2 — Qdrant vector store.** Stores image + tag-set embeddings for retrieval-augmented few-shot and near-duplicate curation; put a `caption_id`/`asset_id` in a point's payload and a search hit joins straight back to the lineage. `open_vector_store()` no-ops when `CORTEX_QDRANT_URL` is unset; qdrant-client lives behind the `[qdrant]` extra. cortex stores vectors it's *handed* — computing embeddings is the caller's job.
@@ -65,6 +71,22 @@ scorer = RemoteBackend.from_host("192.168.1.20", 9000, api_key="…")
65
71
  nim = RemoteBackend.from_provider(RemoteProvider.NVIDIA_NIM, api_key="…")
66
72
  ```
67
73
 
74
+ ```python
75
+ from argus_cortex.xmp import XmpData, XmpRegion, XmpRegions, write_xmp
76
+
77
+ # lens writes captions/keywords …
78
+ write_xmp("pics/beach.jpg", XmpData(keywords=["beach", "sunset"], description="a beach at sunset"))
79
+ # … curator later adds faces + a content rating to the same sidecar; lens's fields survive
80
+ write_xmp(
81
+ "pics/beach.jpg",
82
+ XmpData(
83
+ persons=["Ada"],
84
+ regions=XmpRegions(width=4000, height=3000, regions=[XmpRegion(x=0.52, y=0.4, w=0.1, h=0.18, name="Ada")]),
85
+ content_rating="safe",
86
+ ),
87
+ )
88
+ ```
89
+
68
90
  ```python
69
91
  from argus_cortex.store import open_lineage_store, SourceAsset, Caption, HumanEdit
70
92
 
@@ -10,6 +10,7 @@ Part of the [Argus suite](https://github.com/smk762?tab=repositories&q=argus) (q
10
10
  - **`argus_cortex.wire`** — the versioned wire-schema toolkit: `check_version()` (major-compatibility gate), `make_versioned_base()` (a Pydantic base that stamps + checks a version field like `proof_version` / `manifest_version`), and `wire_schema()` / `render_schema()` for the committed-schema `schema --check` CLI pattern.
11
11
  - **`argus_cortex.backends`** — the AI-backend contract generalised from argus-lens: `Backend` / `LocalBackend` / `RemoteBackend` (point at a hosted service by `base_url`, `host`/`port`, or a known `RemoteProvider`), plus `with_retries()` and `resolve_device()`. httpx lives behind the `[remote]` extra.
12
12
  - **`argus_cortex.server`** — shared write-guard + env-flag scaffolding for the suite's FastAPI micro-servers: `WriteGuard` (a pure-ASGI method-gate that never buffers streaming responses), the `constant_refuse()` / `cross_site_refuse()` predicates (read-only/replay mode and cross-site-write protection are two configs of the one guard), the `UNSAFE_METHODS` / `TRUTHY` / `FALSY` constants, and `env_flag()` (warns on a mistyped protection flag rather than silently disabling it). Starlette lives behind the `[server]` extra.
13
+ - **`argus_cortex.xmp`** — the suite's one XMP sidecar implementation (hoisted from argus-lens): `write_xmp()` / `read_xmp()` / `merge_xmp()` / `parse_xmp()` over an `XmpData` model covering `dc` (keywords, description), `xmp` (Rating, Label), `lr` (hierarchical keywords), `Iptc4xmpExt` (PersonInImage), `mwg-rs` face/pet regions, and `argus:ContentRating`. Writes **merge**: each property you set replaces that property (latest write wins), everything else in the sidecar — Lightroom develop settings, GPS, another stage's fields — is preserved. `None` leaves a property alone, an empty value removes it. Writes are atomic; an unparseable existing sidecar raises `XmpError` instead of being clobbered. Stdlib only.
13
14
  - **`argus_cortex.store`** — the optional **stateful services layer** (the suite's persistent "memory"). External and opt-in — every store is configured by `CORTEX_*` env vars and no-ops when its URL is unset.
14
15
  - **Phase 1 — Postgres lineage store.** Persists the `source_asset → caption → human_edit → dataset_membership → training_run` DAG for LoRA reproducibility, edit capture, and the feedback loop. Backed by a psycopg connection **pool** (concurrent `asyncio.to_thread` calls are safe; a dropped connection is replaced), with a `transaction()` boundary for atomic multi-step writes. `open_lineage_store()` returns a no-op store when `CORTEX_PG_URL` is unset; psycopg lives behind the `[postgres]` extra.
15
16
  - **Phase 2 — Qdrant vector store.** Stores image + tag-set embeddings for retrieval-augmented few-shot and near-duplicate curation; put a `caption_id`/`asset_id` in a point's payload and a search hit joins straight back to the lineage. `open_vector_store()` no-ops when `CORTEX_QDRANT_URL` is unset; qdrant-client lives behind the `[qdrant]` extra. cortex stores vectors it's *handed* — computing embeddings is the caller's job.
@@ -27,6 +28,22 @@ scorer = RemoteBackend.from_host("192.168.1.20", 9000, api_key="…")
27
28
  nim = RemoteBackend.from_provider(RemoteProvider.NVIDIA_NIM, api_key="…")
28
29
  ```
29
30
 
31
+ ```python
32
+ from argus_cortex.xmp import XmpData, XmpRegion, XmpRegions, write_xmp
33
+
34
+ # lens writes captions/keywords …
35
+ write_xmp("pics/beach.jpg", XmpData(keywords=["beach", "sunset"], description="a beach at sunset"))
36
+ # … curator later adds faces + a content rating to the same sidecar; lens's fields survive
37
+ write_xmp(
38
+ "pics/beach.jpg",
39
+ XmpData(
40
+ persons=["Ada"],
41
+ regions=XmpRegions(width=4000, height=3000, regions=[XmpRegion(x=0.52, y=0.4, w=0.1, h=0.18, name="Ada")]),
42
+ content_rating="safe",
43
+ ),
44
+ )
45
+ ```
46
+
30
47
  ```python
31
48
  from argus_cortex.store import open_lineage_store, SourceAsset, Caption, HumanEdit
32
49
 
@@ -24,10 +24,10 @@ dependencies = [
24
24
  ]
25
25
 
26
26
  [project.optional-dependencies]
27
- remote = ["httpx>=0.27"]
27
+ remote = ["httpx>=0.27", "certifi>=2026.10.19"]
28
28
  postgres = ["psycopg[binary,pool]>=3.1"]
29
- qdrant = ["qdrant-client>=1.10"]
30
- s3 = ["minio>=7.2"]
29
+ qdrant = ["qdrant-client>=1.10", "certifi>=2026.10.19"]
30
+ s3 = ["minio>=7.2", "certifi>=2026.10.19"]
31
31
  # The shared ASGI write-guard + env-flag scaffolding for the suite's servers
32
32
  # (argus_cortex.server). Starlette ships with FastAPI, so consumers already have
33
33
  # it via their own [server] extra.
@@ -44,6 +44,8 @@ dev = [
44
44
  "minio>=7.2",
45
45
  "starlette>=0.37",
46
46
  "httpx>=0.27",
47
+ "httpx2>=2.7",
48
+ "certifi>=2026.10.19",
47
49
  ]
48
50
 
49
51
  [project.urls]
@@ -56,6 +56,18 @@ from argus_cortex.wire import (
56
56
  schema_major,
57
57
  wire_schema,
58
58
  )
59
+ from argus_cortex.xmp import (
60
+ XmpData,
61
+ XmpError,
62
+ XmpRegion,
63
+ XmpRegions,
64
+ merge_xmp,
65
+ parse_xmp,
66
+ read_xmp,
67
+ render_xmp,
68
+ sidecar_path,
69
+ write_xmp,
70
+ )
59
71
 
60
72
  __all__ = [
61
73
  "__version__",
@@ -70,6 +82,17 @@ __all__ = [
70
82
  "render_schema",
71
83
  "schema_major",
72
84
  "wire_schema",
85
+ # xmp sidecars
86
+ "XmpData",
87
+ "XmpError",
88
+ "XmpRegion",
89
+ "XmpRegions",
90
+ "merge_xmp",
91
+ "parse_xmp",
92
+ "read_xmp",
93
+ "render_xmp",
94
+ "sidecar_path",
95
+ "write_xmp",
73
96
  # backends
74
97
  "Backend",
75
98
  "BackendError",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
21
+ __version__ = version = '0.3.0'
22
+ __version_tuple__ = version_tuple = (0, 3, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,409 @@
1
+ """XMP sidecar read / merge / write — the suite's one XMP implementation.
2
+
3
+ XMP sidecars (``<image>.<ext>.xmp``) are the zero-coupling interop surface:
4
+ Lightroom, digiKam and Immich all ingest them, so metadata any stage writes
5
+ here shows up in the user's own photo tools. Hoisted from
6
+ ``argus_lens.connectors.xmp`` so lens (captions/keywords) and curator (face
7
+ regions, content rating) share one writer instead of each growing their own.
8
+
9
+ Writes **merge**: the existing sidecar is parsed, each property being written
10
+ replaces that property wholesale (the latest write wins on a conflicting key),
11
+ and every other property — ratings, GPS, develop settings, another stage's
12
+ fields — is preserved untouched. A field left as ``None`` in :class:`XmpData`
13
+ is not touched; an empty list/string removes the property.
14
+
15
+ Stdlib + pydantic only (``xml.etree``), so it is safe for the top-level import.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import io
22
+ import os
23
+ import re
24
+ import tempfile
25
+ import xml.etree.ElementTree as ET
26
+ from pathlib import Path
27
+ from typing import Literal
28
+
29
+ from pydantic import BaseModel, Field
30
+
31
+ NS: dict[str, str] = {
32
+ "x": "adobe:ns:meta/",
33
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
34
+ "dc": "http://purl.org/dc/elements/1.1/",
35
+ "xmp": "http://ns.adobe.com/xap/1.0/",
36
+ "lr": "http://ns.adobe.com/lightroom/1.0/",
37
+ "Iptc4xmpExt": "http://iptc.org/std/Iptc4xmpExt/2008-02-29/",
38
+ "mwg-rs": "http://www.metadataworkinggroup.com/schemas/regions/",
39
+ "stArea": "http://ns.adobe.com/xmp/sType/Area#",
40
+ "stDim": "http://ns.adobe.com/xap/1.0/sType/Dimensions#",
41
+ # Suite-specific properties with no standard home (e.g. a content rating —
42
+ # deliberately not xmp:Rating, which is the user's star rating).
43
+ "argus": "https://github.com/smk762/argus-cortex/ns/xmp/1.0/",
44
+ }
45
+
46
+ for _prefix, _uri in NS.items():
47
+ ET.register_namespace(_prefix, _uri)
48
+
49
+ _XPACKET_BEGIN = '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>\n'
50
+ _XPACKET_END = '\n<?xpacket end="w"?>\n'
51
+
52
+ # Characters that are illegal in XML 1.0 even when escaped. Stripped so a value
53
+ # can never produce a sidecar Lightroom/digiKam/Immich would refuse to parse.
54
+ _ILLEGAL_XML_CHARS = re.compile("[^\u0009\u000a\u000d -퟿-�\U00010000-\U0010ffff]")
55
+ _XML_LANG = "{http://www.w3.org/XML/1998/namespace}lang"
56
+
57
+
58
+ class XmpError(ValueError):
59
+ """An existing sidecar could not be parsed; it is left untouched rather than clobbered."""
60
+
61
+
62
+ class XmpRegion(BaseModel):
63
+ """One MWG region (``mwg-rs:RegionList`` item), e.g. a detected face.
64
+
65
+ Coordinates are normalised to ``[0, 1]`` and, per the MWG spec, ``x``/``y``
66
+ are the region's **centre**, not its top-left corner.
67
+ """
68
+
69
+ x: float = Field(ge=0, le=1)
70
+ y: float = Field(ge=0, le=1)
71
+ w: float = Field(ge=0, le=1)
72
+ h: float = Field(ge=0, le=1)
73
+ name: str | None = None
74
+ type: Literal["Face", "Pet", "Focus", "BarCode"] = "Face"
75
+
76
+
77
+ class XmpRegions(BaseModel):
78
+ """The ``mwg-rs:Regions`` struct: regions plus the image dimensions they apply to."""
79
+
80
+ width: int = Field(gt=0)
81
+ height: int = Field(gt=0)
82
+ regions: list[XmpRegion] = Field(default_factory=list)
83
+
84
+
85
+ class XmpData(BaseModel):
86
+ """The XMP properties the suite reads and writes.
87
+
88
+ ``None`` means "leave this property alone" on a merge; an empty list or
89
+ string removes it.
90
+ """
91
+
92
+ keywords: list[str] | None = None # dc:subject
93
+ description: str | None = None # dc:description (x-default)
94
+ hierarchical_keywords: list[str] | None = None # lr:hierarchicalSubject, "a|b|c"
95
+ rating: int | None = Field(default=None, ge=-1, le=5) # xmp:Rating (user stars; -1 = rejected)
96
+ label: str | None = None # xmp:Label (colour label)
97
+ persons: list[str] | None = None # Iptc4xmpExt:PersonInImage
98
+ regions: XmpRegions | None = None # mwg-rs:Regions
99
+ content_rating: str | None = None # argus:ContentRating (e.g. safe / suggestive / explicit)
100
+
101
+
102
+ def _q(qname: str) -> str:
103
+ """``"dc:subject"`` → ``"{http://purl.org/dc/elements/1.1/}subject"``."""
104
+ prefix, local = qname.split(":", 1)
105
+ return f"{{{NS[prefix]}}}{local}"
106
+
107
+
108
+ def _text(value: str) -> str:
109
+ return _ILLEGAL_XML_CHARS.sub("", value)
110
+
111
+
112
+ def _num(value: float) -> str:
113
+ return f"{value:.6g}"
114
+
115
+
116
+ def _container(tag: str, kind: str, items: list[str]) -> ET.Element:
117
+ prop = ET.Element(_q(tag))
118
+ container = ET.SubElement(prop, _q(f"rdf:{kind}"))
119
+ for item in items:
120
+ ET.SubElement(container, _q("rdf:li")).text = _text(item)
121
+ return prop
122
+
123
+
124
+ def _lang_alt(tag: str, value: str) -> ET.Element:
125
+ prop = ET.Element(_q(tag))
126
+ li = ET.SubElement(ET.SubElement(prop, _q("rdf:Alt")), _q("rdf:li"), {_XML_LANG: "x-default"})
127
+ li.text = _text(value)
128
+ return prop
129
+
130
+
131
+ def _simple(tag: str, value: str) -> ET.Element:
132
+ prop = ET.Element(_q(tag))
133
+ prop.text = _text(value)
134
+ return prop
135
+
136
+
137
+ def _regions(value: XmpRegions) -> ET.Element:
138
+ prop = ET.Element(_q("mwg-rs:Regions"), {_q("rdf:parseType"): "Resource"})
139
+ ET.SubElement(
140
+ prop,
141
+ _q("mwg-rs:AppliedToDimensions"),
142
+ {
143
+ _q("rdf:parseType"): "Resource",
144
+ _q("stDim:w"): str(value.width),
145
+ _q("stDim:h"): str(value.height),
146
+ _q("stDim:unit"): "pixel",
147
+ },
148
+ )
149
+ bag = ET.SubElement(ET.SubElement(prop, _q("mwg-rs:RegionList")), _q("rdf:Bag"))
150
+ for region in value.regions:
151
+ li = ET.SubElement(bag, _q("rdf:li"), {_q("rdf:parseType"): "Resource"})
152
+ if region.name:
153
+ ET.SubElement(li, _q("mwg-rs:Name")).text = _text(region.name)
154
+ ET.SubElement(li, _q("mwg-rs:Type")).text = region.type
155
+ ET.SubElement(
156
+ li,
157
+ _q("mwg-rs:Area"),
158
+ {
159
+ _q("rdf:parseType"): "Resource",
160
+ _q("stArea:x"): _num(region.x),
161
+ _q("stArea:y"): _num(region.y),
162
+ _q("stArea:w"): _num(region.w),
163
+ _q("stArea:h"): _num(region.h),
164
+ _q("stArea:unit"): "normalized",
165
+ },
166
+ )
167
+ return prop
168
+
169
+
170
+ def _properties(data: XmpData) -> list[tuple[str, ET.Element | None]]:
171
+ """``(qname, element)`` for every field that is set; ``element`` is None to remove it."""
172
+ out: list[tuple[str, ET.Element | None]] = []
173
+
174
+ def add(qname: str, value: object, build) -> None:
175
+ if value is None:
176
+ return
177
+ out.append((qname, build(value) if value != [] and value != "" else None))
178
+
179
+ add("dc:subject", data.keywords, lambda v: _container("dc:subject", "Bag", v))
180
+ add("dc:description", data.description, lambda v: _lang_alt("dc:description", v))
181
+ add("lr:hierarchicalSubject", data.hierarchical_keywords, lambda v: _container("lr:hierarchicalSubject", "Bag", v))
182
+ add("xmp:Rating", data.rating, lambda v: _simple("xmp:Rating", str(v)))
183
+ add("xmp:Label", data.label, lambda v: _simple("xmp:Label", v))
184
+ add("Iptc4xmpExt:PersonInImage", data.persons, lambda v: _container("Iptc4xmpExt:PersonInImage", "Bag", v))
185
+ add("mwg-rs:Regions", data.regions, _regions)
186
+ add("argus:ContentRating", data.content_rating, lambda v: _simple("argus:ContentRating", v))
187
+ return out
188
+
189
+
190
+ def _parse(raw: bytes) -> ET.Element:
191
+ """Parse a sidecar into its ``x:xmpmeta`` root (wrapping a bare ``rdf:RDF``)."""
192
+ # XMP never carries a DTD; refusing one outright rules out entity-expansion tricks.
193
+ if b"<!DOCTYPE" in raw or b"<!ENTITY" in raw:
194
+ raise XmpError("XMP sidecar contains a DTD/entity declaration; refusing to parse it")
195
+ for prefix, uri in _declared_namespaces(raw):
196
+ # Keep a foreign schema's own prefix on re-serialisation (cosmetic, but
197
+ # Lightroom-written sidecars stay recognisable). Ours always win.
198
+ if prefix and prefix not in NS:
199
+ with contextlib.suppress(ValueError):
200
+ ET.register_namespace(prefix, uri)
201
+ try:
202
+ root = ET.fromstring(raw)
203
+ except ET.ParseError as exc:
204
+ raise XmpError(f"malformed XMP sidecar: {exc}") from exc
205
+ if root.tag == _q("x:xmpmeta"):
206
+ return root
207
+ if root.tag == _q("rdf:RDF"):
208
+ meta = ET.Element(_q("x:xmpmeta"))
209
+ meta.append(root)
210
+ return meta
211
+ raise XmpError(f"not an XMP document (root element {root.tag!r})")
212
+
213
+
214
+ def _declared_namespaces(raw: bytes) -> list[tuple[str, str]]:
215
+ try:
216
+ return [ns for _, ns in ET.iterparse(io.BytesIO(raw), events=("start-ns",))]
217
+ except ET.ParseError:
218
+ return [] # surfaced properly by ET.fromstring in _parse
219
+
220
+
221
+ def _fresh() -> ET.Element:
222
+ meta = ET.Element(_q("x:xmpmeta"))
223
+ rdf = ET.SubElement(meta, _q("rdf:RDF"))
224
+ ET.SubElement(rdf, _q("rdf:Description"), {_q("rdf:about"): ""})
225
+ return meta
226
+
227
+
228
+ def _descriptions(meta: ET.Element) -> list[ET.Element]:
229
+ rdf = meta.find(_q("rdf:RDF"))
230
+ if rdf is None:
231
+ rdf = ET.SubElement(meta, _q("rdf:RDF"))
232
+ descs = rdf.findall(_q("rdf:Description"))
233
+ if not descs:
234
+ descs = [ET.SubElement(rdf, _q("rdf:Description"), {_q("rdf:about"): ""})]
235
+ return descs
236
+
237
+
238
+ def merge_xmp(existing: bytes | str | None, data: XmpData) -> str:
239
+ """Merge *data* into an existing XMP document and return the new document.
240
+
241
+ Each property set in *data* replaces that property wherever it appears
242
+ (element or attribute form, in any ``rdf:Description``); everything else is
243
+ kept. ``existing=None`` renders a fresh document.
244
+
245
+ Raises:
246
+ XmpError: if *existing* is not a parseable XMP document.
247
+ """
248
+ if isinstance(existing, str):
249
+ existing = existing.encode("utf-8")
250
+ meta = _parse(existing) if existing else _fresh()
251
+ descs = _descriptions(meta)
252
+ for qname, element in _properties(data):
253
+ tag = _q(qname)
254
+ for desc in descs:
255
+ desc.attrib.pop(tag, None)
256
+ for child in desc.findall(tag):
257
+ desc.remove(child)
258
+ if element is not None:
259
+ descs[0].append(element)
260
+ ET.indent(meta, space=" ")
261
+ return _XPACKET_BEGIN + ET.tostring(meta, encoding="unicode") + _XPACKET_END
262
+
263
+
264
+ def render_xmp(data: XmpData) -> str:
265
+ """Render a fresh XMP document holding only *data* (pure; useful for tests)."""
266
+ return merge_xmp(None, data)
267
+
268
+
269
+ def _prop_values(desc: ET.Element, qname: str) -> list[str] | None:
270
+ tag = _q(qname)
271
+ if tag in desc.attrib:
272
+ return [desc.attrib[tag]]
273
+ prop = desc.find(tag)
274
+ if prop is None:
275
+ return None
276
+ items = prop.findall(f"./*/{_q('rdf:li')}")
277
+ if items:
278
+ return [li.text or "" for li in items]
279
+ return [prop.text or ""]
280
+
281
+
282
+ def _read_regions(desc: ET.Element) -> XmpRegions | None:
283
+ prop = desc.find(_q("mwg-rs:Regions"))
284
+ if prop is None:
285
+ return None
286
+
287
+ def field(el: ET.Element | None, qname: str) -> str | None:
288
+ if el is None:
289
+ return None
290
+ if _q(qname) in el.attrib:
291
+ return el.attrib[_q(qname)]
292
+ child = el.find(_q(qname))
293
+ return child.text if child is not None else None
294
+
295
+ dims = prop.find(_q("mwg-rs:AppliedToDimensions"))
296
+ regions: list[XmpRegion] = []
297
+ for li in prop.findall(f"./{_q('mwg-rs:RegionList')}/*/{_q('rdf:li')}"):
298
+ area = li.find(_q("mwg-rs:Area"))
299
+ try:
300
+ regions.append(
301
+ XmpRegion(
302
+ x=float(field(area, "stArea:x") or 0),
303
+ y=float(field(area, "stArea:y") or 0),
304
+ w=float(field(area, "stArea:w") or 0),
305
+ h=float(field(area, "stArea:h") or 0),
306
+ name=field(li, "mwg-rs:Name"),
307
+ type=field(li, "mwg-rs:Type") or "Face",
308
+ )
309
+ )
310
+ except ValueError:
311
+ continue # a foreign region we can't represent; it is still preserved on disk
312
+ try:
313
+ return XmpRegions(
314
+ width=int(float(field(dims, "stDim:w") or 0)),
315
+ height=int(float(field(dims, "stDim:h") or 0)),
316
+ regions=regions,
317
+ )
318
+ except ValueError:
319
+ return None
320
+
321
+
322
+ def parse_xmp(raw: bytes | str) -> XmpData:
323
+ """Read the suite's known properties out of an XMP document.
324
+
325
+ Raises:
326
+ XmpError: if *raw* is not a parseable XMP document.
327
+ """
328
+ if isinstance(raw, str):
329
+ raw = raw.encode("utf-8")
330
+ descs = _descriptions(_parse(raw))
331
+ found: dict[str, object] = {}
332
+
333
+ def first(qname: str) -> list[str] | None:
334
+ for desc in descs:
335
+ values = _prop_values(desc, qname)
336
+ if values is not None:
337
+ return values
338
+ return None
339
+
340
+ for field_name, qname in (
341
+ ("keywords", "dc:subject"),
342
+ ("hierarchical_keywords", "lr:hierarchicalSubject"),
343
+ ("persons", "Iptc4xmpExt:PersonInImage"),
344
+ ):
345
+ if (values := first(qname)) is not None:
346
+ found[field_name] = values
347
+ for field_name, qname in (
348
+ ("description", "dc:description"),
349
+ ("label", "xmp:Label"),
350
+ ("content_rating", "argus:ContentRating"),
351
+ ):
352
+ if (values := first(qname)) is not None:
353
+ found[field_name] = values[0] if values else ""
354
+ if (values := first("xmp:Rating")) and values[0].strip().lstrip("-").isdigit():
355
+ found["rating"] = int(values[0])
356
+ for desc in descs:
357
+ if (regions := _read_regions(desc)) is not None:
358
+ found["regions"] = regions
359
+ break
360
+ return XmpData.model_validate(found)
361
+
362
+
363
+ def sidecar_path(image_path: str | os.PathLike[str]) -> Path:
364
+ """Return the sidecar path for an image: ``photo.jpg`` → ``photo.jpg.xmp``."""
365
+ path = Path(image_path)
366
+ return path.with_suffix(path.suffix + ".xmp")
367
+
368
+
369
+ def read_xmp(image_path: str | os.PathLike[str]) -> XmpData | None:
370
+ """Read the sidecar next to *image_path*, or ``None`` if there is none.
371
+
372
+ Raises:
373
+ XmpError: if the sidecar exists but cannot be parsed.
374
+ """
375
+ sidecar = sidecar_path(image_path)
376
+ if not sidecar.exists():
377
+ return None
378
+ return parse_xmp(sidecar.read_bytes())
379
+
380
+
381
+ def write_xmp(image_path: str | os.PathLike[str], data: XmpData, *, replace: bool = False) -> Path:
382
+ """Merge *data* into the sidecar next to *image_path* and return its path.
383
+
384
+ By default the existing sidecar is merged (latest write wins per property,
385
+ everything else preserved). ``replace=True`` discards the existing sidecar
386
+ and writes a document holding only *data*. The write is atomic (temp file +
387
+ rename), so a crash never leaves a half-written sidecar.
388
+
389
+ Raises:
390
+ XmpError: if merging and the existing sidecar cannot be parsed — it is
391
+ left untouched rather than clobbered.
392
+ """
393
+ sidecar = sidecar_path(image_path)
394
+ exists = sidecar.exists()
395
+ existing = sidecar.read_bytes() if exists and not replace else None
396
+ doc = merge_xmp(existing, data)
397
+ # mkstemp creates 0600; keep the old sidecar's mode (or a world-readable
398
+ # default) so other tools — e.g. an Immich container user — can still read it.
399
+ mode = sidecar.stat().st_mode & 0o777 if exists else 0o644
400
+ fd, tmp = tempfile.mkstemp(dir=sidecar.parent, prefix=f".{sidecar.name}.", suffix=".tmp")
401
+ try:
402
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
403
+ fh.write(doc)
404
+ os.chmod(tmp, mode)
405
+ os.replace(tmp, sidecar)
406
+ except BaseException:
407
+ Path(tmp).unlink(missing_ok=True)
408
+ raise
409
+ return sidecar
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import stat
4
+
5
+ import pytest
6
+ from pydantic import ValidationError
7
+
8
+ from argus_cortex.xmp import (
9
+ XmpData,
10
+ XmpError,
11
+ XmpRegion,
12
+ XmpRegions,
13
+ merge_xmp,
14
+ parse_xmp,
15
+ read_xmp,
16
+ render_xmp,
17
+ sidecar_path,
18
+ write_xmp,
19
+ )
20
+
21
+ # A Lightroom-style sidecar: attribute-form simple properties, a foreign schema
22
+ # (crs = Camera Raw develop settings), GPS, and two rdf:Description blocks.
23
+ LIGHTROOM_SIDECAR = """<?xml version="1.0" encoding="UTF-8"?>
24
+ <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 7.0">
25
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
26
+ <rdf:Description rdf:about=""
27
+ xmlns:xmp="http://ns.adobe.com/xap/1.0/"
28
+ xmlns:exif="http://ns.adobe.com/exif/1.0/"
29
+ xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
30
+ xmp:Rating="4"
31
+ exif:GPSLatitude="51,30.0N"
32
+ crs:Exposure2012="+0.35">
33
+ <dc:subject xmlns:dc="http://purl.org/dc/elements/1.1/">
34
+ <rdf:Bag><rdf:li>holiday</rdf:li></rdf:Bag>
35
+ </dc:subject>
36
+ </rdf:Description>
37
+ <rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Label="Red"/>
38
+ </rdf:RDF>
39
+ </x:xmpmeta>
40
+ """
41
+
42
+
43
+ def test_render_round_trips_every_field() -> None:
44
+ data = XmpData(
45
+ keywords=["mountain", "lake & sky"],
46
+ description="a <scenic> view",
47
+ hierarchical_keywords=["places|alps"],
48
+ rating=3,
49
+ label="Green",
50
+ persons=["Ada"],
51
+ regions=XmpRegions(width=4000, height=3000, regions=[XmpRegion(x=0.5, y=0.4, w=0.1, h=0.2, name="Ada")]),
52
+ content_rating="safe",
53
+ )
54
+ doc = render_xmp(data)
55
+ assert doc.startswith("<?xpacket begin=")
56
+ assert doc.rstrip().endswith('<?xpacket end="w"?>')
57
+ assert "lake &amp; sky" in doc and "&lt;scenic&gt;" in doc
58
+ assert "mwg-rs:Regions" in doc and "argus:ContentRating" in doc
59
+ assert parse_xmp(doc) == data
60
+
61
+
62
+ def test_illegal_xml_chars_are_stripped() -> None:
63
+ doc = render_xmp(XmpData(keywords=["cat\x00\x07dog"], description="line\x0bbreak"))
64
+ assert parse_xmp(doc) == XmpData(keywords=["catdog"], description="linebreak")
65
+
66
+
67
+ def test_merge_preserves_foreign_properties_and_latest_wins() -> None:
68
+ merged = merge_xmp(LIGHTROOM_SIDECAR, XmpData(keywords=["cat"], description="a cat", rating=1))
69
+ # Foreign schemas and untouched properties survive, with their prefixes.
70
+ assert 'crs:Exposure2012="+0.35"' in merged
71
+ assert 'exif:GPSLatitude="51,30.0N"' in merged
72
+ data = parse_xmp(merged)
73
+ assert data.label == "Red" # untouched, still in the second Description
74
+ # Conflicting keys: the latest write replaces the old value wholesale.
75
+ assert data.keywords == ["cat"]
76
+ assert data.rating == 1 # was an attribute on the first Description
77
+ assert merged.count("<xmp:Rating>") == 1 and 'xmp:Rating="' not in merged
78
+ assert data.description == "a cat"
79
+
80
+
81
+ def test_none_leaves_property_and_empty_removes_it() -> None:
82
+ base = render_xmp(XmpData(keywords=["a"], description="d", persons=["Ada"]))
83
+ merged = parse_xmp(merge_xmp(base, XmpData(keywords=[], content_rating="explicit")))
84
+ assert merged.keywords is None # [] removed it
85
+ assert merged.description == "d" # None left it alone
86
+ assert merged.persons == ["Ada"]
87
+ assert merged.content_rating == "explicit"
88
+
89
+
90
+ def test_successive_writers_merge(tmp_path) -> None:
91
+ """Lens then curator writing the same sidecar: neither clobbers the other."""
92
+ img = tmp_path / "photo.jpg"
93
+ write_xmp(img, XmpData(keywords=["beach"], description="a beach"))
94
+ write_xmp(img, XmpData(persons=["Ada"], content_rating="safe"))
95
+ write_xmp(img, XmpData(description="a sunny beach"))
96
+ data = read_xmp(img)
97
+ assert data == XmpData(keywords=["beach"], description="a sunny beach", persons=["Ada"], content_rating="safe")
98
+
99
+
100
+ def test_replace_discards_existing(tmp_path) -> None:
101
+ img = tmp_path / "photo.jpg"
102
+ sidecar_path(img).write_text(LIGHTROOM_SIDECAR, encoding="utf-8")
103
+ write_xmp(img, XmpData(keywords=["cat"]), replace=True)
104
+ assert read_xmp(img) == XmpData(keywords=["cat"])
105
+ assert "crs:" not in sidecar_path(img).read_text(encoding="utf-8")
106
+
107
+
108
+ def test_malformed_sidecar_is_refused_not_clobbered(tmp_path) -> None:
109
+ img = tmp_path / "photo.jpg"
110
+ sidecar = sidecar_path(img)
111
+ sidecar.write_text("<x:xmpmeta not xml", encoding="utf-8")
112
+ with pytest.raises(XmpError):
113
+ write_xmp(img, XmpData(keywords=["cat"]))
114
+ assert sidecar.read_text(encoding="utf-8") == "<x:xmpmeta not xml"
115
+ assert list(tmp_path.iterdir()) == [sidecar] # no temp file left behind
116
+
117
+
118
+ def test_dtd_is_refused() -> None:
119
+ evil = '<?xml version="1.0"?><!DOCTYPE x [<!ENTITY a "aaaa">]><x:xmpmeta xmlns:x="adobe:ns:meta/"/>'
120
+ with pytest.raises(XmpError, match="DTD"):
121
+ parse_xmp(evil)
122
+
123
+
124
+ def test_non_xmp_root_is_refused() -> None:
125
+ with pytest.raises(XmpError, match="not an XMP document"):
126
+ parse_xmp("<html/>")
127
+
128
+
129
+ def test_bare_rdf_root_is_wrapped() -> None:
130
+ bare = (
131
+ '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
132
+ '<rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="2"/></rdf:RDF>'
133
+ )
134
+ merged = merge_xmp(bare, XmpData(label="Blue"))
135
+ assert "x:xmpmeta" in merged
136
+ assert parse_xmp(merged) == XmpData(rating=2, label="Blue")
137
+
138
+
139
+ def test_write_keeps_existing_file_mode(tmp_path) -> None:
140
+ img = tmp_path / "photo.jpg"
141
+ first = write_xmp(img, XmpData(keywords=["a"]))
142
+ assert stat.S_IMODE(first.stat().st_mode) == 0o644
143
+ first.chmod(0o664)
144
+ write_xmp(img, XmpData(keywords=["b"]))
145
+ assert stat.S_IMODE(first.stat().st_mode) == 0o664
146
+
147
+
148
+ def test_sidecar_path_and_missing_read(tmp_path) -> None:
149
+ assert str(sidecar_path("/pics/photo.jpg")) == "/pics/photo.jpg.xmp"
150
+ assert read_xmp(tmp_path / "none.jpg") is None
151
+
152
+
153
+ def test_validation() -> None:
154
+ with pytest.raises(ValidationError):
155
+ XmpData(rating=6)
156
+ with pytest.raises(ValidationError):
157
+ XmpRegion(x=1.5, y=0, w=0, h=0)
File without changes
File without changes
File without changes
File without changes