argus-cortex 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ # Managed by Copier — do not edit by hand.
2
+ # Run `copier update` in this repo to pull template changes.
3
+ _commit: be61abb
4
+ _src_path: /home/smk/argus-pkg-template
5
+ author: smk762
6
+ description: 'Shared foundation for the Argus suite: taxonomy, versioned wire-schema
7
+ tooling, and the local/remote AI backend contract'
8
+ dockerfile: Dockerfile
9
+ github_owner: smk762
10
+ has_cli: false
11
+ has_server: false
12
+ image_name: argus-cortex
13
+ package_name: argus_cortex
14
+ post_test: ''
15
+ project_name: argus-cortex
16
+ python_min: '3.11'
17
+ ruff_pin: 0.15.16
18
+ test_extras: dev
19
+ version_build_arg: false
20
+
@@ -0,0 +1,19 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ concurrency:
10
+ group: ci-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ ci:
15
+ uses: smk762/argus-ci/.github/workflows/python-ci.yml@v1
16
+ with:
17
+ ruff-version: "0.15.16"
18
+ test-extras: "dev"
19
+
@@ -0,0 +1,72 @@
1
+ name: Release to PyPI
2
+
3
+ # Top-level (non-reusable) on purpose: PyPI trusted publishing cannot run inside
4
+ # a reusable workflow. Keep this file in sync across the suite via `copier update`.
5
+
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+
10
+ permissions:
11
+ contents: read
12
+ id-token: write
13
+
14
+ jobs:
15
+ build:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ with:
20
+ fetch-depth: 0
21
+ - uses: astral-sh/setup-uv@v6
22
+ with:
23
+ enable-cache: true
24
+ - run: uv build
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: dist
28
+ path: dist/
29
+
30
+ test-install:
31
+ runs-on: ubuntu-latest
32
+ needs: build
33
+ strategy:
34
+ matrix:
35
+ python-version: ["3.11", "3.12"]
36
+ steps:
37
+ - uses: actions/download-artifact@v4
38
+ with:
39
+ name: dist
40
+ path: dist/
41
+ - uses: astral-sh/setup-uv@v6
42
+ with:
43
+ enable-cache: true
44
+ - name: Install wheel and smoke-test import
45
+ run: |
46
+ uv venv --python ${{ matrix.python-version }}
47
+ uv pip install dist/*.whl
48
+ uv run --no-sync python -c "import argus_cortex; print(f'argus-cortex {argus_cortex.__version__} OK')"
49
+
50
+ publish-testpypi:
51
+ runs-on: ubuntu-latest
52
+ needs: test-install
53
+ environment: testpypi
54
+ steps:
55
+ - uses: actions/download-artifact@v4
56
+ with:
57
+ name: dist
58
+ path: dist/
59
+ - uses: pypa/gh-action-pypi-publish@release/v1
60
+ with:
61
+ repository-url: https://test.pypi.org/legacy/
62
+
63
+ publish-pypi:
64
+ runs-on: ubuntu-latest
65
+ needs: publish-testpypi
66
+ environment: pypi
67
+ steps:
68
+ - uses: actions/download-artifact@v4
69
+ with:
70
+ name: dist
71
+ path: dist/
72
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ # hatch-vcs writes this at build time (version derived from git tags)
10
+ src/argus_core/_version.py
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 smk762
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,27 @@
1
+ UV ?= uv
2
+
3
+ .PHONY: help install lint format test build clean
4
+
5
+ help: ## Show this help
6
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
7
+
8
+ install: ## Create venv and install with test extras
9
+ $(UV) venv
10
+ $(UV) pip install -e ".[dev]"
11
+
12
+ lint: ## Ruff check + format check
13
+ $(UV)x ruff@0.15.16 check src/ tests/
14
+ $(UV)x ruff@0.15.16 format --check src/ tests/
15
+
16
+ format: ## Ruff autoformat + autofix
17
+ $(UV)x ruff@0.15.16 format src/ tests/
18
+ $(UV)x ruff@0.15.16 check --fix src/ tests/
19
+
20
+ test: ## Run the test suite
21
+ $(UV) run --no-sync pytest --tb=short -q
22
+
23
+ build: ## Build sdist + wheel
24
+ $(UV) build
25
+
26
+ clean: ## Remove build + cache artifacts
27
+ rm -rf dist build .venv *.egg-info .pytest_cache .ruff_cache src/argus_cortex/_version.py
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: argus-cortex
3
+ Version: 0.1.0
4
+ Summary: Shared foundation for the Argus suite: taxonomy, versioned wire-schema tooling, and the local/remote AI backend contract
5
+ Project-URL: Repository, https://github.com/smk762/argus-cortex
6
+ Author: smk762
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: pydantic>=2.7
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Requires-Dist: ruff==0.15.16; extra == 'dev'
22
+ Provides-Extra: remote
23
+ Requires-Dist: httpx>=0.27; extra == 'remote'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # argus-cortex
27
+
28
+ Shared foundation for the Argus suite: taxonomy, versioned wire-schema tooling, and the local/remote AI backend contract.
29
+
30
+ Part of the [Argus suite](https://github.com/smk762?tab=repositories&q=argus) (quarry → curator → lens → forge → proof). This is the library the stages import so the code they *share* — the target taxonomy, the wire-schema versioning discipline, and the way they call local or hosted models — lives in one place instead of being copy-pasted per repo.
31
+
32
+ ## What's here
33
+
34
+ - **`argus_cortex.taxonomy`** — `TargetProfile` / `TargetStyle` / `TargetCategory`, the "moat" types every stage inherits verbatim.
35
+ - **`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.
36
+ - **`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.
37
+
38
+ ```python
39
+ from argus_cortex.wire import make_versioned_base, render_schema
40
+ from argus_cortex.backends import RemoteBackend, RemoteProvider
41
+
42
+ # per-package versioned base (readable field name kept local, logic shared)
43
+ _Versioned = make_versioned_base("proof_version", "1.0", ("1",))
44
+
45
+ # reach a hosted scorer by IP/port, or a known provider
46
+ scorer = RemoteBackend.from_host("192.168.1.20", 9000, api_key="…")
47
+ nim = RemoteBackend.from_provider(RemoteProvider.NVIDIA_NIM, api_key="…")
48
+ ```
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ uv pip install argus-cortex # core (pydantic only)
54
+ uv pip install "argus-cortex[remote]" # + httpx for RemoteBackend
55
+ ```
56
+
57
+ ## Develop
58
+
59
+ ```bash
60
+ make install # venv + editable install with the "dev" extras
61
+ make test
62
+ make lint
63
+ ```
64
+
65
+ ## CI / Release
66
+
67
+ - **CI** runs via the shared [`argus-ci`](https://github.com/smk762/argus-ci) reusable workflow.
68
+ - **Release** publishes to PyPI (OIDC trusted publishing) on `v*` tags. No container image — this is a library.
69
+ - Versioning is derived from git tags via `hatch-vcs` — tag `vX.Y.Z` to cut a release.
70
+
71
+ This repo was scaffolded from [`argus-pkg-template`](https://github.com/smk762/argus-pkg-template).
72
+ Run `copier update` to pull template changes (CI, release, tooling).
@@ -0,0 +1,47 @@
1
+ # argus-cortex
2
+
3
+ Shared foundation for the Argus suite: taxonomy, versioned wire-schema tooling, and the local/remote AI backend contract.
4
+
5
+ Part of the [Argus suite](https://github.com/smk762?tab=repositories&q=argus) (quarry → curator → lens → forge → proof). This is the library the stages import so the code they *share* — the target taxonomy, the wire-schema versioning discipline, and the way they call local or hosted models — lives in one place instead of being copy-pasted per repo.
6
+
7
+ ## What's here
8
+
9
+ - **`argus_cortex.taxonomy`** — `TargetProfile` / `TargetStyle` / `TargetCategory`, the "moat" types every stage inherits verbatim.
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
+ - **`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
+
13
+ ```python
14
+ from argus_cortex.wire import make_versioned_base, render_schema
15
+ from argus_cortex.backends import RemoteBackend, RemoteProvider
16
+
17
+ # per-package versioned base (readable field name kept local, logic shared)
18
+ _Versioned = make_versioned_base("proof_version", "1.0", ("1",))
19
+
20
+ # reach a hosted scorer by IP/port, or a known provider
21
+ scorer = RemoteBackend.from_host("192.168.1.20", 9000, api_key="…")
22
+ nim = RemoteBackend.from_provider(RemoteProvider.NVIDIA_NIM, api_key="…")
23
+ ```
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ uv pip install argus-cortex # core (pydantic only)
29
+ uv pip install "argus-cortex[remote]" # + httpx for RemoteBackend
30
+ ```
31
+
32
+ ## Develop
33
+
34
+ ```bash
35
+ make install # venv + editable install with the "dev" extras
36
+ make test
37
+ make lint
38
+ ```
39
+
40
+ ## CI / Release
41
+
42
+ - **CI** runs via the shared [`argus-ci`](https://github.com/smk762/argus-ci) reusable workflow.
43
+ - **Release** publishes to PyPI (OIDC trusted publishing) on `v*` tags. No container image — this is a library.
44
+ - Versioning is derived from git tags via `hatch-vcs` — tag `vX.Y.Z` to cut a release.
45
+
46
+ This repo was scaffolded from [`argus-pkg-template`](https://github.com/smk762/argus-pkg-template).
47
+ Run `copier update` to pull template changes (CI, release, tooling).
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "argus-cortex"
7
+ dynamic = ["version"]
8
+ description = "Shared foundation for the Argus suite: taxonomy, versioned wire-schema tooling, and the local/remote AI backend contract"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "smk762" }]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "pydantic>=2.7",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ remote = ["httpx>=0.27"]
28
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff==0.15.16"]
29
+
30
+ [project.urls]
31
+ Repository = "https://github.com/smk762/argus-cortex"
32
+
33
+ [tool.hatch.version]
34
+ source = "vcs"
35
+
36
+ [tool.hatch.build.hooks.vcs]
37
+ version-file = "src/argus_cortex/_version.py"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/argus_cortex"]
41
+
42
+ [tool.pytest.ini_options]
43
+ asyncio_mode = "auto"
44
+ testpaths = ["tests"]
45
+ filterwarnings = ["ignore::DeprecationWarning"]
46
+
47
+ [tool.ruff]
48
+ target-version = "py311"
49
+ line-length = 120
50
+ extend-exclude = ["src/argus_cortex/_version.py"]
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "W", "I", "UP", "B", "SIM"]
54
+ ignore = ["E501", "B008"]
@@ -0,0 +1,58 @@
1
+ """argus-cortex — Shared foundation for the Argus suite: taxonomy, versioned wire-schema tooling, and the local/remote AI backend contract"""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ # Written by hatch-vcs at build time (see pyproject [tool.hatch.build.hooks.vcs]).
7
+ from argus_cortex._version import __version__
8
+ except ImportError: # running from a source checkout that hasn't been built
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ __version__ = version("argus-cortex")
13
+ except PackageNotFoundError:
14
+ __version__ = "0.0.0+unknown"
15
+
16
+ from argus_cortex.backends import (
17
+ Backend,
18
+ BackendError,
19
+ BackendKind,
20
+ LocalBackend,
21
+ RemoteBackend,
22
+ RemoteProvider,
23
+ resolve_device,
24
+ with_retries,
25
+ )
26
+ from argus_cortex.taxonomy import TargetCategory, TargetProfile, TargetStyle
27
+ from argus_cortex.wire import (
28
+ VersionError,
29
+ check_version,
30
+ make_versioned_base,
31
+ render_schema,
32
+ schema_major,
33
+ wire_schema,
34
+ )
35
+
36
+ __all__ = [
37
+ "__version__",
38
+ # taxonomy
39
+ "TargetProfile",
40
+ "TargetCategory",
41
+ "TargetStyle",
42
+ # wire
43
+ "VersionError",
44
+ "check_version",
45
+ "make_versioned_base",
46
+ "render_schema",
47
+ "schema_major",
48
+ "wire_schema",
49
+ # backends
50
+ "Backend",
51
+ "BackendError",
52
+ "BackendKind",
53
+ "LocalBackend",
54
+ "RemoteBackend",
55
+ "RemoteProvider",
56
+ "resolve_device",
57
+ "with_retries",
58
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,286 @@
1
+ """The local / remote AI-backend contract, shared across the suite.
2
+
3
+ Generalised from argus-lens's captioner backends (``CaptionBackend`` →
4
+ ``LocalBackend`` / ``CloudBackend``) so every stage that calls a model — lens
5
+ captioning, proof scoring — shares one lifecycle, one retry policy, and one way
6
+ to point at a **hosted service by IP/port** instead of loading weights locally.
7
+
8
+ * :class:`Backend` — lifecycle + capability attributes (``load`` / ``unload`` /
9
+ ``is_available``); the domain call (caption an image, score an image) is added
10
+ by the subclass.
11
+ * :class:`LocalBackend` — runs inference in-process on a resolved device.
12
+ * :class:`RemoteBackend` — calls a hosted HTTP endpoint (``base_url`` or
13
+ ``host``/``port``, or a known :class:`RemoteProvider`); httpx lives behind the
14
+ ``argus-cortex[remote]`` extra and is imported lazily.
15
+
16
+ Concrete, domain-specific adapters (a CLIP scorer, an OpenAI captioner) subclass
17
+ these in the consuming package; this module stays domain-agnostic.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from collections.abc import Callable
24
+ from enum import StrEnum
25
+ from typing import TypeVar
26
+
27
+ T = TypeVar("T")
28
+
29
+
30
+ class BackendError(RuntimeError):
31
+ """A backend failure: model unavailable, remote endpoint unreachable."""
32
+
33
+
34
+ class BackendKind(StrEnum):
35
+ """Where a backend runs — locally in-process, or a remote hosted service."""
36
+
37
+ LOCAL = "local"
38
+ REMOTE = "remote"
39
+
40
+
41
+ def with_retries(
42
+ fn: Callable[[], T],
43
+ *,
44
+ attempts: int = 3,
45
+ base_delay: float = 0.5,
46
+ max_delay: float = 8.0,
47
+ retry_on: tuple[type[BaseException], ...] = (Exception,),
48
+ sleep: Callable[[float], None] = time.sleep,
49
+ ) -> T:
50
+ """Call *fn*, retrying on *retry_on* with exponential backoff.
51
+
52
+ Re-raises the last error after *attempts* tries. ``sleep`` is injectable so
53
+ tests exercise the backoff without wall-clock delay. Narrow ``retry_on`` to
54
+ the transient failures you actually want retried — the default of
55
+ ``Exception`` also retries deterministic programming errors.
56
+ """
57
+ if attempts < 1:
58
+ raise ValueError(f"attempts must be >= 1, got {attempts}")
59
+ last: BaseException | None = None
60
+ for attempt in range(attempts):
61
+ try:
62
+ return fn()
63
+ except retry_on as exc:
64
+ last = exc
65
+ if attempt == attempts - 1:
66
+ break
67
+ sleep(min(max_delay, base_delay * (2**attempt)))
68
+ raise last # type: ignore[misc] # attempts >= 1, so a caught error was assigned
69
+
70
+
71
+ def resolve_device(device: str = "auto") -> str:
72
+ """Resolve ``"auto"`` to ``"cuda"``/``"mps"``/``"cpu"`` (or pass a fixed device through).
73
+
74
+ Uses torch only if it's importable; with no torch present, ``"auto"`` becomes
75
+ ``"cpu"`` so a device-free environment still works.
76
+ """
77
+ if device and device != "auto":
78
+ return device
79
+ try:
80
+ import torch
81
+ except ImportError:
82
+ return "cpu" # no torch -> cpu; a real GPU-detection error below must surface
83
+ if torch.cuda.is_available():
84
+ return "cuda"
85
+ mps = getattr(torch.backends, "mps", None)
86
+ if mps is not None and mps.is_available():
87
+ return "mps"
88
+ return "cpu"
89
+
90
+
91
+ class Backend:
92
+ """Base for any model-backed backend: lifecycle + capability descriptor.
93
+
94
+ Subclasses set the class attributes and add the domain call. ``load`` /
95
+ ``unload`` default to no-ops so a trivial backend need not override them;
96
+ ``is_available`` / ``availability_reason`` let a registry skip a backend that
97
+ can't run right now (missing weights, unset API key) with a clear reason.
98
+ """
99
+
100
+ name: str = "base"
101
+ kind: BackendKind = BackendKind.LOCAL
102
+ requires_gpu: bool = False
103
+
104
+ def load(self, device: str = "auto") -> None:
105
+ """Load weights / init the client. Called lazily on first use."""
106
+
107
+ def unload(self) -> None:
108
+ """Release resources (GPU memory, HTTP connections)."""
109
+
110
+ def is_available(self) -> bool:
111
+ """Whether this backend can be used right now."""
112
+ return True
113
+
114
+ def availability_reason(self) -> str | None:
115
+ """Human-readable reason this backend is unavailable, or ``None``."""
116
+ return None
117
+
118
+
119
+ class LocalBackend(Backend):
120
+ """Base for backends that run inference in-process on a device.
121
+
122
+ The target device is supplied once via :meth:`load` and remembered, so the
123
+ domain call stays device-free. Subclasses read ``self.device``.
124
+ """
125
+
126
+ kind = BackendKind.LOCAL
127
+ requires_gpu = True
128
+
129
+ def __init__(self) -> None:
130
+ self.device: str | None = None
131
+
132
+ def load(self, device: str = "auto") -> None:
133
+ self.device = resolve_device(device)
134
+
135
+
136
+ class RemoteProvider(StrEnum):
137
+ """Known hosted inference providers (base URLs in :data:`REMOTE_PROVIDER_URLS`)."""
138
+
139
+ HF_INFERENCE = "hf_inference"
140
+ NVIDIA_NIM = "nvidia_nim"
141
+ OPENAI_COMPAT = "openai_compat"
142
+ REPLICATE = "replicate"
143
+
144
+
145
+ # Default base URLs per provider. OPENAI_COMPAT has none — a local/self-hosted
146
+ # OpenAI-compatible server is addressed by an explicit base_url / host+port.
147
+ REMOTE_PROVIDER_URLS: dict[RemoteProvider, str | None] = {
148
+ RemoteProvider.HF_INFERENCE: "https://api-inference.huggingface.co",
149
+ RemoteProvider.NVIDIA_NIM: "https://integrate.api.nvidia.com/v1",
150
+ RemoteProvider.OPENAI_COMPAT: None,
151
+ RemoteProvider.REPLICATE: "https://api.replicate.com/v1",
152
+ }
153
+
154
+
155
+ class RemoteBackend(Backend):
156
+ """Base for backends that call a hosted HTTP endpoint.
157
+
158
+ Point it at a service by ``base_url``, by ``host``/``port`` via
159
+ :meth:`from_host`, or at a known provider via :meth:`from_provider`. An
160
+ ``api_key`` becomes a bearer ``Authorization`` header unless one is already
161
+ supplied. httpx is imported lazily (``argus-cortex[remote]``); an injected
162
+ ``_http`` client (tests) bypasses the import.
163
+ """
164
+
165
+ kind = BackendKind.REMOTE
166
+ requires_gpu = False
167
+
168
+ def __init__(
169
+ self,
170
+ base_url: str | None = None,
171
+ *,
172
+ api_key: str | None = None,
173
+ timeout: float = 30.0,
174
+ headers: dict[str, str] | None = None,
175
+ provider: RemoteProvider | None = None,
176
+ ) -> None:
177
+ self.base_url = base_url.rstrip("/") if base_url else None
178
+ self.api_key = api_key
179
+ self.timeout = timeout
180
+ self.provider = provider
181
+ self.headers = dict(headers or {})
182
+ if api_key and not any(k.lower() == "authorization" for k in self.headers):
183
+ self.headers["Authorization"] = f"Bearer {api_key}"
184
+ self._http = None # lazily-created httpx.Client (or an injected fake)
185
+
186
+ @classmethod
187
+ def from_host(cls, host: str, port: int, *, scheme: str = "http", **kwargs: object) -> RemoteBackend:
188
+ """Address a service by host + port, e.g. a self-hosted model server."""
189
+ return cls(f"{scheme}://{host}:{port}", **kwargs) # type: ignore[arg-type]
190
+
191
+ @classmethod
192
+ def from_provider(cls, provider: RemoteProvider | str, **kwargs: object) -> RemoteBackend:
193
+ """Address a known hosted provider by its default base URL.
194
+
195
+ This wires up only the URL (and auth) — it does NOT adapt to a provider's
196
+ request/response protocol. Replicate's poll-based predictions, an
197
+ OpenAI-compatible ``/chat/completions`` shape, etc. are the concrete
198
+ subclass's job; the plain :meth:`post_json` / :meth:`get_json` here assume
199
+ a single synchronous JSON round-trip.
200
+ """
201
+ provider = RemoteProvider(provider)
202
+ base = REMOTE_PROVIDER_URLS[provider]
203
+ if base is None and "base_url" not in kwargs:
204
+ raise BackendError(f"provider {provider.value} needs an explicit base_url (host+port of your server)")
205
+ return cls(kwargs.pop("base_url", base), provider=provider, **kwargs) # type: ignore[arg-type]
206
+
207
+ def is_available(self) -> bool:
208
+ return bool(self.base_url)
209
+
210
+ def availability_reason(self) -> str | None:
211
+ return None if self.base_url else "no base_url configured (set base_url or host/port)"
212
+
213
+ def _client(self): # noqa: ANN202 (httpx is optional; avoid importing at module load)
214
+ if self._http is None:
215
+ try:
216
+ import httpx
217
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
218
+ raise BackendError("remote backends need: pip install argus-cortex[remote]") from exc
219
+ if not self.base_url:
220
+ raise BackendError("remote backend has no base_url")
221
+ self._http = httpx.Client(base_url=self.base_url, timeout=self.timeout, headers=self.headers)
222
+ return self._http
223
+
224
+ @staticmethod
225
+ def _transient_errors() -> tuple[type[BaseException], ...]:
226
+ """Errors worth retrying: network/timeout blips, NOT 4xx/5xx responses.
227
+
228
+ ``httpx.TransportError`` covers connect/read/write/pool timeouts and
229
+ connection failures. A ``raise_for_status()`` 4xx/5xx (``HTTPStatusError``)
230
+ is deliberately excluded — a 401/404 never succeeds on retry, and a POST
231
+ that reached the server must not be blindly re-sent.
232
+ """
233
+ try:
234
+ import httpx
235
+
236
+ return (httpx.TransportError,)
237
+ except ImportError: # pragma: no cover
238
+ return (OSError,)
239
+
240
+ @staticmethod
241
+ def _wrap_errors() -> tuple[type[BaseException], ...]:
242
+ """Exception types to wrap as :class:`BackendError` (bad status, bad JSON)."""
243
+ try:
244
+ import httpx
245
+
246
+ return (httpx.HTTPError, ValueError)
247
+ except ImportError: # pragma: no cover
248
+ return (ValueError, OSError)
249
+
250
+ def _send(self, call: Callable[[], dict], *, label: str, attempts: int) -> dict:
251
+ """Retry *call* on transient failures; wrap any failure in BackendError.
252
+
253
+ So callers catch one error type whether the endpoint was unreachable,
254
+ returned a bad status, or sent a non-JSON body.
255
+ """
256
+ try:
257
+ return with_retries(call, attempts=attempts, retry_on=self._transient_errors())
258
+ except self._wrap_errors() as exc:
259
+ raise BackendError(f"{label} failed: {exc}") from exc
260
+
261
+ def post_json(self, path: str, payload: dict, *, attempts: int = 3) -> dict:
262
+ """POST JSON and return the JSON response; retries transient failures only."""
263
+ client = self._client()
264
+
265
+ def call() -> dict:
266
+ resp = client.post(path, json=payload)
267
+ resp.raise_for_status()
268
+ return resp.json()
269
+
270
+ return self._send(call, label=f"POST {path}", attempts=attempts)
271
+
272
+ def get_json(self, path: str, *, attempts: int = 3) -> dict:
273
+ """GET and return the JSON response; retries transient failures only."""
274
+ client = self._client()
275
+
276
+ def call() -> dict:
277
+ resp = client.get(path)
278
+ resp.raise_for_status()
279
+ return resp.json()
280
+
281
+ return self._send(call, label=f"GET {path}", attempts=attempts)
282
+
283
+ def unload(self) -> None:
284
+ if self._http is not None:
285
+ self._http.close()
286
+ self._http = None
@@ -0,0 +1,41 @@
1
+ """The shared target taxonomy — what a dataset is curated *for*.
2
+
3
+ This is the "moat" the whole Argus suite pivots on: the curator scores and
4
+ labels exports against it, the captioner picks caption variants from it, the
5
+ forge renders trainer configs for it, and proof evaluates a LoRA against it. It
6
+ lived, copied verbatim, in argus-curator and argus-forge (whose comment noted it
7
+ should "eventually hoist into a shared argus-cortex package") — this is that hoist.
8
+
9
+ Keep this module dependency-light (pydantic only) and change it deliberately: a
10
+ change here ripples through every stage of the suite.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Literal
16
+
17
+ from pydantic import BaseModel
18
+
19
+ TargetStyle = Literal["photo", "anime"]
20
+
21
+ # What the dataset is curated for. Note this is distinct from argus-lens's
22
+ # caption *buckets* (identity/action/pose_composition/wardrobe/lighting/setting):
23
+ # those describe caption fragments, this describes the training objective.
24
+ TargetCategory = Literal["identity", "wardrobe", "pose_composition", "setting"]
25
+
26
+ TARGET_STYLES: tuple[TargetStyle, ...] = ("photo", "anime")
27
+ TARGET_CATEGORIES: tuple[TargetCategory, ...] = ("identity", "wardrobe", "pose_composition", "setting")
28
+
29
+
30
+ class TargetProfile(BaseModel):
31
+ """What a dataset was curated for — shared verbatim across the suite.
32
+
33
+ Inherited unchanged from stage to stage (curator → lens → forge → proof) so
34
+ a decision made once at curation time is honoured everywhere downstream; no
35
+ stage remaps the taxonomy.
36
+ """
37
+
38
+ target_style: TargetStyle = "photo"
39
+ target_backend: str | None = "sdxl"
40
+ checkpoint: str | None = None
41
+ target_category: TargetCategory = "identity"
@@ -0,0 +1,107 @@
1
+ """Versioned wire-schema tooling shared across the suite.
2
+
3
+ Every Argus service defines a wire contract (Pydantic models exchanged over
4
+ HTTP/CLI and codegen'd against by argus-studio) and stamps it with a version so
5
+ a consumer can refuse an incompatible major instead of misreading it — the
6
+ ``MANIFEST_VERSION`` / ``PROOF_VERSION`` discipline. That machinery was
7
+ re-implemented per repo; this module is the single copy.
8
+
9
+ A package supplies its own version constant and field name (``proof_version``,
10
+ ``manifest_version`` — kept per-package for readable payloads) and gets:
11
+
12
+ * :func:`check_version` — the major-compatibility gate;
13
+ * :func:`make_versioned_base` — a Pydantic base that stamps + checks the field;
14
+ * :func:`render_schema` — the canonical committed-schema string, so the
15
+ ``schema`` / ``schema --check`` CLI is identical everywhere.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import TYPE_CHECKING
22
+
23
+ from pydantic import BaseModel, model_validator
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Iterable, Sequence
27
+
28
+
29
+ class VersionError(RuntimeError):
30
+ """A wire payload's version is incompatible with this build."""
31
+
32
+
33
+ def schema_major(version: str) -> str:
34
+ """The major component of a version string (``'2.7' -> '2'``)."""
35
+ return version.split(".", 1)[0]
36
+
37
+
38
+ def check_version(
39
+ version: str,
40
+ supported_majors: Sequence[str],
41
+ *,
42
+ label: str = "wire schema",
43
+ error: type[Exception] = VersionError,
44
+ ) -> None:
45
+ """Raise if *version*'s major is not supported.
46
+
47
+ The single gate every deserialization path runs through, so an incompatible
48
+ payload is refused with a clear, consistent message across the suite. Pass
49
+ ``error`` to raise a package's own exception type (e.g. ``ProofError``) so
50
+ version failures fold into that package's error hierarchy.
51
+ """
52
+ if schema_major(version) not in tuple(supported_majors):
53
+ understood = ", ".join(f"{m}.x" for m in supported_majors)
54
+ raise error(f"{label} {version} is not supported (this build understands {understood}) — upgrade or regenerate")
55
+
56
+
57
+ def make_versioned_base(
58
+ field_name: str,
59
+ current: str,
60
+ supported_majors: Sequence[str],
61
+ *,
62
+ label: str | None = None,
63
+ class_name: str = "VersionedBase",
64
+ error: type[Exception] = VersionError,
65
+ ) -> type[BaseModel]:
66
+ """Build a Pydantic base that stamps and version-checks *field_name*.
67
+
68
+ Subclasses of the returned base carry ``field_name`` (defaulting to
69
+ *current*) and, on construction or ``model_validate``, refuse a payload
70
+ whose major is not in *supported_majors*. Keeping the field name a parameter
71
+ lets each package keep its readable name (``proof_version``, …) while sharing
72
+ the logic; ``error`` selects the exception type raised on a mismatch.
73
+ """
74
+ majors = tuple(supported_majors)
75
+ lbl = label or field_name
76
+
77
+ def _check_wire_version(self: BaseModel) -> BaseModel:
78
+ check_version(getattr(self, field_name), majors, label=lbl, error=error)
79
+ return self
80
+
81
+ namespace = {
82
+ "__annotations__": {field_name: str},
83
+ field_name: current,
84
+ "_check_wire_version": model_validator(mode="after")(_check_wire_version),
85
+ }
86
+ return type(class_name, (BaseModel,), namespace)
87
+
88
+
89
+ def wire_schema(models: Iterable[type[BaseModel]], *, title: str) -> dict:
90
+ """Combined JSON Schema for *models* (serialization view, ``$defs`` refs)."""
91
+ from pydantic.json_schema import models_json_schema
92
+
93
+ _, schema = models_json_schema(
94
+ [(m, "serialization") for m in models],
95
+ title=title,
96
+ ref_template="#/$defs/{model}",
97
+ )
98
+ return schema
99
+
100
+
101
+ def render_schema(models: Iterable[type[BaseModel]], *, title: str) -> str:
102
+ """The canonical committed-schema string: sorted, indented, newline-terminated.
103
+
104
+ Used by both ``schema`` (write) and ``schema --check`` (compare) so the two
105
+ can never disagree about formatting.
106
+ """
107
+ return json.dumps(wire_schema(models, title=title), indent=2, sort_keys=True) + "\n"
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+
5
+ import pytest
6
+
7
+ from argus_cortex.backends import (
8
+ Backend,
9
+ BackendError,
10
+ BackendKind,
11
+ LocalBackend,
12
+ RemoteBackend,
13
+ RemoteProvider,
14
+ resolve_device,
15
+ with_retries,
16
+ )
17
+
18
+ _HAS_HTTPX = importlib.util.find_spec("httpx") is not None
19
+
20
+
21
+ # --------------------------------------------------------------------------
22
+ # retry
23
+ # --------------------------------------------------------------------------
24
+
25
+
26
+ def test_with_retries_returns_on_first_success() -> None:
27
+ assert with_retries(lambda: 42) == 42
28
+
29
+
30
+ def test_with_retries_retries_then_succeeds_with_backoff() -> None:
31
+ calls = {"n": 0}
32
+ delays: list[float] = []
33
+
34
+ def flaky() -> str:
35
+ calls["n"] += 1
36
+ if calls["n"] < 3:
37
+ raise ValueError("boom")
38
+ return "ok"
39
+
40
+ result = with_retries(flaky, attempts=3, base_delay=1.0, retry_on=(ValueError,), sleep=delays.append)
41
+ assert result == "ok"
42
+ assert calls["n"] == 3
43
+ assert delays == [1.0, 2.0] # exponential: base*2^0, base*2^1
44
+
45
+
46
+ def test_with_retries_reraises_last_after_exhausting() -> None:
47
+ def always() -> None:
48
+ raise ValueError("nope")
49
+
50
+ with pytest.raises(ValueError, match="nope"):
51
+ with_retries(always, attempts=2, retry_on=(ValueError,), sleep=lambda _d: None)
52
+
53
+
54
+ def test_with_retries_does_not_catch_unlisted_exception() -> None:
55
+ def boom() -> None:
56
+ raise KeyError("x")
57
+
58
+ with pytest.raises(KeyError):
59
+ with_retries(boom, attempts=3, retry_on=(ValueError,), sleep=lambda _d: None)
60
+
61
+
62
+ def test_with_retries_rejects_non_positive_attempts() -> None:
63
+ # attempts<1 must fail loudly, never `raise None` (which the old assert did under -O)
64
+ with pytest.raises(ValueError, match="attempts must be >= 1"):
65
+ with_retries(lambda: 1, attempts=0)
66
+
67
+
68
+ # --------------------------------------------------------------------------
69
+ # device
70
+ # --------------------------------------------------------------------------
71
+
72
+
73
+ def test_resolve_device_passes_explicit_through() -> None:
74
+ assert resolve_device("cuda:1") == "cuda:1"
75
+ assert resolve_device("cpu") == "cpu"
76
+
77
+
78
+ def test_resolve_device_auto_falls_back_to_cpu_without_torch() -> None:
79
+ # torch isn't a dependency of argus-cortex, so "auto" resolves to cpu here.
80
+ if importlib.util.find_spec("torch") is None:
81
+ assert resolve_device("auto") == "cpu"
82
+
83
+
84
+ # --------------------------------------------------------------------------
85
+ # Backend base + LocalBackend
86
+ # --------------------------------------------------------------------------
87
+
88
+
89
+ def test_backend_defaults() -> None:
90
+ b = Backend()
91
+ assert b.is_available() is True
92
+ assert b.availability_reason() is None
93
+ assert b.load() is None # no-op
94
+ assert b.unload() is None
95
+
96
+
97
+ def test_local_backend_load_sets_device() -> None:
98
+ b = LocalBackend()
99
+ assert b.kind == BackendKind.LOCAL
100
+ assert b.device is None
101
+ b.load("cpu")
102
+ assert b.device == "cpu"
103
+
104
+
105
+ # --------------------------------------------------------------------------
106
+ # RemoteBackend
107
+ # --------------------------------------------------------------------------
108
+
109
+
110
+ def test_remote_backend_normalizes_url_and_sets_auth() -> None:
111
+ b = RemoteBackend("http://10.0.0.5:9000/", api_key="secret")
112
+ assert b.base_url == "http://10.0.0.5:9000"
113
+ assert b.headers["Authorization"] == "Bearer secret"
114
+ assert b.kind == BackendKind.REMOTE
115
+ assert b.is_available() is True
116
+
117
+
118
+ def test_remote_backend_from_host() -> None:
119
+ b = RemoteBackend.from_host("192.168.1.20", 8188)
120
+ assert b.base_url == "http://192.168.1.20:8188"
121
+
122
+
123
+ def test_remote_backend_from_provider_uses_default_url() -> None:
124
+ b = RemoteBackend.from_provider(RemoteProvider.NVIDIA_NIM, api_key="k")
125
+ assert b.base_url == "https://integrate.api.nvidia.com/v1"
126
+ assert b.provider == RemoteProvider.NVIDIA_NIM
127
+
128
+
129
+ def test_openai_compat_provider_requires_base_url() -> None:
130
+ with pytest.raises(BackendError, match="explicit base_url"):
131
+ RemoteBackend.from_provider(RemoteProvider.OPENAI_COMPAT)
132
+ # ...but works when you supply one (your self-hosted server)
133
+ b = RemoteBackend.from_provider(RemoteProvider.OPENAI_COMPAT, base_url="http://localhost:1234/v1")
134
+ assert b.base_url == "http://localhost:1234/v1"
135
+
136
+
137
+ def test_remote_backend_unavailable_without_url() -> None:
138
+ b = RemoteBackend()
139
+ assert b.is_available() is False
140
+ assert "base_url" in (b.availability_reason() or "")
141
+
142
+
143
+ class _FakeResp:
144
+ def __init__(self, data: dict) -> None:
145
+ self._data = data
146
+
147
+ def raise_for_status(self) -> None:
148
+ pass
149
+
150
+ def json(self) -> dict:
151
+ return self._data
152
+
153
+
154
+ class _FakeClient:
155
+ def __init__(self) -> None:
156
+ self.calls: list[tuple[str, dict | None]] = []
157
+ self.closed = False
158
+
159
+ def post(self, path: str, json: dict) -> _FakeResp:
160
+ self.calls.append((path, json))
161
+ return _FakeResp({"echo": json})
162
+
163
+ def get(self, path: str) -> _FakeResp:
164
+ self.calls.append((path, None))
165
+ return _FakeResp({"path": path})
166
+
167
+ def close(self) -> None:
168
+ self.closed = True
169
+
170
+
171
+ def test_remote_post_and_get_via_injected_client() -> None:
172
+ b = RemoteBackend("http://host:1")
173
+ fake = _FakeClient()
174
+ b._http = fake
175
+ assert b.post_json("/score", {"a": 1}) == {"echo": {"a": 1}}
176
+ assert b.get_json("/health") == {"path": "/health"}
177
+ assert fake.calls == [("/score", {"a": 1}), ("/health", None)]
178
+
179
+
180
+ def test_remote_wraps_request_failures_in_backend_error() -> None:
181
+ class Failing(_FakeClient):
182
+ def post(self, path: str, json: dict) -> _FakeResp:
183
+ raise ValueError("bad json body")
184
+
185
+ b = RemoteBackend("http://host:1")
186
+ b._http = Failing()
187
+ with pytest.raises(BackendError, match="POST /score failed"):
188
+ b.post_json("/score", {"a": 1})
189
+
190
+
191
+ def test_remote_unload_closes_client() -> None:
192
+ b = RemoteBackend("http://host:1")
193
+ fake = _FakeClient()
194
+ b._http = fake
195
+ b.unload()
196
+ assert fake.closed is True
197
+ assert b._http is None
198
+
199
+
200
+ @pytest.mark.skipif(_HAS_HTTPX, reason="httpx installed; the missing-extra path can't be exercised")
201
+ def test_client_without_httpx_raises_helpful_error() -> None:
202
+ with pytest.raises(BackendError, match=r"argus-cortex\[remote\]"):
203
+ RemoteBackend("http://host:1")._client()
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import argus_cortex
4
+
5
+
6
+ def test_version_is_exposed() -> None:
7
+ assert isinstance(argus_cortex.__version__, str)
8
+ assert argus_cortex.__version__
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ from pydantic import ValidationError
5
+
6
+ from argus_cortex.taxonomy import TARGET_CATEGORIES, TARGET_STYLES, TargetProfile
7
+
8
+
9
+ def test_defaults() -> None:
10
+ p = TargetProfile()
11
+ assert p.target_style == "photo"
12
+ assert p.target_category == "identity"
13
+ assert p.target_backend == "sdxl"
14
+ assert p.checkpoint is None
15
+
16
+
17
+ def test_accepts_valid_taxonomy() -> None:
18
+ p = TargetProfile(target_style="anime", target_category="wardrobe", checkpoint="ckpt.safetensors")
19
+ assert p.target_style == "anime"
20
+ assert p.target_category == "wardrobe"
21
+
22
+
23
+ def test_rejects_invalid_style_and_category() -> None:
24
+ with pytest.raises(ValidationError):
25
+ TargetProfile(target_style="watercolor")
26
+ with pytest.raises(ValidationError):
27
+ TargetProfile(target_category="lighting") # a caption bucket, not a target category
28
+
29
+
30
+ def test_round_trips() -> None:
31
+ p = TargetProfile(target_style="anime", target_category="setting")
32
+ assert TargetProfile.model_validate_json(p.model_dump_json()) == p
33
+
34
+
35
+ def test_tuples_match_literals() -> None:
36
+ assert set(TARGET_STYLES) == {"photo", "anime"}
37
+ assert set(TARGET_CATEGORIES) == {"identity", "wardrobe", "pose_composition", "setting"}
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ from pydantic import BaseModel
5
+
6
+ from argus_cortex.wire import (
7
+ VersionError,
8
+ check_version,
9
+ make_versioned_base,
10
+ render_schema,
11
+ schema_major,
12
+ wire_schema,
13
+ )
14
+
15
+
16
+ def test_schema_major() -> None:
17
+ assert schema_major("1.0") == "1"
18
+ assert schema_major("2.7") == "2"
19
+ assert schema_major("10.3.1") == "10"
20
+
21
+
22
+ def test_check_version_accepts_supported_major() -> None:
23
+ check_version("1.4", ("1",), label="proof_version") # no raise
24
+
25
+
26
+ def test_check_version_refuses_unsupported_major() -> None:
27
+ with pytest.raises(VersionError, match="proof_version 2.0 is not supported"):
28
+ check_version("2.0", ("1",), label="proof_version")
29
+
30
+
31
+ def test_versioned_base_stamps_default() -> None:
32
+ Base = make_versioned_base("proof_version", "1.0", ("1",), label="proof_version")
33
+
34
+ class Model(Base): # type: ignore[misc, valid-type]
35
+ x: int = 0
36
+
37
+ assert Model().proof_version == "1.0"
38
+ assert Model(x=5).x == 5
39
+
40
+
41
+ def test_versioned_base_accepts_compatible_major() -> None:
42
+ Base = make_versioned_base("proof_version", "1.0", ("1",))
43
+
44
+ class Model(Base): # type: ignore[misc, valid-type]
45
+ x: int = 0
46
+
47
+ assert Model.model_validate({"proof_version": "1.9", "x": 3}).x == 3
48
+
49
+
50
+ def test_versioned_base_refuses_incompatible_major() -> None:
51
+ Base = make_versioned_base("proof_version", "1.0", ("1",))
52
+
53
+ class Model(Base): # type: ignore[misc, valid-type]
54
+ x: int = 0
55
+
56
+ with pytest.raises(VersionError):
57
+ Model.model_validate({"proof_version": "2.0", "x": 3})
58
+
59
+
60
+ def test_check_version_raises_custom_error_type() -> None:
61
+ class MyError(RuntimeError):
62
+ pass
63
+
64
+ with pytest.raises(MyError, match="not supported"):
65
+ check_version("2.0", ("1",), error=MyError)
66
+
67
+
68
+ def test_versioned_base_raises_custom_error_type() -> None:
69
+ class MyError(RuntimeError):
70
+ pass
71
+
72
+ Base = make_versioned_base("proof_version", "1.0", ("1",), error=MyError)
73
+
74
+ class Model(Base): # type: ignore[misc, valid-type]
75
+ x: int = 0
76
+
77
+ with pytest.raises(MyError):
78
+ Model.model_validate({"proof_version": "2.0"})
79
+
80
+
81
+ def test_versioned_base_supports_custom_field_name() -> None:
82
+ Base = make_versioned_base("manifest_version", "2.0", ("1", "2"))
83
+
84
+ class Row(Base): # type: ignore[misc, valid-type]
85
+ rel_path: str = ""
86
+
87
+ assert Row().manifest_version == "2.0"
88
+ Row.model_validate({"manifest_version": "1.3"}) # legacy major still supported
89
+ with pytest.raises(VersionError):
90
+ Row.model_validate({"manifest_version": "3.0"})
91
+
92
+
93
+ class _A(BaseModel):
94
+ x: int
95
+ y: str = "hi"
96
+
97
+
98
+ class _B(BaseModel):
99
+ a: _A
100
+ flag: bool = False
101
+
102
+
103
+ def test_wire_schema_has_defs() -> None:
104
+ defs = wire_schema([_A, _B], title="test")["$defs"]
105
+ assert "_A" in defs and "_B" in defs
106
+
107
+
108
+ def test_render_schema_is_canonical_and_deterministic() -> None:
109
+ a = render_schema([_A, _B], title="test")
110
+ b = render_schema([_A, _B], title="test")
111
+ assert a == b
112
+ assert a.endswith("\n")
113
+ # sorted keys: within a properties block, keys are alphabetical
114
+ assert a.index('"a"') < a.index('"flag"')