knowledge-graph-rdbms 0.1.2__tar.gz → 0.1.3__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.
Files changed (51) hide show
  1. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/PKG-INFO +2 -2
  2. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/README.md +1 -1
  3. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/__init__.py +1 -1
  4. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/cli.py +6 -0
  5. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/events.py +19 -1
  6. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/rdf.py +26 -8
  7. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/service.py +11 -1
  8. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/pyproject.toml +1 -1
  9. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_cli.py +22 -0
  10. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_events.py +49 -0
  11. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_rdf.py +41 -0
  12. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/.claude/skills/kg-compose/SKILL.md +0 -0
  13. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/.github/workflows/ci.yml +0 -0
  14. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/.github/workflows/publish.yml +0 -0
  15. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/.gitignore +0 -0
  16. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/CLAUDE.md +0 -0
  17. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/CODE_OF_CONDUCT.md +0 -0
  18. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/CONTRIBUTING.md +0 -0
  19. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/LICENSE +0 -0
  20. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/SECURITY.md +0 -0
  21. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/assets/crossover.png +0 -0
  22. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/assets/read_latency.png +0 -0
  23. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/assets/runtimes.png +0 -0
  24. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/assets/write_throughput.png +0 -0
  25. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/README.md +0 -0
  26. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/benchmark.py +0 -0
  27. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/charts.py +0 -0
  28. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/neo4j/README.md +0 -0
  29. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/neo4j/headtohead.py +0 -0
  30. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/postgres/README.md +0 -0
  31. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/postgres/benchmark.py +0 -0
  32. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/postgres/charts.py +0 -0
  33. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/runtimes/compare.py +0 -0
  34. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/runtimes/run_bun.js +0 -0
  35. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/runtimes/run_node.mjs +0 -0
  36. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/bench/runtimes/run_python.py +0 -0
  37. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/backends/__init__.py +0 -0
  38. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/backends/base.py +0 -0
  39. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/backends/neo4j.py +0 -0
  40. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/backends/postgres.py +0 -0
  41. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/backends/sqlite.py +0 -0
  42. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/graph.py +0 -0
  43. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/invariants.py +0 -0
  44. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/mcp_server.py +0 -0
  45. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/policy.py +0 -0
  46. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/kgrdbms/resolver.py +0 -0
  47. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_bulk.py +0 -0
  48. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_graph.py +0 -0
  49. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_mcp_server.py +0 -0
  50. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_policy.py +0 -0
  51. {knowledge_graph_rdbms-0.1.2 → knowledge_graph_rdbms-0.1.3}/tests/test_postgres.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: knowledge-graph-rdbms
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: A label property graph on an RDBMS (SQLite): nodes, typed edges, an append-only event log, and an optional MCP server.
5
5
  Project-URL: Homepage, https://github.com/cunicopia-dev/knowledge-graph-rdbms
6
6
  Project-URL: Repository, https://github.com/cunicopia-dev/knowledge-graph-rdbms
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
34
34
  ![Python](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white)
35
35
  ![License: MIT](https://img.shields.io/badge/license-MIT-green)
36
36
  ![core dependencies: 0](https://img.shields.io/badge/core_dependencies-0-success)
37
- ![tests: 79 passing](https://img.shields.io/badge/tests-79_passing-brightgreen)
37
+ ![tests: 87 passing](https://img.shields.io/badge/tests-87_passing-brightgreen)
38
38
  ![storage: SQLite](https://img.shields.io/badge/storage-SQLite-003B57?logo=sqlite&logoColor=white)
39
39
  ![MCP](https://img.shields.io/badge/MCP-ready-FF6F00)
40
40
 
@@ -3,7 +3,7 @@
3
3
  ![Python](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white)
4
4
  ![License: MIT](https://img.shields.io/badge/license-MIT-green)
5
5
  ![core dependencies: 0](https://img.shields.io/badge/core_dependencies-0-success)
6
- ![tests: 79 passing](https://img.shields.io/badge/tests-79_passing-brightgreen)
6
+ ![tests: 87 passing](https://img.shields.io/badge/tests-87_passing-brightgreen)
7
7
  ![storage: SQLite](https://img.shields.io/badge/storage-SQLite-003B57?logo=sqlite&logoColor=white)
8
8
  ![MCP](https://img.shields.io/badge/MCP-ready-FF6F00)
9
9
 
@@ -12,7 +12,7 @@ A small, dependency-free knowledge-graph core:
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- __version__ = "0.1.2"
15
+ __version__ = "0.1.3"
16
16
 
17
17
  from kgrdbms.graph import Edge, Graph, Node, default_graph_path, slug
18
18
  from kgrdbms.events import (
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import argparse
23
23
  import json
24
+ import sqlite3
24
25
  import sys
25
26
  from typing import Any
26
27
 
@@ -600,6 +601,11 @@ def main(argv: list[str] | None = None) -> int:
600
601
  except (KeyError, ValueError, FileNotFoundError) as e:
601
602
  print(f"error: {e}", file=sys.stderr)
602
603
  return 1
604
+ except sqlite3.IntegrityError as e:
605
+ # Safety net for any FK/constraint path not pre-checked in service.py
606
+ # (e.g. restoring a deleted node whose edge endpoint is since gone).
607
+ print(f"error: {e}", file=sys.stderr)
608
+ return 1
603
609
  finally:
604
610
  app.close()
605
611
 
@@ -53,6 +53,7 @@ OP_NODE_DEL_PROPERTY = "NODE_DEL_PROPERTY"
53
53
  OP_EDGE_ADD = "EDGE_ADD"
54
54
  OP_EDGE_REMOVE = "EDGE_REMOVE"
55
55
  OP_RESTORE = "RESTORE" # re-create a captured node + its edges (used to undo a delete)
56
+ OP_NODE_RESTORE_STATE = "NODE_RESTORE_STATE" # exact-restore a node to a prior snapshot (undo of an upsert)
56
57
  OP_BATCH = "BATCH" # add many nodes + edges in one event
57
58
  OP_GENESIS = "GENESIS"
58
59
 
@@ -226,7 +227,11 @@ class EventLog:
226
227
  prior = p.get("prior")
227
228
  if prior is None:
228
229
  return OP_NODE_DELETE, {"node": p["after"], "edges": []}
229
- return OP_NODE_UPSERT, {"after": prior, "prior": p["after"]}
230
+ # A plain re-upsert of `prior` would MERGE, so it cannot remove the
231
+ # labels/properties this upsert *added* — leaving a non-inverse. The
232
+ # exact-restore op carries the prior snapshot plus the `added` delta,
233
+ # so it can strip those additions and rebuild `prior` precisely.
234
+ return OP_NODE_RESTORE_STATE, {"node": prior, "added": p["after"]}
230
235
  if op == OP_NODE_DELETE:
231
236
  return OP_RESTORE, {"node": p["node"], "edges": p.get("edges", [])}
232
237
  if op == OP_NODE_SET_LABEL:
@@ -261,6 +266,19 @@ def apply_event(graph: "Graph", ev: GraphEvent) -> None:
261
266
  labels=spec.get("labels", []), properties=spec.get("properties", {}))
262
267
  for e in p.get("edges", []):
263
268
  graph.add_edge(e["from"], e["to"], e["type"], e.get("properties", {}))
269
+ elif op == OP_NODE_RESTORE_STATE:
270
+ # Exact-restore a node to `node`, removing the labels/properties that the
271
+ # reverted upsert added (`added` is that upsert's delta). add_node is a
272
+ # merge, so we must strip the additions first, then rebuild the snapshot.
273
+ target = p["node"]
274
+ added = p.get("added", {})
275
+ nid = target["id"]
276
+ for label in set(added.get("labels", [])) - set(target.get("labels", [])):
277
+ graph.remove_label(nid, label)
278
+ for key in set(added.get("properties", {}).keys()) - set(target.get("properties", {}).keys()):
279
+ graph.del_property(nid, key)
280
+ graph.add_node(nid, target["kind"], target["name"],
281
+ labels=target.get("labels", []), properties=target.get("properties", {}))
264
282
  elif op == OP_NODE_SET_LABEL:
265
283
  graph.add_label(p["id"], p["label"])
266
284
  elif op == OP_NODE_REMOVE_LABEL:
@@ -29,6 +29,7 @@ import json
29
29
  import re
30
30
  from dataclasses import dataclass, field
31
31
  from typing import Any, Iterator
32
+ from urllib.parse import quote, unquote
32
33
 
33
34
  from kgrdbms.backends.base import GraphBackend
34
35
  from kgrdbms.graph import Edge, Node
@@ -78,6 +79,22 @@ XSD = "http://www.w3.org/2001/XMLSchema#"
78
79
  KG = "https://kg.local/vocab#"
79
80
 
80
81
 
82
+ def _enc(segment: str) -> str:
83
+ """Percent-encode an arbitrary string into a valid IRI path/fragment segment.
84
+
85
+ Node references are slug-safe, but `kind`, edge `type`, and property `key`
86
+ are free-form user text — a space or '%' there would otherwise emit an IRI
87
+ no conformant RDF store will parse. `safe=""` also encodes '/', so a key like
88
+ 'a/b' can't masquerade as a path boundary. Inverted by `_dec` on import.
89
+ """
90
+ return quote(segment, safe="")
91
+
92
+
93
+ def _dec(segment: str) -> str:
94
+ """Invert `_enc` — percent-decode an IRI segment back to its stored value."""
95
+ return unquote(segment)
96
+
97
+
81
98
  # ---- IRI context: the CURIE -> IRI expansion table -------------------
82
99
 
83
100
 
@@ -106,13 +123,13 @@ class IriContext:
106
123
  base = self.prefix_bases.get(prefix, f"{self.default_base}{prefix}/")
107
124
  else:
108
125
  prefix, ref, base = "", node_id, self.default_base
109
- return Iri(f"{base}{ref}")
126
+ return Iri(f"{base}{_enc(ref)}")
110
127
 
111
128
  def prop_predicate(self, key: str) -> Iri:
112
- return Iri(f"{self.prop_base}{key}")
129
+ return Iri(f"{self.prop_base}{_enc(key)}")
113
130
 
114
131
  def edge_predicate(self, edge_type: str) -> Iri:
115
- return Iri(f"{self.edge_base}{edge_type}")
132
+ return Iri(f"{self.edge_base}{_enc(edge_type)}")
116
133
 
117
134
 
118
135
  # ---- value -> literal typing -----------------------------------------
@@ -163,7 +180,7 @@ def node_to_triples(node: Node, ctx: IriContext) -> list[Triple]:
163
180
  s = ctx.expand_node(node.id)
164
181
  triples: list[Triple] = [
165
182
  # kind -> rdf:type, pointing at a class IRI under the kg vocab.
166
- (s, Iri(f"{RDF}type"), Iri(f"{KG}{node.kind}")),
183
+ (s, Iri(f"{RDF}type"), Iri(f"{KG}{_enc(node.kind)}")),
167
184
  ]
168
185
  if node.name:
169
186
  triples.append((s, Iri(f"{KG}name"), Literal(node.name)))
@@ -494,18 +511,19 @@ def contract_iri(iri: str, ctx: IriContext) -> str:
494
511
  # Explicit prefix bindings win (longest base first to avoid prefix overlap).
495
512
  for prefix, base in sorted(ctx.prefix_bases.items(), key=lambda kv: -len(kv[1])):
496
513
  if iri.startswith(base):
497
- return f"{prefix}:{iri[len(base):]}"
514
+ return f"{prefix}:{_dec(iri[len(base):])}"
498
515
  if iri.startswith(ctx.default_base):
499
516
  rest = iri[len(ctx.default_base):]
500
517
  if "/" in rest:
501
518
  prefix, ref = rest.split("/", 1)
502
- return f"{prefix}:{ref}"
503
- return rest
519
+ return f"{prefix}:{_dec(ref)}"
520
+ return _dec(rest)
504
521
  return iri # foreign IRI — keep verbatim
505
522
 
506
523
 
507
524
  def _local_after(iri: str, base: str) -> str | None:
508
- return iri[len(base):] if iri.startswith(base) else None
525
+ """Strip `base` and percent-decode the remaining segment (kind/type/key)."""
526
+ return _dec(iri[len(base):]) if iri.startswith(base) else None
509
527
 
510
528
 
511
529
  def triples_to_graph(triples: list[Triple], ctx: IriContext | None = None) -> tuple[list[dict], list[dict]]:
@@ -95,6 +95,11 @@ def upsert_node(
95
95
 
96
96
  def set_label(graph: Graph, events: EventLog, id: str, label: str, actor: str = "anonymous") -> Node | None:
97
97
  guard(graph, _node_ctx(graph, id, "node_set_label"))
98
+ node = graph.node(id)
99
+ if node is None:
100
+ raise ValueError(f"node {id!r} does not exist")
101
+ if label in node.labels:
102
+ return node # already present: a true no-op, so don't log a non-invertible event
98
103
  graph.add_label(id, label)
99
104
  events.record(actor, OP_NODE_SET_LABEL, {"id": id, "label": label})
100
105
  return graph.node(id)
@@ -107,7 +112,9 @@ def set_property(
107
112
  ctx.property_key = key
108
113
  guard(graph, ctx)
109
114
  prior_node = graph.node(id)
110
- prior_value = prior_node.properties.get(key, _MISSING) if prior_node else _MISSING
115
+ if prior_node is None:
116
+ raise ValueError(f"node {id!r} does not exist")
117
+ prior_value = prior_node.properties.get(key, _MISSING)
111
118
  graph.set_property(id, key, value)
112
119
  events.record(actor, OP_NODE_SET_PROPERTY, {"id": id, "key": key, "value": value, "prior": prior_value})
113
120
  return graph.node(id)
@@ -139,6 +146,9 @@ def add_edge(
139
146
  ) -> Edge:
140
147
  ctx = MutationContext(operation="edge_add", edge_type=type, from_node_id=from_id, to_node_id=to_id)
141
148
  guard(graph, ctx)
149
+ for endpoint, role in ((from_id, "from"), (to_id, "to")):
150
+ if graph.node(endpoint) is None:
151
+ raise ValueError(f"{role} node {endpoint!r} does not exist")
142
152
  edge = graph.add_edge(from_node=from_id, to_node=to_id, type=type, properties=properties or {})
143
153
  events.record(actor, OP_EDGE_ADD, {"edge": edge_spec(edge)})
144
154
  return edge
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "knowledge-graph-rdbms"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "A label property graph on an RDBMS (SQLite): nodes, typed edges, an append-only event log, and an optional MCP server."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -189,3 +189,25 @@ def test_rdf_export_lossy_reports_dropped(db, capsys):
189
189
  assert "rel/influences" in captured.out # bare edge present
190
190
  assert "prop/since" not in captured.out # property dropped
191
191
  assert "dropped" in captured.err # but loudly, not silently
192
+
193
+
194
+ # ---- regression: FK violations exit 1 cleanly, no traceback ---------
195
+
196
+
197
+ def test_set_label_missing_node_exits_1(db, capsys):
198
+ assert run(db, "node", "add-label", "ghost:1", "L") == 1
199
+ err = capsys.readouterr().err
200
+ assert "does not exist" in err and "Traceback" not in err
201
+
202
+
203
+ def test_set_prop_missing_node_exits_1(db, capsys):
204
+ assert run(db, "node", "set-prop", "ghost:1", "k", "1") == 1
205
+ assert "does not exist" in capsys.readouterr().err
206
+
207
+
208
+ def test_edge_add_missing_endpoint_exits_1(db, capsys):
209
+ run(db, "node", "add", "x:1", "--kind", "T")
210
+ capsys.readouterr()
211
+ assert run(db, "edge", "add", "x:1", "y:1", "LINK") == 1
212
+ err = capsys.readouterr().err
213
+ assert "to node 'y:1' does not exist" in err and "Traceback" not in err
@@ -154,3 +154,52 @@ def test_batch_op_is_replayable(tmp_path):
154
154
  assert g.node("n:1") is not None
155
155
  assert g.out("n:1", "LINK")
156
156
  g.close()
157
+
158
+
159
+ # ---- regression: revert of an upsert is a TRUE inverse --------------
160
+
161
+
162
+ def test_revert_upsert_removes_added_labels_and_props(tmp_path):
163
+ """An upsert that ADDS labels/props must, on revert, restore the node
164
+ exactly to its prior state — not merely overwrite changed values.
165
+ Regression for: compensation merged instead of replacing."""
166
+ from kgrdbms import service
167
+
168
+ g, log = _fresh(tmp_path)
169
+ service.upsert_node(g, log, id="t:1", kind="T", labels=["A"], properties={"color": "red"})
170
+ service.upsert_node(g, log, id="t:1", kind="T", labels=["B"],
171
+ properties={"color": "blue", "size": "big"})
172
+ ev2 = log.tail(1)[0].id
173
+
174
+ service.revert_event(log, ev2)
175
+ n = g.node("t:1")
176
+ assert sorted(n.labels) == ["A"] # added label B removed
177
+ assert n.properties == {"color": "red"} # added prop dropped, value restored
178
+
179
+
180
+ def test_replay_after_revert_is_consistent(tmp_path):
181
+ """The new restore-state op must itself be replayable: rebuilding from the
182
+ log reproduces the post-revert state."""
183
+ from kgrdbms import service
184
+
185
+ g, log = _fresh(tmp_path)
186
+ service.upsert_node(g, log, id="t:1", kind="T", labels=["A"], properties={"color": "red"})
187
+ service.upsert_node(g, log, id="t:1", kind="T", labels=["B"], properties={"color": "blue"})
188
+ service.revert_event(log, log.tail(1)[0].id)
189
+
190
+ service.replay_log(g, log)
191
+ n = g.node("t:1")
192
+ assert sorted(n.labels) == ["A"] and n.properties == {"color": "red"}
193
+
194
+
195
+ def test_resetting_existing_label_is_noop_not_logged(tmp_path):
196
+ """Re-adding a label a node already has must not log an event whose revert
197
+ would then remove the pre-existing label."""
198
+ from kgrdbms import service
199
+
200
+ g, log = _fresh(tmp_path)
201
+ service.upsert_node(g, log, id="t:1", kind="T", labels=["A"])
202
+ before = log.count()
203
+ service.set_label(g, log, "t:1", "A") # already present -> no-op
204
+ assert log.count() == before # nothing logged
205
+ assert "A" in g.node("t:1").labels
@@ -97,6 +97,47 @@ def test_rdf_star_annotates_the_quoted_triple(populated):
97
97
  assert "<https://kg.local/prop/since>" in nt
98
98
 
99
99
 
100
+ def test_special_chars_produce_valid_iris(populated):
101
+ """Regression: kind / edge-type / property-key with spaces or punctuation
102
+ must percent-encode into valid IRIs, not emit a raw space a store rejects."""
103
+ g = _fresh_graph()
104
+ g.add_node("topic:ml", kind="Knowledge Area", name="ML",
105
+ properties={"first name": "Ada", "rate %": 50, "a/b": 1})
106
+ g.add_node("topic:cs", kind="Knowledge Area", name="CS")
107
+ g.add_edge("topic:ml", "topic:cs", "is part of", properties={"note": "x"})
108
+
109
+ nt = rdf.export(g, "ntriples")
110
+ assert "Knowledge%20Area" in nt
111
+ assert "prop/first%20name" in nt
112
+ assert "rel/is%20part%20of" in nt
113
+ assert "prop/a%2Fb" in nt # '/' encoded so it can't fake a path boundary
114
+ # No IRI reference may contain a raw space (would break any conformant parser).
115
+ import re
116
+ for iri in re.findall(r"<([^<>]+)>", nt):
117
+ assert " " not in iri, f"raw space in IRI: {iri!r}"
118
+
119
+
120
+ def test_special_chars_round_trip_and_rdflib_accepts(populated):
121
+ rdflib = pytest.importorskip("rdflib")
122
+ g = _fresh_graph()
123
+ g.add_node("topic:ml", kind="Knowledge Area", name="ML",
124
+ properties={"first name": "Ada", "rate %": 50, "a/b": 1})
125
+ g.add_node("topic:cs", kind="Knowledge Area", name="CS")
126
+ g.add_edge("topic:ml", "topic:cs", "is part of", properties={"note": "x"})
127
+
128
+ # A conformant store accepts the export (reification — rdflib is RDF 1.1).
129
+ ctx = rdf.IriContext(edge_strategy="reification")
130
+ rdflib.Graph().parse(data=rdf.export(g, "ntriples", ctx), format="nt")
131
+
132
+ # And the values survive a full star round-trip unchanged.
133
+ dst = _reimport(rdf.export(g, "ntriples"), "ntriples")
134
+ n = dst.node("topic:ml")
135
+ assert n.kind == "Knowledge Area"
136
+ assert n.properties == {"first name": "Ada", "rate %": 50, "a/b": 1}
137
+ e = dst.out("topic:ml")[0][0]
138
+ assert e.type == "is part of" and e.properties == {"note": "x"}
139
+
140
+
100
141
  def test_reification_emits_statement_node(populated):
101
142
  ctx = rdf.IriContext(edge_strategy="reification")
102
143
  triples = rdf.export_graph(populated, ctx)