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.
- argus_cortex-0.1.0/.copier-answers.yml +20 -0
- argus_cortex-0.1.0/.github/workflows/ci.yml +19 -0
- argus_cortex-0.1.0/.github/workflows/release.yml +72 -0
- argus_cortex-0.1.0/.gitignore +10 -0
- argus_cortex-0.1.0/LICENSE +21 -0
- argus_cortex-0.1.0/Makefile +27 -0
- argus_cortex-0.1.0/PKG-INFO +72 -0
- argus_cortex-0.1.0/README.md +47 -0
- argus_cortex-0.1.0/pyproject.toml +54 -0
- argus_cortex-0.1.0/src/argus_cortex/__init__.py +58 -0
- argus_cortex-0.1.0/src/argus_cortex/_version.py +24 -0
- argus_cortex-0.1.0/src/argus_cortex/backends.py +286 -0
- argus_cortex-0.1.0/src/argus_cortex/py.typed +1 -0
- argus_cortex-0.1.0/src/argus_cortex/taxonomy.py +41 -0
- argus_cortex-0.1.0/src/argus_cortex/wire.py +107 -0
- argus_cortex-0.1.0/tests/test_backends.py +203 -0
- argus_cortex-0.1.0/tests/test_smoke.py +8 -0
- argus_cortex-0.1.0/tests/test_taxonomy.py +37 -0
- argus_cortex-0.1.0/tests/test_wire.py +114 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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"')
|