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.
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/.github/workflows/release.yml +9 -1
- argus_cortex-0.3.0/CHANGELOG.md +77 -0
- argus_cortex-0.3.0/CLAUDE.md +48 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/PKG-INFO +24 -2
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/README.md +17 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/pyproject.toml +5 -3
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/__init__.py +23 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/_version.py +2 -2
- argus_cortex-0.3.0/src/argus_cortex/xmp.py +409 -0
- argus_cortex-0.3.0/tests/test_xmp.py +157 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/.copier-answers.yml +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/.env.example +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/.github/workflows/ci.yml +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/.gitignore +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/LICENSE +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/Makefile +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/backends.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/py.typed +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/server.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/__init__.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/blob.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/config.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/errors.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/lineage.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/models.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/schema.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/store/vector.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/taxonomy.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/src/argus_cortex/wire.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_backends.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_blob.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_server.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_smoke.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_store.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_store_pg_integration.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_taxonomy.py +0 -0
- {argus_cortex-0.2.0 → argus_cortex-0.3.0}/tests/test_vector.py +0 -0
- {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
|
-
|
|
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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: argus-cortex
|
|
3
|
-
Version: 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.
|
|
22
|
-
__version_tuple__ = version_tuple = (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 & sky" in doc and "<scenic>" 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|