mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
* refactor(export): centralize graph-payload key normalization
Graph payloads circulate under two vocabularies, entities/relationships and nodes/edges, and consumers each reconciled them locally with competing idioms. The same payload could be exported, silently dropped, or rejected depending on which consumer read it.
Add normalize_graph_payload() to utils.helpers as the single place that decision is made. Both spellings present with one empty resolves to the populated one, which is the shape JSONExporter emits; both non-empty and different is refused, since there is no basis to prefer either and picking one would silently discard the other; a non-empty mapping with no recognized key raises rather than returning empty collections, with require_recognized=False for callers that should degrade.
Adopt it in the three exporters that genuinely alias. LPGExporter read nodes with entities as the default, so it dropped every entity when nodes was present but empty, losing everything on a JSON round-trip. ArangoAQLExporter had the same idiom plus a manual fallback. Neo4jCSVExporter routes its mapping branch through the shared resolver so the reference implementation cannot drift; its attribute branch stays local, since objects are not mappings.
Also feed LPGExporter._generate_indexes the resolved entities. It read entities directly, so a nodes/edges payload produced no indexes even once node generation was fixed.
CSVExporter and JSONExporter are deliberately excluded: they write entities, relationships, nodes and edges as separate outputs by design rather than reconciling two spellings of one collection, so normalizing there would rename output files.
* fix(export): reject non-mapping input to the YAML exporters
export_yaml declared Union[Dict[str, Any], List[Dict[str, Any]]], but both
YAML exporters read their payload by key, so a list reached .get() and
surfaced as a bare AttributeError from inside the exporter, naming neither
the offending argument nor the shape expected.
Reject rather than wrap. These formats distinguish entities from
relationships from triplets, so inferring which collection a bare list
represents would silently mislabel the records, and wrapping it under an
unrecognised key would write a structurally valid file with every
collection empty - trading a loud failure for silent data loss.
Validate in the exporters, matching the existing precedent in
Neo4jCSVExporter._normalize_graph, so direct users of the classes get the
same contract as callers of the convenience wrapper. Narrow the wrapper
type hint to Dict[str, Any] to match.
* fix(export): address YAML exporter review findings
- semantica/export/yaml_exporter.py — import Sequence from typing
instead of collections.abc. `Sequence[str]` in _require_mapping's
annotation is evaluated at function-definition time; collections.abc.Sequence
only became subscriptable in Python 3.9, so on the 3.8 this project
declares support for, importing this module raised TypeError.
typing.Sequence has supported subscripting since 3.5.3. Mapping stays
imported from collections.abc since it's only used for isinstance.
- tests/export/test_yaml_exporter_input_validation.py — clean up each
test's tempfile.mkdtemp() dir via addCleanup instead of leaking it,
and read exported YAML through a context manager instead of an
unclosed yaml.safe_load(open(...)).
* fix(export): reject YAML export payloads with no recognized key
Both YAML exporters built their output from a fixed set of `.get(key, [])`
lookups, so a mapping keyed by anything else serialized to a structurally
valid file with every collection empty. Nothing signalled the loss: no
exception, no warning, and the progress log reported a completed export.
The only way to notice was to open the file. The realistic trigger is
re-exporting an `export_json` payload, whose `{"data", "count", "metadata"}`
envelope drops every record.
- SemanticNetworkYAMLExporter.export_semantic_network now resolves its
collections through normalize_graph_payload(), which raises rather than
returning empty collections for an unrecognized mapping. Adopting the
shared resolver rather than repeating the check locally also brings the
'nodes'/'edges' aliases, so ContextGraph.to_dict() — the most direct path
from this library's own graph type to YAML, used in
examples/capability_gap_context_graphs_example.py — exports its records
instead of an empty file.
- export_for_pipeline built its nested semantic network from the same
defaulted lookups and had the same defect; it goes through the resolver
too.
- YAMLSchemaExporter.export_ontology_schema gets the equivalent check over
its own key set. Schemas are a separate vocabulary with no aliasing, so
_require_recognized_keys lives in this module rather than in the shared
graph resolver.
- 'metadata' is deliberately not sufficient to make a payload recognized.
An export_json envelope carries one, so accepting it would readmit the
case this fix is most likely to be needed for.
- An empty mapping is still exported: an empty graph is legitimate and has
no records to lose.
- SemanticNetworkYAMLExporter.export() serializes before creating the
output directory, so a rejected export leaves nothing behind.
The two rejections keep distinct exception types, following what the
codebase already does: a payload of the wrong *type* cannot be exported at
all and raises ProcessingError, matching Neo4jCSVExporter._normalize_graph;
a mapping whose *contents* are unusable raises ValidationError, matching
normalize_graph_payload. _require_mapping therefore runs first at every
entry point, so a non-mapping never reaches the resolver.
Docstring Raises sections, export_usage.md and docs/reference/export.md
record the accepted input shapes and both failures.
Closes #953.
* fix(export): reject payloads whose records resolve to nothing
Addresses the Qodo findings on #958.
Presence-only recognition (finding 1): checking that a recognized key is
present answered "did the caller use our vocabulary" when the question that
matters is "did anything the caller supplied survive". A payload like
{"entities": [], "data": [...records...]} cleared the check, resolved to
empty, and dropped every record under 'data' -- the silent-empty export by a
narrower route.
- utils/helpers.py — split the check in two. _require_recognized_keys keeps
the presence rule; _require_nothing_dropped runs after resolution and
refuses a payload that resolved to nothing while an unread key still holds
records. Only a non-empty list counts as evidence: ContextGraph.to_dict()
always carries a populated 'statistics' dict, and an empty graph must stay
exportable, so 'metadata', 'statistics' and 'count' are named as context
rather than records.
- export/yaml_exporter.py — the schema path had the same hole and now runs
both checks through the shared helpers rather than its own copy, so the
two vocabularies cannot drift apart in what counts as a silent-empty
export.
Progress reported success on a failed write (finding 3): export_semantic_
network stops its tracking as completed once serialization returns, but
export() then creates the directory and writes the file. A failure there
left the tracker showing a completed export with no output.
- export/yaml_exporter.py — the serialization span now says it serialized,
not that it exported, and export() opens its own span around the
filesystem work that stops as failed on error. Nothing reports a completed
export until the bytes are on disk.
Finding 2 (export_yaml no longer accepts List[Dict]) is the intended
resolution of #952 rather than a regression: wrapping a bare list under a
guessed key is what would mislabel the records. The signature, docstring and
PR description already record the narrowed contract.
Tests cover both directions of each fix, including that an empty
ContextGraph still exports and that a failing write is not reported as
completed.
* fix(export): validate collection values and make Neo4j mappings strict
Two gaps at the boundary the shared normalizer is supposed to own.
_resolve_collection() resolved on truthiness alone, so a recognized key
could still hold something that is not a collection of records:
{"entities": "abc"} normalized to three single-character "records", and
{"entities": 42} surfaced as a raw TypeError from list() inside whichever
exporter happened to read it, naming the exporter rather than the payload
key at fault. Collection values are now validated before conversion --
strings, bytes, mappings, and non-iterable scalars are rejected by key
name, and each element must be a mapping or an attribute-carrying object,
the two record shapes the exporters actually read. None stays legal as an
absent collection, the spelling a JSON round-trip produces for []; it
cannot hide dropped records, since _require_nothing_dropped() still runs.
Every spelling present is validated, not just the one that wins, so a
malformed alias is not excused by a well-formed canonical key.
Neo4jCSVExporter._normalize_graph() opted out of the recognized-key check
for mappings, which left it able to turn {"data": [...]} into header-only
CSVs indistinguishable from a genuinely empty graph -- the exact failure
the rest of the change exists to prevent. Mapping payloads now go through
normalize_graph_payload() on its default terms. The attribute path for
graph objects is untouched. With no caller left opting out, the
require_recognized flag is removed rather than kept as a way back into
the silent-empty export.
Regression tests cover the malformed values end to end through every
export path that reads the normalizer, and assert the rejected Neo4j
export writes no CSV files.
* fix(export): close YAML schema and record validation gaps
Fix 1 -- _require_usable_schema silent data loss (P1):
_require_usable_schema() passed all values from _SCHEMA_KEYS into
_require_nothing_dropped() as evidence that records survived. Scalar
metadata fields such as version='1.0' and uri='http://...' are truthy
strings, so any one of them caused _require_nothing_dropped() to return
early and silently discard records stored under an unread key alongside
them (e.g. {'version': '1.0', 'nodes': [{'id': 'c1'}]}). Fixed by
building the resolved list from only non-empty list/tuple values of
recognised schema keys.
Fix 2 -- _is_record accepts modules and type objects (P2):
_is_record() accepted any object with __dict__, which includes Python
modules and class objects. Elements that passed _coerce_records then
reached exporters and raised AttributeError (e.g. module 'math' has no
attribute 'get') rather than a ValidationError at the validation
boundary. Fixed by excluding types.ModuleType and type from the
__dict__ branch while preserving support for all user-defined
attribute-bearing record objects.
Tests: 101 tests pass across
tests/utils/test_normalize_graph_payload.py
tests/export/test_yaml_exporter_key_recognition.py
tests/export/test_yaml_exporter_input_validation.py
tests/export/test_neo4j_csv_exporter.py
* fix(export): close exception-type and record-shape gaps in normalize_graph_payload
LPGExporter and ArangoAQLExporter called normalize_graph_payload() with no
type guard, so non-mapping input raised ValidationError from inside the
resolver while the YAML and Neo4j exporters raised ProcessingError for the
identical mistake -- inconsistent with the exception-type contract this PR
establishes. Both now use the shared _require_mapping() guard (moved from
yaml_exporter.py into utils/helpers.py so all three can use it).
Neo4jCSVExporter._normalize_graph checked isinstance(graph, dict), so a
non-dict Mapping (MappingProxyType, ChainMap) fell through to the
object-attribute branch and was rejected, even though the identical payload
exported fine via the other three exporters. Now checks isinstance(graph,
Mapping).
normalize_graph_payload() accepts dataclass/attribute-bearing object
records, but LPGExporter/ArangoAQLExporter call .get(...) directly on
resolved entities -- an object-shaped record passed validation only to
crash with a raw AttributeError once used, the exact failure this
boundary exists to prevent. Records are now converted to plain dicts at
the boundary (_coerce_records -> new _record_to_dict), so every consumer
gets a uniform shape regardless of which reading the caller used.
Two non-empty spellings of the same collection holding identical records
in a different order were rejected as conflicting, since the check used
plain list equality. Comparison is now an order-independent multiset of
each record's canonical JSON form.
* docs(changelog): add entry for #958 YAML export input hardening
Documents the full arc of #958 -- the normalize_graph_payload()
centralization, YAML input validation, both review rounds from
@Sameer6305, and the exception-type/record-shape follow-up fixes -- plus
closes #956, #952, #953.
---------
Co-authored-by: Pravit Ampapathini <pravitampapathini@Pravits-MacBook-Air-3.local>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
488 lines
20 KiB
Python
488 lines
20 KiB
Python
"""Tests for the shared graph-payload normalizer (issue #956).
|
|
|
|
Graph payloads circulate under two vocabularies -- 'entities'/'relationships'
|
|
and 'nodes'/'edges' -- and consumers each reconciled them locally with at
|
|
least three competing idioms. The same payload could therefore be exported,
|
|
silently dropped, or rejected depending on which consumer read it:
|
|
``export_lpg`` dropped every entity when 'nodes' was present but empty, which
|
|
is precisely the shape ``JSONExporter`` emits.
|
|
|
|
The end-to-end assertions run the real exporters rather than mocking them,
|
|
since the behaviour under test is that the exporters now agree.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
from dataclasses import dataclass
|
|
|
|
from semantica.export import methods as export_methods
|
|
from semantica.utils import normalize_graph_payload
|
|
from semantica.utils.exceptions import ValidationError
|
|
|
|
ENTITY = {"id": "e1", "name": "Acme"}
|
|
RELATIONSHIP = {"id": "r1", "source": "e1", "target": "e2"}
|
|
|
|
|
|
class TestVocabularyResolution(unittest.TestCase):
|
|
def test_canonical_keys_pass_through(self):
|
|
result = normalize_graph_payload(
|
|
{"entities": [ENTITY], "relationships": [RELATIONSHIP]}
|
|
)
|
|
self.assertEqual(result["entities"], [ENTITY])
|
|
self.assertEqual(result["relationships"], [RELATIONSHIP])
|
|
self.assertEqual(result["triplets"], [])
|
|
|
|
def test_aliases_are_mapped_to_canonical_keys(self):
|
|
result = normalize_graph_payload({"nodes": [ENTITY], "edges": [RELATIONSHIP]})
|
|
self.assertEqual(result["entities"], [ENTITY])
|
|
self.assertEqual(result["relationships"], [RELATIONSHIP])
|
|
|
|
def test_empty_alias_does_not_mask_a_populated_canonical_key(self):
|
|
"""The JSONExporter round-trip shape, and the #956 data-loss case."""
|
|
result = normalize_graph_payload(
|
|
{"entities": [ENTITY], "nodes": [], "relationships": [], "edges": []}
|
|
)
|
|
self.assertEqual(result["entities"], [ENTITY])
|
|
|
|
def test_empty_canonical_key_does_not_mask_a_populated_alias(self):
|
|
result = normalize_graph_payload({"entities": [], "nodes": [ENTITY]})
|
|
self.assertEqual(result["entities"], [ENTITY])
|
|
|
|
def test_identical_spellings_are_accepted(self):
|
|
result = normalize_graph_payload({"entities": [ENTITY], "nodes": [ENTITY]})
|
|
self.assertEqual(result["entities"], [ENTITY])
|
|
|
|
def test_conflicting_spellings_are_refused(self):
|
|
"""No basis to prefer either, and picking one would lose the other."""
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
normalize_graph_payload(
|
|
{"entities": [ENTITY], "nodes": [{"id": "different"}]}
|
|
)
|
|
message = str(ctx.exception)
|
|
self.assertIn("entities", message)
|
|
self.assertIn("nodes", message)
|
|
|
|
def test_reordered_identical_spellings_are_accepted(self):
|
|
"""Same records, different order, is not a conflict.
|
|
|
|
A caller round-tripping through a dict-keyed cache or a set has no
|
|
reason to preserve list order; comparing spellings with plain list
|
|
equality rejected this as if the records differed.
|
|
"""
|
|
other = {"id": "e2", "name": "Beta"}
|
|
result = normalize_graph_payload(
|
|
{"entities": [ENTITY, other], "nodes": [other, ENTITY]}
|
|
)
|
|
self.assertCountEqual(result["entities"], [ENTITY, other])
|
|
|
|
def test_reordered_spellings_with_duplicate_records_still_conflict(self):
|
|
"""Multiset comparison must still catch a real count mismatch."""
|
|
with self.assertRaises(ValidationError):
|
|
normalize_graph_payload({"entities": [ENTITY, ENTITY], "nodes": [ENTITY]})
|
|
|
|
def test_triplets_are_carried_through(self):
|
|
result = normalize_graph_payload({"triplets": [{"s": "a", "p": "b", "o": "c"}]})
|
|
self.assertEqual(result["triplets"], [{"s": "a", "p": "b", "o": "c"}])
|
|
|
|
def test_missing_collections_default_to_empty_lists(self):
|
|
result = normalize_graph_payload({"entities": [ENTITY]})
|
|
self.assertEqual(result["relationships"], [])
|
|
self.assertEqual(result["triplets"], [])
|
|
|
|
def test_result_does_not_alias_the_input_collections(self):
|
|
payload = {"entities": [ENTITY]}
|
|
result = normalize_graph_payload(payload)
|
|
result["entities"].append({"id": "e2"})
|
|
self.assertEqual(len(payload["entities"]), 1)
|
|
|
|
|
|
class TestUnrecognizedInput(unittest.TestCase):
|
|
def test_unrecognized_keys_raise_by_default(self):
|
|
for payload in ({"data": [ENTITY]}, {"records": [ENTITY]}, {"foo": "bar"}):
|
|
with self.subTest(payload=payload):
|
|
with self.assertRaises(ValidationError):
|
|
normalize_graph_payload(payload)
|
|
|
|
def test_error_names_supplied_and_expected_keys(self):
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
normalize_graph_payload({"data": [ENTITY]})
|
|
message = str(ctx.exception)
|
|
self.assertIn("data", message)
|
|
self.assertIn("entities", message)
|
|
self.assertIn("nodes", message)
|
|
|
|
def test_empty_mapping_is_accepted(self):
|
|
"""An empty graph is legitimate and carries nothing that could be lost."""
|
|
result = normalize_graph_payload({})
|
|
self.assertEqual(result, {"entities": [], "relationships": [], "triplets": []})
|
|
|
|
def test_non_mapping_input_raises(self):
|
|
for payload in ([ENTITY], (ENTITY,), "entities", 42, None):
|
|
with self.subTest(payload=repr(payload)):
|
|
with self.assertRaises(ValidationError):
|
|
normalize_graph_payload(payload)
|
|
|
|
|
|
class TestExportersAgree(unittest.TestCase):
|
|
"""The divergence from #956, run against the real exporters."""
|
|
|
|
# export_csv is excluded: it writes entities/relationships/nodes/edges to
|
|
# four separate files by design, so it is not resolving two spellings of
|
|
# one collection and is out of scope for this change.
|
|
EXPORTERS = ("export_json", "export_arango", "export_neo4j_csv", "export_lpg")
|
|
|
|
def setUp(self):
|
|
self.tmpdir = tempfile.mkdtemp()
|
|
self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True)
|
|
|
|
def _export_and_read(self, name, payload):
|
|
outdir = os.path.join(self.tmpdir, name)
|
|
os.makedirs(outdir, exist_ok=True)
|
|
getattr(export_methods, name)(payload, os.path.join(outdir, "out"))
|
|
blob = ""
|
|
for root, _, files in os.walk(outdir):
|
|
for filename in files:
|
|
with open(os.path.join(root, filename), errors="ignore") as handle:
|
|
blob += handle.read()
|
|
return blob
|
|
|
|
def test_exporter_list_is_populated(self):
|
|
"""Guard against a vacuous suite if the list is emptied."""
|
|
self.assertGreaterEqual(len(self.EXPORTERS), 4)
|
|
|
|
def test_every_exporter_keeps_records_when_an_alias_is_empty(self):
|
|
payload = {
|
|
"entities": [ENTITY],
|
|
"nodes": [],
|
|
"relationships": [],
|
|
"edges": [],
|
|
}
|
|
for name in self.EXPORTERS:
|
|
with self.subTest(exporter=name):
|
|
self.assertIn(
|
|
"Acme",
|
|
self._export_and_read(name, payload),
|
|
f"{name} dropped the entity when 'nodes' was present but empty",
|
|
)
|
|
|
|
def test_every_exporter_accepts_the_alias_vocabulary(self):
|
|
payload = {"nodes": [ENTITY], "edges": []}
|
|
for name in self.EXPORTERS:
|
|
with self.subTest(exporter=name):
|
|
self.assertIn(
|
|
"Acme",
|
|
self._export_and_read(name, payload),
|
|
f"{name} dropped the entity supplied as 'nodes'",
|
|
)
|
|
|
|
def test_every_exporter_raises_processing_error_for_non_mapping_input(self):
|
|
"""A wrong-type payload is rejected the same way everywhere.
|
|
|
|
export_yaml and export_neo4j_csv raised ProcessingError for a bare
|
|
list; export_lpg and export_arango called normalize_graph_payload()
|
|
directly with no type guard, so they alone raised ValidationError
|
|
(from inside the resolver) for the identical mistake.
|
|
"""
|
|
from semantica.utils.exceptions import ProcessingError
|
|
|
|
for name in ("export_arango", "export_neo4j_csv", "export_lpg"):
|
|
with self.subTest(exporter=name):
|
|
outdir = os.path.join(self.tmpdir, name + "_bad_type")
|
|
os.makedirs(outdir, exist_ok=True)
|
|
with self.assertRaises(ProcessingError):
|
|
getattr(export_methods, name)([ENTITY], os.path.join(outdir, "out"))
|
|
|
|
def test_every_exporter_converts_object_shaped_records(self):
|
|
"""A dataclass record must not merely pass validation.
|
|
|
|
normalize_graph_payload() accepts dataclass/attribute-bearing
|
|
records (Neo4jCSVExporter reads them off attributes), but
|
|
export_lpg and export_arango read records with ``.get(...)``. A
|
|
record that passed validation unconverted crashed with a raw
|
|
AttributeError once used -- the exact failure the boundary exists
|
|
to prevent.
|
|
"""
|
|
|
|
@dataclass
|
|
class Node:
|
|
id: str
|
|
name: str
|
|
|
|
payload = {"entities": [Node(id="e1", name="Acme")], "relationships": []}
|
|
for name in ("export_arango", "export_neo4j_csv", "export_lpg"):
|
|
with self.subTest(exporter=name):
|
|
self.assertIn("Acme", self._export_and_read(name, payload))
|
|
|
|
def test_neo4j_accepts_non_dict_mappings(self):
|
|
"""Neo4jCSVExporter's mapping path must not be narrower than the rest.
|
|
|
|
_normalize_graph checked isinstance(graph, dict), so a non-dict
|
|
Mapping (a MappingProxyType, a ChainMap) fell into the
|
|
object-attribute branch and was rejected as an unrecognized object,
|
|
even though the identical payload exports fine via LPG/Arango/YAML.
|
|
"""
|
|
import types
|
|
|
|
payload = types.MappingProxyType({"entities": [ENTITY], "relationships": []})
|
|
self.assertIn("Acme", self._export_and_read("export_neo4j_csv", payload))
|
|
|
|
|
|
class TestRecordsCannotBeDroppedSilently(unittest.TestCase):
|
|
"""Presence of a recognized key is not proof the records survived.
|
|
|
|
``{"entities": [], "data": [...]}`` clears a presence-only check and still
|
|
resolves to empty, so the records under 'data' would be dropped with no
|
|
signal -- the same failure the recognition check exists to prevent.
|
|
"""
|
|
|
|
def test_empty_recognized_key_does_not_excuse_records_elsewhere(self):
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
normalize_graph_payload({"entities": [], "data": [ENTITY]})
|
|
|
|
message = str(ctx.exception)
|
|
self.assertIn("'data'", message)
|
|
self.assertIn("holds records", message)
|
|
|
|
def test_check_applies_to_every_recognized_spelling(self):
|
|
for key in ("entities", "nodes", "relationships", "edges", "triplets"):
|
|
with self.subTest(key=key):
|
|
with self.assertRaises(ValidationError):
|
|
normalize_graph_payload({key: [], "records": [ENTITY]})
|
|
|
|
def test_non_record_keys_are_not_mistaken_for_dropped_records(self):
|
|
"""ContextGraph.to_dict() always carries 'statistics'.
|
|
|
|
An empty graph must stay exportable, so only a non-empty list counts
|
|
as evidence that records were dropped.
|
|
"""
|
|
result = normalize_graph_payload(
|
|
{"nodes": [], "edges": [], "statistics": {"node_count": 0}}
|
|
)
|
|
|
|
self.assertEqual(result["entities"], [])
|
|
self.assertEqual(result["relationships"], [])
|
|
|
|
def test_records_alongside_a_populated_collection_are_not_refused(self):
|
|
"""Something resolved, so the export is not silently empty."""
|
|
result = normalize_graph_payload({"entities": [ENTITY], "statistics": {"n": 1}})
|
|
|
|
self.assertEqual(result["entities"], [ENTITY])
|
|
|
|
|
|
class TestCollectionValuesAreValidated(unittest.TestCase):
|
|
"""A recognized key is not proof its value is a collection of records.
|
|
|
|
Resolving on truthiness alone let ``{"entities": "abc"}`` through as three
|
|
single-character "records" and let ``{"entities": 42}`` surface as a raw
|
|
``TypeError`` from ``list()`` inside an exporter, naming the exporter
|
|
rather than the payload key at fault. Both are rejected here, at the
|
|
boundary that owns the question.
|
|
"""
|
|
|
|
COLLECTION_KEYS = ("entities", "nodes", "relationships", "edges", "triplets")
|
|
|
|
# Every public export path that reads its payload through the normalizer.
|
|
# export_json is excluded: it treats the payload as opaque records rather
|
|
# than resolving graph collections, so it never calls the normalizer.
|
|
NORMALIZING_EXPORTERS = (
|
|
"export_arango",
|
|
"export_neo4j_csv",
|
|
"export_lpg",
|
|
"export_yaml",
|
|
)
|
|
|
|
def test_string_value_is_not_treated_as_a_collection(self):
|
|
for key in self.COLLECTION_KEYS:
|
|
with self.subTest(key=key):
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
normalize_graph_payload({key: "abc"})
|
|
message = str(ctx.exception)
|
|
self.assertIn(f"'{key}'", message)
|
|
self.assertIn("str", message)
|
|
|
|
def test_bytes_value_is_not_treated_as_a_collection(self):
|
|
for value in (b"abc", bytearray(b"abc")):
|
|
with self.subTest(value=repr(value)):
|
|
with self.assertRaises(ValidationError):
|
|
normalize_graph_payload({"entities": value})
|
|
|
|
def test_scalar_value_raises_validation_error_not_type_error(self):
|
|
for key in self.COLLECTION_KEYS:
|
|
for value in (42, 3.5, True, object()):
|
|
with self.subTest(key=key, value=repr(value)):
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
normalize_graph_payload({key: value})
|
|
self.assertIn(f"'{key}'", str(ctx.exception))
|
|
|
|
def test_mapping_value_is_not_treated_as_a_collection(self):
|
|
"""``{"nodes": {"id": "n1"}}`` -- a single record, or an ID index."""
|
|
for payload in (
|
|
{"nodes": {"id": "n1"}},
|
|
{"entities": {"e1": ENTITY}},
|
|
{"edges": {"id": "r1"}},
|
|
):
|
|
with self.subTest(payload=payload):
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
normalize_graph_payload(payload)
|
|
self.assertIn("mapping", str(ctx.exception))
|
|
|
|
def test_non_record_elements_are_rejected(self):
|
|
for value in (["Acme"], [ENTITY, "Acme"], [42], [None], [[ENTITY]]):
|
|
with self.subTest(value=repr(value)):
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
normalize_graph_payload({"entities": value})
|
|
self.assertIn("'entities'", str(ctx.exception))
|
|
|
|
def test_error_names_the_offending_index(self):
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
normalize_graph_payload({"entities": [ENTITY, ENTITY, "Acme"]})
|
|
self.assertIn("index 2", str(ctx.exception))
|
|
|
|
def test_object_records_are_accepted(self):
|
|
"""Attribute-bearing objects are accepted and converted to dicts.
|
|
|
|
LPGExporter and ArangoAQLExporter read records with ``.get(...)``, so
|
|
an object record that merely passed validation unconverted would
|
|
still crash with AttributeError once used; the boundary converts it.
|
|
"""
|
|
|
|
class Node:
|
|
def __init__(self):
|
|
self.id = "e1"
|
|
self.name = "Acme"
|
|
|
|
node = Node()
|
|
result = normalize_graph_payload({"entities": [node]})
|
|
self.assertEqual(result["entities"], [{"id": "e1", "name": "Acme"}])
|
|
|
|
def test_dataclass_records_are_accepted(self):
|
|
@dataclass
|
|
class Node:
|
|
id: str
|
|
|
|
node = Node(id="e1")
|
|
result = normalize_graph_payload({"entities": [node]})
|
|
self.assertEqual(result["entities"], [{"id": "e1"}])
|
|
|
|
def test_tuple_collections_are_accepted_and_materialized(self):
|
|
result = normalize_graph_payload({"entities": (ENTITY,)})
|
|
self.assertEqual(result["entities"], [ENTITY])
|
|
|
|
def test_none_is_read_as_an_absent_collection(self):
|
|
"""JSON round-trips an absent collection to null."""
|
|
result = normalize_graph_payload(
|
|
{"entities": None, "relationships": [RELATIONSHIP]}
|
|
)
|
|
self.assertEqual(result["entities"], [])
|
|
self.assertEqual(result["relationships"], [RELATIONSHIP])
|
|
|
|
def test_null_collection_still_cannot_hide_dropped_records(self):
|
|
with self.assertRaises(ValidationError):
|
|
normalize_graph_payload({"entities": None, "data": [ENTITY]})
|
|
|
|
def test_every_spelling_is_validated_not_just_the_winner(self):
|
|
"""A malformed alias is a defect even when the canonical key resolves."""
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
normalize_graph_payload({"entities": [ENTITY], "nodes": "abc"})
|
|
self.assertIn("'nodes'", str(ctx.exception))
|
|
|
|
def test_malformed_value_reaches_no_exporter(self):
|
|
"""The end-to-end half: no exporter sees a TypeError from list()."""
|
|
tmpdir = tempfile.mkdtemp()
|
|
self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True)
|
|
|
|
for name in self.NORMALIZING_EXPORTERS:
|
|
for value in ("abc", 42, {"id": "n1"}):
|
|
with self.subTest(exporter=name, value=repr(value)):
|
|
outdir = os.path.join(tmpdir, f"{name}_{type(value).__name__}")
|
|
os.makedirs(outdir, exist_ok=True)
|
|
with self.assertRaises(ValidationError):
|
|
getattr(export_methods, name)(
|
|
{"entities": value}, os.path.join(outdir, "out")
|
|
)
|
|
|
|
|
|
class TestIsRecordBoundary(unittest.TestCase):
|
|
"""_is_record gates the validation boundary introduced by this PR.
|
|
|
|
Modules and class/type objects carry ``__dict__`` but are not graph
|
|
records. Passing them through previously produced ``AttributeError``
|
|
inside exporters rather than a ``ValidationError`` at the boundary.
|
|
"""
|
|
|
|
def test_python_module_in_entities_raises_validation_error(self):
|
|
"""import math; {"entities": [math]} must be rejected at the boundary."""
|
|
import math
|
|
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
normalize_graph_payload({"entities": [math]})
|
|
self.assertIn("'entities'", str(ctx.exception))
|
|
|
|
def test_class_object_in_entities_raises_validation_error(self):
|
|
"""A class (type object) is not a graph record."""
|
|
|
|
class MyNode:
|
|
pass
|
|
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
normalize_graph_payload({"entities": [MyNode]})
|
|
self.assertIn("'entities'", str(ctx.exception))
|
|
|
|
def test_user_defined_instance_with_attributes_is_accepted(self):
|
|
"""Attribute-bearing instances are the legitimate use-case, converted
|
|
to a dict so every exporter -- not just Neo4jCSVExporter -- can read
|
|
it with ``.get(...)``."""
|
|
|
|
class Node:
|
|
def __init__(self):
|
|
self.id = "n1"
|
|
self.name = "Alice"
|
|
|
|
node = Node()
|
|
result = normalize_graph_payload({"entities": [node]})
|
|
self.assertEqual(result["entities"], [{"id": "n1", "name": "Alice"}])
|
|
|
|
def test_dataclass_instance_is_accepted(self):
|
|
"""Dataclasses are a common record type used by Neo4jCSVExporter,
|
|
converted to a dict at the boundary so LPGExporter and
|
|
ArangoAQLExporter can read it too."""
|
|
node = dataclass_node()
|
|
result = normalize_graph_payload({"entities": [node]})
|
|
self.assertEqual(result["entities"], [{"id": "dc1"}])
|
|
|
|
def test_mapping_record_is_accepted(self):
|
|
"""Plain dicts are the canonical record shape."""
|
|
result = normalize_graph_payload({"entities": [ENTITY]})
|
|
self.assertEqual(result["entities"], [ENTITY])
|
|
|
|
def test_module_rejected_through_normalizing_exporter(self):
|
|
"""End-to-end: a module element must not reach an exporter's internals."""
|
|
import math
|
|
|
|
tmpdir = tempfile.mkdtemp()
|
|
self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True)
|
|
|
|
for name in ("export_arango", "export_neo4j_csv", "export_lpg"):
|
|
with self.subTest(exporter=name):
|
|
outdir = os.path.join(tmpdir, name)
|
|
os.makedirs(outdir, exist_ok=True)
|
|
with self.assertRaises(ValidationError):
|
|
getattr(export_methods, name)(
|
|
{"entities": [math]}, os.path.join(outdir, "out")
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _DataclassNode:
|
|
id: str
|
|
|
|
|
|
def dataclass_node():
|
|
return _DataclassNode(id="dc1")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|