fairmind 0.3.1a0__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,366 @@
1
+ # Internal planning docs (local only)
2
+ TODO_DEEP_ANALYSIS.md
3
+
4
+ # Dependencies
5
+ node_modules/
6
+ junk/
7
+ .pnp
8
+ .pnp.js
9
+
10
+ # Testing
11
+ coverage/
12
+ .nyc_output
13
+
14
+ # Next.js
15
+ .next/
16
+ out/
17
+ build/
18
+ dist/
19
+
20
+ # Production
21
+ build/
22
+
23
+ # Misc
24
+ .DS_Store
25
+ *.pem
26
+ *.p12
27
+ *.key
28
+ *.pfx
29
+
30
+ # Debug
31
+ npm-debug.log*
32
+ yarn-debug.log*
33
+ yarn-error.log*
34
+
35
+ # Local env files
36
+ .env
37
+ .env*.local
38
+ .env.development.local
39
+ .env.test.local
40
+ .env.production.local
41
+ .env.prisma
42
+
43
+ # Vercel
44
+ .vercel
45
+
46
+ # TypeScript
47
+ *.tsbuildinfo
48
+ next-env.d.ts
49
+
50
+ # Python
51
+ __pycache__/
52
+ *.py[cod]
53
+ *$py.class
54
+ *.so
55
+ .Python
56
+ build/
57
+ develop-eggs/
58
+ dist/
59
+ downloads/
60
+ eggs/
61
+ .eggs/
62
+ # Python lib directories (but not frontend src/lib)
63
+ /lib/
64
+ /lib64/
65
+ apps/backend/lib/
66
+ apps/backend/lib64/
67
+ parts/
68
+ sdist/
69
+ var/
70
+ wheels/
71
+ *.egg-info/
72
+ .installed.cfg
73
+ *.egg
74
+ MANIFEST
75
+
76
+ # PyInstaller
77
+ *.manifest
78
+ *.spec
79
+
80
+ # Installer logs
81
+ pip-log.txt
82
+ pip-delete-this-directory.txt
83
+
84
+ # Unit test / coverage reports
85
+ htmlcov/
86
+ .tox/
87
+ .coverage
88
+ .coverage.*
89
+ .cache
90
+ nosetests.xml
91
+ coverage.xml
92
+ *.cover
93
+ .hypothesis/
94
+ .pytest_cache/
95
+
96
+ # Translations
97
+ *.mo
98
+ *.pot
99
+
100
+ # Django stuff:
101
+ *.log
102
+ local_settings.py
103
+ db.sqlite3
104
+
105
+ # Flask stuff:
106
+ instance/
107
+ .webassets-cache
108
+
109
+ # Scrapy stuff:
110
+ .scrapy
111
+
112
+ # Sphinx documentation
113
+ docs/_build/
114
+
115
+ # PyBuilder
116
+ target/
117
+
118
+ # Jupyter Notebook
119
+ .ipynb_checkpoints
120
+
121
+ # pyenv
122
+ .python-version
123
+
124
+ # celery beat schedule file
125
+ celerybeat-schedule
126
+
127
+ # SageMath parsed files
128
+ *.sage.py
129
+
130
+ # Environments
131
+ .env
132
+ .venv
133
+ env/
134
+ venv/
135
+ ENV/
136
+ env.bak/
137
+ venv.bak/
138
+
139
+ # Spyder project settings
140
+ .spyderproject
141
+ .spyproject
142
+
143
+ # Rope project settings
144
+ .ropeproject
145
+
146
+ # mkdocs documentation
147
+ /site
148
+ /WORKSPACE_CLEANUP_SUMMARY.md
149
+ # mypy
150
+ .mypy_cache/
151
+ .dmypy.json
152
+ dmypy.json
153
+
154
+ # Pyre type checker
155
+ .pyre/
156
+
157
+ # Security & Secrets
158
+ *.pem
159
+ *.key
160
+ *.p12
161
+ *.pfx
162
+ *.crt
163
+ *.csr
164
+ *.pem
165
+ private_key*
166
+ public_key*
167
+ id_rsa*
168
+ id_dsa*
169
+ id_ecdsa*
170
+ id_ed25519*
171
+ *.env
172
+ .env*
173
+ secrets*
174
+ config/secrets*
175
+ credentials*
176
+
177
+ # JSON files (but allow important config files)
178
+ *.json
179
+ # fumadocs section indexes order the docs navigation; they are source, not data.
180
+ !apps/docs/content/**/meta.json
181
+ !package.json
182
+ !package-lock.json
183
+ !research/aibom-fairness-evidence/**/*.json
184
+ !apps/backend/src/domain/assurance/evidence-passport-v2.schema.json
185
+ !apps/backend/src/api/routers/pack_catalogue_data/*.json
186
+ !apps/backend/reference_data/frameworks/*.json
187
+ !apps/eval-runner/seccomp/sandbox.json
188
+ !tsconfig.json
189
+ !next.config.js
190
+ !tailwind.config.ts
191
+ !postcss.config.js
192
+ !eslint.config.js
193
+ !babel.config.js
194
+ !jest.config.js
195
+ !vite.config.js
196
+ !rollup.config.js
197
+ !webpack.config.js
198
+ !metro.config.js
199
+ !bunfig.toml
200
+ !pyproject.toml
201
+ !setup.py
202
+ !requirements.txt
203
+ !Pipfile
204
+ !Pipfile.lock
205
+ !poetry.lock
206
+ !Cargo.toml
207
+ !Cargo.lock
208
+ !go.mod
209
+ !go.sum
210
+ !composer.json
211
+ !composer.lock
212
+ !Gemfile
213
+ !Gemfile.lock
214
+ !yarn.lock
215
+ !pnpm-lock.yaml
216
+ !bun.lockb
217
+
218
+ # Database
219
+ *.db
220
+ *.sqlite
221
+ *.sqlite3
222
+ *.db-journal
223
+
224
+ # Logs
225
+ *.log
226
+ logs/
227
+
228
+ # IDE
229
+ .vscode/
230
+ .idea/
231
+
232
+ # Local/external nested repositories (never track in this repo)
233
+ .claude/worktrees/
234
+ .wiki/
235
+ _external/
236
+ apps/backend/src/domain/bias/bias_bench_repo/
237
+ apps/backend/uploads/
238
+ *.swp
239
+ *.swo
240
+ *~
241
+
242
+ # OS
243
+ .DS_Store
244
+ .DS_Store?
245
+ ._*
246
+ .Spotlight-V100
247
+ .Trashes
248
+ ehthumbs.db
249
+ Thumbs.db
250
+
251
+ # Temporary files
252
+ *.tmp
253
+ *.temp
254
+ *.bak
255
+ *.backup
256
+ *.orig
257
+
258
+ # Large files
259
+ *.zip
260
+ *.tar.gz
261
+ *.rar
262
+ *.7z
263
+ *.iso
264
+ *.dmg
265
+ *.pkg
266
+ *.deb
267
+ *.rpm
268
+ *.msi
269
+ *.exe
270
+ *.dll
271
+ *.so
272
+ *.dylib
273
+ *.bin
274
+
275
+ # Model files (large ML models)
276
+ *.pkl
277
+ *.joblib
278
+ *.h5
279
+ *.hdf5
280
+ *.onnx
281
+ *.pb
282
+ *.pt
283
+ *.pth
284
+ *.safetensors
285
+ *.tflite
286
+ *.mlmodel
287
+ models/
288
+ checkpoints/
289
+
290
+ # Data files
291
+ *.csv
292
+ !research/aibom-fairness-evidence/**/*.csv
293
+ *.xml
294
+ *.yaml
295
+ *.yml
296
+ # GitHub workflows and composite actions are source.
297
+ !.github/**/*.yml
298
+ !.github/**/*.yaml
299
+ data/
300
+ datasets/
301
+ *.parquet
302
+ *.feather
303
+ *.hdf
304
+ *.h5
305
+ *.nc
306
+ *.netcdf
307
+
308
+ # Cache
309
+ .cache/
310
+ cache/
311
+ tmp/
312
+ temp/
313
+
314
+ # Specific security-sensitive files
315
+ localhost.har
316
+ *.har
317
+ apps/backend/public_key.pem
318
+
319
+ # The broad ML artifact rule ``models/`` must not hide active API source.
320
+ # Re-including the directory still leaves *.pt, *.pkl etc. ignored inside it.
321
+ !apps/backend/api/models/
322
+ !apps/backend/api/models/**/*.py
323
+ !apps/backend/src/**/models/
324
+ apps/backend/private_key.pem
325
+ apps/backend/api/services/real_bias_detection_service.py
326
+ apps/backend/email_service.py
327
+ apps/backend/neo4j_integration.py
328
+ apps/frontend/src/app/\(auth\)/signup/page.tsx
329
+ apps/website/bun.lock
330
+ apps/website/src/content/docs/installation.mdx
331
+
332
+ # Local Netlify folder
333
+ .netlify
334
+
335
+ # Archive directory
336
+ archive/
337
+ neura-dash-backup/
338
+
339
+ .qoder/
340
+
341
+ # Playwright test artifacts
342
+ .playwright-mcp/
343
+ playwright-report/
344
+ test-results/
345
+
346
+ # Screenshots and images (except logo and issue screenshots)
347
+ *.png
348
+ !**/public/logo.png
349
+ !**/logo.png
350
+ !docs/assets/issues/*.png
351
+ !assets/logo/*.png
352
+ !assets/diagrams/*.png
353
+
354
+ # Temporary documentation files
355
+ .temp-docs/
356
+
357
+ # Frontend archive (preserved for reference)
358
+ apps/frontend-archive/
359
+ apps/frontend-archive/node_modules/
360
+ apps/frontend-archive/.next/
361
+ apps/frontend-archive/out/
362
+ apps/frontend-archive/build/
363
+ apps/frontend-archive/dist/
364
+
365
+ # graft's local graph cache: regenerable, not committed (run `graft build`).
366
+ /graft/
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.5
2
+ Name: fairmind
3
+ Version: 0.3.1a0
4
+ Summary: Typed Python client for FairMind: release gate, fairness tests, monitoring and attestations.
5
+ Author-email: Adhithya Rajasekaran <adhithya@axonome.xyz>
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx>=0.27
9
+ Description-Content-Type: text/markdown
10
+
11
+ # fairmind (Python SDK)
12
+
13
+ A small typed client for the FairMind API: read the release gate, run fairness tests, record monitoring snapshots and attestations. One dependency, `httpx`.
14
+
15
+ ```bash
16
+ pip install ./apps/sdk # from a checkout; or: uv add ./apps/sdk
17
+ export FAIRMIND_API_URL=https://fairmind.example.com
18
+ export FAIRMIND_API_KEY=... # minted at POST /api/v1/auth/api-keys
19
+ ```
20
+
21
+ ```python
22
+ from fairmind import FairMind, FairMindError
23
+
24
+ fm = FairMind() # reads FAIRMIND_API_URL and FAIRMIND_API_KEY
25
+
26
+ system = fm.systems()[0]["id"]
27
+ run = fm.run_fairness(system, "predictions.csv", outcome="repaid", prediction="approved", attributes=["gender", "region"])
28
+ gate = run["gate"]
29
+ print(gate["verdict"], gate["confidence"])
30
+ for check in gate["checks"]:
31
+ print(check["label"], check["state"])
32
+ ```
33
+
34
+ ## Methods
35
+
36
+ | Method | Endpoint |
37
+ | --- | --- |
38
+ | `systems()` | `GET /systems` |
39
+ | `gate(system_id)` | `GET /systems/{id}/gate` |
40
+ | `clause_map(system_id, frameworks=None)` | `GET /systems/{id}/clause-map`; `frameworks` (for example `["EUAIA", "ISO42001"]`) filters the result |
41
+ | `seals(system_id)` | `GET /systems/{id}/seals` |
42
+ | `inspect_csv(system_id, path)` | `POST /systems/{id}/fairness-runs/inspect` |
43
+ | `run_fairness(system_id, path, *, outcome, attributes, prediction=None, score=None, favorable_label=None)` | `POST /systems/{id}/fairness-runs` |
44
+ | `fairness_runs(system_id)` | `GET /systems/{id}/fairness-runs` |
45
+ | `run_monitoring(system_id, path, *, attributes, prediction=None, score=None, favorable_label=None)` | `POST /systems/{id}/monitoring-runs` |
46
+ | `monitoring_runs(system_id)` | `GET /systems/{id}/monitoring-runs` |
47
+ | `attest(system_id, check, statement, reference=None)` | `POST /systems/{id}/attestations` |
48
+ | `evaluations(query=None, pillar=None)` | `GET /api/v1/packs/evaluations` |
49
+ | `evaluation_engine(evaluation_id)` | `GET /api/v1/packs/evaluations/{id}/engine`: the files and params it takes |
50
+ | `run_evaluation(system_id, evaluation_id, files=None, params=None, live=False, source="upload")` | `POST /api/v1/packs/evaluations/{id}/runs`; waits for behaviour-test jobs and returns the stored run |
51
+ | `evaluation_runs(evaluation_id, system_id=None)` | `GET /api/v1/packs/evaluations/{id}/runs` |
52
+
53
+ The evaluation methods act in the organisation from `FAIRMIND_ORG_ID`, or the key's first organisation when it is unset.
54
+
55
+ `attributes` is a list or a comma-separated string. `run_fairness` and `run_monitoring` need `prediction` (a 0/1 column) or `score` (a 0 to 1 column). Responses are plain dicts typed with `TypedDict`s (`Gate`, `GateCheck`, `ClauseMap`, `FairnessRun`, `MonitoringRun`, ...).
56
+
57
+ Every failure raises `FairMindError` with `status_code` (`None` when the server could not be reached) and `message` (the server's `detail` when it sent one).
58
+
59
+ ## Tests
60
+
61
+ ```bash
62
+ cd apps/sdk && uv run --with pytest pytest -q -p no:cacheprovider tests
63
+ ```
@@ -0,0 +1,53 @@
1
+ # fairmind (Python SDK)
2
+
3
+ A small typed client for the FairMind API: read the release gate, run fairness tests, record monitoring snapshots and attestations. One dependency, `httpx`.
4
+
5
+ ```bash
6
+ pip install ./apps/sdk # from a checkout; or: uv add ./apps/sdk
7
+ export FAIRMIND_API_URL=https://fairmind.example.com
8
+ export FAIRMIND_API_KEY=... # minted at POST /api/v1/auth/api-keys
9
+ ```
10
+
11
+ ```python
12
+ from fairmind import FairMind, FairMindError
13
+
14
+ fm = FairMind() # reads FAIRMIND_API_URL and FAIRMIND_API_KEY
15
+
16
+ system = fm.systems()[0]["id"]
17
+ run = fm.run_fairness(system, "predictions.csv", outcome="repaid", prediction="approved", attributes=["gender", "region"])
18
+ gate = run["gate"]
19
+ print(gate["verdict"], gate["confidence"])
20
+ for check in gate["checks"]:
21
+ print(check["label"], check["state"])
22
+ ```
23
+
24
+ ## Methods
25
+
26
+ | Method | Endpoint |
27
+ | --- | --- |
28
+ | `systems()` | `GET /systems` |
29
+ | `gate(system_id)` | `GET /systems/{id}/gate` |
30
+ | `clause_map(system_id, frameworks=None)` | `GET /systems/{id}/clause-map`; `frameworks` (for example `["EUAIA", "ISO42001"]`) filters the result |
31
+ | `seals(system_id)` | `GET /systems/{id}/seals` |
32
+ | `inspect_csv(system_id, path)` | `POST /systems/{id}/fairness-runs/inspect` |
33
+ | `run_fairness(system_id, path, *, outcome, attributes, prediction=None, score=None, favorable_label=None)` | `POST /systems/{id}/fairness-runs` |
34
+ | `fairness_runs(system_id)` | `GET /systems/{id}/fairness-runs` |
35
+ | `run_monitoring(system_id, path, *, attributes, prediction=None, score=None, favorable_label=None)` | `POST /systems/{id}/monitoring-runs` |
36
+ | `monitoring_runs(system_id)` | `GET /systems/{id}/monitoring-runs` |
37
+ | `attest(system_id, check, statement, reference=None)` | `POST /systems/{id}/attestations` |
38
+ | `evaluations(query=None, pillar=None)` | `GET /api/v1/packs/evaluations` |
39
+ | `evaluation_engine(evaluation_id)` | `GET /api/v1/packs/evaluations/{id}/engine`: the files and params it takes |
40
+ | `run_evaluation(system_id, evaluation_id, files=None, params=None, live=False, source="upload")` | `POST /api/v1/packs/evaluations/{id}/runs`; waits for behaviour-test jobs and returns the stored run |
41
+ | `evaluation_runs(evaluation_id, system_id=None)` | `GET /api/v1/packs/evaluations/{id}/runs` |
42
+
43
+ The evaluation methods act in the organisation from `FAIRMIND_ORG_ID`, or the key's first organisation when it is unset.
44
+
45
+ `attributes` is a list or a comma-separated string. `run_fairness` and `run_monitoring` need `prediction` (a 0/1 column) or `score` (a 0 to 1 column). Responses are plain dicts typed with `TypedDict`s (`Gate`, `GateCheck`, `ClauseMap`, `FairnessRun`, `MonitoringRun`, ...).
46
+
47
+ Every failure raises `FairMindError` with `status_code` (`None` when the server could not be reached) and `message` (the server's `detail` when it sent one).
48
+
49
+ ## Tests
50
+
51
+ ```bash
52
+ cd apps/sdk && uv run --with pytest pytest -q -p no:cacheprovider tests
53
+ ```
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "fairmind"
3
+ version = "0.3.1a0"
4
+ description = "Typed Python client for FairMind: release gate, fairness tests, monitoring and attestations."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "Apache-2.0" }
8
+ authors = [{ name = "Adhithya Rajasekaran", email = "adhithya@axonome.xyz" }]
9
+ dependencies = ["httpx>=0.27"]
10
+
11
+ [dependency-groups]
12
+ dev = ["pytest>=7.4"]
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/fairmind"]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
@@ -0,0 +1,31 @@
1
+ """Typed Python client for FairMind."""
2
+
3
+ __version__ = "0.3.1a0"
4
+
5
+ from .client import FairMind, FairMindError # noqa: E402
6
+ from .types import ( # noqa: E402
7
+ Attestation,
8
+ Clause,
9
+ ClauseMap,
10
+ FairnessRun,
11
+ Framework,
12
+ Gate,
13
+ GateCheck,
14
+ MonitoringRun,
15
+ System,
16
+ )
17
+
18
+ __all__ = [
19
+ "FairMind",
20
+ "FairMindError",
21
+ "Attestation",
22
+ "Clause",
23
+ "ClauseMap",
24
+ "FairnessRun",
25
+ "Framework",
26
+ "Gate",
27
+ "GateCheck",
28
+ "MonitoringRun",
29
+ "System",
30
+ "__version__",
31
+ ]
@@ -0,0 +1,247 @@
1
+ """HTTP client for the FairMind API. Mirrors the conventions of the fairmind CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from contextlib import ExitStack
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Sequence, Union
11
+
12
+ import httpx
13
+
14
+ from . import __version__
15
+ from .types import Attestation, AttestableCheck, ClauseMap, FairnessRun, Gate, MonitoringRun, System
16
+
17
+ GOV = "/api/v1/ai-governance"
18
+ PACKS = "/api/v1/packs"
19
+ DEFAULT_API_URL = "http://localhost:8010"
20
+
21
+ # legacy framework ids -> catalogue keys, as accepted by the report request
22
+ _FRAMEWORK_ALIASES = {"eu_ai_act": "EUAIA", "iso_42001": "ISO42001", "nist_ai_rmf": "NISTRMF", "india_ai": "INDAI"}
23
+
24
+ PathLike = Union[str, "os.PathLike[str]"]
25
+
26
+
27
+ class FairMindError(Exception):
28
+ """A failed call. `status_code` is None when the server could not be reached."""
29
+
30
+ def __init__(self, message: str, status_code: Optional[int] = None) -> None:
31
+ super().__init__(message)
32
+ self.message = message
33
+ self.status_code = status_code
34
+
35
+ def __str__(self) -> str:
36
+ return f"{self.status_code}: {self.message}" if self.status_code else self.message
37
+
38
+
39
+ def _split(value: Union[str, Sequence[str]]) -> str:
40
+ return value if isinstance(value, str) else ",".join(value)
41
+
42
+
43
+ class FairMind:
44
+ """`FairMind(api_url=None, api_key=None)`; FAIRMIND_API_URL and FAIRMIND_API_KEY are read when omitted."""
45
+
46
+ def __init__(
47
+ self,
48
+ api_url: Optional[str] = None,
49
+ api_key: Optional[str] = None,
50
+ *,
51
+ timeout: Union[float, httpx.Timeout] = httpx.Timeout(180.0, connect=10.0),
52
+ transport: Optional[httpx.BaseTransport] = None,
53
+ ) -> None:
54
+ self.api_url = (api_url or os.environ.get("FAIRMIND_API_URL") or DEFAULT_API_URL).rstrip("/")
55
+ key = api_key or os.environ.get("FAIRMIND_API_KEY") or ""
56
+ headers = {"Accept": "application/json", "User-Agent": f"fairmind-sdk/{__version__}"}
57
+ if key:
58
+ headers["Authorization"] = f"Bearer {key}"
59
+ self._http = httpx.Client(base_url=self.api_url, headers=headers, timeout=timeout, transport=transport)
60
+
61
+ def close(self) -> None:
62
+ self._http.close()
63
+
64
+ def __enter__(self) -> "FairMind":
65
+ return self
66
+
67
+ def __exit__(self, *exc: Any) -> None:
68
+ self.close()
69
+
70
+ # -- transport -----------------------------------------------------------
71
+
72
+ def _call(self, method: str, path: str, **kwargs: Any) -> Any:
73
+ try:
74
+ response = self._http.request(method, path, **kwargs)
75
+ except httpx.HTTPError as exc:
76
+ raise FairMindError(f"Could not reach FairMind at {self.api_url}: {exc}") from exc
77
+ code = response.status_code
78
+ if code == 401:
79
+ raise FairMindError("Not signed in. Set FAIRMIND_API_KEY to a key from POST /api/v1/auth/api-keys.", code)
80
+ if code >= 400:
81
+ try:
82
+ body = response.json()
83
+ detail = body.get("detail") or body.get("message") or response.text
84
+ except (ValueError, AttributeError):
85
+ detail = response.text
86
+ if code == 404:
87
+ detail = f"Not found: {path}. Check the system id and that your key belongs to its organisation. {detail}"
88
+ raise FairMindError(f"{method} {path} failed: {detail}", code)
89
+ try:
90
+ return response.json()
91
+ except ValueError as exc:
92
+ raise FairMindError(f"{method} {path} returned something that is not JSON", code) from exc
93
+
94
+ def _upload(self, path: str, csv_path: PathLike, data: Dict[str, str]) -> Any:
95
+ p = Path(csv_path)
96
+ if not p.is_file():
97
+ raise FairMindError(f"No such file: {p}")
98
+ with p.open("rb") as handle:
99
+ return self._call("POST", path, data=data, files={"file": (p.name, handle, "text/csv")})
100
+
101
+ # -- reads ---------------------------------------------------------------
102
+
103
+ def systems(self) -> List[System]:
104
+ return self._call("GET", f"{GOV}/systems")
105
+
106
+ def gate(self, system_id: str) -> Gate:
107
+ return self._call("GET", f"{GOV}/systems/{system_id}/gate")
108
+
109
+ def clause_map(self, system_id: str, frameworks: Optional[Sequence[str]] = None) -> ClauseMap:
110
+ """The gate read per framework clause. `frameworks` filters client-side by catalogue key (EUAIA, ISO42001, ...)."""
111
+ result = self._call("GET", f"{GOV}/systems/{system_id}/clause-map")
112
+ if frameworks:
113
+ wanted = {_FRAMEWORK_ALIASES.get(f.lower(), f).upper() for f in frameworks}
114
+ result["frameworks"] = [f for f in result["frameworks"] if f["key"].upper() in wanted]
115
+ return result
116
+
117
+ def seals(self, system_id: str) -> List[Dict[str, Any]]:
118
+ return self._call("GET", f"{GOV}/systems/{system_id}/seals")
119
+
120
+ def fairness_runs(self, system_id: str) -> List[Dict[str, Any]]:
121
+ return self._call("GET", f"{GOV}/systems/{system_id}/fairness-runs")
122
+
123
+ def monitoring_runs(self, system_id: str) -> List[Dict[str, Any]]:
124
+ return self._call("GET", f"{GOV}/systems/{system_id}/monitoring-runs")
125
+
126
+ # -- writes --------------------------------------------------------------
127
+
128
+ def inspect_csv(self, system_id: str, path: PathLike) -> Dict[str, Any]:
129
+ """Columns of a CSV with sample values and a suggested outcome/prediction/score/attributes mapping."""
130
+ return self._upload(f"{GOV}/systems/{system_id}/fairness-runs/inspect", path, {})
131
+
132
+ def run_fairness(
133
+ self,
134
+ system_id: str,
135
+ path: PathLike,
136
+ *,
137
+ outcome: str,
138
+ attributes: Union[str, Sequence[str]],
139
+ prediction: Optional[str] = None,
140
+ score: Optional[str] = None,
141
+ favorable_label: Optional[str] = None,
142
+ ) -> FairnessRun:
143
+ if not prediction and not score:
144
+ raise FairMindError("Give prediction (a 0/1 column) or score (a 0 to 1 column)")
145
+ data = {"outcome": outcome, "attributes": _split(attributes)}
146
+ for key, value in (("prediction", prediction), ("score", score), ("favorable_label", favorable_label)):
147
+ if value:
148
+ data[key] = value
149
+ return self._upload(f"{GOV}/systems/{system_id}/fairness-runs", path, data)
150
+
151
+ def run_monitoring(
152
+ self,
153
+ system_id: str,
154
+ path: PathLike,
155
+ *,
156
+ attributes: Union[str, Sequence[str]],
157
+ prediction: Optional[str] = None,
158
+ score: Optional[str] = None,
159
+ favorable_label: Optional[str] = None,
160
+ ) -> MonitoringRun:
161
+ if not prediction and not score:
162
+ raise FairMindError("Give prediction (a 0/1 column) or score (a 0 to 1 column)")
163
+ data = {"attributes": _split(attributes)}
164
+ for key, value in (("prediction", prediction), ("score", score), ("favorable_label", favorable_label)):
165
+ if value:
166
+ data[key] = value
167
+ return self._upload(f"{GOV}/systems/{system_id}/monitoring-runs", path, data)
168
+
169
+ def attest(self, system_id: str, check: AttestableCheck, statement: str, reference: Optional[str] = None) -> Attestation:
170
+ """Record that a person states a gate check is satisfied. Manual provenance: it attests, it never proves."""
171
+ body: Dict[str, Any] = {"check": check, "statement": statement}
172
+ if reference:
173
+ body["reference"] = reference
174
+ return self._call("POST", f"{GOV}/systems/{system_id}/attestations", json=body)
175
+
176
+ # -- catalogue evaluations ---------------------------------------------
177
+
178
+ def organization_id(self) -> str:
179
+ """The organisation the evaluation endpoints act in: FAIRMIND_ORG_ID, else the key's first organisation."""
180
+ if not getattr(self, "_org_id", None):
181
+ org = os.environ.get("FAIRMIND_ORG_ID")
182
+ if not org:
183
+ orgs = self._call("GET", "/api/v1/organizations").get("organizations") or []
184
+ if not orgs:
185
+ raise FairMindError("This key belongs to no organisation")
186
+ org = orgs[0]["id"]
187
+ self._org_id = org
188
+ return self._org_id
189
+
190
+ def _packs(self, method: str, path: str, **kwargs: Any) -> Any:
191
+ params = {**kwargs.pop("params", {}), "org_id": self.organization_id()}
192
+ return self._call(method, f"{PACKS}{path}", params=params, **kwargs)
193
+
194
+ def evaluations(self, query: Optional[str] = None, pillar: Optional[str] = None) -> List[Dict[str, Any]]:
195
+ """Catalogue evaluations; `runnable` is true for those FairMind computes itself."""
196
+ params = {k: v for k, v in (("q", query), ("pillar", pillar)) if v}
197
+ return self._packs("GET", "/evaluations", params=params)
198
+
199
+ def evaluation_engine(self, evaluation_id: str) -> Dict[str, Any]:
200
+ """The files (slots) and params an evaluation takes, and its live alternate if it has one."""
201
+ return self._packs("GET", f"/evaluations/{evaluation_id}/engine")
202
+
203
+ def evaluation_runs(self, evaluation_id: str, system_id: Optional[str] = None) -> List[Dict[str, Any]]:
204
+ params = {"system_id": system_id} if system_id else {}
205
+ return self._packs("GET", f"/evaluations/{evaluation_id}/runs", params=params)
206
+
207
+ def run_evaluation(
208
+ self,
209
+ system_id: str,
210
+ evaluation_id: str,
211
+ files: Optional[Dict[str, PathLike]] = None,
212
+ params: Optional[Dict[str, Any]] = None,
213
+ *,
214
+ live: bool = False,
215
+ source: str = "upload",
216
+ timeout: float = 900.0,
217
+ poll_interval: float = 3.0,
218
+ ) -> Dict[str, Any]:
219
+ """Run a catalogue evaluation and return the stored run (status passed, failed or insufficient, metrics, detail).
220
+
221
+ `files` maps the engine's slot names to local paths. `live=True` runs the evaluation's behaviour test
222
+ against the system's connected endpoint; `source="connection"` reads the organisation's agent platform.
223
+ Behaviour tests run as background jobs on the server; this waits for them up to `timeout` seconds.
224
+ """
225
+ data = {"system_id": system_id, "params": json.dumps(params or {}), "source": source}
226
+ if live:
227
+ data["mode"] = "live"
228
+ with ExitStack() as stack:
229
+ uploads = {}
230
+ for slot, path in (files or {}).items():
231
+ p = Path(path)
232
+ if not p.is_file():
233
+ raise FairMindError(f"No such file: {p}")
234
+ uploads[slot] = (p.name, stack.enter_context(p.open("rb")))
235
+ result = self._packs("POST", f"/evaluations/{evaluation_id}/runs", data=data, files=uploads or None)
236
+ job = result.get("job") if isinstance(result, dict) else None
237
+ if not job:
238
+ return result
239
+ deadline = time.monotonic() + timeout
240
+ while job["status"] not in ("done", "failed"):
241
+ if time.monotonic() > deadline:
242
+ raise FairMindError(f"Evaluation job {job['id']} is still running after {timeout:.0f}s; check it later")
243
+ time.sleep(poll_interval)
244
+ job = self._packs("GET", f"/evaluation-jobs/{job['id']}")
245
+ if job["status"] == "failed":
246
+ raise FairMindError(f"Evaluation {evaluation_id} failed: {job.get('error') or 'no reason given'}")
247
+ return self._packs("GET", f"/evaluation-runs/{job['evidenceId']}")
@@ -0,0 +1,102 @@
1
+ """Response shapes, as the API returns them (camelCase keys where the API uses them)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Literal, Optional, TypedDict
6
+
7
+ Verdict = Literal["go", "conditional_go", "no_go"]
8
+ ApprovalStatus = Literal["not_requested", "pending", "approved", "rejected"]
9
+ CheckState = Literal["proven", "attested", "open"]
10
+ AttestableCheck = Literal["data_governance", "human_oversight", "monitoring", "environmental"]
11
+
12
+
13
+ class System(TypedDict, total=False):
14
+ id: str
15
+ name: str
16
+ owner: str
17
+ riskTier: str
18
+ lifecycleStage: str
19
+ workspaceId: str
20
+
21
+
22
+ class GateCheck(TypedDict):
23
+ key: str
24
+ label: str
25
+ state: CheckState
26
+ confidence: float
27
+ evidenceId: Optional[str]
28
+ detail: Optional[Dict[str, Any]]
29
+
30
+
31
+ class Gate(TypedDict, total=False):
32
+ verdict: Verdict
33
+ confidence: float
34
+ band: str
35
+ checks: List[GateCheck]
36
+ reasons: List[str]
37
+ proven: int
38
+ attested: int
39
+ open: int
40
+ systemId: str
41
+ riskTier: str
42
+ approvalStatus: ApprovalStatus # latest release decision; "rejected" forces verdict no_go
43
+
44
+
45
+ class ClauseCheck(TypedDict):
46
+ check: str
47
+ label: str
48
+ state: CheckState
49
+ confidence: float
50
+ evidenceId: Optional[str]
51
+ relation: str
52
+
53
+
54
+ class Clause(TypedDict):
55
+ key: str
56
+ control: str
57
+ title: str
58
+ group: Optional[str]
59
+ checks: List[ClauseCheck]
60
+ state: CheckState
61
+
62
+
63
+ class Framework(TypedDict):
64
+ key: str
65
+ name: str
66
+ clauses: List[Clause]
67
+ counts: Dict[str, int]
68
+
69
+
70
+ class ClauseMap(TypedDict):
71
+ verdict: Verdict
72
+ confidence: float
73
+ frameworks: List[Framework]
74
+ systemId: str
75
+
76
+
77
+ class FairnessRun(TypedDict):
78
+ """Result of POST /fairness-runs. `result` holds per-attribute metrics and `all_checks_pass`."""
79
+
80
+ runId: str
81
+ evidenceId: str
82
+ systemId: str
83
+ title: str
84
+ result: Dict[str, Any]
85
+ gate: Gate
86
+
87
+
88
+ class MonitoringRun(TypedDict):
89
+ """Result of POST /monitoring-runs. `result` holds drift, impact ratio and score PSI per attribute."""
90
+
91
+ evidenceId: str
92
+ systemId: str
93
+ title: str
94
+ result: Dict[str, Any]
95
+ gate: Gate
96
+
97
+
98
+ class Attestation(TypedDict):
99
+ evidenceId: str
100
+ systemId: str
101
+ check: AttestableCheck
102
+ gate: Gate
@@ -0,0 +1,181 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from fairmind import FairMind, FairMindError
7
+
8
+ GOV = "/api/v1/ai-governance"
9
+
10
+
11
+ def make(handler, **kwargs):
12
+ return FairMind(api_url="http://fm.test/", api_key="k", transport=httpx.MockTransport(handler), **kwargs)
13
+
14
+
15
+ def test_auth_header_and_base_url():
16
+ seen = {}
17
+
18
+ def handler(request):
19
+ seen["url"], seen["auth"] = str(request.url), request.headers["authorization"]
20
+ return httpx.Response(200, json=[])
21
+
22
+ assert make(handler).systems() == []
23
+ assert seen == {"url": f"http://fm.test{GOV}/systems", "auth": "Bearer k"}
24
+
25
+
26
+ def test_env_defaults(monkeypatch):
27
+ monkeypatch.setenv("FAIRMIND_API_URL", "http://env.test")
28
+ monkeypatch.setenv("FAIRMIND_API_KEY", "envkey")
29
+ client = FairMind()
30
+ assert client.api_url == "http://env.test"
31
+ assert client._http.headers["authorization"] == "Bearer envkey"
32
+
33
+
34
+ def test_gate_and_error_carries_status_and_detail():
35
+ def handler(request):
36
+ if request.url.path.endswith("/s1/gate"):
37
+ return httpx.Response(200, json={"verdict": "go"})
38
+ return httpx.Response(403, json={"detail": "read only key"})
39
+
40
+ fm = make(handler)
41
+ assert fm.gate("s1")["verdict"] == "go"
42
+ with pytest.raises(FairMindError) as err:
43
+ fm.gate("s2")
44
+ assert err.value.status_code == 403 and "read only key" in err.value.message
45
+
46
+
47
+ def test_401_and_404_messages():
48
+ fm = make(lambda r: httpx.Response(401 if "a" in r.url.path.split("/")[-2] else 404, json={"detail": "x"}))
49
+ with pytest.raises(FairMindError, match="FAIRMIND_API_KEY") as err:
50
+ fm.gate("a")
51
+ assert err.value.status_code == 401
52
+ with pytest.raises(FairMindError, match="Not found") as err:
53
+ fm.gate("zz")
54
+ assert err.value.status_code == 404
55
+
56
+
57
+ def test_network_failure_has_no_status():
58
+ def handler(request):
59
+ raise httpx.ConnectError("refused")
60
+
61
+ with pytest.raises(FairMindError, match="Could not reach") as err:
62
+ make(handler).systems()
63
+ assert err.value.status_code is None
64
+
65
+
66
+ def test_clause_map_filters_frameworks():
67
+ payload = {"verdict": "go", "confidence": 0.9, "frameworks": [{"key": "EUAIA"}, {"key": "ISO42001"}, {"key": "GDPR"}]}
68
+ fm = make(lambda r: httpx.Response(200, json=json.loads(json.dumps(payload))))
69
+ assert [f["key"] for f in fm.clause_map("s")["frameworks"]] == ["EUAIA", "ISO42001", "GDPR"]
70
+ assert [f["key"] for f in fm.clause_map("s", frameworks=["eu_ai_act", "GDPR"])["frameworks"]] == ["EUAIA", "GDPR"]
71
+
72
+
73
+ def test_run_fairness_sends_multipart(tmp_path):
74
+ csv = tmp_path / "p.csv"
75
+ csv.write_text("y,p,g\n1,1,a\n")
76
+ seen = {}
77
+
78
+ def handler(request):
79
+ seen["path"], seen["body"] = request.url.path, request.content.decode()
80
+ return httpx.Response(200, json={"runId": "r"})
81
+
82
+ out = make(handler).run_fairness("s1", csv, outcome="y", prediction="p", attributes=["g", "h"], favorable_label="1")
83
+ assert out["runId"] == "r"
84
+ assert seen["path"] == f"{GOV}/systems/s1/fairness-runs"
85
+ for needle in ('name="outcome"', "\r\ny\r\n", "g,h", 'name="favorable_label"', 'filename="p.csv"', "y,p,g"):
86
+ assert needle in seen["body"]
87
+ assert 'name="score"' not in seen["body"]
88
+
89
+
90
+ def test_run_requires_prediction_or_score_and_existing_file(tmp_path):
91
+ fm = make(lambda r: httpx.Response(200, json={}))
92
+ csv = tmp_path / "p.csv"
93
+ csv.write_text("a\n1\n")
94
+ with pytest.raises(FairMindError, match="prediction"):
95
+ fm.run_fairness("s", csv, outcome="y", attributes="g")
96
+ with pytest.raises(FairMindError, match="No such file"):
97
+ fm.run_monitoring("s", tmp_path / "missing.csv", attributes="g", score="s")
98
+
99
+
100
+ def test_monitoring_inspect_and_attest(tmp_path):
101
+ csv = tmp_path / "d.csv"
102
+ csv.write_text("s,g\n0.4,a\n")
103
+ calls = []
104
+
105
+ def handler(request):
106
+ calls.append((request.method, request.url.path, request.headers.get("content-type", "")))
107
+ if request.url.path.endswith("/attestations"):
108
+ assert json.loads(request.content) == {"check": "monitoring", "statement": "Dashboards reviewed weekly", "reference": "RUN-1"}
109
+ return httpx.Response(200, json={})
110
+
111
+ fm = make(handler)
112
+ fm.run_monitoring("s", csv, attributes="g", score="s")
113
+ fm.inspect_csv("s", csv)
114
+ fm.attest("s", "monitoring", "Dashboards reviewed weekly", "RUN-1")
115
+ assert [(m, p.rsplit("/", 1)[-1]) for m, p, _ in calls] == [("POST", "monitoring-runs"), ("POST", "inspect"), ("POST", "attestations")]
116
+ assert all(c[2].startswith("multipart/") for c in calls[:2]) and calls[2][2] == "application/json"
117
+
118
+
119
+ def test_org_id_from_env_or_first_organisation(monkeypatch):
120
+ calls = []
121
+
122
+ def handler(request):
123
+ calls.append(request.url.path)
124
+ return httpx.Response(200, json={"organizations": [{"id": "o1"}, {"id": "o2"}]})
125
+
126
+ monkeypatch.delenv("FAIRMIND_ORG_ID", raising=False)
127
+ fm = make(handler)
128
+ assert fm.organization_id() == "o1" and fm.organization_id() == "o1"
129
+ assert calls == ["/api/v1/organizations"]
130
+ monkeypatch.setenv("FAIRMIND_ORG_ID", "o9")
131
+ assert make(handler).organization_id() == "o9"
132
+
133
+
134
+ def test_file_evaluation_uploads_slots_and_returns_run(tmp_path, monkeypatch):
135
+ monkeypatch.setenv("FAIRMIND_ORG_ID", "o1")
136
+ data = tmp_path / "train.csv"
137
+ data.write_text("a\n1\n")
138
+ seen = {}
139
+
140
+ def handler(request):
141
+ seen["url"] = str(request.url)
142
+ seen["body"] = request.read()
143
+ return httpx.Response(200, json={"id": "ev1", "status": "passed"})
144
+
145
+ run = make(handler).run_evaluation("s1", "EV-DAT-01", {"train": data}, {"column": "a"})
146
+ assert run["status"] == "passed"
147
+ assert seen["url"] == "http://fm.test/api/v1/packs/evaluations/EV-DAT-01/runs?org_id=o1"
148
+ assert b'name="train"; filename="train.csv"' in seen["body"]
149
+ assert b'{"column": "a"}' in seen["body"] and b'name="mode"' not in seen["body"]
150
+
151
+
152
+ def test_live_evaluation_waits_for_its_job(monkeypatch):
153
+ monkeypatch.setenv("FAIRMIND_ORG_ID", "o1")
154
+ polls = iter([{"id": "j1", "status": "running"}, {"id": "j1", "status": "done", "evidenceId": "ev7"}])
155
+
156
+ def handler(request):
157
+ path = request.url.path
158
+ if path.endswith("/runs"):
159
+ assert b"mode=live" in request.read() # form-encoded when no files
160
+ return httpx.Response(202, json={"job": {"id": "j1", "status": "running"}})
161
+ if path.endswith("/evaluation-jobs/j1"):
162
+ return httpx.Response(200, json=next(polls))
163
+ assert path.endswith("/evaluation-runs/ev7")
164
+ return httpx.Response(200, json={"id": "ev7", "status": "failed"})
165
+
166
+ assert make(handler).run_evaluation("s1", "EV-SEC-01", live=True, poll_interval=0)["id"] == "ev7"
167
+
168
+
169
+ def test_failed_job_and_missing_file_raise(tmp_path, monkeypatch):
170
+ monkeypatch.setenv("FAIRMIND_ORG_ID", "o1")
171
+
172
+ def handler(request):
173
+ if request.url.path.endswith("/runs"):
174
+ return httpx.Response(202, json={"job": {"id": "j1", "status": "failed", "error": "no judge"}})
175
+ raise AssertionError(request.url)
176
+
177
+ fm = make(handler)
178
+ with pytest.raises(FairMindError, match="no judge"):
179
+ fm.run_evaluation("s1", "EV-SEC-01", live=True)
180
+ with pytest.raises(FairMindError, match="No such file"):
181
+ fm.run_evaluation("s1", "EV-DAT-01", {"train": tmp_path / "missing.csv"})