mindgraph-sdk 0.7.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: '3.12'
17
+
18
+ - name: Install (with dev deps)
19
+ run: pip install -e ".[dev]"
20
+
21
+ - name: Lint
22
+ run: ruff check mindgraph/
23
+
24
+ # Offline contract tests only. The live prod-mutating E2E
25
+ # (tests/test_integration.py) SKIPS here because MINDGRAPH_E2E is unset,
26
+ # so no API key / network is required.
27
+ - name: Test (offline)
28
+ run: pytest -q
@@ -0,0 +1,33 @@
1
+ name: Live E2E (manual)
2
+
3
+ # Manual-only. This runs the LIVE, PROD-MUTATING integration suite
4
+ # (tests/test_integration.py): it creates/updates/tombstones real nodes in the
5
+ # org the key belongs to. NEVER point MINDGRAPH_E2E_API_KEY at a customer org —
6
+ # use a DEDICATED TEST ORG key. Set it in repo secrets as MINDGRAPH_E2E_API_KEY.
7
+ on:
8
+ workflow_dispatch:
9
+ inputs:
10
+ base_url:
11
+ description: 'API base URL to target (test org)'
12
+ required: false
13
+ default: 'https://api.mindgraph.cloud'
14
+
15
+ jobs:
16
+ e2e:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: '3.12'
24
+
25
+ - name: Install (with dev deps)
26
+ run: pip install -e ".[dev]"
27
+
28
+ - name: Live integration tests (mutates the test org)
29
+ env:
30
+ MINDGRAPH_E2E: '1'
31
+ API_KEY: ${{ secrets.MINDGRAPH_E2E_API_KEY }}
32
+ BASE_URL: ${{ github.event.inputs.base_url }}
33
+ run: pytest tests/test_integration.py -v
@@ -0,0 +1,45 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+ environment: pypi
13
+ permissions:
14
+ id-token: write
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: '3.12'
21
+
22
+ - name: Verify tag matches package version
23
+ if: github.event_name == 'push'
24
+ run: |
25
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
26
+ PKG_VERSION="$(python3 -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
27
+ echo "Tag version: $TAG_VERSION"
28
+ echo "Package version: $PKG_VERSION"
29
+ if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
30
+ echo "::error::Tag ($TAG_VERSION) does not match pyproject.toml version ($PKG_VERSION). Aborting publish."
31
+ exit 1
32
+ fi
33
+
34
+ - run: pip install -e ".[dev]"
35
+
36
+ - name: Lint
37
+ run: ruff check mindgraph/
38
+
39
+ - name: Test
40
+ run: pytest
41
+
42
+ - run: pip install build
43
+ - run: python -m build
44
+
45
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 (2026-06-29)
4
+
5
+ ### Added
6
+
7
+ - `ingest_document()` gains optional `page_offsets`, `page_count`, `mime_type`, and
8
+ `force_reingest`. `page_offsets` entries are `{"page", "char_start"}` where
9
+ `char_start` is a UTF-8 **byte** offset into `content`. Identical content reuses the
10
+ existing Document (response `deduplicated`). Server ≥ 1.5.0.
11
+ - `retrieve_context()` responses: Claim/graph nodes may carry `source_chunks`
12
+ (citation provenance: chunk offsets, page range, matched quote, anchor selector)
13
+ and `believed_by` (per-agent assertion stance: `agent_uid`, `agent_label`,
14
+ `confidence`). Documented in the method docstring.
15
+ - `backfill_anchors()` — start the `/backfill/anchors` job (populate
16
+ `ExtractedFrom.location` selectors for pre-existing edges).
17
+
18
+ ## 0.8.0 (2026-06-17)
19
+
20
+ ### Added
21
+
22
+ - `list_ontology_tools()` — the generated read-tool manifest
23
+ (`GET /v1/ontology/tools`): per active object type, descriptors for
24
+ `search_<objs>` / `get_<obj>` / `summarize_<obj>` that an MCP server (or your
25
+ own agent loop) renders into tools. `summarize_<obj>` returns the object —
26
+ whether mapped from a connected SQL database or extracted from documents —
27
+ plus its cognitive context. Parity with the TypeScript SDK.
28
+ - `backing` kwarg on ontology object-type creation — bind a type to an external
29
+ SQL source (Layer 7 semantic contract). Connection management itself stays in
30
+ the dashboard.
31
+
3
32
  ## 0.7.0 (2026-06-14)
4
33
 
5
34
  ### Added
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Shuruheel Ahamed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mindgraph-sdk
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Python client for the MindGraph Cloud API
5
5
  License-Expression: MIT
6
+ License-File: LICENSE
6
7
  Keywords: agent,ai,cognitive,knowledge-graph,mindgraph
7
8
  Requires-Python: >=3.9
8
9
  Requires-Dist: httpx>=0.25
@@ -142,6 +143,27 @@ job = graph.run_synthesis(project["uid"])
142
143
  status = graph.get_job(job["job_id"])
143
144
  ```
144
145
 
146
+ ### Operational Ontology (Layer 7)
147
+
148
+ Define typed domain objects (Customer, Order, Contract…) as a semantic contract and either **bind them to a SQL database** or extract them from documents — fused onto one object. Connecting a database (credentials/sync) is done in the dashboard; the SDK proposes/reviews schemas, queries, and lists the generated agent read tools.
149
+
150
+ | Method | Description |
151
+ |--------|-------------|
152
+ | `propose_ontology_schema(...)` | Draft a schema from a description (+ optional sample docs); returns `{"schema_id", "job_id"}` |
153
+ | `activate_ontology_schema(id)` / `get_ontology_schema(id)` / `list_ontology_schemas()` | Schema lifecycle |
154
+ | `list_ontology_proposals(...)` / `approve_ontology_proposal(id)` / `reject_ontology_proposal(id)` | Review extracted-object proposals |
155
+ | `query_ontology(query=..., schema_id=...)` | Typed retrieval with the cognitive overlay fused in |
156
+ | `list_ontology_tools()` | The generated read-tool manifest (`search_/get_/summarize_<obj>`) the MCP server renders |
157
+
158
+ ```python
159
+ res = graph.propose_ontology_schema(description="Clients, orders, contracts.")
160
+ graph.activate_ontology_schema(res["schema_id"])
161
+ tools = graph.list_ontology_tools()
162
+ ctx = graph.query_ontology(query="Which customers are a churn risk?", schema_id=res["schema_id"])
163
+ ```
164
+
165
+ See the [Operational Ontology](https://mindgraph.cloud/docs/ontology) and [Connect a database](https://mindgraph.cloud/docs/connect) docs.
166
+
145
167
  ### CRUD
146
168
 
147
169
  | Method | Description |
@@ -128,6 +128,27 @@ job = graph.run_synthesis(project["uid"])
128
128
  status = graph.get_job(job["job_id"])
129
129
  ```
130
130
 
131
+ ### Operational Ontology (Layer 7)
132
+
133
+ Define typed domain objects (Customer, Order, Contract…) as a semantic contract and either **bind them to a SQL database** or extract them from documents — fused onto one object. Connecting a database (credentials/sync) is done in the dashboard; the SDK proposes/reviews schemas, queries, and lists the generated agent read tools.
134
+
135
+ | Method | Description |
136
+ |--------|-------------|
137
+ | `propose_ontology_schema(...)` | Draft a schema from a description (+ optional sample docs); returns `{"schema_id", "job_id"}` |
138
+ | `activate_ontology_schema(id)` / `get_ontology_schema(id)` / `list_ontology_schemas()` | Schema lifecycle |
139
+ | `list_ontology_proposals(...)` / `approve_ontology_proposal(id)` / `reject_ontology_proposal(id)` | Review extracted-object proposals |
140
+ | `query_ontology(query=..., schema_id=...)` | Typed retrieval with the cognitive overlay fused in |
141
+ | `list_ontology_tools()` | The generated read-tool manifest (`search_/get_/summarize_<obj>`) the MCP server renders |
142
+
143
+ ```python
144
+ res = graph.propose_ontology_schema(description="Clients, orders, contracts.")
145
+ graph.activate_ontology_schema(res["schema_id"])
146
+ tools = graph.list_ontology_tools()
147
+ ctx = graph.query_ontology(query="Which customers are a churn risk?", schema_id=res["schema_id"])
148
+ ```
149
+
150
+ See the [Operational Ontology](https://mindgraph.cloud/docs/ontology) and [Connect a database](https://mindgraph.cloud/docs/connect) docs.
151
+
131
152
  ### CRUD
132
153
 
133
154
  | Method | Description |
@@ -908,6 +908,10 @@ class MindGraph:
908
908
  citation_count: int | None = None,
909
909
  arxiv_id: str | None = None,
910
910
  language: str | None = None,
911
+ page_offsets: list[dict[str, Any]] | None = None,
912
+ page_count: int | None = None,
913
+ mime_type: str | None = None,
914
+ force_reingest: bool | None = None,
911
915
  ) -> dict[str, Any]:
912
916
  body: dict[str, Any] = {"content": content}
913
917
  if title:
@@ -944,6 +948,17 @@ class MindGraph:
944
948
  body["arxiv_id"] = arxiv_id
945
949
  if language:
946
950
  body["language"] = language
951
+ if page_offsets:
952
+ # Each entry is {"page": int (1-based), "char_start": int}, where
953
+ # char_start is a UTF-8 BYTE offset into `content` (not a codepoint
954
+ # count) — use len(s.encode("utf-8")), or pages drift on non-ASCII text.
955
+ body["page_offsets"] = page_offsets
956
+ if page_count is not None:
957
+ body["page_count"] = page_count
958
+ if mime_type:
959
+ body["mime_type"] = mime_type
960
+ if force_reingest is not None:
961
+ body["force_reingest"] = force_reingest
947
962
  return self._request("POST", "/ingest/document", body)
948
963
 
949
964
  def ingest_session(
@@ -989,6 +1004,19 @@ class MindGraph:
989
1004
  Returns articles (synthesized wiki summaries), graph nodes with
990
1005
  source_documents provenance, and optionally raw chunks.
991
1006
 
1007
+ Each graph node may also carry an optional ``source_chunks`` array of
1008
+ citation-provenance spans. Each entry is a dict with keys:
1009
+ ``chunk_uid`` (str), ``char_start``/``char_end`` (int|None, UTF-8 byte
1010
+ offsets into the chunk), ``page_start``/``page_end`` (int|None, 1-based
1011
+ page numbers, None if no page map), ``quote`` (str|None, the verbatim
1012
+ source span when matched), and ``anchor`` (str|None, a JSON-encoded
1013
+ Web-Annotation selector string).
1014
+
1015
+ Claim nodes may additionally carry an optional ``believed_by`` array of
1016
+ per-agent stances. Each entry is a dict with keys: ``agent_uid`` (str),
1017
+ ``agent_label`` (str), and ``confidence`` (float|None, the asserting
1018
+ agent's certainty, None if unspecified).
1019
+
992
1020
  Args:
993
1021
  query: Natural language search query.
994
1022
  node_limit: Max graph nodes (default 10).
@@ -1020,6 +1048,13 @@ class MindGraph:
1020
1048
  """Backfill node_source provenance from existing ExtractedFrom edges."""
1021
1049
  return self._request("POST", "/backfill/node-sources", {})
1022
1050
 
1051
+ def backfill_anchors(self) -> dict[str, Any]:
1052
+ """Backfill citation anchors (source_chunks spans) for existing graphs.
1053
+
1054
+ Returns a background job descriptor (e.g. ``{"job_id": ...}``).
1055
+ """
1056
+ return self._request("POST", "/backfill/anchors", {})
1057
+
1023
1058
  def list_jobs(self) -> list[dict[str, Any]]:
1024
1059
  return self._request("GET", "/jobs")
1025
1060
 
@@ -1231,7 +1266,23 @@ class MindGraph:
1231
1266
 
1232
1267
  Recognized kwargs: name (required), display_name, description, fields,
1233
1268
  required_fields, identity_fields, aliases, examples, extraction_hints,
1234
- default_confidence, review_policy.
1269
+ default_confidence, review_policy, backing.
1270
+
1271
+ `backing` binds the type to an external source (the semantic-contract
1272
+ mapping). Omit it for extracted/authored types. SQL example::
1273
+
1274
+ backing={
1275
+ "kind": "sql",
1276
+ "sources": [{
1277
+ "connection_ref": "conn_1",
1278
+ "table": "customers",
1279
+ "key": "id",
1280
+ "field_map": {"name": {"column": "full_name", "mode": "indexed"}},
1281
+ }],
1282
+ "primary_key": "id",
1283
+ "title_field": "name",
1284
+ "sync": {"mode": "incremental", "cursor_column": "updated_at"},
1285
+ }
1235
1286
  """
1236
1287
  return self._request(
1237
1288
  "POST", f"/v1/ontology/schemas/{schema_id}/object-types", kwargs
@@ -1368,6 +1419,18 @@ class MindGraph:
1368
1419
  "POST", "/v1/ontology/proposals/batch-reject", body
1369
1420
  )
1370
1421
 
1422
+ def list_ontology_tools(self) -> dict[str, Any]:
1423
+ """Read-only agent tool manifest for the org's active ontology schema(s).
1424
+
1425
+ Returns ``{"tools": [...]}`` — per active object type, descriptors for
1426
+ ``search_<objs>`` / ``get_<obj>`` / ``summarize_<obj>`` that an MCP
1427
+ server (or your own agent loop) renders into tools and dispatches into
1428
+ the ``/ontology`` read endpoints. ``summarize_<obj>`` returns the object
1429
+ — whether mapped from a connected SQL database or extracted from
1430
+ documents — plus its cognitive context (claims, risks, decisions).
1431
+ """
1432
+ return self._request("GET", "/v1/ontology/tools")
1433
+
1371
1434
  # ---- Retrieval (graph server) ----
1372
1435
 
1373
1436
  def query_ontology(
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mindgraph-sdk"
7
- version = "0.7.0"
7
+ version = "0.9.0"
8
8
  description = "Python client for the MindGraph Cloud API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,398 @@
1
+ """Canonical wire-contract fixture for the MindGraph Python SDK.
2
+
3
+ This is the single source of truth that the offline parity test
4
+ (``tests/test_offline.py``) checks the client against. It is derived from the
5
+ authoritative contract in ``CLAUDE.md`` ("Cognitive Endpoint Actions" table +
6
+ "SDK-Server Field Name Conventions"), which R6 verified matches the server.
7
+
8
+ Each entry describes the *wire* request a client method must produce:
9
+
10
+ {
11
+ "method": "<MindGraph attribute name>",
12
+ "http_method": "GET" | "POST" | "PATCH" | "DELETE" | "PUT",
13
+ "path": "/exact/path", # leading-slash path the client must hit
14
+ "path_contains": "/fragment", # OR: a substring the path must contain
15
+ # (for path-templated / query-string URLs)
16
+ "action": "<action>" | None, # required value of JSON body["action"]
17
+ "required_fields": ["a", "b"], # keys that must appear in the JSON body
18
+ "args": {...}, # sample kwargs to call the method with
19
+ "positional": [...], # sample positional args (optional)
20
+ }
21
+
22
+ Action-dispatch endpoints must send their ``action`` in the JSON body and that
23
+ action must be one of the endpoint's valid values per CLAUDE.md. Field-name
24
+ conventions (``start_uid``/``end_uid`` for /traverse; structured monolithic
25
+ /epistemic/argument) are asserted here too.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Valid action sets per CLAUDE.md "Cognitive Endpoint Actions (exhaustive)".
32
+ # The offline test asserts every contract entry's action is in its endpoint set
33
+ # and that the SDK never sends an action outside these sets.
34
+ # ---------------------------------------------------------------------------
35
+
36
+ VALID_ACTIONS: dict[str, set[str]] = {
37
+ "/reality/capture": {"source", "snippet", "observation"},
38
+ "/reality/entity": {
39
+ "create",
40
+ "alias",
41
+ "resolve",
42
+ "fuzzy_resolve",
43
+ "merge",
44
+ "relate",
45
+ },
46
+ # /epistemic/argument is MONOLITHIC — no action field.
47
+ "/epistemic/inquiry": {
48
+ "hypothesis",
49
+ "theory",
50
+ "paradigm",
51
+ "anomaly",
52
+ "assumption",
53
+ "question",
54
+ "open_question",
55
+ },
56
+ "/epistemic/structure": {
57
+ "concept",
58
+ "pattern",
59
+ "mechanism",
60
+ "model",
61
+ "model_evaluation",
62
+ "analogy",
63
+ "inference_chain",
64
+ "reasoning_strategy",
65
+ "sensitivity_analysis",
66
+ "theorem",
67
+ "equation",
68
+ "method",
69
+ "experiment",
70
+ },
71
+ "/intent/commitment": {"goal", "project", "milestone"},
72
+ "/intent/deliberation": {
73
+ "open_decision",
74
+ "add_option",
75
+ "add_constraint",
76
+ "resolve",
77
+ "get_open",
78
+ },
79
+ "/action/procedure": {
80
+ "create_flow",
81
+ "add_step",
82
+ "add_affordance",
83
+ "add_control",
84
+ },
85
+ "/action/risk": {"assess", "get_assessments"},
86
+ "/memory/session": {"open", "trace", "close", "journal"},
87
+ # /memory/distill is MONOLITHIC — no action field.
88
+ "/memory/config": {
89
+ "set_preference",
90
+ "get_preferences",
91
+ "set_policy",
92
+ "get_policies",
93
+ },
94
+ "/agent/plan": {
95
+ "create_task",
96
+ "create_plan",
97
+ "add_step",
98
+ "update_status",
99
+ "get_plan",
100
+ },
101
+ "/agent/governance": {
102
+ "create_policy",
103
+ "set_budget",
104
+ "request_approval",
105
+ "resolve_approval",
106
+ "get_pending",
107
+ },
108
+ "/agent/execution": {
109
+ "start",
110
+ "complete",
111
+ "fail",
112
+ "register_agent",
113
+ "get_executions",
114
+ },
115
+ # The full /retrieve action set — the drift-prone invariant from the task.
116
+ "/retrieve": {
117
+ "text",
118
+ "semantic",
119
+ "hybrid",
120
+ "active_goals",
121
+ "open_questions",
122
+ "weak_claims",
123
+ "pending_approvals",
124
+ "unresolved_contradictions",
125
+ "merge_candidates",
126
+ "curation_counts",
127
+ "preferences",
128
+ "layer",
129
+ "recent",
130
+ },
131
+ "/traverse": {"chain", "neighborhood", "path", "subgraph"},
132
+ "/evolve": {
133
+ "update",
134
+ "tombstone",
135
+ "restore",
136
+ "decay",
137
+ "history",
138
+ "snapshot",
139
+ "tombstone_edge",
140
+ "restore_edge",
141
+ "tombstone_cascade",
142
+ },
143
+ }
144
+
145
+ # Endpoints that are monolithic (must NOT carry an ``action`` field).
146
+ MONOLITHIC_ENDPOINTS: set[str] = {"/epistemic/argument", "/memory/distill"}
147
+
148
+ # Field-name conventions enforced by the server (SDK-Server Field Name
149
+ # Conventions in CLAUDE.md). The offline test asserts these positively.
150
+ FIELD_CONVENTIONS = {
151
+ "traverse_start_uid": "start_uid", # NOT uid / from_uid
152
+ "traverse_path_end_uid": "end_uid", # NOT to_uid
153
+ }
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Per-method contract entries.
158
+ # These cover the action-dispatch convenience/raw methods plus the field-name
159
+ # conventions. Pure CRUD / passthrough (**kwargs) methods that don't bake in an
160
+ # action are exercised separately or recorded in coverage_gaps.
161
+ # ---------------------------------------------------------------------------
162
+
163
+ CONTRACT: list[dict] = [
164
+ # ---- Reality ----
165
+ {
166
+ "method": "find_or_create_entity",
167
+ "http_method": "POST",
168
+ "path": "/reality/entity",
169
+ "action": "create",
170
+ "required_fields": ["action", "label", "props"],
171
+ "positional": ["Acme Corp"],
172
+ "args": {},
173
+ },
174
+ {
175
+ "method": "find_or_create_person",
176
+ "http_method": "POST",
177
+ "path": "/reality/entity",
178
+ "action": "create",
179
+ "required_fields": ["action", "label", "props"],
180
+ "positional": ["Ada Lovelace"],
181
+ "args": {},
182
+ },
183
+ {
184
+ "method": "find_or_create_organization",
185
+ "http_method": "POST",
186
+ "path": "/reality/entity",
187
+ "action": "create",
188
+ "required_fields": ["action", "label", "props"],
189
+ "positional": ["OpenAI"],
190
+ "args": {},
191
+ },
192
+ {
193
+ "method": "find_or_create_nation",
194
+ "http_method": "POST",
195
+ "path": "/reality/entity",
196
+ "action": "create",
197
+ "required_fields": ["action", "label", "props"],
198
+ "positional": ["France"],
199
+ "args": {},
200
+ },
201
+ {
202
+ "method": "find_or_create_event",
203
+ "http_method": "POST",
204
+ "path": "/reality/entity",
205
+ "action": "create",
206
+ "required_fields": ["action", "label", "props"],
207
+ "positional": ["WWDC 2026"],
208
+ "args": {},
209
+ },
210
+ {
211
+ "method": "find_or_create_place",
212
+ "http_method": "POST",
213
+ "path": "/reality/entity",
214
+ "action": "create",
215
+ "required_fields": ["action", "label", "props"],
216
+ "positional": ["Paris"],
217
+ "args": {},
218
+ },
219
+ {
220
+ "method": "find_or_create_concept",
221
+ "http_method": "POST",
222
+ "path": "/reality/entity",
223
+ "action": "create",
224
+ "required_fields": ["action", "label", "props"],
225
+ "positional": ["Entropy"],
226
+ "args": {},
227
+ },
228
+ {
229
+ "method": "add_observation",
230
+ "http_method": "POST",
231
+ "path": "/reality/capture",
232
+ "action": "observation",
233
+ "required_fields": ["action", "label", "summary"],
234
+ "positional": ["Obs", "the sky is blue"],
235
+ "args": {},
236
+ },
237
+ {
238
+ "method": "resolve_entity",
239
+ "http_method": "POST",
240
+ "path": "/reality/entity",
241
+ "action": "resolve",
242
+ "required_fields": ["action", "text"],
243
+ "positional": ["Acme"],
244
+ "args": {},
245
+ },
246
+ {
247
+ "method": "fuzzy_resolve_entity",
248
+ "http_method": "POST",
249
+ "path": "/reality/entity",
250
+ "action": "fuzzy_resolve",
251
+ "required_fields": ["action", "text", "limit"],
252
+ "positional": ["Acme"],
253
+ "args": {},
254
+ },
255
+ # ---- Epistemic ----
256
+ {
257
+ "method": "add_claim",
258
+ "http_method": "POST",
259
+ # NOTE: KNOWN R4 DIVERGENCE — Py routes add_claim to /epistemic/inquiry
260
+ # (hypothesis), TS routes addClaim to /epistemic/argument (Claim). The
261
+ # offline test records this via KNOWN_DIVERGENCES, not the action check.
262
+ "path": "/epistemic/inquiry",
263
+ "action": "hypothesis",
264
+ "required_fields": ["action", "label", "summary"],
265
+ "positional": ["Claim label", "claim content"],
266
+ "args": {},
267
+ },
268
+ # ---- Intent ----
269
+ {
270
+ "method": "open_decision",
271
+ "http_method": "POST",
272
+ "path": "/intent/deliberation",
273
+ "action": "open_decision",
274
+ "required_fields": ["action", "label"],
275
+ "positional": ["Pick a DB"],
276
+ "args": {},
277
+ },
278
+ {
279
+ "method": "add_option",
280
+ "http_method": "POST",
281
+ "path": "/intent/deliberation",
282
+ "action": "add_option",
283
+ "required_fields": ["action", "decision_uid", "label"],
284
+ "positional": ["dec_1", "Postgres"],
285
+ "args": {},
286
+ },
287
+ {
288
+ "method": "resolve_decision",
289
+ "http_method": "POST",
290
+ "path": "/intent/deliberation",
291
+ "action": "resolve",
292
+ "required_fields": ["action", "decision_uid", "chosen_option_uid"],
293
+ "positional": ["dec_1", "opt_1"],
294
+ "args": {},
295
+ },
296
+ # ---- Memory ----
297
+ {
298
+ "method": "journal",
299
+ "http_method": "POST",
300
+ "path": "/memory/session",
301
+ "action": "journal",
302
+ "required_fields": ["action", "label"],
303
+ "positional": ["A journal entry"],
304
+ "args": {},
305
+ },
306
+ # ---- Cross-cutting / retrieve ----
307
+ {
308
+ "method": "merge_candidates",
309
+ "http_method": "POST",
310
+ "path": "/retrieve",
311
+ "action": "merge_candidates",
312
+ "required_fields": ["action"],
313
+ "positional": [],
314
+ "args": {},
315
+ },
316
+ {
317
+ "method": "preferences",
318
+ "http_method": "POST",
319
+ "path": "/retrieve",
320
+ "action": "preferences",
321
+ "required_fields": ["action"],
322
+ "positional": [],
323
+ "args": {},
324
+ },
325
+ {
326
+ "method": "hybrid_search",
327
+ "http_method": "POST",
328
+ "path": "/retrieve",
329
+ "action": "hybrid",
330
+ "required_fields": ["action", "query"],
331
+ "positional": ["graph databases"],
332
+ "args": {},
333
+ },
334
+ # ---- Traverse (field-name conventions) ----
335
+ {
336
+ "method": "reasoning_chain",
337
+ "http_method": "POST",
338
+ "path": "/traverse",
339
+ "action": "chain",
340
+ "required_fields": ["action", "start_uid", "max_depth"],
341
+ "positional": ["n_1"],
342
+ "args": {},
343
+ "forbidden_fields": ["uid", "from_uid"],
344
+ },
345
+ {
346
+ "method": "neighborhood",
347
+ "http_method": "POST",
348
+ "path": "/traverse",
349
+ "action": "neighborhood",
350
+ "required_fields": ["action", "start_uid", "max_depth"],
351
+ "positional": ["n_1"],
352
+ "args": {},
353
+ "forbidden_fields": ["uid", "from_uid"],
354
+ },
355
+ # ---- Evolve shortcuts ----
356
+ {
357
+ "method": "tombstone",
358
+ "http_method": "POST",
359
+ "path": "/evolve",
360
+ "action": "tombstone",
361
+ "required_fields": ["action", "uid"],
362
+ "positional": ["n_1"],
363
+ "args": {},
364
+ },
365
+ {
366
+ "method": "restore",
367
+ "http_method": "POST",
368
+ "path": "/evolve",
369
+ "action": "restore",
370
+ "required_fields": ["action", "uid"],
371
+ "positional": ["n_1"],
372
+ "args": {},
373
+ },
374
+ ]
375
+
376
+
377
+ # Raw passthrough (**kwargs) methods: the client forwards the body verbatim,
378
+ # so the action is supplied by the caller. The offline test drives each with a
379
+ # valid action and asserts the path + that the action lands in the body and is a
380
+ # member of the endpoint's valid set.
381
+ PASSTHROUGH: list[dict] = [
382
+ {"method": "capture", "path": "/reality/capture", "action": "observation"},
383
+ {"method": "entity", "path": "/reality/entity", "action": "create"},
384
+ {"method": "inquire", "path": "/epistemic/inquiry", "action": "hypothesis"},
385
+ {"method": "structure", "path": "/epistemic/structure", "action": "concept"},
386
+ {"method": "commit", "path": "/intent/commitment", "action": "goal"},
387
+ {"method": "deliberate", "path": "/intent/deliberation", "action": "open_decision"},
388
+ {"method": "procedure", "path": "/action/procedure", "action": "create_flow"},
389
+ {"method": "risk", "path": "/action/risk", "action": "assess"},
390
+ {"method": "session", "path": "/memory/session", "action": "open"},
391
+ {"method": "memory_config", "path": "/memory/config", "action": "set_preference"},
392
+ {"method": "plan", "path": "/agent/plan", "action": "create_plan"},
393
+ {"method": "governance", "path": "/agent/governance", "action": "create_policy"},
394
+ {"method": "execution", "path": "/agent/execution", "action": "start"},
395
+ {"method": "retrieve", "path": "/retrieve", "action": "text"},
396
+ {"method": "traverse", "path": "/traverse", "action": "chain"},
397
+ {"method": "evolve", "path": "/evolve", "action": "update"},
398
+ ]
@@ -2,7 +2,16 @@
2
2
  Comprehensive integration tests for the MindGraph Python SDK.
3
3
  Tests every public method against the live Cloud API.
4
4
 
5
- Run: API_KEY=mg_live_... pytest tests/test_integration.py -v
5
+ WARNING: this suite is a LIVE, PROD-MUTATING E2E. It creates/updates/tombstones
6
+ real nodes in whatever org the supplied key belongs to. It is OPT-IN ONLY and
7
+ SKIPS by default so a plain `pytest` run (CI, publish) executes only the offline
8
+ contract tests in tests/test_offline.py.
9
+
10
+ To run it you must BOTH:
11
+ 1. set MINDGRAPH_E2E=1 (explicit opt-in acknowledging it mutates a test org), and
12
+ 2. provide an API_KEY (or MINDGRAPH_API_KEY) for a DEDICATED TEST org.
13
+
14
+ Run: MINDGRAPH_E2E=1 API_KEY=mg_live_... pytest tests/test_integration.py -v
6
15
  """
7
16
  import os
8
17
  import pytest
@@ -11,8 +20,12 @@ from mindgraph import MindGraph
11
20
 
12
21
  API_KEY = os.environ.get("API_KEY") or os.environ.get("MINDGRAPH_API_KEY", "")
13
22
  BASE_URL = os.environ.get("BASE_URL", "https://api.mindgraph.cloud")
23
+ E2E_OPT_IN = os.environ.get("MINDGRAPH_E2E", "").lower() in ("1", "true", "yes", "on")
14
24
 
15
- pytestmark = pytest.mark.skipif(not API_KEY, reason="API_KEY not set")
25
+ pytestmark = pytest.mark.skipif(
26
+ not (E2E_OPT_IN and API_KEY),
27
+ reason="live prod-mutating E2E: set MINDGRAPH_E2E=1 AND API_KEY to opt in",
28
+ )
16
29
 
17
30
 
18
31
  @pytest.fixture(scope="module")
@@ -0,0 +1,416 @@
1
+ """Offline wire-contract parity tests for the MindGraph Python SDK.
2
+
3
+ These tests hit NO network. They install an ``httpx.MockTransport`` into the
4
+ client's underlying ``httpx.Client`` to capture the outgoing request, then
5
+ assert the request path / HTTP method / JSON body ``action`` + required fields
6
+ match the canonical contract in ``tests/contract_fixture.py`` (derived from
7
+ CLAUDE.md, verified against the server by R6).
8
+
9
+ Run: ``pytest tests/test_offline.py`` — no API key, no secrets, no network.
10
+
11
+ KNOWN R4 DIVERGENCES (a pending owner/product decision, NOT bugs to fix here)
12
+ are pinned in ``test_known_r4_divergences`` so they are documented and do not
13
+ fail CI; any NEW/undocumented drift from the contract DOES fail.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any
20
+
21
+ import httpx
22
+ import pytest
23
+
24
+ from mindgraph import MindGraph
25
+
26
+ from .contract_fixture import (
27
+ CONTRACT,
28
+ FIELD_CONVENTIONS,
29
+ MONOLITHIC_ENDPOINTS,
30
+ PASSTHROUGH,
31
+ VALID_ACTIONS,
32
+ )
33
+
34
+ BASE_URL = "https://api.mindgraph.test"
35
+
36
+
37
+ class Capture:
38
+ """Records the single request a method makes through MockTransport."""
39
+
40
+ def __init__(self) -> None:
41
+ self.request: httpx.Request | None = None
42
+
43
+ @property
44
+ def path(self) -> str:
45
+ assert self.request is not None, "no request was captured"
46
+ return self.request.url.path
47
+
48
+ @property
49
+ def full_path(self) -> str:
50
+ assert self.request is not None, "no request was captured"
51
+ url = self.request.url
52
+ return url.path + (("?" + url.query.decode()) if url.query else "")
53
+
54
+ @property
55
+ def method(self) -> str:
56
+ assert self.request is not None
57
+ return self.request.method
58
+
59
+ @property
60
+ def body(self) -> dict[str, Any]:
61
+ assert self.request is not None
62
+ content = self.request.content
63
+ if not content:
64
+ return {}
65
+ return json.loads(content)
66
+
67
+
68
+ def make_client(capture: Capture, response_json: Any = None) -> MindGraph:
69
+ """Build a MindGraph client whose transport records and replies offline."""
70
+
71
+ def handler(request: httpx.Request) -> httpx.Response:
72
+ capture.request = request
73
+ payload = {"uid": "n_test"} if response_json is None else response_json
74
+ return httpx.Response(200, json=payload)
75
+
76
+ client = MindGraph(BASE_URL, api_key="mg_test_key")
77
+ # Swap the real transport for an offline mock; never touches the network.
78
+ client._client = httpx.Client(
79
+ base_url=client.base_url,
80
+ headers={"Content-Type": "application/json"},
81
+ transport=httpx.MockTransport(handler),
82
+ )
83
+ return client
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Sanity: the fixture itself is internally consistent.
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def test_retrieve_action_set_is_exact():
92
+ """The /retrieve action enum is the most drift-prone invariant."""
93
+ assert VALID_ACTIONS["/retrieve"] == {
94
+ "text",
95
+ "semantic",
96
+ "hybrid",
97
+ "active_goals",
98
+ "open_questions",
99
+ "weak_claims",
100
+ "pending_approvals",
101
+ "unresolved_contradictions",
102
+ "merge_candidates",
103
+ "curation_counts",
104
+ "preferences",
105
+ "layer",
106
+ "recent",
107
+ }
108
+
109
+
110
+ def test_fixture_actions_are_valid_for_their_endpoint():
111
+ """Every contract entry's declared action belongs to its endpoint's set."""
112
+ for entry in CONTRACT:
113
+ action = entry["action"]
114
+ path = entry["path"]
115
+ if action is None:
116
+ assert path in MONOLITHIC_ENDPOINTS
117
+ continue
118
+ if path in VALID_ACTIONS:
119
+ assert action in VALID_ACTIONS[path], (
120
+ f"{entry['method']} declares action={action!r} "
121
+ f"not in valid set for {path}"
122
+ )
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Per-method wire parity: every CONTRACT entry.
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ @pytest.mark.parametrize("entry", CONTRACT, ids=[e["method"] for e in CONTRACT])
131
+ def test_contract_method_wire_shape(entry: dict):
132
+ cap = Capture()
133
+ client = make_client(cap)
134
+ method = getattr(client, entry["method"])
135
+
136
+ positional = entry.get("positional", [])
137
+ kwargs = entry.get("args", {})
138
+ method(*positional, **kwargs)
139
+
140
+ # HTTP method.
141
+ assert cap.method == entry["http_method"], (
142
+ f"{entry['method']}: expected {entry['http_method']}, got {cap.method}"
143
+ )
144
+
145
+ # Path (exact or substring).
146
+ if "path" in entry:
147
+ assert cap.path == entry["path"], (
148
+ f"{entry['method']}: expected path {entry['path']}, got {cap.path}"
149
+ )
150
+ elif "path_contains" in entry:
151
+ assert entry["path_contains"] in cap.full_path
152
+
153
+ body = cap.body
154
+
155
+ # Action: present, correct, and a valid member of the endpoint set.
156
+ if entry["action"] is not None:
157
+ assert body.get("action") == entry["action"], (
158
+ f"{entry['method']}: expected action {entry['action']}, "
159
+ f"got {body.get('action')}"
160
+ )
161
+ path = entry.get("path")
162
+ if path in VALID_ACTIONS:
163
+ assert body["action"] in VALID_ACTIONS[path]
164
+ else:
165
+ # Monolithic endpoints must not carry an action field.
166
+ assert "action" not in body, (
167
+ f"{entry['method']}: monolithic endpoint must not send action"
168
+ )
169
+
170
+ # Required fields present.
171
+ for field in entry["required_fields"]:
172
+ assert field in body, (
173
+ f"{entry['method']}: missing required field {field!r} in body {body}"
174
+ )
175
+
176
+ # Forbidden (anti-convention) fields absent.
177
+ for field in entry.get("forbidden_fields", []):
178
+ assert field not in body, (
179
+ f"{entry['method']}: forbidden field {field!r} present in body {body}"
180
+ )
181
+
182
+ client.close()
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Passthrough (**kwargs) methods: action is supplied by the caller and must
187
+ # land in the body unchanged, on the right path, within the valid set.
188
+ # ---------------------------------------------------------------------------
189
+
190
+
191
+ @pytest.mark.parametrize(
192
+ "entry", PASSTHROUGH, ids=[e["method"] for e in PASSTHROUGH]
193
+ )
194
+ def test_passthrough_method_forwards_action(entry: dict):
195
+ cap = Capture()
196
+ client = make_client(cap)
197
+ method = getattr(client, entry["method"])
198
+
199
+ method(action=entry["action"], label="x", summary="y")
200
+
201
+ assert cap.method == "POST"
202
+ assert cap.path == entry["path"]
203
+ body = cap.body
204
+ assert body.get("action") == entry["action"]
205
+ assert body["action"] in VALID_ACTIONS[entry["path"]]
206
+ client.close()
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Field-name conventions (SDK-Server Field Name Conventions in CLAUDE.md).
211
+ # ---------------------------------------------------------------------------
212
+
213
+
214
+ def test_traverse_uses_start_uid_not_uid_or_from_uid():
215
+ cap = Capture()
216
+ client = make_client(cap)
217
+ client.traverse(action="chain", start_uid="n_1", max_depth=2)
218
+ body = cap.body
219
+ assert body.get(FIELD_CONVENTIONS["traverse_start_uid"]) == "n_1"
220
+ assert "uid" not in body
221
+ assert "from_uid" not in body
222
+ client.close()
223
+
224
+
225
+ def test_traverse_path_uses_end_uid_not_to_uid():
226
+ cap = Capture()
227
+ client = make_client(cap)
228
+ client.traverse(action="path", start_uid="n_1", end_uid="n_2")
229
+ body = cap.body
230
+ assert body.get("action") == "path"
231
+ assert body.get(FIELD_CONVENTIONS["traverse_start_uid"]) == "n_1"
232
+ assert body.get(FIELD_CONVENTIONS["traverse_path_end_uid"]) == "n_2"
233
+ assert "to_uid" not in body
234
+ client.close()
235
+
236
+
237
+ def test_reasoning_chain_shortcut_uses_start_uid():
238
+ cap = Capture()
239
+ client = make_client(cap, response_json={"steps": []})
240
+ client.reasoning_chain("n_1", max_depth=3)
241
+ body = cap.body
242
+ assert cap.path == "/traverse"
243
+ assert body == {"action": "chain", "start_uid": "n_1", "max_depth": 3}
244
+ client.close()
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # /epistemic/argument is MONOLITHIC: no action; structured claim + evidence
249
+ # (evidence is an ARRAY per the server's ArgumentRequest contract).
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ def test_argue_is_monolithic_no_action_evidence_array():
254
+ cap = Capture()
255
+ client = make_client(cap, response_json={"claim_uid": "c_1"})
256
+ client.argue(
257
+ claim={"label": "C", "statement": "s"},
258
+ evidence=[{"label": "E", "statement": "e"}],
259
+ )
260
+ body = cap.body
261
+ assert cap.path == "/epistemic/argument"
262
+ assert "action" not in body # monolithic
263
+ assert "claim" in body
264
+ assert isinstance(body["evidence"], list) # server contract: evidence is array
265
+ client.close()
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # The full /retrieve action set is reachable through the raw retrieve() method
270
+ # (the Python RetrieveRequest is untyped **kwargs, so all 13 are accepted).
271
+ # ---------------------------------------------------------------------------
272
+
273
+
274
+ @pytest.mark.parametrize("action", sorted(VALID_ACTIONS["/retrieve"]))
275
+ def test_retrieve_accepts_every_valid_action(action: str):
276
+ cap = Capture()
277
+ client = make_client(cap, response_json=[])
278
+ client.retrieve(action=action)
279
+ body = cap.body
280
+ assert cap.path == "/retrieve"
281
+ assert body.get("action") == action
282
+ client.close()
283
+
284
+
285
+ def test_retrieve_includes_merge_candidates_and_curation_counts():
286
+ """These two are valid server-side but omitted from the TS enum (R4 #3).
287
+ Python's untyped kwargs accept both — assert they reach the wire."""
288
+ for action in ("merge_candidates", "curation_counts"):
289
+ cap = Capture()
290
+ client = make_client(cap, response_json=[])
291
+ client.retrieve(action=action)
292
+ assert cap.body.get("action") == action
293
+ client.close()
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Auth header wiring (verifies the client still sets Bearer auth offline).
298
+ # ---------------------------------------------------------------------------
299
+
300
+
301
+ def test_api_key_sets_bearer_auth_header():
302
+ captured: dict[str, Any] = {}
303
+
304
+ def handler(request: httpx.Request) -> httpx.Response:
305
+ captured["auth"] = request.headers.get("Authorization")
306
+ return httpx.Response(200, json={"status": "ok"})
307
+
308
+ client = MindGraph(BASE_URL, api_key="mg_secret")
309
+ client._client = httpx.Client(
310
+ base_url=client.base_url,
311
+ headers={"Authorization": "Bearer mg_secret"},
312
+ transport=httpx.MockTransport(handler),
313
+ )
314
+ client.health()
315
+ assert captured["auth"] == "Bearer mg_secret"
316
+ client.close()
317
+
318
+
319
+ # ===========================================================================
320
+ # KNOWN R4 DIVERGENCES — pinned, documented, NON-failing.
321
+ #
322
+ # These restate the *current* Python behavior that diverges from the TS SDK
323
+ # and/or the canonical reconciliation R4 will eventually land. R4 is a pending
324
+ # owner/product decision (a confirmed breaking change), so these tests assert
325
+ # present behavior so the divergence is visible and intentional in CI, while
326
+ # any UNDOCUMENTED drift still fails the contract tests above.
327
+ # ===========================================================================
328
+
329
+ # Allowlist: human-readable record of each pinned divergence.
330
+ KNOWN_DIVERGENCES = [
331
+ "R4#1 add_claim -> POST /epistemic/inquiry action=hypothesis -> Hypothesis "
332
+ "(TS addClaim -> POST /epistemic/argument -> Claim). Different endpoint AND node type.",
333
+ "R4#2 add_evidence sends a single OBJECT under 'evidence' with 'summary' "
334
+ "(TS addEvidence sends an ARRAY + props).",
335
+ "R4#3 Python retrieve() accepts merge_candidates and curation_counts "
336
+ "(TS RetrieveRequest.action omits both, though both are valid server-side).",
337
+ "R4#4 get_article_by_subject maps ONLY 404 -> None and re-raises other errors "
338
+ "(TS getArticleBySubject swallows ALL errors as not-found).",
339
+ "R4#5 Python client has a 30s default timeout (TS request() has no timeout).",
340
+ ]
341
+
342
+
343
+ def test_known_divergence_1_add_claim_routes_to_inquiry_hypothesis():
344
+ cap = Capture()
345
+ client = make_client(cap)
346
+ client.add_claim("Label", "content", confidence=0.8)
347
+ body = cap.body
348
+ # Diverges from TS: Py uses /epistemic/inquiry + hypothesis, NOT /epistemic/argument.
349
+ assert cap.path == "/epistemic/inquiry"
350
+ assert body.get("action") == "hypothesis"
351
+ assert body.get("label") == "Label"
352
+ assert body.get("summary") == "content"
353
+ client.close()
354
+
355
+
356
+ def test_known_divergence_2_add_evidence_sends_single_object():
357
+ cap = Capture()
358
+ client = make_client(cap, response_json={"uid": "e_1"})
359
+ client.add_evidence("Evidence label", "the description")
360
+ body = cap.body
361
+ assert cap.path == "/epistemic/argument"
362
+ # Diverges from TS array form: Py sends a single dict object + summary.
363
+ assert isinstance(body.get("evidence"), dict)
364
+ assert body["evidence"]["label"] == "Evidence label"
365
+ assert body["evidence"]["summary"] == "the description"
366
+ client.close()
367
+
368
+
369
+ def test_known_divergence_3_retrieve_accepts_ts_omitted_actions():
370
+ for action in ("merge_candidates", "curation_counts"):
371
+ cap = Capture()
372
+ client = make_client(cap, response_json=[])
373
+ client.retrieve(action=action)
374
+ assert cap.body.get("action") == action
375
+ client.close()
376
+
377
+
378
+ def test_known_divergence_4_get_article_by_subject_404_only():
379
+ # 404 -> None
380
+ def handler_404(request: httpx.Request) -> httpx.Response:
381
+ return httpx.Response(404, json={"error": "not found"})
382
+
383
+ client = MindGraph(BASE_URL, api_key="mg_test")
384
+ client._client = httpx.Client(
385
+ base_url=client.base_url, transport=httpx.MockTransport(handler_404)
386
+ )
387
+ assert client.get_article_by_subject("subj_1") is None
388
+ client.close()
389
+
390
+ # 500 -> re-raised (NOT swallowed, unlike TS).
391
+ def handler_500(request: httpx.Request) -> httpx.Response:
392
+ return httpx.Response(500, json={"error": "boom"})
393
+
394
+ from mindgraph import MindGraphError
395
+
396
+ client2 = MindGraph(BASE_URL, api_key="mg_test")
397
+ client2._client = httpx.Client(
398
+ base_url=client2.base_url, transport=httpx.MockTransport(handler_500)
399
+ )
400
+ with pytest.raises(MindGraphError):
401
+ client2.get_article_by_subject("subj_1")
402
+ client2.close()
403
+
404
+
405
+ def test_known_divergence_5_default_timeout_is_30s():
406
+ # Build a normal client (real transport, but we never send a request) and
407
+ # inspect its configured timeout — Python default is 30s; TS has none.
408
+ client = MindGraph(BASE_URL, api_key="mg_test")
409
+ assert client._client.timeout.read == 30.0
410
+ client.close()
411
+
412
+
413
+ def test_known_divergences_documented():
414
+ """Guard: the allowlist stays populated so divergences remain visible."""
415
+ assert len(KNOWN_DIVERGENCES) == 5
416
+ assert all(d.startswith("R4#") for d in KNOWN_DIVERGENCES)
@@ -1,25 +0,0 @@
1
- name: Publish to PyPI
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v*'
7
- workflow_dispatch:
8
-
9
- jobs:
10
- publish:
11
- runs-on: ubuntu-latest
12
- environment: pypi
13
- permissions:
14
- id-token: write
15
- steps:
16
- - uses: actions/checkout@v4
17
-
18
- - uses: actions/setup-python@v5
19
- with:
20
- python-version: '3.12'
21
-
22
- - run: pip install build
23
- - run: python -m build
24
-
25
- - uses: pypa/gh-action-pypi-publish@release/v1
File without changes