mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-06 11:52:10 +02:00
* feat: streaming N-Quads serializer for wire-format triples Groundwork for Phase 2 of #877 (knowledge export). Hand-rolled N-Triples term encoding: rdflib's term.n3() emits Turtle-style forms (numeric shorthand, unescaped newlines) that are invalid in line-oriented N-Quads, so literals are escaped per the ECHAR grammar and IRIs validated for representability. Round-trip tests parse the output back with rdflib's nquads parser and compare term-for-term. * feat: export/import workspace knowledge in .tgx bundles (#877) Phase 2 of the workspace bundle commands: tg-export-workspace now includes the workspace's knowledge by default — per-collection knowledge-graph triples as N-Quads (the collection names the graph, streamed through a tempfile so memory stays flat regardless of knowledge-base size) and the document library (metadata plus content, fetched one document at a time). --config-only skips knowledge on both sides; --triples-limit bounds very large graphs; -f/--flow-id selects the flow the triples services run through. tg-import-workspace streams triples back through the bulk import per collection and recreates library documents (children after parents). Knowledge import is additive, unlike config's skip-existing semantics. Embedding vectors are not carried in bundles: --process re-runs imported documents through the flow, which regenerates extraction output and embeddings; --process-collection targets it. Round-trip covered by unit tests over real archives: export with a mocked Api, re-import, and assert the bulk triples stream and library add calls reproduce the original values (including datatyped literals via the N-Quads path).
This commit is contained in:
parent
68e816e65c
commit
cfbd5b9079
6 changed files with 1040 additions and 181 deletions
|
|
@ -45,4 +45,18 @@ def sample_metadata():
|
||||||
"metadata": [],
|
"metadata": [],
|
||||||
"user": "test-user",
|
"user": "test-user",
|
||||||
"collection": "test-collection"
|
"collection": "test-collection"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def iri(v):
|
||||||
|
"""Wire-format IRI term dict, as triples_query_stream yields them."""
|
||||||
|
return {"t": "i", "i": v}
|
||||||
|
|
||||||
|
|
||||||
|
def lit(v, d=None, lang=None):
|
||||||
|
"""Wire-format literal term dict (optional datatype / language)."""
|
||||||
|
t = {"t": "l", "v": v}
|
||||||
|
if d:
|
||||||
|
t["d"] = d
|
||||||
|
if lang:
|
||||||
|
t["l"] = lang
|
||||||
|
return t
|
||||||
|
|
|
||||||
97
tests/unit/test_cli/test_nquads.py
Normal file
97
tests/unit/test_cli/test_nquads.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""
|
||||||
|
Round-trip tests for the streaming N-Quads serializer: wire-format triples
|
||||||
|
are serialized line-by-line, then parsed back with rdflib's nquads parser
|
||||||
|
and compared term-for-term — proving the output is valid N-Quads and the
|
||||||
|
encoding (escaping, datatypes, language tags, unicode) is lossless.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
import rdflib
|
||||||
|
|
||||||
|
from trustgraph.cli.nquads import serialize_nquads, triple_to_nquad
|
||||||
|
|
||||||
|
from tests.unit.test_cli.conftest import iri, lit
|
||||||
|
|
||||||
|
GRAPH = "urn:trustgraph:collection:default"
|
||||||
|
|
||||||
|
|
||||||
|
def roundtrip(batches):
|
||||||
|
"""Serialize then parse back; return (parsed_dataset, written, skipped)."""
|
||||||
|
out = io.StringIO()
|
||||||
|
written, skipped = serialize_nquads(batches, GRAPH, out)
|
||||||
|
ds = rdflib.Dataset()
|
||||||
|
ds.parse(data=out.getvalue(), format="nquads")
|
||||||
|
return ds, written, skipped
|
||||||
|
|
||||||
|
|
||||||
|
class TestNquadsRoundTrip:
|
||||||
|
|
||||||
|
def test_iri_and_literal_flavours_survive_roundtrip(self):
|
||||||
|
batches = [[
|
||||||
|
{"s": iri("http://example.com/s"), "p": iri("http://example.com/p"),
|
||||||
|
"o": iri("http://example.com/o")},
|
||||||
|
{"s": iri("http://example.com/s"), "p": iri("http://example.com/label"),
|
||||||
|
"o": lit("plain value")},
|
||||||
|
{"s": iri("http://example.com/s"), "p": iri("http://example.com/label"),
|
||||||
|
"o": lit("bonjour", lang="fr")},
|
||||||
|
{"s": iri("http://example.com/s"), "p": iri("http://example.com/count"),
|
||||||
|
"o": lit("42", d="http://www.w3.org/2001/XMLSchema#integer")},
|
||||||
|
]]
|
||||||
|
ds, written, skipped = roundtrip(batches)
|
||||||
|
|
||||||
|
assert (written, skipped) == (4, 0)
|
||||||
|
quads = list(ds.quads((None, None, None, None)))
|
||||||
|
assert len(quads) == 4
|
||||||
|
g = rdflib.URIRef(GRAPH)
|
||||||
|
assert all(q[3] == g for q in quads)
|
||||||
|
|
||||||
|
objects = {q[2] for q in quads}
|
||||||
|
assert rdflib.URIRef("http://example.com/o") in objects
|
||||||
|
assert rdflib.Literal("plain value") in objects
|
||||||
|
assert rdflib.Literal("bonjour", lang="fr") in objects
|
||||||
|
assert rdflib.Literal(
|
||||||
|
"42", datatype=rdflib.URIRef("http://www.w3.org/2001/XMLSchema#integer")
|
||||||
|
) in objects
|
||||||
|
|
||||||
|
def test_hostile_literal_content_is_escaped_losslessly(self):
|
||||||
|
nasty = 'line1\nline2\t"quoted" back\\slash 中文 emoji\U0001f680'
|
||||||
|
batches = [[{
|
||||||
|
"s": iri("http://example.com/s"),
|
||||||
|
"p": iri("http://example.com/note"),
|
||||||
|
"o": lit(nasty),
|
||||||
|
}]]
|
||||||
|
ds, written, skipped = roundtrip(batches)
|
||||||
|
|
||||||
|
assert (written, skipped) == (1, 0)
|
||||||
|
obj = next(iter(ds.quads((None, None, None, None))))[2]
|
||||||
|
assert str(obj) == nasty
|
||||||
|
|
||||||
|
def test_malformed_and_unrepresentable_terms_are_skipped_not_emitted(self):
|
||||||
|
batches = [[
|
||||||
|
# IRI with a space (matches graph_to_turtle's malformed skip)
|
||||||
|
{"s": iri("http://example.com/bad iri"), "p": iri("http://example.com/p"),
|
||||||
|
"o": lit("x")},
|
||||||
|
# RDF-star quoted triple: no N-Quads encoding
|
||||||
|
{"s": iri("http://example.com/s"), "p": iri("http://example.com/p"),
|
||||||
|
"o": {"t": "r", "r": {}}},
|
||||||
|
# literal in predicate position: invalid RDF
|
||||||
|
{"s": iri("http://example.com/s"), "p": lit("not-a-predicate"),
|
||||||
|
"o": lit("x")},
|
||||||
|
# one good triple to prove the stream continues past skips
|
||||||
|
{"s": iri("http://example.com/s"), "p": iri("http://example.com/p"),
|
||||||
|
"o": lit("good")},
|
||||||
|
]]
|
||||||
|
ds, written, skipped = roundtrip(batches)
|
||||||
|
|
||||||
|
assert (written, skipped) == (1, 3)
|
||||||
|
assert len(list(ds.quads((None, None, None, None)))) == 1
|
||||||
|
|
||||||
|
def test_streaming_shape_one_line_per_triple(self):
|
||||||
|
line = triple_to_nquad(
|
||||||
|
{"s": iri("http://example.com/s"), "p": iri("http://example.com/p"),
|
||||||
|
"o": lit("v")},
|
||||||
|
f"<{GRAPH}>",
|
||||||
|
)
|
||||||
|
assert line.endswith(" .\n")
|
||||||
|
assert line.count("\n") == 1
|
||||||
|
|
@ -3,19 +3,25 @@ Tests for tg-export-workspace / tg-import-workspace (.tgx bundle commands).
|
||||||
|
|
||||||
The Api class is mocked in each command module's namespace (same pattern as
|
The Api class is mocked in each command module's namespace (same pattern as
|
||||||
test_config_commands.py); bundles are written to and read from tmp_path so
|
test_config_commands.py); bundles are written to and read from tmp_path so
|
||||||
the archive format itself is exercised end-to-end.
|
the archive format itself is exercised end-to-end, including the Phase-2
|
||||||
|
knowledge tree (per-collection N-Quads + library documents).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import tarfile
|
import tarfile
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from trustgraph.api.types import ConfigValue
|
from trustgraph.api.types import ConfigValue, Triple
|
||||||
from trustgraph.cli.export_workspace import export_workspace
|
from trustgraph.cli.export_workspace import export_workspace
|
||||||
from trustgraph.cli.import_workspace import import_workspace
|
from trustgraph.cli.import_workspace import import_workspace
|
||||||
|
|
||||||
|
from tests.unit.test_cli.conftest import iri, lit
|
||||||
|
|
||||||
SAMPLE_CONFIG = {
|
SAMPLE_CONFIG = {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
"extract-concepts": json.dumps({"template": "Extract {{q}}"}),
|
"extract-concepts": json.dumps({"template": "Extract {{q}}"}),
|
||||||
|
|
@ -26,25 +32,110 @@ SAMPLE_CONFIG = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Wire-format triples for one collection, incl. a datatyped literal.
|
||||||
|
WIRE_BATCHES = [[
|
||||||
|
{"s": iri("http://ex.com/s"), "p": iri("http://ex.com/p"),
|
||||||
|
"o": iri("http://ex.com/o")},
|
||||||
|
{"s": iri("http://ex.com/s"), "p": iri("http://ex.com/count"),
|
||||||
|
"o": lit("42", d="http://www.w3.org/2001/XMLSchema#integer")},
|
||||||
|
]]
|
||||||
|
|
||||||
def make_mock_api():
|
DOC = SimpleNamespace(
|
||||||
mock_config = Mock()
|
id="doc-1",
|
||||||
|
time=datetime.datetime(2026, 7, 1, 12, 0, 0),
|
||||||
|
kind="text/plain",
|
||||||
|
title="Policy",
|
||||||
|
comments="returns policy",
|
||||||
|
metadata=[Triple(s="http://ex.com/doc-1", p="http://ex.com/about",
|
||||||
|
o="returns")],
|
||||||
|
tags=["policy"],
|
||||||
|
parent_id="",
|
||||||
|
document_type="source",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_mock_api(collections=(), batches=(), docs=(), contents=None):
|
||||||
|
"""Full-surface Api mock; returns (mock_api, mock_config)."""
|
||||||
mock_api = Mock()
|
mock_api = Mock()
|
||||||
|
|
||||||
|
mock_config = Mock()
|
||||||
mock_api.config.return_value = mock_config
|
mock_api.config.return_value = mock_config
|
||||||
|
mock_config.all.return_value = (SAMPLE_CONFIG, "v42")
|
||||||
|
|
||||||
|
mock_api.collection.return_value.list_collections.return_value = \
|
||||||
|
list(collections)
|
||||||
|
|
||||||
|
flow = mock_api.socket.return_value.flow.return_value
|
||||||
|
flow.triples_query_stream.return_value = iter(batches)
|
||||||
|
|
||||||
|
library = mock_api.library.return_value
|
||||||
|
library.get_documents.return_value = list(docs)
|
||||||
|
library.get_document_content.side_effect = \
|
||||||
|
lambda id: (contents or {}).get(id, b"")
|
||||||
|
|
||||||
return mock_api, mock_config
|
return mock_api, mock_config
|
||||||
|
|
||||||
|
|
||||||
|
def export_bundle(path, collections=(), batches=(), docs=(), contents=None,
|
||||||
|
**kwargs):
|
||||||
|
"""Export SAMPLE_CONFIG (+ optional knowledge mocks) to path."""
|
||||||
|
mock_api, mock_config = make_mock_api(
|
||||||
|
collections=collections, batches=batches, docs=docs,
|
||||||
|
contents=contents,
|
||||||
|
)
|
||||||
|
with patch("trustgraph.cli.export_workspace.Api") as api_cls:
|
||||||
|
api_cls.return_value = mock_api
|
||||||
|
export_workspace(
|
||||||
|
url="http://api/", workspace="source-ws", output=str(path),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
return mock_api, mock_config
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_MANIFEST = {
|
||||||
|
"format": "tgx", "format_version": 1, "workspace": "w",
|
||||||
|
"contents": {"config": True, "knowledge": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_bundle(path, members, manifest=DEFAULT_MANIFEST):
|
||||||
|
"""Write a raw .tgx from name -> bytes (manifest added first)."""
|
||||||
|
entries = {"manifest.json": json.dumps(manifest).encode(), **members}
|
||||||
|
with tarfile.open(path, "w:gz") as tar:
|
||||||
|
for name, data in entries.items():
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
info.size = len(data)
|
||||||
|
tar.addfile(info, io.BytesIO(data))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def run_import(mock_api, input, **kwargs):
|
||||||
|
"""Run import_workspace against a mocked Api."""
|
||||||
|
with patch("trustgraph.cli.import_workspace.Api") as api_cls:
|
||||||
|
api_cls.return_value = mock_api
|
||||||
|
import_workspace(url="http://api/", input=str(input), **kwargs)
|
||||||
|
return api_cls
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def bundle(tmp_path):
|
def bundle(tmp_path):
|
||||||
"""Export SAMPLE_CONFIG to a real .tgx and yield its path."""
|
"""Config-only-shaped bundle (no collections/docs mocked)."""
|
||||||
path = tmp_path / "ws.tgx"
|
path = tmp_path / "ws.tgx"
|
||||||
with patch("trustgraph.cli.export_workspace.Api") as api_cls:
|
export_bundle(path)
|
||||||
mock_api, mock_config = make_mock_api()
|
return path
|
||||||
api_cls.return_value = mock_api
|
|
||||||
mock_config.all.return_value = (SAMPLE_CONFIG, "v42")
|
|
||||||
export_workspace(
|
@pytest.fixture
|
||||||
url="http://api/", workspace="source-ws", output=str(path),
|
def knowledge_bundle(tmp_path):
|
||||||
)
|
"""Bundle with one collection (2 triples) and one document."""
|
||||||
|
path = tmp_path / "kws.tgx"
|
||||||
|
export_bundle(
|
||||||
|
path,
|
||||||
|
collections=[SimpleNamespace(collection="research")],
|
||||||
|
batches=WIRE_BATCHES,
|
||||||
|
docs=[DOC],
|
||||||
|
contents={"doc-1": b"Customers may return items."},
|
||||||
|
)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,7 +149,10 @@ class TestExportWorkspace:
|
||||||
assert manifest["format"] == "tgx"
|
assert manifest["format"] == "tgx"
|
||||||
assert manifest["workspace"] == "source-ws"
|
assert manifest["workspace"] == "source-ws"
|
||||||
assert manifest["config_version"] == "v42"
|
assert manifest["config_version"] == "v42"
|
||||||
assert manifest["contents"] == {"config": True, "knowledge": False}
|
assert manifest["contents"] == {"config": True, "knowledge": True}
|
||||||
|
assert manifest["knowledge"] == {
|
||||||
|
"collections": [], "documents": 0, "triples": {},
|
||||||
|
}
|
||||||
|
|
||||||
assert "config/prompt/extract-concepts.json" in names
|
assert "config/prompt/extract-concepts.json" in names
|
||||||
assert "config/prompt/answer.json" in names
|
assert "config/prompt/answer.json" in names
|
||||||
|
|
@ -79,12 +173,12 @@ class TestExportWorkspace:
|
||||||
|
|
||||||
def test_path_unsafe_keys_are_quoted_in_filenames(self, tmp_path):
|
def test_path_unsafe_keys_are_quoted_in_filenames(self, tmp_path):
|
||||||
path = tmp_path / "ws.tgx"
|
path = tmp_path / "ws.tgx"
|
||||||
|
mock_api, mock_config = make_mock_api()
|
||||||
|
mock_config.all.return_value = (
|
||||||
|
{"prompt": {"a/b": json.dumps({"x": 1})}}, "v1",
|
||||||
|
)
|
||||||
with patch("trustgraph.cli.export_workspace.Api") as api_cls:
|
with patch("trustgraph.cli.export_workspace.Api") as api_cls:
|
||||||
mock_api, mock_config = make_mock_api()
|
|
||||||
api_cls.return_value = mock_api
|
api_cls.return_value = mock_api
|
||||||
mock_config.all.return_value = (
|
|
||||||
{"prompt": {"a/b": json.dumps({"x": 1})}}, "v1",
|
|
||||||
)
|
|
||||||
export_workspace(
|
export_workspace(
|
||||||
url="http://api/", workspace="ws", output=str(path),
|
url="http://api/", workspace="ws", output=str(path),
|
||||||
)
|
)
|
||||||
|
|
@ -94,16 +188,63 @@ class TestExportWorkspace:
|
||||||
assert "config/prompt/a%2Fb.json" in names
|
assert "config/prompt/a%2Fb.json" in names
|
||||||
assert entry["key"] == "a/b"
|
assert entry["key"] == "a/b"
|
||||||
|
|
||||||
|
def test_knowledge_tree_written_per_collection_and_document(
|
||||||
|
self, knowledge_bundle):
|
||||||
|
with tarfile.open(knowledge_bundle, "r:gz") as tar:
|
||||||
|
names = tar.getnames()
|
||||||
|
manifest = json.load(tar.extractfile("manifest.json"))
|
||||||
|
nq = tar.extractfile(
|
||||||
|
"knowledge/research/triples.nq").read().decode()
|
||||||
|
meta = json.load(
|
||||||
|
tar.extractfile("knowledge/library/doc-1.meta.json"))
|
||||||
|
content = tar.extractfile(
|
||||||
|
"knowledge/library/doc-1.content").read()
|
||||||
|
|
||||||
|
assert manifest["contents"]["knowledge"] is True
|
||||||
|
assert manifest["knowledge"] == {
|
||||||
|
"collections": ["research"], "documents": 1,
|
||||||
|
"triples": {"research": 2},
|
||||||
|
}
|
||||||
|
assert "knowledge/research/triples.nq" in names
|
||||||
|
# N-Quads: one line per triple, graph = the collection IRI, and
|
||||||
|
# the datatyped literal keeps its full quoted form.
|
||||||
|
lines = [ln for ln in nq.splitlines() if ln]
|
||||||
|
assert len(lines) == 2
|
||||||
|
assert all("<urn:trustgraph:collection:research>" in ln
|
||||||
|
for ln in lines)
|
||||||
|
assert '"42"^^<http://www.w3.org/2001/XMLSchema#integer>' in nq
|
||||||
|
|
||||||
|
assert meta["id"] == "doc-1"
|
||||||
|
assert meta["title"] == "Policy"
|
||||||
|
assert meta["metadata"] == [{
|
||||||
|
"s": "http://ex.com/doc-1", "p": "http://ex.com/about",
|
||||||
|
"o": "returns",
|
||||||
|
}]
|
||||||
|
assert content == b"Customers may return items."
|
||||||
|
|
||||||
|
def test_config_only_skips_knowledge(self, tmp_path):
|
||||||
|
path = tmp_path / "co.tgx"
|
||||||
|
mock_api, _ = export_bundle(
|
||||||
|
path,
|
||||||
|
collections=[SimpleNamespace(collection="research")],
|
||||||
|
config_only=True,
|
||||||
|
)
|
||||||
|
with tarfile.open(path, "r:gz") as tar:
|
||||||
|
names = tar.getnames()
|
||||||
|
manifest = json.load(tar.extractfile("manifest.json"))
|
||||||
|
assert manifest["contents"] == {"config": True, "knowledge": False}
|
||||||
|
assert "knowledge" not in manifest
|
||||||
|
assert not any(n.startswith("knowledge/") for n in names)
|
||||||
|
mock_api.collection.return_value.list_collections.assert_not_called()
|
||||||
|
mock_api.socket.assert_not_called()
|
||||||
|
mock_api.library.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestImportWorkspace:
|
class TestImportWorkspace:
|
||||||
|
|
||||||
def test_roundtrip_puts_all_values_with_overwrite(self, bundle):
|
def test_roundtrip_puts_all_values_with_overwrite(self, bundle):
|
||||||
with patch("trustgraph.cli.import_workspace.Api") as api_cls:
|
mock_api, mock_config = make_mock_api()
|
||||||
mock_api, mock_config = make_mock_api()
|
api_cls = run_import(mock_api, bundle, overwrite=True)
|
||||||
api_cls.return_value = mock_api
|
|
||||||
import_workspace(
|
|
||||||
url="http://api/", input=str(bundle), overwrite=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Target workspace defaults to the manifest's workspace.
|
# Target workspace defaults to the manifest's workspace.
|
||||||
api_cls.assert_called_once_with(
|
api_cls.assert_called_once_with(
|
||||||
|
|
@ -123,27 +264,22 @@ class TestImportWorkspace:
|
||||||
assert all(isinstance(v, ConfigValue) for v in values)
|
assert all(isinstance(v, ConfigValue) for v in values)
|
||||||
|
|
||||||
def test_workspace_flag_renames_target(self, bundle):
|
def test_workspace_flag_renames_target(self, bundle):
|
||||||
with patch("trustgraph.cli.import_workspace.Api") as api_cls:
|
mock_api, mock_config = make_mock_api()
|
||||||
mock_api, mock_config = make_mock_api()
|
api_cls = run_import(
|
||||||
api_cls.return_value = mock_api
|
mock_api, bundle, workspace="staging", overwrite=True,
|
||||||
import_workspace(
|
)
|
||||||
url="http://api/", input=str(bundle), workspace="staging",
|
|
||||||
overwrite=True,
|
|
||||||
)
|
|
||||||
api_cls.assert_called_once_with(
|
api_cls.assert_called_once_with(
|
||||||
"http://api/", token=None, workspace="staging",
|
"http://api/", token=None, workspace="staging",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_default_skips_existing_keys(self, bundle):
|
def test_default_skips_existing_keys(self, bundle):
|
||||||
"""WorkspaceInit re-run semantics: only missing keys are written."""
|
"""WorkspaceInit re-run semantics: only missing keys are written."""
|
||||||
with patch("trustgraph.cli.import_workspace.Api") as api_cls:
|
mock_api, mock_config = make_mock_api()
|
||||||
mock_api, mock_config = make_mock_api()
|
mock_config.list.side_effect = lambda t: {
|
||||||
api_cls.return_value = mock_api
|
"prompt": ["extract-concepts"],
|
||||||
mock_config.list.side_effect = lambda t: {
|
"tool": [],
|
||||||
"prompt": ["extract-concepts"],
|
}[t]
|
||||||
"tool": [],
|
run_import(mock_api, bundle)
|
||||||
}[t]
|
|
||||||
import_workspace(url="http://api/", input=str(bundle))
|
|
||||||
|
|
||||||
values = mock_config.put.call_args.args[0]
|
values = mock_config.put.call_args.args[0]
|
||||||
assert sorted((v.type, v.key) for v in values) == [
|
assert sorted((v.type, v.key) for v in values) == [
|
||||||
|
|
@ -152,17 +288,11 @@ class TestImportWorkspace:
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_dry_run_writes_nothing(self, bundle, capsys):
|
def test_dry_run_writes_nothing(self, bundle, capsys):
|
||||||
with patch("trustgraph.cli.import_workspace.Api") as api_cls:
|
mock_api, mock_config = make_mock_api()
|
||||||
mock_api, mock_config = make_mock_api()
|
run_import(mock_api, bundle, overwrite=True, dry_run=True)
|
||||||
api_cls.return_value = mock_api
|
|
||||||
import_workspace(
|
|
||||||
url="http://api/", input=str(bundle), overwrite=True,
|
|
||||||
dry_run=True,
|
|
||||||
)
|
|
||||||
mock_config.put.assert_not_called()
|
mock_config.put.assert_not_called()
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "would import prompt/extract-concepts" in out
|
assert "would import prompt/extract-concepts" in out
|
||||||
assert "3 item(s) would be imported" in out
|
|
||||||
|
|
||||||
def test_rejects_bundle_without_manifest(self, tmp_path):
|
def test_rejects_bundle_without_manifest(self, tmp_path):
|
||||||
path = tmp_path / "bad.tgx"
|
path = tmp_path / "bad.tgx"
|
||||||
|
|
@ -173,52 +303,147 @@ class TestImportWorkspace:
|
||||||
import_workspace(url="http://api/", input=str(path))
|
import_workspace(url="http://api/", input=str(path))
|
||||||
|
|
||||||
def test_rejects_newer_format_version(self, tmp_path):
|
def test_rejects_newer_format_version(self, tmp_path):
|
||||||
import io
|
path = write_bundle(
|
||||||
path = tmp_path / "future.tgx"
|
tmp_path / "future.tgx", {},
|
||||||
manifest = json.dumps({
|
manifest={**DEFAULT_MANIFEST, "format_version": 99},
|
||||||
"format": "tgx", "format_version": 99, "workspace": "w",
|
)
|
||||||
"contents": {"config": True, "knowledge": False},
|
|
||||||
}).encode()
|
|
||||||
with tarfile.open(path, "w:gz") as tar:
|
|
||||||
info = tarfile.TarInfo("manifest.json")
|
|
||||||
info.size = len(manifest)
|
|
||||||
tar.addfile(info, io.BytesIO(manifest))
|
|
||||||
with patch("trustgraph.cli.import_workspace.Api"):
|
with patch("trustgraph.cli.import_workspace.Api"):
|
||||||
with pytest.raises(RuntimeError, match="newer than this tool"):
|
with pytest.raises(RuntimeError, match="newer than this tool"):
|
||||||
import_workspace(url="http://api/", input=str(path))
|
import_workspace(url="http://api/", input=str(path))
|
||||||
|
|
||||||
def test_refuses_knowledge_bundle_without_config_only(self, tmp_path):
|
|
||||||
import io
|
|
||||||
path = tmp_path / "knowledge.tgx"
|
|
||||||
manifest = json.dumps({
|
|
||||||
"format": "tgx", "format_version": 1, "workspace": "w",
|
|
||||||
"contents": {"config": True, "knowledge": True},
|
|
||||||
}).encode()
|
|
||||||
with tarfile.open(path, "w:gz") as tar:
|
|
||||||
info = tarfile.TarInfo("manifest.json")
|
|
||||||
info.size = len(manifest)
|
|
||||||
tar.addfile(info, io.BytesIO(manifest))
|
|
||||||
with patch("trustgraph.cli.import_workspace.Api"):
|
|
||||||
with pytest.raises(RuntimeError, match="--config-only"):
|
|
||||||
import_workspace(url="http://api/", input=str(path))
|
|
||||||
|
|
||||||
def test_config_only_flag_allows_knowledge_bundle(self, tmp_path):
|
class TestImportKnowledge:
|
||||||
import io
|
|
||||||
path = tmp_path / "knowledge.tgx"
|
def test_roundtrip_imports_triples_and_documents(self, knowledge_bundle):
|
||||||
manifest = json.dumps({
|
mock_api, mock_config = make_mock_api()
|
||||||
"format": "tgx", "format_version": 1, "workspace": "w",
|
run_import(mock_api, knowledge_bundle, overwrite=True)
|
||||||
"contents": {"config": True, "knowledge": True},
|
|
||||||
}).encode()
|
# Triples land in the bulk import stream for the right collection.
|
||||||
with tarfile.open(path, "w:gz") as tar:
|
bulk = mock_api.bulk.return_value
|
||||||
info = tarfile.TarInfo("manifest.json")
|
call = bulk.import_triples.call_args
|
||||||
info.size = len(manifest)
|
assert call.args[0] == "default" # flow id
|
||||||
tar.addfile(info, io.BytesIO(manifest))
|
triples = sorted(list(call.args[1]), key=lambda t: t.p)
|
||||||
with patch("trustgraph.cli.import_workspace.Api") as api_cls:
|
assert triples == [
|
||||||
mock_api, mock_config = make_mock_api()
|
Triple(s="http://ex.com/s", p="http://ex.com/count", o="42"),
|
||||||
api_cls.return_value = mock_api
|
Triple(s="http://ex.com/s", p="http://ex.com/p",
|
||||||
import_workspace(
|
o="http://ex.com/o"),
|
||||||
url="http://api/", input=str(path), config_only=True,
|
]
|
||||||
overwrite=True,
|
assert call.kwargs["metadata"]["collection"] == "research"
|
||||||
)
|
|
||||||
# No config entries in this bundle; nothing written, no error.
|
# The document is recreated with its metadata and content.
|
||||||
mock_config.put.assert_not_called()
|
add = mock_api.library.return_value.add_document.call_args
|
||||||
|
assert add.kwargs["id"] == "doc-1"
|
||||||
|
assert add.kwargs["document"] == b"Customers may return items."
|
||||||
|
assert add.kwargs["title"] == "Policy"
|
||||||
|
assert add.kwargs["kind"] == "text/plain"
|
||||||
|
assert add.kwargs["tags"] == ["policy"]
|
||||||
|
assert add.kwargs["metadata"] == [
|
||||||
|
Triple(s="http://ex.com/doc-1", p="http://ex.com/about",
|
||||||
|
o="returns"),
|
||||||
|
]
|
||||||
|
# No processing unless asked: embeddings re-derivation is opt-in.
|
||||||
|
mock_api.library.return_value.start_processing.assert_not_called()
|
||||||
|
|
||||||
|
def test_config_only_skips_knowledge_on_import(self, knowledge_bundle):
|
||||||
|
mock_api, mock_config = make_mock_api()
|
||||||
|
run_import(mock_api, knowledge_bundle, overwrite=True,
|
||||||
|
config_only=True)
|
||||||
|
mock_api.bulk.assert_not_called()
|
||||||
|
mock_api.library.return_value.add_document.assert_not_called()
|
||||||
|
mock_config.put.assert_called_once()
|
||||||
|
|
||||||
|
def test_dry_run_covers_knowledge(self, knowledge_bundle, capsys):
|
||||||
|
mock_api, mock_config = make_mock_api()
|
||||||
|
run_import(mock_api, knowledge_bundle, overwrite=True, dry_run=True)
|
||||||
|
mock_api.bulk.assert_not_called()
|
||||||
|
mock_api.library.return_value.add_document.assert_not_called()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "would import 2 triple(s) into collection 'research'" in out
|
||||||
|
assert "would import document doc-1" in out
|
||||||
|
|
||||||
|
def test_process_flag_reprocesses_documents(self, knowledge_bundle):
|
||||||
|
mock_api, mock_config = make_mock_api()
|
||||||
|
run_import(mock_api, knowledge_bundle, overwrite=True, process=True,
|
||||||
|
process_collection="research")
|
||||||
|
proc = mock_api.library.return_value.start_processing.call_args
|
||||||
|
assert proc.kwargs["document_id"] == "doc-1"
|
||||||
|
assert proc.kwargs["flow"] == "default"
|
||||||
|
assert proc.kwargs["collection"] == "research"
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportDocumentSkipOverwrite:
|
||||||
|
"""Live-verified semantics: existing documents skip by default, replace
|
||||||
|
with --overwrite (remove + add; the library API has no content update)."""
|
||||||
|
|
||||||
|
def _bundle_with_doc(self, tmp_path):
|
||||||
|
return write_bundle(tmp_path / "doc.tgx", {
|
||||||
|
"knowledge/library/doc-1.meta.json": json.dumps(
|
||||||
|
{"id": "doc-1", "title": "T", "metadata": []}).encode(),
|
||||||
|
"knowledge/library/doc-1.content": b"hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_existing_document_is_skipped_not_fatal(self, tmp_path):
|
||||||
|
path = self._bundle_with_doc(tmp_path)
|
||||||
|
mock_api, mock_config = make_mock_api(
|
||||||
|
docs=[SimpleNamespace(id="doc-1")],
|
||||||
|
)
|
||||||
|
run_import(mock_api, path, overwrite=False)
|
||||||
|
lib = mock_api.library.return_value
|
||||||
|
lib.add_document.assert_not_called()
|
||||||
|
lib.remove_document.assert_not_called()
|
||||||
|
|
||||||
|
def test_overwrite_replaces_existing_document(self, tmp_path):
|
||||||
|
path = self._bundle_with_doc(tmp_path)
|
||||||
|
mock_api, mock_config = make_mock_api(
|
||||||
|
docs=[SimpleNamespace(id="doc-1")],
|
||||||
|
)
|
||||||
|
run_import(mock_api, path, overwrite=True)
|
||||||
|
lib = mock_api.library.return_value
|
||||||
|
lib.remove_document.assert_called_once_with("doc-1")
|
||||||
|
lib.add_document.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectionDiscovery:
|
||||||
|
"""Live-verified: implicitly-created collections (raw triple loads) are
|
||||||
|
queryable but unlisted, so export merges --collection extras and import
|
||||||
|
registers what it restores."""
|
||||||
|
|
||||||
|
def test_export_includes_extra_unregistered_collections(self, tmp_path):
|
||||||
|
path = tmp_path / "ws.tgx"
|
||||||
|
mock_api, _ = export_bundle(
|
||||||
|
path,
|
||||||
|
collections=[SimpleNamespace(collection="default")],
|
||||||
|
batches=[[]],
|
||||||
|
extra_collections=["research"],
|
||||||
|
)
|
||||||
|
with tarfile.open(path, "r:gz") as tar:
|
||||||
|
manifest = json.load(tar.extractfile("manifest.json"))
|
||||||
|
assert manifest["knowledge"]["collections"] == ["default", "research"]
|
||||||
|
|
||||||
|
def test_import_registers_each_restored_collection(self, tmp_path):
|
||||||
|
path = write_bundle(tmp_path / "kb.tgx", {
|
||||||
|
"knowledge/research/triples.nq": (
|
||||||
|
b'<http://ex.com/s> <http://ex.com/p> "v" '
|
||||||
|
b'<urn:trustgraph:collection:research> .\n'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
mock_api, mock_config = make_mock_api()
|
||||||
|
run_import(mock_api, path)
|
||||||
|
mock_api.collection.return_value.update_collection.assert_called_once_with(
|
||||||
|
"research", name="research",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registered_collections_are_not_reregistered(self, tmp_path):
|
||||||
|
"""update_collection is an upsert that clears omitted fields, so a
|
||||||
|
collection already in the registry must be left untouched."""
|
||||||
|
path = write_bundle(tmp_path / "kb.tgx", {
|
||||||
|
"knowledge/research/triples.nq": (
|
||||||
|
b'<http://ex.com/s> <http://ex.com/p> "v" '
|
||||||
|
b'<urn:trustgraph:collection:research> .\n'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
mock_api, mock_config = make_mock_api(
|
||||||
|
collections=[SimpleNamespace(collection="research")],
|
||||||
|
)
|
||||||
|
run_import(mock_api, path)
|
||||||
|
mock_api.collection.return_value.update_collection.assert_not_called()
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
"""
|
"""
|
||||||
Exports a workspace's full configuration state as a portable .tgx bundle
|
Exports a workspace's full state as a portable .tgx bundle (a gzipped tar
|
||||||
(a gzipped tar archive) for backup, migration between deployments, or
|
archive) for backup, migration between deployments, or sharing a
|
||||||
sharing a pre-configured workspace.
|
pre-configured workspace.
|
||||||
|
|
||||||
The bundle is human-readable: a manifest.json plus one pretty-printed JSON
|
The bundle carries the workspace configuration (one pretty-printed JSON
|
||||||
file per config key under config/<type>/, so it can be inspected and
|
file per config key) and, by default, its knowledge: per-collection
|
||||||
hand-edited before import. Each entry file embeds its own type and key, so
|
knowledge-graph triples as N-Quads (the collection names the graph) and
|
||||||
filenames are cosmetic. Knowledge export (triples, documents, embeddings)
|
the document library (metadata plus content). Pass --config-only to
|
||||||
is not yet included; the manifest records that so future importers can
|
export just the configuration. Embedding vectors are not exported —
|
||||||
distinguish config-only bundles.
|
re-processing imported documents through a flow regenerates them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -17,11 +17,14 @@ import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from trustgraph.api import Api
|
from trustgraph.api import Api
|
||||||
|
|
||||||
|
from . nquads import serialize_nquads
|
||||||
|
|
||||||
default_url = os.getenv("TRUSTGRAPH_URL", 'http://localhost:8088/')
|
default_url = os.getenv("TRUSTGRAPH_URL", 'http://localhost:8088/')
|
||||||
default_token = os.getenv("TRUSTGRAPH_TOKEN", None)
|
default_token = os.getenv("TRUSTGRAPH_TOKEN", None)
|
||||||
default_workspace = os.getenv("TRUSTGRAPH_WORKSPACE", "default")
|
default_workspace = os.getenv("TRUSTGRAPH_WORKSPACE", "default")
|
||||||
|
|
@ -29,6 +32,10 @@ default_workspace = os.getenv("TRUSTGRAPH_WORKSPACE", "default")
|
||||||
MANIFEST_FORMAT = "tgx"
|
MANIFEST_FORMAT = "tgx"
|
||||||
MANIFEST_FORMAT_VERSION = 1
|
MANIFEST_FORMAT_VERSION = 1
|
||||||
|
|
||||||
|
# triples_query_stream is bounded by a limit; exports want "everything", so
|
||||||
|
# default high and let --triples-limit override for truly huge graphs.
|
||||||
|
DEFAULT_TRIPLES_LIMIT = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
def _add_bytes(tar, name, data):
|
def _add_bytes(tar, name, data):
|
||||||
info = tarfile.TarInfo(name=name)
|
info = tarfile.TarInfo(name=name)
|
||||||
|
|
@ -37,56 +44,176 @@ def _add_bytes(tar, name, data):
|
||||||
tar.addfile(info, io.BytesIO(data))
|
tar.addfile(info, io.BytesIO(data))
|
||||||
|
|
||||||
|
|
||||||
def export_workspace(url, workspace, output, token=None):
|
def _export_config(tar, config):
|
||||||
|
"""Write one self-describing JSON file per config key; return count."""
|
||||||
api = Api(url, token=token, workspace=workspace).config()
|
|
||||||
|
|
||||||
config, version = api.all()
|
|
||||||
|
|
||||||
manifest = {
|
|
||||||
"format": MANIFEST_FORMAT,
|
|
||||||
"format_version": MANIFEST_FORMAT_VERSION,
|
|
||||||
"workspace": workspace,
|
|
||||||
"config_version": version,
|
|
||||||
"exported_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
||||||
"contents": {"config": True, "knowledge": False},
|
|
||||||
}
|
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
|
for type_, entries in sorted(config.items()):
|
||||||
|
for key, raw in sorted(entries.items()):
|
||||||
|
|
||||||
|
# Config values are stored as JSON strings; parse so the
|
||||||
|
# bundle is pretty-printed and hand-editable. A value that
|
||||||
|
# isn't valid JSON is preserved verbatim.
|
||||||
|
try:
|
||||||
|
value = json.loads(raw)
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
value = raw
|
||||||
|
|
||||||
|
entry = {"type": type_, "key": key, "value": value}
|
||||||
|
|
||||||
|
# Keys may contain path-unsafe characters; the entry embeds
|
||||||
|
# the real key, so the quoted filename is cosmetic only.
|
||||||
|
name = f"config/{quote(type_, safe='')}/{quote(key, safe='')}.json"
|
||||||
|
|
||||||
|
_add_bytes(
|
||||||
|
tar, name,
|
||||||
|
json.dumps(entry, indent=2).encode("utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _export_triples(tar, api, flow_id, collections, triples_limit):
|
||||||
|
"""Stream each collection's triples into knowledge/<c>/triples.nq.
|
||||||
|
|
||||||
|
N-Quads are written to a tempfile first (tar members need their size
|
||||||
|
upfront), so memory stays flat regardless of knowledge-base size.
|
||||||
|
Returns {collection: written} and a total skipped count.
|
||||||
|
"""
|
||||||
|
counts = {}
|
||||||
|
skipped_total = 0
|
||||||
|
socket = api.socket()
|
||||||
|
try:
|
||||||
|
flow = socket.flow(flow_id)
|
||||||
|
for c in collections:
|
||||||
|
graph_iri = f"urn:trustgraph:collection:{quote(c, safe='')}"
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
"w", encoding="utf-8", suffix=".nq", delete=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with tmp:
|
||||||
|
written, skipped = serialize_nquads(
|
||||||
|
flow.triples_query_stream(
|
||||||
|
s=None, p=None, o=None,
|
||||||
|
collection=c,
|
||||||
|
limit=triples_limit,
|
||||||
|
batch_size=100,
|
||||||
|
),
|
||||||
|
graph_iri,
|
||||||
|
tmp,
|
||||||
|
)
|
||||||
|
if written:
|
||||||
|
tar.add(
|
||||||
|
tmp.name,
|
||||||
|
arcname=(
|
||||||
|
f"knowledge/{quote(c, safe='')}/triples.nq"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
counts[c] = written
|
||||||
|
skipped_total += skipped
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
finally:
|
||||||
|
socket.close()
|
||||||
|
return counts, skipped_total
|
||||||
|
|
||||||
|
|
||||||
|
def _export_library(tar, api):
|
||||||
|
"""Write each library document's metadata + content; return count."""
|
||||||
|
library = api.library()
|
||||||
|
count = 0
|
||||||
|
for doc in library.get_documents(include_children=True):
|
||||||
|
# Content is fetched one document at a time so memory is bounded
|
||||||
|
# by the largest single document, not the whole library.
|
||||||
|
content = library.get_document_content(doc.id)
|
||||||
|
meta = {
|
||||||
|
"id": doc.id,
|
||||||
|
"time": doc.time.isoformat() if doc.time else None,
|
||||||
|
"kind": doc.kind,
|
||||||
|
"title": doc.title,
|
||||||
|
"comments": doc.comments,
|
||||||
|
"metadata": [
|
||||||
|
{"s": t.s, "p": t.p, "o": t.o} for t in (doc.metadata or [])
|
||||||
|
],
|
||||||
|
"tags": list(doc.tags or []),
|
||||||
|
"parent_id": doc.parent_id or "",
|
||||||
|
"document_type": getattr(doc, "document_type", "") or "",
|
||||||
|
}
|
||||||
|
base = f"knowledge/library/{quote(doc.id, safe='')}"
|
||||||
|
_add_bytes(
|
||||||
|
tar, f"{base}.meta.json",
|
||||||
|
json.dumps(meta, indent=2).encode("utf-8"),
|
||||||
|
)
|
||||||
|
_add_bytes(tar, f"{base}.content", content or b"")
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def export_workspace(
|
||||||
|
url, workspace, output, token=None, config_only=False,
|
||||||
|
flow_id="default", triples_limit=DEFAULT_TRIPLES_LIMIT,
|
||||||
|
extra_collections=(),
|
||||||
|
):
|
||||||
|
|
||||||
|
api = Api(url, token=token, workspace=workspace)
|
||||||
|
|
||||||
|
config, version = api.config().all()
|
||||||
|
|
||||||
|
# Collection discovery is registry-based: collections created implicitly
|
||||||
|
# by raw triple loads (e.g. tg-load-knowledge) are queryable but not
|
||||||
|
# listed, so they would silently drop out of the bundle. --collection
|
||||||
|
# names them explicitly; the enumeration is printed so what's included
|
||||||
|
# is never a guess.
|
||||||
|
collections = []
|
||||||
|
if not config_only:
|
||||||
|
registered = [c.collection for c in api.collection().list_collections()]
|
||||||
|
collections = sorted(set(registered) | set(extra_collections))
|
||||||
|
print(f"Exporting collections: {', '.join(collections)}", flush=True)
|
||||||
|
|
||||||
with tarfile.open(output, "w:gz") as tar:
|
with tarfile.open(output, "w:gz") as tar:
|
||||||
|
|
||||||
|
config_count = _export_config(tar, config)
|
||||||
|
|
||||||
|
triple_counts = {}
|
||||||
|
skipped = 0
|
||||||
|
doc_count = 0
|
||||||
|
if not config_only:
|
||||||
|
triple_counts, skipped = _export_triples(
|
||||||
|
tar, api, flow_id, collections, triples_limit,
|
||||||
|
)
|
||||||
|
doc_count = _export_library(tar, api)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"format": MANIFEST_FORMAT,
|
||||||
|
"format_version": MANIFEST_FORMAT_VERSION,
|
||||||
|
"workspace": workspace,
|
||||||
|
"config_version": version,
|
||||||
|
"exported_at": time.strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(),
|
||||||
|
),
|
||||||
|
"contents": {"config": True, "knowledge": not config_only},
|
||||||
|
}
|
||||||
|
if not config_only:
|
||||||
|
manifest["knowledge"] = {
|
||||||
|
"collections": collections,
|
||||||
|
"documents": doc_count,
|
||||||
|
"triples": triple_counts,
|
||||||
|
}
|
||||||
|
|
||||||
_add_bytes(
|
_add_bytes(
|
||||||
tar, "manifest.json",
|
tar, "manifest.json",
|
||||||
json.dumps(manifest, indent=2).encode("utf-8"),
|
json.dumps(manifest, indent=2).encode("utf-8"),
|
||||||
)
|
)
|
||||||
|
|
||||||
for type_, entries in sorted(config.items()):
|
summary = f"Exported {config_count} config item(s)"
|
||||||
for key, raw in sorted(entries.items()):
|
if not config_only:
|
||||||
|
summary += (
|
||||||
# Config values are stored as JSON strings; parse so the
|
f", {sum(triple_counts.values())} triple(s) across "
|
||||||
# bundle is pretty-printed and hand-editable. A value that
|
f"{len(triple_counts)} collection(s), {doc_count} document(s)"
|
||||||
# isn't valid JSON is preserved verbatim.
|
)
|
||||||
try:
|
if skipped:
|
||||||
value = json.loads(raw)
|
summary += f" ({skipped} triple(s) not representable, skipped)"
|
||||||
except (TypeError, json.JSONDecodeError):
|
print(f"{summary} from workspace '{workspace}' to {output}", flush=True)
|
||||||
value = raw
|
|
||||||
|
|
||||||
entry = {"type": type_, "key": key, "value": value}
|
|
||||||
|
|
||||||
# Keys may contain path-unsafe characters; the entry embeds
|
|
||||||
# the real key, so the quoted filename is cosmetic only.
|
|
||||||
name = f"config/{quote(type_, safe='')}/{quote(key, safe='')}.json"
|
|
||||||
|
|
||||||
_add_bytes(
|
|
||||||
tar, name,
|
|
||||||
json.dumps(entry, indent=2).encode("utf-8"),
|
|
||||||
)
|
|
||||||
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
print(f"Exported {count} config item(s) from workspace "
|
|
||||||
f"'{workspace}' to {output}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -114,12 +241,41 @@ def main():
|
||||||
help=f'Workspace to export (default: {default_workspace})',
|
help=f'Workspace to export (default: {default_workspace})',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'-c', '--collection',
|
||||||
|
action='append',
|
||||||
|
default=[],
|
||||||
|
help='Additionally export this collection even if it is not '
|
||||||
|
'registered in collection management (repeatable)',
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'-o', '--output',
|
'-o', '--output',
|
||||||
required=True,
|
required=True,
|
||||||
help='Output bundle path, e.g. workspace-default.tgx',
|
help='Output bundle path, e.g. workspace-default.tgx',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'-f', '--flow-id',
|
||||||
|
default="default",
|
||||||
|
help='Flow to query triples through (default: default)',
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--config-only',
|
||||||
|
action='store_true',
|
||||||
|
help='Export only the configuration, skipping knowledge '
|
||||||
|
'(triples and library documents)',
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--triples-limit',
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_TRIPLES_LIMIT,
|
||||||
|
help='Maximum triples to export per collection '
|
||||||
|
f'(default: {DEFAULT_TRIPLES_LIMIT})',
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -129,6 +285,10 @@ def main():
|
||||||
workspace=args.workspace,
|
workspace=args.workspace,
|
||||||
output=args.output,
|
output=args.output,
|
||||||
token=args.token,
|
token=args.token,
|
||||||
|
config_only=args.config_only,
|
||||||
|
flow_id=args.flow_id,
|
||||||
|
triples_limit=args.triples_limit,
|
||||||
|
extra_collections=args.collection,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,16 @@ Imports a workspace bundle (.tgx, produced by tg-export-workspace) into a
|
||||||
TrustGraph deployment. The target workspace defaults to the name recorded
|
TrustGraph deployment. The target workspace defaults to the name recorded
|
||||||
in the bundle's manifest and can be renamed with --workspace.
|
in the bundle's manifest and can be renamed with --workspace.
|
||||||
|
|
||||||
By default existing (type, key) entries in the target workspace are left
|
Configuration import follows WorkspaceInit's re-run behaviour: existing
|
||||||
untouched and only missing keys are added, matching WorkspaceInit's
|
(type, key) entries are left untouched and only missing keys are added;
|
||||||
re-run behaviour; pass --overwrite to replace every imported key. Use
|
pass --overwrite to replace every imported key. Knowledge import (triples
|
||||||
--dry-run to show what would be written without changing anything.
|
and library documents) is additive — triples are streamed into the target
|
||||||
|
collection and documents are added to the library; re-importing the same
|
||||||
|
bundle twice will duplicate knowledge, not merge it. Use --dry-run to
|
||||||
|
show what would be written without changing anything, --config-only to
|
||||||
|
skip a bundle's knowledge, and --process to re-run imported documents
|
||||||
|
through a flow (which regenerates embeddings, so bundles don't carry
|
||||||
|
vectors).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -14,9 +20,12 @@ import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
|
import uuid
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from trustgraph.api import Api
|
from trustgraph.api import Api
|
||||||
from trustgraph.api.types import ConfigValue
|
from trustgraph.api.types import ConfigValue, Triple
|
||||||
|
from trustgraph.cli.nquads import parse_nquads
|
||||||
|
|
||||||
default_url = os.getenv("TRUSTGRAPH_URL", 'http://localhost:8088/')
|
default_url = os.getenv("TRUSTGRAPH_URL", 'http://localhost:8088/')
|
||||||
default_token = os.getenv("TRUSTGRAPH_TOKEN", None)
|
default_token = os.getenv("TRUSTGRAPH_TOKEN", None)
|
||||||
|
|
@ -26,10 +35,21 @@ SUPPORTED_FORMAT_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
def _read_bundle(path):
|
def _read_bundle(path):
|
||||||
"""Read manifest and config entries from a .tgx bundle."""
|
"""Read manifest, config entries, triples and documents from a .tgx.
|
||||||
|
|
||||||
|
Returns (manifest, config_entries, triples_by_collection, documents)
|
||||||
|
where triples_by_collection maps collection -> list[Triple] and
|
||||||
|
documents is a list of {meta: dict, content: bytes}.
|
||||||
|
"""
|
||||||
|
|
||||||
manifest = None
|
manifest = None
|
||||||
entries = []
|
config_entries = []
|
||||||
|
triples = {}
|
||||||
|
doc_meta = {}
|
||||||
|
doc_content = {}
|
||||||
|
|
||||||
|
def member_id(name, prefix, suffix):
|
||||||
|
return unquote(name[len(prefix):-len(suffix)])
|
||||||
|
|
||||||
with tarfile.open(path, "r:gz") as tar:
|
with tarfile.open(path, "r:gz") as tar:
|
||||||
for member in tar.getmembers():
|
for member in tar.getmembers():
|
||||||
|
|
@ -39,11 +59,28 @@ def _read_bundle(path):
|
||||||
if f is None:
|
if f is None:
|
||||||
continue
|
continue
|
||||||
data = f.read()
|
data = f.read()
|
||||||
if member.name == "manifest.json":
|
name = member.name
|
||||||
|
|
||||||
|
if name == "manifest.json":
|
||||||
manifest = json.loads(data)
|
manifest = json.loads(data)
|
||||||
elif member.name.startswith("config/") and \
|
|
||||||
member.name.endswith(".json"):
|
elif name.startswith("config/") and name.endswith(".json"):
|
||||||
entries.append(json.loads(data))
|
config_entries.append(json.loads(data))
|
||||||
|
|
||||||
|
elif name.startswith("knowledge/library/") and \
|
||||||
|
name.endswith(".meta.json"):
|
||||||
|
doc_id = member_id(name, "knowledge/library/", ".meta.json")
|
||||||
|
doc_meta[doc_id] = json.loads(data)
|
||||||
|
|
||||||
|
elif name.startswith("knowledge/library/") and \
|
||||||
|
name.endswith(".content"):
|
||||||
|
doc_id = member_id(name, "knowledge/library/", ".content")
|
||||||
|
doc_content[doc_id] = data
|
||||||
|
|
||||||
|
elif name.startswith("knowledge/") and \
|
||||||
|
name.endswith("/triples.nq"):
|
||||||
|
collection = member_id(name, "knowledge/", "/triples.nq")
|
||||||
|
triples[collection] = parse_nquads(data)
|
||||||
|
|
||||||
if manifest is None:
|
if manifest is None:
|
||||||
raise RuntimeError("not a workspace bundle: manifest.json missing")
|
raise RuntimeError("not a workspace bundle: manifest.json missing")
|
||||||
|
|
@ -60,27 +97,18 @@ def _read_bundle(path):
|
||||||
"upgrade trustgraph-cli"
|
"upgrade trustgraph-cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
return manifest, entries
|
documents = [
|
||||||
|
{"meta": meta, "content": doc_content.get(doc_id, b"")}
|
||||||
|
for doc_id, meta in doc_meta.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
return manifest, config_entries, triples, documents
|
||||||
|
|
||||||
|
|
||||||
def import_workspace(
|
def _import_config(api, entries, overwrite, dry_run):
|
||||||
url, input, workspace=None, overwrite=False, config_only=False,
|
"""Import config entries; returns (imported, skipped) counts."""
|
||||||
dry_run=False, token=None,
|
|
||||||
):
|
|
||||||
|
|
||||||
manifest, entries = _read_bundle(input)
|
config = api.config()
|
||||||
|
|
||||||
# Knowledge import (triples, documents, embeddings) is not implemented
|
|
||||||
# yet; refuse to silently drop it from a bundle that carries it.
|
|
||||||
if manifest.get("contents", {}).get("knowledge") and not config_only:
|
|
||||||
raise RuntimeError(
|
|
||||||
"bundle contains knowledge data, which this tool cannot import "
|
|
||||||
"yet; re-run with --config-only to import just the configuration"
|
|
||||||
)
|
|
||||||
|
|
||||||
target = workspace or manifest.get("workspace") or "default"
|
|
||||||
|
|
||||||
api = Api(url, token=token, workspace=target).config()
|
|
||||||
|
|
||||||
# Mirror WorkspaceInit's re-run behaviour: without --overwrite, keys
|
# Mirror WorkspaceInit's re-run behaviour: without --overwrite, keys
|
||||||
# already present in the target workspace are skipped (per key, not per
|
# already present in the target workspace are skipped (per key, not per
|
||||||
|
|
@ -88,7 +116,7 @@ def import_workspace(
|
||||||
existing = {}
|
existing = {}
|
||||||
if not overwrite:
|
if not overwrite:
|
||||||
for type_ in sorted({e["type"] for e in entries}):
|
for type_ in sorted({e["type"] for e in entries}):
|
||||||
existing[type_] = set(api.list(type_))
|
existing[type_] = set(config.list(type_))
|
||||||
|
|
||||||
values = []
|
values = []
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
@ -106,16 +134,191 @@ def import_workspace(
|
||||||
if dry_run:
|
if dry_run:
|
||||||
for v in values:
|
for v in values:
|
||||||
print(f"would import {v.type}/{v.key}", flush=True)
|
print(f"would import {v.type}/{v.key}", flush=True)
|
||||||
print(f"Dry run: {len(values)} item(s) would be imported into "
|
elif values:
|
||||||
f"workspace '{target}', {skipped} skipped as existing",
|
config.put(values)
|
||||||
flush=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
if values:
|
return len(values), skipped
|
||||||
api.put(values)
|
|
||||||
|
|
||||||
print(f"Imported {len(values)} config item(s) into workspace "
|
|
||||||
f"'{target}', {skipped} skipped as existing", flush=True)
|
def _import_triples(api, flow_id, triples_by_collection, dry_run):
|
||||||
|
"""Stream each collection's triples into the flow; returns count."""
|
||||||
|
|
||||||
|
# Collections restored by the bulk triples path (unlike document
|
||||||
|
# processing) are not auto-registered, and an unregistered collection
|
||||||
|
# would silently drop out of a future export. Register only the missing
|
||||||
|
# ones: update_collection is an upsert whose omitted fields clear the
|
||||||
|
# description/tags that _import_config may have just restored.
|
||||||
|
# Best-effort — a registry hiccup shouldn't fail a completed triple
|
||||||
|
# import (tg-set-collection is the manual remedy).
|
||||||
|
registered = None
|
||||||
|
if triples_by_collection and not dry_run:
|
||||||
|
try:
|
||||||
|
registered = {
|
||||||
|
c.collection for c in api.collection().list_collections()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(
|
||||||
|
f"warning: could not list collections for registration: "
|
||||||
|
f"{e} — register manually with tg-set-collection",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for collection, triples in sorted(triples_by_collection.items()):
|
||||||
|
if dry_run:
|
||||||
|
print(
|
||||||
|
f"would import {len(triples)} triple(s) into "
|
||||||
|
f"collection '{collection}'",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
api.bulk().import_triples(
|
||||||
|
flow_id,
|
||||||
|
triples,
|
||||||
|
metadata={
|
||||||
|
"id": f"workspace-import-{uuid.uuid4()}",
|
||||||
|
"metadata": [],
|
||||||
|
"collection": collection,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if registered is not None and collection not in registered:
|
||||||
|
try:
|
||||||
|
api.collection().update_collection(
|
||||||
|
collection, name=collection,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(
|
||||||
|
f"warning: could not register collection "
|
||||||
|
f"'{collection}': {e} — register manually with "
|
||||||
|
f"tg-set-collection",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
total += len(triples)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _import_documents(
|
||||||
|
api, documents, flow_id, process, process_collection, overwrite,
|
||||||
|
dry_run,
|
||||||
|
):
|
||||||
|
"""Add library documents back (children after parents).
|
||||||
|
|
||||||
|
Mirrors the config semantics: documents already present in the target
|
||||||
|
workspace are skipped unless --overwrite, which replaces them (the
|
||||||
|
library API has no in-place content update, so replace = remove + add).
|
||||||
|
Returns (imported, skipped).
|
||||||
|
"""
|
||||||
|
|
||||||
|
library = api.library()
|
||||||
|
|
||||||
|
existing = set()
|
||||||
|
if documents:
|
||||||
|
existing = {d.id for d in library.get_documents(include_children=True)}
|
||||||
|
|
||||||
|
# Parents must exist before their children.
|
||||||
|
ordered = sorted(
|
||||||
|
documents, key=lambda d: bool(d["meta"].get("parent_id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
doc_skipped = 0
|
||||||
|
for doc in ordered:
|
||||||
|
meta = doc["meta"]
|
||||||
|
|
||||||
|
if meta["id"] in existing and not overwrite:
|
||||||
|
doc_skipped += 1
|
||||||
|
if dry_run:
|
||||||
|
print(f"would skip existing document {meta['id']}", flush=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f"would import document {meta['id']}", flush=True)
|
||||||
|
count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if meta["id"] in existing:
|
||||||
|
library.remove_document(meta["id"])
|
||||||
|
|
||||||
|
metadata = [
|
||||||
|
Triple(s=t["s"], p=t["p"], o=t["o"])
|
||||||
|
for t in meta.get("metadata", [])
|
||||||
|
]
|
||||||
|
if meta.get("parent_id"):
|
||||||
|
library.add_child_document(
|
||||||
|
document=doc["content"],
|
||||||
|
id=meta["id"],
|
||||||
|
parent_id=meta["parent_id"],
|
||||||
|
title=meta.get("title", ""),
|
||||||
|
comments=meta.get("comments", ""),
|
||||||
|
kind=meta.get("kind", "text/plain"),
|
||||||
|
tags=meta.get("tags", []),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
library.add_document(
|
||||||
|
document=doc["content"],
|
||||||
|
id=meta["id"],
|
||||||
|
metadata=metadata,
|
||||||
|
title=meta.get("title", ""),
|
||||||
|
comments=meta.get("comments", ""),
|
||||||
|
kind=meta.get("kind", "text/plain"),
|
||||||
|
tags=meta.get("tags", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-processing regenerates extraction output and embeddings for
|
||||||
|
# the imported content (bundles carry no vectors).
|
||||||
|
if process:
|
||||||
|
library.start_processing(
|
||||||
|
id=f"proc-{uuid.uuid4()}",
|
||||||
|
document_id=meta["id"],
|
||||||
|
flow=flow_id,
|
||||||
|
collection=process_collection,
|
||||||
|
tags=meta.get("tags", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
return count, doc_skipped
|
||||||
|
|
||||||
|
|
||||||
|
def import_workspace(
|
||||||
|
url, input, workspace=None, overwrite=False, config_only=False,
|
||||||
|
flow_id="default", process=False, process_collection="default",
|
||||||
|
dry_run=False, token=None,
|
||||||
|
):
|
||||||
|
|
||||||
|
manifest, config_entries, triples, documents = _read_bundle(input)
|
||||||
|
|
||||||
|
target = workspace or manifest.get("workspace") or "default"
|
||||||
|
|
||||||
|
api = Api(url, token=token, workspace=target)
|
||||||
|
|
||||||
|
imported, skipped = _import_config(
|
||||||
|
api, config_entries, overwrite, dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
triple_count = 0
|
||||||
|
doc_count = 0
|
||||||
|
has_knowledge = bool(triples or documents)
|
||||||
|
if has_knowledge and not config_only:
|
||||||
|
triple_count = _import_triples(api, flow_id, triples, dry_run)
|
||||||
|
doc_count, doc_skipped = _import_documents(
|
||||||
|
api, documents, flow_id, process, process_collection, overwrite,
|
||||||
|
dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
verb = "Dry run:" if dry_run else "Imported"
|
||||||
|
summary = (
|
||||||
|
f"{verb} {imported} config item(s) into workspace '{target}', "
|
||||||
|
f"{skipped} skipped as existing"
|
||||||
|
)
|
||||||
|
if has_knowledge and not config_only:
|
||||||
|
summary += (
|
||||||
|
f"; {triple_count} triple(s), {doc_count} document(s)"
|
||||||
|
f" ({doc_skipped} skipped as existing)"
|
||||||
|
)
|
||||||
|
elif has_knowledge:
|
||||||
|
summary += "; knowledge skipped (--config-only)"
|
||||||
|
print(summary, flush=True)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -150,11 +353,18 @@ def main():
|
||||||
'bundle manifest)',
|
'bundle manifest)',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'-f', '--flow-id',
|
||||||
|
default="default",
|
||||||
|
help='Flow to import triples through and process documents with '
|
||||||
|
'(default: default)',
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--overwrite',
|
'--overwrite',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
help='Replace existing keys in the target workspace (default: '
|
help='Replace existing config keys in the target workspace '
|
||||||
'keep existing keys and only add missing ones)',
|
'(default: keep existing keys and only add missing ones)',
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|
@ -164,6 +374,19 @@ def main():
|
||||||
'in the bundle',
|
'in the bundle',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--process',
|
||||||
|
action='store_true',
|
||||||
|
help='Re-process imported documents through the flow after import '
|
||||||
|
'(regenerates extraction output and embeddings)',
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--process-collection',
|
||||||
|
default="default",
|
||||||
|
help='Collection that --process targets (default: default)',
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--dry-run',
|
'--dry-run',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
|
|
@ -180,6 +403,9 @@ def main():
|
||||||
workspace=args.workspace,
|
workspace=args.workspace,
|
||||||
overwrite=args.overwrite,
|
overwrite=args.overwrite,
|
||||||
config_only=args.config_only,
|
config_only=args.config_only,
|
||||||
|
flow_id=args.flow_id,
|
||||||
|
process=args.process,
|
||||||
|
process_collection=args.process_collection,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
token=args.token,
|
token=args.token,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
137
trustgraph-cli/trustgraph/cli/nquads.py
Normal file
137
trustgraph-cli/trustgraph/cli/nquads.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""
|
||||||
|
N-Quads serialization and parsing for workspace knowledge bundles: the
|
||||||
|
wire-format triples yielded by triples_query_stream go out one line per
|
||||||
|
triple (so an export never holds a whole graph in memory), and bundle
|
||||||
|
members come back as api Triple values. Term encoding is hand-rolled to
|
||||||
|
the N-Triples grammar: rdflib's term.n3() emits Turtle-style forms
|
||||||
|
(numeric shorthand, unescaped newlines) that are not valid in
|
||||||
|
line-oriented N-Quads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import rdflib
|
||||||
|
|
||||||
|
from trustgraph.schema import IRI, LITERAL
|
||||||
|
from trustgraph.api.types import Triple
|
||||||
|
|
||||||
|
# RDF-star quoted triples have no standard N-Quads encoding; they are
|
||||||
|
# skipped with a count so callers can surface the omission.
|
||||||
|
|
||||||
|
# N-Triples string-literal escapes (ECHAR): backslash first, then the rest.
|
||||||
|
_ESCAPES = [
|
||||||
|
("\\", "\\\\"),
|
||||||
|
('"', '\\"'),
|
||||||
|
("\n", "\\n"),
|
||||||
|
("\r", "\\r"),
|
||||||
|
("\t", "\\t"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Characters the IRIREF production cannot carry: controls/space plus the
|
||||||
|
# explicitly forbidden set. One compiled scan keeps this off the profile
|
||||||
|
# for large exports (it runs per term).
|
||||||
|
_BAD_IRI = re.compile(r'[\x00-\x20<>"{}|^\x60]')
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_literal(value):
|
||||||
|
for raw, esc in _ESCAPES:
|
||||||
|
value = value.replace(raw, esc)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_iri(iri):
|
||||||
|
"""<iri>, or None for values the grammar cannot carry."""
|
||||||
|
if not iri or _BAD_IRI.search(iri):
|
||||||
|
return None
|
||||||
|
return f"<{iri}>"
|
||||||
|
|
||||||
|
|
||||||
|
def encode_term(term, is_object=False):
|
||||||
|
"""Encode one wire-format term dict for an N-Quads line.
|
||||||
|
|
||||||
|
:param is_object: literals are only valid in object position;
|
||||||
|
subjects and predicates must be IRIs (bnodes never appear on
|
||||||
|
the wire).
|
||||||
|
:returns: encoded string, or None when the term can't be represented.
|
||||||
|
"""
|
||||||
|
if term is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
t = term.get("t", "")
|
||||||
|
|
||||||
|
if t == IRI:
|
||||||
|
return _encode_iri(term.get("i", ""))
|
||||||
|
|
||||||
|
if t == LITERAL and is_object:
|
||||||
|
value = _escape_literal(term.get("v", ""))
|
||||||
|
language = term.get("l")
|
||||||
|
datatype = term.get("d")
|
||||||
|
if language:
|
||||||
|
return f'"{value}"@{language}'
|
||||||
|
if datatype:
|
||||||
|
dt = _encode_iri(datatype)
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
return f'"{value}"^^{dt}'
|
||||||
|
return f'"{value}"'
|
||||||
|
|
||||||
|
# literals outside object position, RDF-star, unknown types
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def triple_to_nquad(triple, graph_encoded):
|
||||||
|
"""One wire-format triple dict -> an N-Quads line, or None to skip.
|
||||||
|
|
||||||
|
:param triple: {"s": term, "p": term, "o": term} wire dict
|
||||||
|
:param graph_encoded: pre-encoded <graph-iri> string
|
||||||
|
"""
|
||||||
|
s = encode_term(triple.get("s"))
|
||||||
|
p = encode_term(triple.get("p"))
|
||||||
|
o = encode_term(triple.get("o"), is_object=True)
|
||||||
|
if s is None or p is None or o is None:
|
||||||
|
return None
|
||||||
|
return f"{s} {p} {o} {graph_encoded} .\n"
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_nquads(batches, graph_iri, out):
|
||||||
|
"""Write wire-format triple batches to a text file-like as N-Quads.
|
||||||
|
|
||||||
|
:param batches: iterable of lists of wire triple dicts
|
||||||
|
(e.g. triples_query_stream output)
|
||||||
|
:param graph_iri: graph name for every quad (str)
|
||||||
|
:param out: text-mode file-like with .write()
|
||||||
|
:returns: (written, skipped) counts
|
||||||
|
"""
|
||||||
|
g = _encode_iri(graph_iri)
|
||||||
|
if g is None:
|
||||||
|
raise ValueError(f"graph IRI not representable in N-Quads: {graph_iri!r}")
|
||||||
|
written = 0
|
||||||
|
skipped = 0
|
||||||
|
for batch in batches:
|
||||||
|
for t in batch:
|
||||||
|
line = triple_to_nquad(t, g)
|
||||||
|
if line is None:
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
out.write(line)
|
||||||
|
written += 1
|
||||||
|
return written, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nquads(data):
|
||||||
|
"""Parse N-Quads bytes back into api Triple values.
|
||||||
|
|
||||||
|
Terms are stringified with str(), the same convention tg-load-knowledge
|
||||||
|
uses, so values survive the store round trip unchanged. The whole
|
||||||
|
member is materialized in memory (bundles are bounded by
|
||||||
|
--triples-limit at export); line-streaming is a possible follow-up.
|
||||||
|
|
||||||
|
:param data: N-Quads bytes (one bundle member)
|
||||||
|
:returns: list of Triple
|
||||||
|
"""
|
||||||
|
ds = rdflib.Dataset()
|
||||||
|
ds.parse(data=data.decode("utf-8"), format="nquads")
|
||||||
|
return [
|
||||||
|
Triple(s=str(s), p=str(p), o=str(o))
|
||||||
|
for s, p, o, _g in ds.quads((None, None, None, None))
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue