fairmind 0.3.1a0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
fairmind/client.py ADDED
@@ -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']}")
fairmind/types.py ADDED
@@ -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,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,6 @@
1
+ fairmind/__init__.py,sha256=TTE4AQr_EJvAmSO9773u9idfSg5-wP8MfHuPhWEekxc,514
2
+ fairmind/client.py,sha256=io29ARGfzcy4OLUHp7x-BcLOz6Z4NwkNf9CMs-J0wcA,11278
3
+ fairmind/types.py,sha256=w5DVPEDO379fdeIEfXm0eXRL2ru4n67DkhV1Y_NXfZg,2233
4
+ fairmind-0.3.1a0.dist-info/METADATA,sha256=1DTgv0GJvRFfbbReXNY6Y4g-brsLLXwGRe87CzKlrOs,3205
5
+ fairmind-0.3.1a0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ fairmind-0.3.1a0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any