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.
- fairmind-0.3.1a0/.gitignore +366 -0
- fairmind-0.3.1a0/PKG-INFO +63 -0
- fairmind-0.3.1a0/README.md +53 -0
- fairmind-0.3.1a0/pyproject.toml +22 -0
- fairmind-0.3.1a0/src/fairmind/__init__.py +31 -0
- fairmind-0.3.1a0/src/fairmind/client.py +247 -0
- fairmind-0.3.1a0/src/fairmind/types.py +102 -0
- fairmind-0.3.1a0/tests/test_client.py +181 -0
|
@@ -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"})
|