Files
semantica/tests/export/test_timestamp_timezones.py
T

153 lines
6.0 KiB
Python

"""Timestamps that leave the process must carry a timezone (issue #1114).
Every timestamp an exporter wrote was naive: ``datetime.now().isoformat()``
reads the local clock, ``datetime.utcnow().isoformat()`` reads UTC, and the two
serialize identically, so nothing downstream can tell which zone a value belongs
to. In RDF the consequence is not a parse error but a silent one: under XSD 1.1
a value with no timezone compared against one with a timezone is indeterminate
whenever they fall inside the +/-14 hour window, SPARQL turns that into an error,
and FILTER discards errors as non-matches. A timezone-qualified query therefore
returns an answer with every Semantica-written record quietly missing from it.
"""
from datetime import datetime, timedelta, timezone
import pytest
from semantica.export.json_exporter import JSONExporter
from semantica.export.report_generator import ReportGenerator
from semantica.export.yaml_exporter import SemanticNetworkYAMLExporter
from semantica.utils.helpers import utc_now, utc_now_iso
KG = {
"entities": [{"id": "https://example.org/e1", "text": "Bob"}],
"relationships": [],
}
def assert_offset_aware(value):
"""An ISO 8601 string is only an instant if it says which zone it is in."""
assert isinstance(value, str), value
parsed = datetime.fromisoformat(value)
assert parsed.tzinfo is not None, f"timezone-naive timestamp: {value!r}"
assert parsed.utcoffset() is not None
def test_utc_now_iso_is_offset_aware():
assert_offset_aware(utc_now_iso())
assert utc_now().tzinfo is not None
def test_jsonld_export_timestamp_is_offset_aware():
document = JSONExporter()._convert_kg_to_jsonld(KG)
assert_offset_aware(document["semantica:exportedAt"])
def test_json_export_metadata_timestamp_is_offset_aware(tmp_path):
import json
exporter = JSONExporter()
exporter.export_entities(KG["entities"], tmp_path / "entities.json")
exporter.export_relationships([], tmp_path / "relationships.json")
for name in ("entities.json", "relationships.json"):
payload = json.loads((tmp_path / name).read_text())
assert_offset_aware(payload["metadata"]["exported_at"])
def test_yaml_export_timestamp_is_offset_aware():
yaml = pytest.importorskip("yaml")
document = SemanticNetworkYAMLExporter().export_entities(KG["entities"])
payload = yaml.safe_load(document)
assert_offset_aware(payload["metadata"]["exported_at"])
def test_report_timestamp_is_offset_aware():
import json
report = json.loads(
ReportGenerator().generate_quality_report({"score": 0.9}, format="json")
)
assert_offset_aware(report["generated_at"])
def test_exported_timestamp_compares_against_a_timezone_aware_instant():
"""The naive form raised TypeError here, or compared as if it were UTC."""
exported = datetime.fromisoformat(
JSONExporter()._convert_kg_to_jsonld(KG)["semantica:exportedAt"]
)
assert exported <= utc_now()
assert exported > datetime(2020, 1, 1, tzinfo=timezone.utc)
def test_exported_timestamp_survives_a_timezone_qualified_sparql_filter():
"""The regression in #1114: a strict engine dropped the naive value."""
pyoxigraph = pytest.importorskip("pyoxigraph")
exported = JSONExporter()._convert_kg_to_jsonld(KG)["semantica:exportedAt"]
store = pyoxigraph.Store()
store.load(
(
'<https://example.org/export> '
'<https://semantica.dev/ns#exportedAt> '
f'"{exported}"^^<http://www.w3.org/2001/XMLSchema#dateTime> .'
).encode(),
format=pyoxigraph.RdfFormat.N_TRIPLES,
)
# The bound has to sit inside the +/-14 hour window that makes an
# untimezoned comparison indeterminate. A bound years away is determinate
# even for a naive value, and the test would pass without the fix.
bound = (utc_now() + timedelta(hours=1)).isoformat().replace("+00:00", "Z")
rows = list(store.query(
"PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> "
"SELECT ?e WHERE { ?e <https://semantica.dev/ns#exportedAt> ?t . "
f'FILTER (?t < "{bound}"^^xsd:dateTime) }}'
))
assert len(rows) == 1, "the export was dropped by a timezone-qualified filter"
def test_document_iri_is_a_valid_iri():
"""The graph @id must be a valid IRI regardless of how it is minted.
Before #1147, this @id was minted from the offset-carrying timestamp
itself (``+00:00`` interpolated straight into the path), so this test
asserted the offset survived without breaking IRI validity. #1147 mints
the @id from the graph's content instead, so the timestamp no longer
appears here at all — it stays in ``semantica:exportedAt`` (still
offset-aware, per ``test_jsonld_export_timestamp_is_offset_aware`` above).
What's left worth guarding is the general case: whatever the @id is
minted from, it has to be a valid IRI that round-trips through RDF.
"""
rdflib = pytest.importorskip("rdflib")
document_iri = JSONExporter()._convert_kg_to_jsonld(KG)["@id"]
assert rdflib.term._is_valid_uri(document_iri)
graph = rdflib.Graph()
graph.add(
(
rdflib.URIRef(document_iri),
rdflib.RDF.type,
rdflib.URIRef("https://semantica.dev/ns#KnowledgeGraph"),
)
)
reparsed = rdflib.Graph().parse(data=graph.serialize(format="nt"), format="nt")
assert document_iri in {str(s) for s in reparsed.subjects()}
def test_vocabulary_range_matches_what_the_exporter_writes():
"""The declared range says the offset is required; the export must carry it."""
rdflib = pytest.importorskip("rdflib")
from rdflib.namespace import RDFS, XSD
from semantica.ontology.vocabulary import NAMESPACE, vocabulary_turtle
graph = rdflib.Graph()
graph.parse(data=vocabulary_turtle(), format="turtle")
declared = graph.value(rdflib.URIRef(f"{NAMESPACE}exportedAt"), RDFS.range)
assert declared == XSD.dateTimeStamp
exported = JSONExporter()._convert_kg_to_jsonld(KG)["semantica:exportedAt"]
assert datetime.fromisoformat(exported).utcoffset() is not None