mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge upstream/main into metadata-passthrough
#1123 through #1127 landed while this was open, and #1125 rewrote the same four entity loops this branch extends. Confidence is now normalised through normalize_confidence, which returns None for a value that has no xsd:decimal form, so the clause can be absent. Resolved by folding that into the clause list this branch already builds: the Turtle path assembles its predicate-object clauses and then terminates the last one, which is what makes a variable-length list work at all, and an omitted confidence is simply one clause fewer. RDF/XML and JSON-LD take the upstream conditional as written, with the metadata call after it.
This commit is contained in:
@@ -80,6 +80,7 @@ import numpy as np
|
||||
|
||||
from ..utils.exceptions import ConfigurationError, ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.custom_methods import CUSTOM_METHOD_FELL_BACK, call_custom_method
|
||||
from .config import embeddings_config
|
||||
from .embedding_generator import EmbeddingGenerator
|
||||
from .pooling_strategies import PoolingStrategyFactory
|
||||
@@ -119,12 +120,12 @@ def generate_embeddings(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("generation", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(data, data_type=data_type, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, data, data_type=data_type, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
if method == "default":
|
||||
@@ -167,12 +168,12 @@ def embed_text(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("text", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(text, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, text, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -227,12 +228,12 @@ def calculate_similarity(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("similarity", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(embedding1, embedding2, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, embedding1, embedding2, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
generator = EmbeddingGenerator(**kwargs)
|
||||
@@ -274,12 +275,12 @@ def pool_embeddings(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("pooling", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(embeddings, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, embeddings, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
strategy = PoolingStrategyFactory.create(method, **kwargs)
|
||||
|
||||
+79
-78
@@ -164,6 +164,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from ..utils.exceptions import ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.custom_methods import CUSTOM_METHOD_FELL_BACK, call_custom_method
|
||||
from .arango_aql_exporter import ArangoAQLExporter
|
||||
from .arrow_exporter import ArrowExporter
|
||||
from .config import export_config
|
||||
@@ -221,12 +222,12 @@ def export_rdf(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("rdf", method)
|
||||
if custom_method and custom_method is not export_rdf:
|
||||
try:
|
||||
return custom_method(data, file_path, format=format, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, data, file_path, format=format, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -270,12 +271,12 @@ def export_json(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("json", method)
|
||||
if custom_method and custom_method is not export_json:
|
||||
try:
|
||||
return custom_method(data, file_path, format=format, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, data, file_path, format=format, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -316,12 +317,12 @@ def export_csv(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("csv", method)
|
||||
if custom_method and custom_method is not export_csv:
|
||||
try:
|
||||
return custom_method(data, file_path, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, data, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -361,12 +362,12 @@ def export_arrow(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("arrow", method)
|
||||
if custom_method and custom_method is not export_arrow:
|
||||
try:
|
||||
return custom_method(data, file_path, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, data, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -421,12 +422,12 @@ def export_parquet(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("parquet", method)
|
||||
if custom_method and custom_method is not export_parquet:
|
||||
try:
|
||||
return custom_method(data, file_path, compression=compression, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, data, file_path, compression=compression, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -472,12 +473,12 @@ def export_graph(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("graph", method)
|
||||
if custom_method and custom_method is not export_graph:
|
||||
try:
|
||||
return custom_method(graph_data, file_path, format=format, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, graph_data, file_path, format=format, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -538,12 +539,12 @@ def export_yaml(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("yaml", method)
|
||||
if custom_method and custom_method is not export_yaml:
|
||||
try:
|
||||
return custom_method(data, file_path, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, data, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -595,12 +596,12 @@ def export_owl(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("owl", method)
|
||||
if custom_method and custom_method is not export_owl:
|
||||
try:
|
||||
return custom_method(ontology, file_path, format=format, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, ontology, file_path, format=format, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -647,12 +648,12 @@ def export_vector(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("vector", method)
|
||||
if custom_method and custom_method is not export_vector:
|
||||
try:
|
||||
return custom_method(vectors, file_path, format=format, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, vectors, file_path, format=format, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -695,12 +696,12 @@ def export_lpg(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("lpg", method)
|
||||
if custom_method and custom_method is not export_lpg:
|
||||
try:
|
||||
return custom_method(knowledge_graph, file_path, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, knowledge_graph, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -738,12 +739,12 @@ def export_neo4j_csv(
|
||||
"""
|
||||
custom_method = method_registry.get("neo4j_csv", method)
|
||||
if custom_method and custom_method is not export_neo4j_csv:
|
||||
try:
|
||||
return custom_method(knowledge_graph, output_dir, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, knowledge_graph, output_dir, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = export_config.get_method_config("neo4j_csv")
|
||||
@@ -808,12 +809,12 @@ def export_arango(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("arango", method)
|
||||
if custom_method and custom_method is not export_arango:
|
||||
try:
|
||||
return custom_method(knowledge_graph, file_path, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, knowledge_graph, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -859,12 +860,12 @@ def generate_report(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("report", method)
|
||||
if custom_method and custom_method is not generate_report:
|
||||
try:
|
||||
return custom_method(data, file_path, format=format, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, data, file_path, format=format, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
|
||||
@@ -30,6 +30,7 @@ License: MIT
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, List, Optional, Set, Union
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
@@ -49,6 +50,75 @@ DEFAULT_ENTITY_TYPE = f"{SEMANTICA_NS}Entity"
|
||||
#: Written when a relationship carries no type of its own. Same reasoning.
|
||||
DEFAULT_RELATION_TYPE = f"{SEMANTICA_NS}related_to"
|
||||
|
||||
#: The one datatype every serializer writes confidence in.
|
||||
#:
|
||||
#: The four paths used to disagree: Turtle wrote the value bare, which the
|
||||
#: Turtle grammar reads as xsd:decimal, N-Triples typed it xsd:float, RDF/XML
|
||||
#: emitted a plain literal with no datatype, and JSON-LD emitted a native
|
||||
#: number, which becomes xsd:double. Those are four distinct RDF terms for one
|
||||
#: value (issue #1100).
|
||||
#:
|
||||
#: xsd:decimal is the choice because it is what the Turtle path already
|
||||
#: produced, so the most used output is unchanged, and because it is exact:
|
||||
#: xsd:float is 32 bit binary, and cannot represent 0.9 or 0.95 at all.
|
||||
CONFIDENCE_DATATYPE = "http://www.w3.org/2001/XMLSchema#decimal"
|
||||
|
||||
#: Largest power of ten a confidence may carry. xsd:decimal has no exponent
|
||||
#: notation, so a value has to be written out in full, and a compact literal
|
||||
#: such as "1e100000000" would expand to a hundred million digits.
|
||||
MAX_CONFIDENCE_EXPONENT = 100
|
||||
|
||||
|
||||
def normalize_confidence(value: Any) -> Optional[str]:
|
||||
"""
|
||||
Return the canonical xsd:decimal lexical form of a confidence value.
|
||||
|
||||
Returns None when the value cannot be a decimal, so callers omit the triple
|
||||
rather than writing something the vocabulary contradicts. The Turtle path
|
||||
used to interpolate the raw value, so a confidence of "high" produced
|
||||
`semantica:confidence high .` and made the whole document unparseable
|
||||
(issue #1102).
|
||||
|
||||
Booleans are rejected. `bool` is a subclass of `int` in Python, so True
|
||||
would otherwise silently become a confidence of 1.
|
||||
"""
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
if not isinstance(value, (int, float, str, Decimal)):
|
||||
return None
|
||||
|
||||
try:
|
||||
decimal_value = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
# NaN and the infinities are Decimal values with no xsd:decimal form.
|
||||
if not decimal_value.is_finite():
|
||||
return None
|
||||
|
||||
# xsd:decimal has no exponent notation, so writing one means expanding it.
|
||||
# "1e100000000" is eleven characters that expand to a hundred million, and
|
||||
# the export path continues past validation errors, so a single malformed
|
||||
# field could exhaust memory. Nothing near this magnitude is a confidence.
|
||||
if not -MAX_CONFIDENCE_EXPONENT <= decimal_value.adjusted() <= MAX_CONFIDENCE_EXPONENT:
|
||||
return None
|
||||
|
||||
# `str(Decimal("0.00001"))` gives "0.00001", but a float that has already
|
||||
# been through repr can arrive as "1e-05", which xsd:decimal does not allow.
|
||||
formatted = format(decimal_value, "f")
|
||||
if "." in formatted:
|
||||
formatted = formatted.rstrip("0").rstrip(".") or "0"
|
||||
|
||||
# Decimal keeps the sign of zero, so 0.0 and -0.0 would serialise as two
|
||||
# distinct RDF terms and defeat the point of a canonical form.
|
||||
if formatted.lstrip("-").strip("0.") == "":
|
||||
formatted = "0"
|
||||
return formatted
|
||||
|
||||
|
||||
def mint_entity_iri(text: str) -> str:
|
||||
"""Mint a stable IRI for an entity that arrived without an id.
|
||||
@@ -578,13 +648,21 @@ class RDFSerializer:
|
||||
|
||||
entity_type = entity.get("type", DEFAULT_ENTITY_TYPE)
|
||||
text = entity.get("text") or entity.get("label", "")
|
||||
confidence = entity.get("confidence", 1.0)
|
||||
confidence = normalize_confidence(entity.get("confidence", 1.0))
|
||||
|
||||
clauses = [
|
||||
f"a <{entity_type}>",
|
||||
f'semantica:text "{text}"',
|
||||
f"semantica:confidence {confidence}",
|
||||
]
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
f"Entity {entity_id} has a confidence that is not a number "
|
||||
f"({entity.get('confidence')!r}), so no confidence is written"
|
||||
)
|
||||
else:
|
||||
clauses.append(
|
||||
f'semantica:confidence "{confidence}"^^<{CONFIDENCE_DATATYPE}>'
|
||||
)
|
||||
clauses.extend(
|
||||
_turtle_metadata_clauses(
|
||||
_metadata_statements(
|
||||
@@ -611,6 +689,17 @@ class RDFSerializer:
|
||||
if include_temporal:
|
||||
owl_lines = self._owl_time_triples_for_rel(rel, idx, time_axis)
|
||||
if owl_lines:
|
||||
# The interval hangs off the relationship's own IRI, and a
|
||||
# relationship written as a single triple has no such node
|
||||
# in the graph. Without this the timestamps are well formed
|
||||
# and unreachable: no query can get from the edge to its
|
||||
# validity interval (#1106). The shape matches the JSON-LD
|
||||
# export, and every term is declared in the vocabulary.
|
||||
lines.extend(
|
||||
self._reified_relationship_triples(
|
||||
rel, idx, source_id, target_id, rel_type
|
||||
)
|
||||
)
|
||||
lines.extend(owl_lines)
|
||||
|
||||
# Graph-level metadata needs a subject, and this serializer has never
|
||||
@@ -639,6 +728,44 @@ class RDFSerializer:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _reified_relationship_triples(
|
||||
self,
|
||||
rel: Dict[str, Any],
|
||||
idx: int,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
rel_type: str,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Emit the reified relationship node that OWL-Time triples hang off.
|
||||
|
||||
The direct triple stays. This adds a subject the interval can attach
|
||||
to, using the same sem:Relationship shape the JSON-LD export already
|
||||
writes, so the two serializations describe relationships the same way.
|
||||
"""
|
||||
rel_id = rel.get("id") or mint_relationship_iri(idx, source_id or "", target_id or "")
|
||||
|
||||
# The full predicate, not its local name. Truncating to the fragment
|
||||
# made https://a.example/ns#employs and https://b.example/ns#employs the
|
||||
# same literal, so the temporal node no longer said which predicate it
|
||||
# described, and it disagreed with the direct triple beside it.
|
||||
escaped = (
|
||||
str(rel_type)
|
||||
.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
)
|
||||
|
||||
predicates = [f"a semantica:Relationship"]
|
||||
if source_id:
|
||||
predicates.append(f"semantica:source <{source_id}>")
|
||||
if target_id:
|
||||
predicates.append(f"semantica:target <{target_id}>")
|
||||
predicates.append(f'semantica:type "{escaped}"')
|
||||
|
||||
return ["", f"<{rel_id}> " + " ;\n ".join(predicates) + " ."]
|
||||
|
||||
def _owl_time_triples_for_rel(
|
||||
self, rel: Dict[str, Any], idx: int, time_axis: str
|
||||
) -> List[str]:
|
||||
@@ -751,15 +878,22 @@ class RDFSerializer:
|
||||
|
||||
entity_type = entity.get("type", DEFAULT_ENTITY_TYPE)
|
||||
text = entity.get("text") or entity.get("label", "")
|
||||
confidence = entity.get("confidence", 1.0)
|
||||
confidence = normalize_confidence(entity.get("confidence", 1.0))
|
||||
|
||||
# RDF/XML syntax: rdf:Description with rdf:about
|
||||
lines.append(f' <rdf:Description rdf:about="{entity_id}">')
|
||||
lines.append(f' <rdf:type rdf:resource="{entity_type}"/>')
|
||||
lines.append(f" <semantica:text>{text}</semantica:text>")
|
||||
lines.append(
|
||||
f" <semantica:confidence>{confidence}</semantica:confidence>"
|
||||
)
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
f"Entity {entity_id} has a confidence that is not a number "
|
||||
f"({entity.get('confidence')!r}), so no confidence is written"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f' <semantica:confidence rdf:datatype="{CONFIDENCE_DATATYPE}">'
|
||||
f"{confidence}</semantica:confidence>"
|
||||
)
|
||||
lines.extend(
|
||||
_rdfxml_metadata_lines(
|
||||
_metadata_statements(
|
||||
@@ -863,8 +997,20 @@ class RDFSerializer:
|
||||
"@id": entity_id,
|
||||
"@type": entity.get("type", "semantica:Entity"),
|
||||
"semantica:text": entity.get("text") or entity.get("label", ""),
|
||||
"semantica:confidence": entity.get("confidence", 1.0),
|
||||
}
|
||||
confidence = normalize_confidence(entity.get("confidence", 1.0))
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
f"Entity {entity_id} has a confidence that is not a number "
|
||||
f"({entity.get('confidence')!r}), so no confidence is written"
|
||||
)
|
||||
else:
|
||||
# A native JSON number becomes xsd:double once expanded, so the
|
||||
# value is written as a typed literal instead.
|
||||
node["semantica:confidence"] = {
|
||||
"@value": confidence,
|
||||
"@type": CONFIDENCE_DATATYPE,
|
||||
}
|
||||
node.update(
|
||||
_jsonld_metadata_entries(
|
||||
_metadata_statements(
|
||||
@@ -969,11 +1115,20 @@ class RDFSerializer:
|
||||
f'{subject} {expand_uri("semantica:text")} "{safe_text}" .'
|
||||
)
|
||||
|
||||
# Confidence property
|
||||
confidence = entity.get("confidence")
|
||||
if confidence is not None:
|
||||
# Confidence property. The default matches the other serializers,
|
||||
# which have always written one; omitting it here was half of why
|
||||
# Turtle and N-Triples of one KG were different graphs (#1100).
|
||||
raw_confidence = entity.get("confidence", 1.0)
|
||||
confidence = normalize_confidence(raw_confidence)
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
f"Entity {entity.get('id')} has a confidence that is not a "
|
||||
f"number ({raw_confidence!r}), so no confidence is written"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f'{subject} {expand_uri("semantica:confidence")} "{confidence}"^^<http://www.w3.org/2001/XMLSchema#float> .'
|
||||
f'{subject} {expand_uri("semantica:confidence")} '
|
||||
f'"{confidence}"^^<{CONFIDENCE_DATATYPE}> .'
|
||||
)
|
||||
|
||||
lines.extend(
|
||||
|
||||
+79
-78
@@ -180,6 +180,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from ..utils.exceptions import ConfigurationError, ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.custom_methods import CUSTOM_METHOD_FELL_BACK, call_custom_method
|
||||
from .config import ingest_config
|
||||
from .file_ingestor import FileIngestor, FileObject
|
||||
from .registry import method_registry
|
||||
@@ -248,12 +249,12 @@ def ingest_file(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("file", method)
|
||||
if custom_method and custom_method != ingest_file:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -314,12 +315,12 @@ def ingest_parquet(
|
||||
"""
|
||||
custom_method = method_registry.get("parquet", method)
|
||||
if custom_method and custom_method != ingest_parquet:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -393,12 +394,12 @@ def ingest_arrow(
|
||||
"""
|
||||
custom_method = method_registry.get("arrow", method)
|
||||
if custom_method and custom_method != ingest_arrow:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -477,12 +478,12 @@ def ingest_xml(
|
||||
"""
|
||||
custom_method = method_registry.get("xml", method)
|
||||
if custom_method and custom_method != ingest_xml:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
from .xml_ingestor import XMLIngestor
|
||||
@@ -541,12 +542,12 @@ def ingest_web(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("web", method)
|
||||
if custom_method and custom_method != ingest_web:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -631,12 +632,12 @@ def ingest_public_api(
|
||||
"""
|
||||
custom_method = method_registry.get("public_api", method)
|
||||
if custom_method and custom_method != ingest_public_api:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
from .public_api_ingestor import PublicAPIExamples, PublicAPIIngestor
|
||||
@@ -718,12 +719,12 @@ def ingest_feed(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("feed", method)
|
||||
if custom_method and custom_method != ingest_feed:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -787,12 +788,12 @@ def ingest_stream(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("stream", method)
|
||||
if custom_method and custom_method != ingest_stream:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
from .stream_ingestor import StreamIngestor
|
||||
@@ -864,12 +865,12 @@ def ingest_repository(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("repo", method)
|
||||
if custom_method and custom_method != ingest_repository:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -936,12 +937,12 @@ def ingest_email(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("email", method)
|
||||
if custom_method and custom_method != ingest_email:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -1015,12 +1016,12 @@ def ingest_ontology(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("ontology", method)
|
||||
if custom_method and custom_method != ingest_ontology:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
from .ontology_ingestor import OntologyIngestor
|
||||
@@ -1081,12 +1082,12 @@ def ingest_database(
|
||||
if method:
|
||||
custom_method = method_registry.get("db", method)
|
||||
if custom_method and custom_method != ingest_database:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
from .db_ingestor import DBIngestor
|
||||
@@ -1188,12 +1189,12 @@ def ingest_mcp(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("mcp", method)
|
||||
if custom_method and custom_method != ingest_mcp:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
from .mcp_ingestor import MCPIngestor
|
||||
|
||||
+19
-18
@@ -142,6 +142,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from ..utils.exceptions import ConfigurationError, ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.custom_methods import CUSTOM_METHOD_FELL_BACK, call_custom_method
|
||||
from .centrality_calculator import CentralityCalculator
|
||||
from .community_detector import CommunityDetector
|
||||
from .config import kg_config
|
||||
@@ -189,12 +190,12 @@ def build_kg(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("build", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(sources, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, sources, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -237,12 +238,12 @@ def analyze_graph(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("analyze", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(graph, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, graph, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
@@ -441,12 +442,12 @@ def analyze_connectivity(
|
||||
# Check for custom method in registry
|
||||
custom_method = method_registry.get("connectivity", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(graph, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, graph, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
# Get config
|
||||
|
||||
@@ -125,6 +125,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from ..utils.exceptions import ConfigurationError, ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.custom_methods import CUSTOM_METHOD_FELL_BACK, call_custom_method
|
||||
from .config import normalize_config
|
||||
from .data_cleaner import DataCleaner
|
||||
from .date_normalizer import DateNormalizer
|
||||
@@ -168,12 +169,12 @@ def normalize_text(text: str, method: str = "default", **kwargs) -> str:
|
||||
"""
|
||||
custom_method = method_registry.get("text", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(text, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, text, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("text")
|
||||
@@ -212,12 +213,12 @@ def clean_text(text: str, method: str = "default", **kwargs) -> str:
|
||||
"""
|
||||
custom_method = method_registry.get("clean", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(text, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, text, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("clean")
|
||||
@@ -262,12 +263,12 @@ def normalize_entity(
|
||||
"""
|
||||
custom_method = method_registry.get("entity", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(entity_name, entity_type, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, entity_name, entity_type, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("entity")
|
||||
@@ -309,12 +310,12 @@ def resolve_aliases(
|
||||
"""
|
||||
custom_method = method_registry.get("entity", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(entity_name, entity_type, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, entity_name, entity_type, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("entity")
|
||||
@@ -356,12 +357,12 @@ def disambiguate_entity(
|
||||
"""
|
||||
custom_method = method_registry.get("entity", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(entity_name, **context)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = context.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, entity_name, fallback_on_custom_error=fallback, **context
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("entity")
|
||||
@@ -411,12 +412,12 @@ def normalize_date(
|
||||
"""
|
||||
custom_method = method_registry.get("date", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(date_input, format, timezone, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, date_input, format, timezone, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("date")
|
||||
@@ -452,12 +453,12 @@ def normalize_time(time_input: Any, method: str = "default", **kwargs) -> str:
|
||||
"""
|
||||
custom_method = method_registry.get("date", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(time_input, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, time_input, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("date")
|
||||
@@ -498,12 +499,12 @@ def normalize_number(
|
||||
"""
|
||||
custom_method = method_registry.get("number", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(number_input, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, number_input, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("number")
|
||||
@@ -542,12 +543,12 @@ def normalize_quantity(
|
||||
"""
|
||||
custom_method = method_registry.get("number", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(quantity_input, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, quantity_input, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("number")
|
||||
@@ -598,14 +599,12 @@ def clean_data(
|
||||
"""
|
||||
custom_method = method_registry.get("clean", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(
|
||||
dataset, remove_duplicates, validate, handle_missing, **kwargs
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, dataset, remove_duplicates, validate, handle_missing, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("clean")
|
||||
@@ -653,12 +652,12 @@ def detect_duplicates(
|
||||
"""
|
||||
custom_method = method_registry.get("clean", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(dataset, threshold, key_fields, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, dataset, threshold, key_fields, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("clean")
|
||||
@@ -700,12 +699,12 @@ def detect_language(
|
||||
"""
|
||||
custom_method = method_registry.get("language", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(text, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, text, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("language")
|
||||
@@ -761,12 +760,12 @@ def handle_encoding(
|
||||
"""
|
||||
custom_method = method_registry.get("encoding", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(data, operation, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, data, operation, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = normalize_config.get_method_config("encoding")
|
||||
|
||||
@@ -203,6 +203,8 @@ class OntologyEngine:
|
||||
include_inherited: bool = True,
|
||||
severity: str = "Violation",
|
||||
quality_tier: str = "standard",
|
||||
target_namespace: Optional[str] = None,
|
||||
attach_domainless_properties: bool = False,
|
||||
validate_output: bool = False,
|
||||
**options,
|
||||
) -> str:
|
||||
@@ -217,6 +219,12 @@ class OntologyEngine:
|
||||
include_inherited: Propagate parent class property shapes to child shapes.
|
||||
severity: Default severity — "Violation", "Warning", or "Info".
|
||||
quality_tier: Constraint completeness — "basic", "standard" (default), "strict".
|
||||
target_namespace: Namespace sh:targetClass and sh:path are expanded in,
|
||||
when the ontology supplies no absolute IRIs. Separate from base_uri,
|
||||
which says where the shape resources themselves live.
|
||||
attach_domainless_properties: Attach properties with no declared domain
|
||||
to every node shape. Off by default: it states a constraint the
|
||||
ontology does not.
|
||||
validate_output: Syntax-check output via rdflib before returning.
|
||||
|
||||
Returns:
|
||||
@@ -236,12 +244,16 @@ class OntologyEngine:
|
||||
or (ns.get("base_uri") if isinstance(ns, dict) else None)
|
||||
or "https://semantica.dev/shapes/"
|
||||
)
|
||||
# These two are constructor-only on SHACLGenerator, so forwarding
|
||||
# them through generate(**options) silently dropped them.
|
||||
generator = SHACLGenerator(
|
||||
base_uri=resolved_base,
|
||||
shapes_uri=shapes_uri,
|
||||
include_inherited=include_inherited,
|
||||
severity=severity,
|
||||
quality_tier=quality_tier,
|
||||
target_namespace=target_namespace,
|
||||
attach_domainless_properties=attach_domainless_properties,
|
||||
)
|
||||
graph = generator.generate(ontology, **options)
|
||||
self.progress.update_tracking(tracking_id, message="Serializing SHACL graph")
|
||||
|
||||
@@ -30,6 +30,7 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field, replace as dataclass_replace
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -781,6 +782,13 @@ class SHACLGraph:
|
||||
shapes_uri: str
|
||||
node_shapes: List[NodeShape] = field(default_factory=list)
|
||||
prefixes: Dict[str, str] = field(default_factory=dict)
|
||||
# Bare names mapped to the absolute IRI the data uses for them. Shapes are
|
||||
# indexed internally by name; this is what those names expand to at
|
||||
# serialisation time (#1104). Classes and properties are kept apart because
|
||||
# a property may legitimately share a class's name, and a single map would
|
||||
# silently give it the class's IRI.
|
||||
class_iris: Dict[str, str] = field(default_factory=dict)
|
||||
property_iris: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
class SHACLGenerator:
|
||||
@@ -813,8 +821,23 @@ class SHACLGenerator:
|
||||
include_inherited: bool = True,
|
||||
severity: str = "Violation",
|
||||
quality_tier: str = "standard",
|
||||
target_namespace: Optional[str] = None,
|
||||
attach_domainless_properties: bool = False,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
base_uri: Namespace the shape resources themselves live in.
|
||||
target_namespace: Namespace the terms being validated live in, used
|
||||
to expand sh:targetClass and sh:path when the ontology does not
|
||||
supply absolute IRIs. This is deliberately separate from
|
||||
base_uri: shapes that target their own namespace match nothing,
|
||||
and pySHACL reports that as conforming (#1104).
|
||||
attach_domainless_properties: When True, a property with no declared
|
||||
domain is attached to every node shape, which is the pre-0.6.6
|
||||
behaviour. It invents a constraint the ontology never stated, so
|
||||
it is off by default (#1105).
|
||||
"""
|
||||
self.logger = get_logger("ontology_shacl")
|
||||
self.progress_tracker = get_progress_tracker()
|
||||
self.base_uri = base_uri.rstrip("/") + "/"
|
||||
@@ -822,6 +845,8 @@ class SHACLGenerator:
|
||||
self.include_inherited = include_inherited
|
||||
self.severity = severity
|
||||
self.quality_tier = quality_tier
|
||||
self.target_namespace = target_namespace
|
||||
self.attach_domainless_properties = attach_domainless_properties
|
||||
self.config = config or {}
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
@@ -859,10 +884,15 @@ class SHACLGenerator:
|
||||
"ex": base_uri,
|
||||
}
|
||||
|
||||
target_ns = self._resolve_target_namespace(ontology, base_uri)
|
||||
prefixes["ex"] = target_ns
|
||||
|
||||
graph = SHACLGraph(
|
||||
base_uri=base_uri,
|
||||
shapes_uri=self.shapes_uri,
|
||||
prefixes=prefixes,
|
||||
class_iris=self._build_term_index(classes, target_ns),
|
||||
property_iris=self._build_term_index(properties, target_ns),
|
||||
)
|
||||
|
||||
self.progress_tracker.update_tracking(tracking_id, message="Generating node shapes")
|
||||
@@ -931,6 +961,80 @@ class SHACLGenerator:
|
||||
|
||||
# ── Internal pipeline stages ──────────────────────────────────────────────
|
||||
|
||||
_DEFAULT_TARGET_NAMESPACE = "https://semantica.dev/ns#"
|
||||
|
||||
@staticmethod
|
||||
def _is_absolute_iri(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(
|
||||
re.match(r"^[A-Za-z][A-Za-z0-9+.\-]*:", value.strip())
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _join_iri(base: str, local: str) -> str:
|
||||
separator = "" if base.endswith(("#", "/", ":")) else "#"
|
||||
return f"{base}{separator}{local}"
|
||||
|
||||
def _resolve_target_namespace(self, ontology: Dict[str, Any], base_uri: str) -> str:
|
||||
"""
|
||||
Decide which namespace sh:targetClass and sh:path are expanded in.
|
||||
|
||||
base_uri says where the shapes live. It is only the right answer here
|
||||
when the ontology declared it, meaning shapes and terms deliberately
|
||||
share a namespace. Falling back to the shapes namespace produces shapes
|
||||
that target terms no data graph uses.
|
||||
"""
|
||||
if self.target_namespace:
|
||||
return self.target_namespace
|
||||
|
||||
namespace = ontology.get("namespace")
|
||||
if isinstance(namespace, dict) and namespace.get("base_uri"):
|
||||
return str(namespace["base_uri"])
|
||||
|
||||
# An IRI already carried by a term is the most reliable evidence of
|
||||
# where the data lives, so prefer it over any configured default.
|
||||
for terms in (ontology.get("classes"), ontology.get("properties")):
|
||||
for term in terms or []:
|
||||
if not isinstance(term, dict):
|
||||
continue
|
||||
for key in ("uri", "iri", "id"):
|
||||
value = term.get(key)
|
||||
if self._is_absolute_iri(value):
|
||||
value = value.strip()
|
||||
cut = max(value.rfind("#"), value.rfind("/"))
|
||||
if cut != -1:
|
||||
return value[: cut + 1]
|
||||
|
||||
if ontology.get("uri") and self._is_absolute_iri(ontology["uri"]):
|
||||
return str(ontology["uri"])
|
||||
|
||||
if base_uri != self.base_uri:
|
||||
return base_uri
|
||||
|
||||
return self._DEFAULT_TARGET_NAMESPACE
|
||||
|
||||
def _build_term_index(
|
||||
self, terms: List[Dict[str, Any]], target_ns: str
|
||||
) -> Dict[str, str]:
|
||||
"""Map each term's name to the absolute IRI it expands to."""
|
||||
index: Dict[str, str] = {}
|
||||
for term in terms or []:
|
||||
if not isinstance(term, dict):
|
||||
continue
|
||||
name = term.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
name = name.strip()
|
||||
|
||||
iri = ""
|
||||
for key in ("uri", "iri", "id"):
|
||||
value = term.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
value = value.strip()
|
||||
iri = value if self._is_absolute_iri(value) else self._join_iri(target_ns, value)
|
||||
break
|
||||
index.setdefault(name, iri or self._join_iri(target_ns, name))
|
||||
return index
|
||||
|
||||
def _build_class_index(
|
||||
self, classes: List[Dict[str, Any]]
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
@@ -979,13 +1083,23 @@ class SHACLGenerator:
|
||||
self.logger.debug(
|
||||
f"Property '{pname}' domain '{d}' has no matching node shape — skipped"
|
||||
)
|
||||
else:
|
||||
# No domain declared → attach to all shapes
|
||||
self.logger.debug(
|
||||
f"Property '{pname}' has no domain — attaching to all node shapes"
|
||||
elif self.attach_domainless_properties:
|
||||
self.logger.warning(
|
||||
f"Property '{pname}' declares no domain and is being attached "
|
||||
"to every node shape because attach_domainless_properties is "
|
||||
"set. This states a constraint the ontology does not."
|
||||
)
|
||||
for node_shape in graph.node_shapes:
|
||||
node_shape.property_shapes.append(self._build_property_shape(prop))
|
||||
else:
|
||||
# Attaching here would state a constraint the ontology does not.
|
||||
# With minCount 1 that invalidates every instance of every
|
||||
# class, so the property is left unattached (#1105).
|
||||
self.logger.warning(
|
||||
f"Property '{pname}' declares no domain, so it is not attached "
|
||||
"to any node shape. Declare a domain, or pass "
|
||||
"attach_domainless_properties=True to restore the old behaviour."
|
||||
)
|
||||
|
||||
def _build_property_shape(self, prop: Dict[str, Any]) -> PropertyShape:
|
||||
ptype = prop.get("type", "")
|
||||
@@ -1065,13 +1179,40 @@ class SHACLGenerator:
|
||||
def _prefix_decls(self, graph: SHACLGraph) -> str:
|
||||
return "\n".join(f"@prefix {p}: <{u}> ." for p, u in sorted(graph.prefixes.items()))
|
||||
|
||||
def _uri(self, graph: SHACLGraph, local: str) -> str:
|
||||
"""Return a compact URI reference; fall back to ex:local for bare names."""
|
||||
def _term_iri(self, graph: SHACLGraph, local: str, kind: str = "class") -> str:
|
||||
"""
|
||||
Resolve a class or property name to the absolute IRI the data uses.
|
||||
|
||||
Every serializer goes through here. Turtle alone was corrected at first,
|
||||
which left JSON-LD and N-Triples still pasting names onto the shapes
|
||||
namespace, so the shapes they produced went on matching nothing (#1104).
|
||||
|
||||
`kind` selects the index: a property may share a class's name, and the
|
||||
two can carry different IRIs.
|
||||
"""
|
||||
if local.startswith("http://") or local.startswith("https://"):
|
||||
return f"<{local}>"
|
||||
return local
|
||||
index = graph.property_iris if kind == "property" else graph.class_iris
|
||||
resolved = index.get(local)
|
||||
if resolved:
|
||||
return resolved
|
||||
# Fall back to the other index before giving up: sh:class names a class,
|
||||
# but a caller may pass a term only registered on the other side.
|
||||
other = graph.class_iris if kind == "property" else graph.property_iris
|
||||
resolved = other.get(local)
|
||||
if resolved:
|
||||
return resolved
|
||||
if ":" in local:
|
||||
return local
|
||||
return f"ex:{local}"
|
||||
separator = "" if graph.base_uri.endswith(("#", "/", ":")) else "#"
|
||||
return f"{graph.base_uri}{separator}{local}"
|
||||
|
||||
def _uri(self, graph: SHACLGraph, local: str, kind: str = "class") -> str:
|
||||
"""Turtle-facing wrapper: the resolved IRI, in angle brackets."""
|
||||
resolved = self._term_iri(graph, local, kind)
|
||||
if resolved.startswith(("http://", "https://", "urn:")):
|
||||
return f"<{resolved}>"
|
||||
return resolved
|
||||
|
||||
def _serialize_turtle(self, graph: SHACLGraph) -> str:
|
||||
lines = [self._prefix_decls(graph), ""]
|
||||
@@ -1098,11 +1239,11 @@ class SHACLGenerator:
|
||||
is_last = i == len(node_shape.property_shapes) - 1
|
||||
terminator = " ." if is_last else " ;"
|
||||
parts = [" sh:property ["]
|
||||
parts.append(f" sh:path {self._uri(graph, ps.path)} ;")
|
||||
parts.append(f' sh:path {self._uri(graph, ps.path, "property")} ;')
|
||||
if ps.datatype:
|
||||
parts.append(f" sh:datatype {ps.datatype} ;")
|
||||
if ps.class_:
|
||||
parts.append(f" sh:class {self._uri(graph, ps.class_)} ;")
|
||||
parts.append(f' sh:class {self._uri(graph, ps.class_, "class")} ;')
|
||||
if ps.min_count is not None:
|
||||
parts.append(f" sh:minCount {ps.min_count} ;")
|
||||
if ps.max_count is not None:
|
||||
@@ -1143,7 +1284,7 @@ class SHACLGenerator:
|
||||
node: Dict[str, Any] = {
|
||||
"@id": shape_id,
|
||||
"@type": "sh:NodeShape",
|
||||
"sh:targetClass": {"@id": f"{graph.base_uri}{node_shape.target_class}"},
|
||||
"sh:targetClass": {"@id": self._term_iri(graph, node_shape.target_class, "class")},
|
||||
}
|
||||
if node_shape.name:
|
||||
node["sh:name"] = node_shape.name
|
||||
@@ -1156,7 +1297,7 @@ class SHACLGenerator:
|
||||
props = []
|
||||
for ps in node_shape.property_shapes:
|
||||
p: Dict[str, Any] = {
|
||||
"sh:path": {"@id": f"{graph.base_uri}{ps.path}"}
|
||||
"sh:path": {"@id": self._term_iri(graph, ps.path, "property")}
|
||||
}
|
||||
if ps.datatype:
|
||||
dt = ps.datatype.replace(
|
||||
@@ -1164,7 +1305,7 @@ class SHACLGenerator:
|
||||
)
|
||||
p["sh:datatype"] = {"@id": dt}
|
||||
if ps.class_:
|
||||
p["sh:class"] = {"@id": f"{graph.base_uri}{ps.class_}"}
|
||||
p["sh:class"] = {"@id": self._term_iri(graph, ps.class_, "class")}
|
||||
if ps.min_count is not None:
|
||||
p["sh:minCount"] = ps.min_count
|
||||
if ps.max_count is not None:
|
||||
@@ -1197,7 +1338,7 @@ class SHACLGenerator:
|
||||
|
||||
for i, node_shape in enumerate(graph.node_shapes):
|
||||
shape_uri = f"<{graph.base_uri}{node_shape.target_class}Shape>"
|
||||
class_uri = f"<{graph.base_uri}{node_shape.target_class}>"
|
||||
class_uri = f'<{self._term_iri(graph, node_shape.target_class, "class")}>'
|
||||
t(shape_uri, f"<{RDF}type>", f"<{SHACL}NodeShape>")
|
||||
t(shape_uri, f"<{SHACL}targetClass>", class_uri)
|
||||
if node_shape.name:
|
||||
@@ -1212,13 +1353,13 @@ class SHACLGenerator:
|
||||
for j, ps in enumerate(node_shape.property_shapes):
|
||||
bnode = f"_:ps{i}_{j}"
|
||||
t(shape_uri, f"<{SHACL}property>", bnode)
|
||||
prop_uri = f"<{graph.base_uri}{ps.path}>"
|
||||
prop_uri = f'<{self._term_iri(graph, ps.path, "property")}>'
|
||||
t(bnode, f"<{SHACL}path>", prop_uri)
|
||||
if ps.datatype:
|
||||
dt_uri = ps.datatype.replace("xsd:", XSD)
|
||||
t(bnode, f"<{SHACL}datatype>", f"<{dt_uri}>")
|
||||
if ps.class_:
|
||||
t(bnode, f"<{SHACL}class>", f"<{graph.base_uri}{ps.class_}>")
|
||||
t(bnode, f"<{SHACL}class>", f'<{self._term_iri(graph, ps.class_, "class")}>')
|
||||
if ps.min_count is not None:
|
||||
t(bnode, f"<{SHACL}minCount>", f'"{ps.min_count}"^^<{XSD}integer>')
|
||||
if ps.max_count is not None:
|
||||
|
||||
@@ -53,16 +53,17 @@ this IRI.""" ;
|
||||
|
||||
sem:confidence a owl:DatatypeProperty ;
|
||||
rdfs:label "confidence" ;
|
||||
rdfs:range xsd:decimal ;
|
||||
rdfs:comment """Extractor confidence in the assertion, on the unit interval.
|
||||
Emitted for both entities and relationships, so the domain is left open rather
|
||||
than tied to sem:Entity.
|
||||
|
||||
No rdfs:range is declared, deliberately. The Turtle serializer writes the value
|
||||
bare, which the Turtle grammar reads as xsd:decimal, while the N-Triples
|
||||
serializer types it xsd:float explicitly, and those two datatypes are disjoint.
|
||||
Declaring either one would make the vocabulary contradict one of the exporters.
|
||||
Issue #1100 tracks the disagreement; a range belongs here once the serializers
|
||||
agree on one.""" ;
|
||||
The range was left undeclared when this vocabulary first shipped, because the
|
||||
four serializers disagreed: Turtle wrote the value bare, which the Turtle
|
||||
grammar reads as xsd:decimal, N-Triples typed it xsd:float, RDF/XML wrote a
|
||||
plain literal, and JSON-LD wrote a native number, which expands to xsd:double.
|
||||
Declaring any one of them would have contradicted three exporters. Issue #1100
|
||||
settled that on xsd:decimal, which every serializer now writes.""" ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
sem:metadata a owl:AnnotationProperty ;
|
||||
|
||||
+73
-72
@@ -125,6 +125,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from ..utils.exceptions import ConfigurationError, ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.custom_methods import CUSTOM_METHOD_FELL_BACK, call_custom_method
|
||||
from .code_parser import CodeParser
|
||||
from .config import parse_config
|
||||
from .csv_parser import CSVParser
|
||||
@@ -178,12 +179,12 @@ def parse_document(
|
||||
"""
|
||||
custom_method = method_registry.get("document", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(file_path, file_type, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, file_type, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("document")
|
||||
@@ -289,12 +290,12 @@ def parse_web_content(
|
||||
"""
|
||||
custom_method = method_registry.get("web", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(content, content_type, base_url, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, content, content_type, base_url, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("web")
|
||||
@@ -345,12 +346,12 @@ def parse_structured_data(
|
||||
"""
|
||||
custom_method = method_registry.get("structured", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(data, data_format, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, data, data_format, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("structured")
|
||||
@@ -392,12 +393,12 @@ def parse_email(
|
||||
"""
|
||||
custom_method = method_registry.get("email", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(email_content, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, email_content, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("email")
|
||||
@@ -442,12 +443,12 @@ def parse_code(
|
||||
"""
|
||||
custom_method = method_registry.get("code", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(file_path, language, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, language, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("code")
|
||||
@@ -495,12 +496,12 @@ def parse_media(
|
||||
"""
|
||||
custom_method = method_registry.get("media", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(file_path, media_type, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, media_type, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("media")
|
||||
@@ -541,12 +542,12 @@ def parse_pdf(
|
||||
"""
|
||||
custom_method = method_registry.get("document", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(file_path, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("document")
|
||||
@@ -585,12 +586,12 @@ def parse_docx(
|
||||
"""
|
||||
custom_method = method_registry.get("document", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(file_path, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("document")
|
||||
@@ -628,12 +629,12 @@ def parse_json(file_path: Union[str, Path], method: str = "default", **kwargs) -
|
||||
"""
|
||||
custom_method = method_registry.get("structured", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(file_path, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("structured")
|
||||
@@ -675,12 +676,12 @@ def parse_csv(
|
||||
"""
|
||||
custom_method = method_registry.get("structured", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(file_path, delimiter, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, delimiter, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("structured")
|
||||
@@ -714,12 +715,12 @@ def parse_xml(file_path: Union[str, Path], method: str = "default", **kwargs) ->
|
||||
"""
|
||||
custom_method = method_registry.get("structured", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(file_path, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("structured")
|
||||
@@ -762,12 +763,12 @@ def parse_image(
|
||||
"""
|
||||
custom_method = method_registry.get("media", method)
|
||||
if custom_method:
|
||||
try:
|
||||
return custom_method(file_path, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
config = parse_config.get_method_config("media")
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Invocation policy for methods registered through a MethodRegistry.
|
||||
|
||||
Every module that supports custom methods used to wrap the registered callable
|
||||
in a bare `except Exception`, log a warning, and carry on into the built-in
|
||||
implementation. That makes a registered method advisory: it can add behaviour,
|
||||
but it cannot decline.
|
||||
|
||||
For a gate, a validator or a policy check that is the whole point. Raising is
|
||||
how such a method says "do not produce this output". Catching the exception and
|
||||
running the default produces exactly the output the caller registered the method
|
||||
to prevent, and the only trace is a warning (issue #1108).
|
||||
|
||||
Exceptions from a registered method therefore propagate by default. Callers who
|
||||
relied on the old behaviour can pass `fallback_on_custom_error=True`, which
|
||||
restores the warn-and-continue path for that call.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class _FellBack:
|
||||
"""Sentinel: the custom method failed and the caller should use the default."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return "CUSTOM_METHOD_FELL_BACK"
|
||||
|
||||
|
||||
#: Returned by :func:`call_custom_method` when a custom method raised and
|
||||
#: ``fallback_on_custom_error=True`` was passed. Compare with ``is``.
|
||||
CUSTOM_METHOD_FELL_BACK = _FellBack()
|
||||
|
||||
|
||||
def call_custom_method(
|
||||
logger: Any,
|
||||
method: Any,
|
||||
custom_method: Callable[..., Any],
|
||||
/,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Invoke a registered custom method.
|
||||
|
||||
Args:
|
||||
logger: Module logger, used only on the opt-in fallback path.
|
||||
method: The registered name, for the warning message.
|
||||
custom_method: The registered callable.
|
||||
*args: Positional arguments for the custom method.
|
||||
**kwargs: Keyword arguments for the custom method. The reserved key
|
||||
``fallback_on_custom_error`` is consumed here and never forwarded.
|
||||
|
||||
Returns:
|
||||
Whatever the custom method returns, or :data:`CUSTOM_METHOD_FELL_BACK`
|
||||
when it raised and the caller opted into falling back.
|
||||
|
||||
Raises:
|
||||
Exception: Whatever the custom method raised, unless the caller passed
|
||||
``fallback_on_custom_error=True``.
|
||||
"""
|
||||
fallback = bool(kwargs.pop("fallback_on_custom_error", False))
|
||||
|
||||
if not fallback:
|
||||
return custom_method(*args, **kwargs)
|
||||
|
||||
try:
|
||||
return custom_method(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {exc}, falling back to default "
|
||||
"because fallback_on_custom_error was set"
|
||||
)
|
||||
return CUSTOM_METHOD_FELL_BACK
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Regression tests for #1100 and #1102.
|
||||
|
||||
#1100: the four RDF serializers rendered the same confidence value four
|
||||
different ways. Turtle wrote it bare, which the Turtle grammar reads as
|
||||
xsd:decimal. N-Triples typed it xsd:float. RDF/XML wrote a plain literal with
|
||||
no datatype at all. JSON-LD emitted a native JSON number, which becomes
|
||||
xsd:double. Those are four distinct RDF terms, so a FILTER matches at most one
|
||||
of them and merging two exports of one graph yields two confidence values for
|
||||
the same entity.
|
||||
|
||||
#1102: the Turtle path interpolated the value with no type check, so a
|
||||
non-numeric confidence produced `semantica:confidence high .`, which is not
|
||||
parseable Turtle. One bad field made the whole export unreadable.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
rdflib = pytest.importorskip("rdflib")
|
||||
from rdflib import Graph, URIRef # noqa: E402
|
||||
from rdflib.compare import isomorphic # noqa: E402
|
||||
|
||||
from semantica.export.rdf_exporter import RDFSerializer # noqa: E402
|
||||
|
||||
NS = "https://semantica.dev/ns#"
|
||||
CONFIDENCE = URIRef(NS + "confidence")
|
||||
XSD_DECIMAL = URIRef("http://www.w3.org/2001/XMLSchema#decimal")
|
||||
|
||||
|
||||
def _kg(confidence):
|
||||
entity = {"id": NS + "e1", "text": "Acme", "type": NS + "ORG"}
|
||||
if confidence is not _ABSENT:
|
||||
entity["confidence"] = confidence
|
||||
return {"entities": [entity], "relationships": []}
|
||||
|
||||
|
||||
_ABSENT = object()
|
||||
|
||||
|
||||
def _graphs(kg):
|
||||
"""Parse every serialization of one KG into a graph, keyed by format."""
|
||||
serializer = RDFSerializer()
|
||||
out = {}
|
||||
|
||||
out["turtle"] = Graph()
|
||||
out["turtle"].parse(data=serializer.serialize_to_turtle(json.loads(json.dumps(kg))),
|
||||
format="turtle")
|
||||
|
||||
out["ntriples"] = Graph()
|
||||
out["ntriples"].parse(data=serializer.serialize_to_ntriples(json.loads(json.dumps(kg))),
|
||||
format="nt")
|
||||
|
||||
out["rdfxml"] = Graph()
|
||||
out["rdfxml"].parse(data=serializer.serialize_to_rdfxml(json.loads(json.dumps(kg))),
|
||||
format="xml")
|
||||
|
||||
jsonld = serializer.serialize_to_jsonld(json.loads(json.dumps(kg)))
|
||||
out["jsonld"] = Graph()
|
||||
out["jsonld"].parse(
|
||||
data=jsonld if isinstance(jsonld, str) else json.dumps(jsonld), format="json-ld"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _confidence_terms(graph):
|
||||
return [o for _, p, o in graph if p == CONFIDENCE]
|
||||
|
||||
|
||||
def test_every_serializer_agrees_on_the_confidence_term():
|
||||
"""The heart of #1100. One value, one RDF term, whatever the format."""
|
||||
terms = {}
|
||||
for name, graph in _graphs(_kg(0.9)).items():
|
||||
values = _confidence_terms(graph)
|
||||
assert len(values) == 1, f"{name} emitted {len(values)} confidence triples"
|
||||
terms[name] = values[0]
|
||||
|
||||
distinct = set(terms.values())
|
||||
assert len(distinct) == 1, (
|
||||
"the same confidence serialised as different RDF terms: "
|
||||
+ ", ".join(f"{k}={v!r} ({v.datatype})" for k, v in terms.items())
|
||||
)
|
||||
|
||||
|
||||
def test_the_agreed_term_is_a_typed_decimal():
|
||||
for name, graph in _graphs(_kg(0.9)).items():
|
||||
term = _confidence_terms(graph)[0]
|
||||
assert term.datatype == XSD_DECIMAL, f"{name} typed it {term.datatype}"
|
||||
assert str(term) == "0.9", f"{name} wrote the lexical form {str(term)!r}"
|
||||
|
||||
|
||||
def test_turtle_and_ntriples_are_the_same_graph():
|
||||
"""#1100 as filed: two serializations of one KG must not be two graphs."""
|
||||
graphs = _graphs(_kg(0.9))
|
||||
assert isomorphic(graphs["turtle"], graphs["ntriples"]), (
|
||||
"Turtle only:\n"
|
||||
+ "\n".join(str(t) for t in set(graphs["turtle"]) - set(graphs["ntriples"]))
|
||||
+ "\nN-Triples only:\n"
|
||||
+ "\n".join(str(t) for t in set(graphs["ntriples"]) - set(graphs["turtle"]))
|
||||
)
|
||||
|
||||
|
||||
def test_a_numeric_string_is_accepted():
|
||||
for name, graph in _graphs(_kg("0.85")).items():
|
||||
terms = _confidence_terms(graph)
|
||||
assert terms, f"{name} dropped a usable numeric string"
|
||||
assert terms[0].datatype == XSD_DECIMAL
|
||||
assert str(terms[0]) == "0.85"
|
||||
|
||||
|
||||
def test_an_integer_confidence_is_accepted():
|
||||
for name, graph in _graphs(_kg(1)).items():
|
||||
terms = _confidence_terms(graph)
|
||||
assert terms, f"{name} dropped an integer confidence"
|
||||
assert terms[0].datatype == XSD_DECIMAL
|
||||
|
||||
|
||||
def test_a_small_value_is_not_written_in_exponent_notation():
|
||||
"""1e-05 is a valid Python repr and an invalid xsd:decimal lexical form."""
|
||||
for name, graph in _graphs(_kg(0.00001)).items():
|
||||
term = _confidence_terms(graph)[0]
|
||||
assert "e" not in str(term).lower(), f"{name} wrote {str(term)!r}"
|
||||
assert term.value is not None, f"{name} produced an ill-typed literal"
|
||||
|
||||
|
||||
# ── #1102 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad", ["high", "", "not a number", None, True, False, [0.9], {"v": 0.9},
|
||||
float("nan"), float("inf")],
|
||||
)
|
||||
def test_an_unusable_confidence_never_breaks_the_export(bad):
|
||||
"""`semantica:confidence high .` made the whole Turtle document unparseable."""
|
||||
serializer = RDFSerializer()
|
||||
kg = _kg(bad)
|
||||
|
||||
turtle = serializer.serialize_to_turtle(json.loads(json.dumps(kg, default=str)))
|
||||
graph = Graph()
|
||||
graph.parse(data=turtle, format="turtle") # must not raise
|
||||
|
||||
assert _confidence_terms(graph) == [], (
|
||||
f"{bad!r} was emitted as a confidence value: {_confidence_terms(graph)}"
|
||||
)
|
||||
# The rest of the entity must survive.
|
||||
assert (URIRef(NS + "e1"), URIRef(NS + "text"), rdflib.Literal("Acme")) in graph
|
||||
|
||||
|
||||
def test_an_unusable_confidence_is_dropped_consistently_everywhere():
|
||||
for name, graph in _graphs(_kg("high")).items():
|
||||
assert _confidence_terms(graph) == [], f"{name} still emitted it"
|
||||
|
||||
|
||||
def test_all_serializers_stay_parseable_with_an_unusable_confidence():
|
||||
graphs = _graphs(_kg("high")) # each parse would raise on malformed output
|
||||
assert isomorphic(graphs["turtle"], graphs["ntriples"])
|
||||
|
||||
|
||||
def test_the_emitted_datatype_matches_the_shipped_vocabulary():
|
||||
"""
|
||||
Drift guard. The vocabulary shipped with no rdfs:range on sem:confidence
|
||||
precisely because the serializers disagreed. Now that they agree, the range
|
||||
is declared, and the two must not drift apart again.
|
||||
"""
|
||||
from rdflib import RDFS
|
||||
|
||||
from semantica.export.rdf_exporter import CONFIDENCE_DATATYPE
|
||||
from semantica.ontology.vocabulary import vocabulary_turtle
|
||||
|
||||
vocabulary = Graph()
|
||||
vocabulary.parse(data=vocabulary_turtle(), format="turtle")
|
||||
|
||||
declared = list(vocabulary.objects(URIRef(NS + "confidence"), RDFS.range))
|
||||
assert declared, "sem:confidence declares no rdfs:range"
|
||||
assert str(declared[0]) == CONFIDENCE_DATATYPE, (
|
||||
f"vocabulary says {declared[0]}, serializers write {CONFIDENCE_DATATYPE}"
|
||||
)
|
||||
|
||||
|
||||
# ── Review findings on the first revision of this fix ────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("huge", ["1e100000000", "1E1000000", "-1e999999", 10**400])
|
||||
def test_an_absurd_magnitude_is_rejected_not_expanded(huge):
|
||||
"""
|
||||
xsd:decimal has no exponent notation, so the value has to be written out.
|
||||
"1e100000000" is eleven characters that expand to a hundred million digits,
|
||||
and the export path continues past validation errors.
|
||||
"""
|
||||
from semantica.export.rdf_exporter import normalize_confidence
|
||||
|
||||
assert normalize_confidence(huge) is None
|
||||
|
||||
|
||||
def test_a_legitimately_small_confidence_is_still_accepted():
|
||||
from semantica.export.rdf_exporter import normalize_confidence
|
||||
|
||||
assert normalize_confidence(1e-9) == "0.000000001"
|
||||
|
||||
|
||||
def test_signed_zero_is_normalized():
|
||||
"""0.0 and -0.0 would otherwise be two distinct RDF terms."""
|
||||
from semantica.export.rdf_exporter import normalize_confidence
|
||||
|
||||
assert normalize_confidence(0.0) == normalize_confidence(-0.0) == "0"
|
||||
|
||||
|
||||
def test_signed_zero_gives_one_term_across_serializers():
|
||||
positive = {n: _confidence_terms(g)[0] for n, g in _graphs(_kg(0.0)).items()}
|
||||
negative = {n: _confidence_terms(g)[0] for n, g in _graphs(_kg(-0.0)).items()}
|
||||
|
||||
assert set(positive.values()) | set(negative.values()) == set(positive.values())
|
||||
assert len(set(positive.values())) == 1
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Regression test for #1106.
|
||||
|
||||
`include_temporal=True` emitted a well formed OWL-Time interval hanging off a
|
||||
relationship IRI that appears nowhere else in the graph. The relationship
|
||||
itself is written as a single triple, `<e1> <employs> <e2>`, so there is no
|
||||
node to carry the time and no path from the edge to its validity interval.
|
||||
The timestamps parsed, validated and meant nothing: no query could reach them
|
||||
from the relationship they describe.
|
||||
|
||||
The JSON-LD path already reifies relationships as sem:Relationship with
|
||||
sem:source, sem:target and sem:type, and the shipped vocabulary declares all
|
||||
four terms. Turtle now emits the same shape when it has temporal data to
|
||||
attach, so the interval hangs off a node the graph can actually reach.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
rdflib = pytest.importorskip("rdflib")
|
||||
from rdflib import Graph, RDF, URIRef # noqa: E402
|
||||
|
||||
from semantica.export.rdf_exporter import RDFSerializer # noqa: E402
|
||||
|
||||
NS = "https://semantica.dev/ns#"
|
||||
TIME = "http://www.w3.org/2006/time#"
|
||||
|
||||
E1, E2 = NS + "e1", NS + "e2"
|
||||
EMPLOYS = NS + "employs"
|
||||
|
||||
KG = {
|
||||
"entities": [
|
||||
{"id": E1, "text": "Acme", "type": NS + "ORG"},
|
||||
{"id": E2, "text": "Globex", "type": NS + "ORG"},
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"source_id": E1,
|
||||
"target_id": E2,
|
||||
"type": EMPLOYS,
|
||||
"valid_from": "2024-01-01T00:00:00Z",
|
||||
"valid_until": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _graph(**options):
|
||||
turtle = RDFSerializer().serialize_to_turtle(
|
||||
{k: [dict(v) for v in vs] for k, vs in KG.items()}, **options
|
||||
)
|
||||
graph = Graph()
|
||||
graph.parse(data=turtle, format="turtle")
|
||||
return graph
|
||||
|
||||
|
||||
def test_the_interval_holder_is_reachable_from_the_graph():
|
||||
"""The heart of #1106: the node carrying time had no inbound arc at all."""
|
||||
graph = _graph(include_temporal=True)
|
||||
holders = [s for s, p, _ in graph if str(p) == TIME + "hasTime"]
|
||||
assert holders, "no OWL-Time interval was emitted"
|
||||
|
||||
for holder in holders:
|
||||
inbound = [(s, p) for s, p, o in graph if o == holder]
|
||||
outbound = [(p, o) for s, p, o in graph if s == holder and str(p) != TIME + "hasTime"]
|
||||
assert inbound or outbound, (
|
||||
f"{holder} carries an interval but nothing else in the graph mentions it"
|
||||
)
|
||||
|
||||
|
||||
def test_the_relationship_is_reified_so_time_has_a_subject():
|
||||
graph = _graph(include_temporal=True)
|
||||
relationships = list(graph.subjects(RDF.type, URIRef(NS + "Relationship")))
|
||||
assert len(relationships) == 1, f"expected one reified relationship, got {relationships}"
|
||||
|
||||
node = relationships[0]
|
||||
assert (node, URIRef(NS + "source"), URIRef(E1)) in graph
|
||||
assert (node, URIRef(NS + "target"), URIRef(E2)) in graph
|
||||
assert list(graph.objects(node, URIRef(TIME + "hasTime"))), (
|
||||
"the reified relationship does not carry the interval"
|
||||
)
|
||||
|
||||
|
||||
def test_a_query_can_walk_from_the_edge_to_its_interval():
|
||||
"""What the dangling node made impossible."""
|
||||
graph = _graph(include_temporal=True)
|
||||
rows = list(graph.query(
|
||||
"""
|
||||
PREFIX sem: <https://semantica.dev/ns#>
|
||||
PREFIX time: <http://www.w3.org/2006/time#>
|
||||
SELECT ?begin WHERE {
|
||||
?s ?p ?o .
|
||||
?rel sem:source ?s ;
|
||||
sem:target ?o ;
|
||||
time:hasTime/time:hasBeginning/time:inXSDDateTimeStamp ?begin .
|
||||
}
|
||||
"""
|
||||
))
|
||||
assert rows, "no path from the relationship to its validity interval"
|
||||
assert str(rows[0][0]) == "2024-01-01T00:00:00Z"
|
||||
|
||||
|
||||
def test_the_direct_triple_is_still_written():
|
||||
"""Reification is added alongside the edge, it does not replace it."""
|
||||
graph = _graph(include_temporal=True)
|
||||
assert (URIRef(E1), URIRef(EMPLOYS), URIRef(E2)) in graph
|
||||
|
||||
|
||||
def test_default_output_is_unchanged():
|
||||
"""Nothing is reified when there is no temporal data to attach."""
|
||||
graph = _graph()
|
||||
assert list(graph.subjects(RDF.type, URIRef(NS + "Relationship"))) == []
|
||||
assert (URIRef(E1), URIRef(EMPLOYS), URIRef(E2)) in graph
|
||||
|
||||
|
||||
def test_a_relationship_without_temporal_data_is_not_reified():
|
||||
kg = {
|
||||
"entities": KG["entities"],
|
||||
"relationships": [
|
||||
{"source_id": E1, "target_id": E2, "type": EMPLOYS},
|
||||
dict(KG["relationships"][0]),
|
||||
],
|
||||
}
|
||||
turtle = RDFSerializer().serialize_to_turtle(kg, include_temporal=True)
|
||||
graph = Graph()
|
||||
graph.parse(data=turtle, format="turtle")
|
||||
|
||||
assert len(list(graph.subjects(RDF.type, URIRef(NS + "Relationship")))) == 1
|
||||
|
||||
|
||||
def test_the_reified_terms_are_declared_in_the_shipped_vocabulary():
|
||||
"""A reification nobody declared would just move the problem."""
|
||||
from semantica.ontology.vocabulary import vocabulary_turtle
|
||||
|
||||
vocabulary = Graph()
|
||||
vocabulary.parse(data=vocabulary_turtle(), format="turtle")
|
||||
declared = {str(s) for s in vocabulary.subjects()}
|
||||
|
||||
graph = _graph(include_temporal=True)
|
||||
node = next(iter(graph.subjects(RDF.type, URIRef(NS + "Relationship"))))
|
||||
emitted = {str(p) for p in graph.predicates(node, None)} | {NS + "Relationship"}
|
||||
|
||||
undeclared = {t for t in emitted if t.startswith(NS) and t not in declared}
|
||||
assert not undeclared, f"emitted but not declared in the vocabulary: {undeclared}"
|
||||
|
||||
|
||||
# ── Review finding on the first revision of this fix ─────────────────────────
|
||||
|
||||
def test_the_reified_type_keeps_the_full_predicate():
|
||||
"""
|
||||
Truncating to the local name made two predicates from different namespaces
|
||||
indistinguishable, and disagreed with the direct triple beside it.
|
||||
"""
|
||||
from rdflib import Literal
|
||||
|
||||
def reified_type(rel_type):
|
||||
kg = {
|
||||
"entities": KG["entities"],
|
||||
"relationships": [
|
||||
{
|
||||
"source_id": E1,
|
||||
"target_id": E2,
|
||||
"type": rel_type,
|
||||
"valid_from": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
],
|
||||
}
|
||||
graph = Graph()
|
||||
graph.parse(
|
||||
data=RDFSerializer().serialize_to_turtle(kg, include_temporal=True),
|
||||
format="turtle",
|
||||
)
|
||||
node = next(iter(graph.subjects(RDF.type, URIRef(NS + "Relationship"))))
|
||||
return set(graph.objects(node, URIRef(NS + "type")))
|
||||
|
||||
a = reified_type("https://a.example/ns#employs")
|
||||
b = reified_type("https://b.example/ns#employs")
|
||||
|
||||
assert a == {Literal("https://a.example/ns#employs")}, a
|
||||
assert a != b, "two distinct predicates produced the same reified type"
|
||||
|
||||
|
||||
def test_the_reified_type_matches_the_direct_triples_predicate():
|
||||
from rdflib import Literal
|
||||
|
||||
graph = _graph(include_temporal=True)
|
||||
node = next(iter(graph.subjects(RDF.type, URIRef(NS + "Relationship"))))
|
||||
|
||||
assert set(graph.objects(node, URIRef(NS + "type"))) == {Literal(EMPLOYS)}
|
||||
assert (URIRef(E1), URIRef(EMPLOYS), URIRef(E2)) in graph
|
||||
@@ -293,20 +293,33 @@ class TestSHACLHierarchicalAndValidation(unittest.TestCase):
|
||||
f"Duplicate paths in {node_shape.target_class}: {paths}")
|
||||
|
||||
# 21
|
||||
def test_no_domain_property_attaches_to_all_shapes(self):
|
||||
onto = {
|
||||
"classes": [{"name": "A"}, {"name": "B"}],
|
||||
"properties": [
|
||||
{"name": "globalProp", "type": "datatype", "range": "string"}
|
||||
# no domain
|
||||
],
|
||||
}
|
||||
gen = self._make_gen()
|
||||
graph = gen.generate(onto)
|
||||
_NO_DOMAIN_ONTOLOGY = {
|
||||
"classes": [{"name": "A"}, {"name": "B"}],
|
||||
"properties": [
|
||||
{"name": "globalProp", "type": "datatype", "range": "string"}
|
||||
# no domain
|
||||
],
|
||||
}
|
||||
|
||||
def test_no_domain_property_attaches_to_all_shapes_when_opted_in(self):
|
||||
"""attach_domainless_properties=True keeps the pre-0.6.6 behaviour."""
|
||||
gen = self._make_gen(attach_domainless_properties=True)
|
||||
graph = gen.generate(self._NO_DOMAIN_ONTOLOGY)
|
||||
for node_shape in graph.node_shapes:
|
||||
paths = {ps.path for ps in node_shape.property_shapes}
|
||||
self.assertIn("globalProp", paths)
|
||||
|
||||
def test_no_domain_property_is_not_attached_by_default(self):
|
||||
"""
|
||||
Attaching states a constraint the ontology does not (#1105). This test
|
||||
previously asserted the opposite, which pinned the defect in place.
|
||||
"""
|
||||
gen = self._make_gen()
|
||||
graph = gen.generate(self._NO_DOMAIN_ONTOLOGY)
|
||||
for node_shape in graph.node_shapes:
|
||||
paths = {ps.path for ps in node_shape.property_shapes}
|
||||
self.assertNotIn("globalProp", paths)
|
||||
|
||||
# 22
|
||||
def test_empty_classes_produces_no_shapes(self):
|
||||
gen = self._make_gen()
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Regression tests for #1104 and #1105.
|
||||
|
||||
#1104: SHACLGenerator used one namespace for two different jobs. `base_uri`
|
||||
names where the shape resources live, and it was also used to expand every
|
||||
sh:targetClass and sh:path. With the default `https://semantica.dev/shapes/`
|
||||
that made shapes target `.../shapes/Person`, while data carries
|
||||
`.../ns#Person` or the ontology's own class IRI. The shapes matched nothing.
|
||||
pySHACL then reported conforms=True, because a shape with no focus nodes is
|
||||
vacuously satisfied, so the mismatch was invisible to the validator the
|
||||
package ships with.
|
||||
|
||||
#1105: a property with no declared domain was attached to every node shape,
|
||||
which invents a constraint the ontology never stated. With minCount 1 that
|
||||
makes every instance of every class invalid.
|
||||
|
||||
These tests validate real data through pySHACL rather than reading the shapes
|
||||
text, so a shape that targets nothing cannot pass by being ignored.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
rdflib = pytest.importorskip("rdflib")
|
||||
pyshacl = pytest.importorskip("pyshacl")
|
||||
|
||||
from rdflib import Graph, RDF, Namespace # noqa: E402
|
||||
|
||||
from semantica.ontology.ontology_generator import SHACLGenerator # noqa: E402
|
||||
|
||||
SH = Namespace("http://www.w3.org/ns/shacl#")
|
||||
ONTOLOGY_NS = "https://example.org/onto#"
|
||||
SHAPES_NS = "https://semantica.dev/shapes/"
|
||||
|
||||
|
||||
def _ontology(*, declare_namespace: bool, carry_class_uris: bool):
|
||||
"""
|
||||
Build the same ontology in the shapes the generator can hand over.
|
||||
|
||||
A generated ontology carries class URIs; a hand written one often carries
|
||||
only names, and only sometimes declares a namespace. All of them have to
|
||||
produce shapes that match the data.
|
||||
"""
|
||||
def class_def(name):
|
||||
entry = {"name": name, "label": name}
|
||||
if carry_class_uris:
|
||||
entry["uri"] = ONTOLOGY_NS + name
|
||||
return entry
|
||||
|
||||
def prop_def(name, **extra):
|
||||
entry = {"name": name, "type": "datatype", "range": "string", **extra}
|
||||
if carry_class_uris:
|
||||
entry["uri"] = ONTOLOGY_NS + name
|
||||
return entry
|
||||
|
||||
ontology = {
|
||||
"classes": [class_def("Person"), class_def("Organization")],
|
||||
"properties": [
|
||||
prop_def("fullName", domain="Person", required=True),
|
||||
# No domain. The ontology never says which class this belongs to.
|
||||
prop_def("sourceDocument", required=True),
|
||||
],
|
||||
}
|
||||
if declare_namespace:
|
||||
ontology["namespace"] = {"base_uri": ONTOLOGY_NS}
|
||||
return ontology
|
||||
|
||||
|
||||
SHAPES = [
|
||||
pytest.param(True, True, id="namespace+uris"),
|
||||
pytest.param(True, False, id="namespace-only"),
|
||||
pytest.param(False, True, id="uris-only"),
|
||||
]
|
||||
|
||||
# A Person with no fullName. This violates the shape the ontology does state.
|
||||
VIOLATING_DATA = f"""
|
||||
@prefix ex: <{ONTOLOGY_NS}> .
|
||||
ex:alice a ex:Person .
|
||||
"""
|
||||
|
||||
# A Person that satisfies every constraint the ontology actually declares.
|
||||
CONFORMING_DATA = f"""
|
||||
@prefix ex: <{ONTOLOGY_NS}> .
|
||||
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
||||
ex:bob a ex:Person ;
|
||||
ex:fullName "Bob Smith"^^xsd:string .
|
||||
"""
|
||||
|
||||
|
||||
def _shapes_graph(ontology, **kwargs):
|
||||
generator = SHACLGenerator(**kwargs)
|
||||
graph = generator.generate(ontology)
|
||||
shapes = Graph()
|
||||
shapes.parse(data=generator.serialize(graph, "turtle"), format="turtle")
|
||||
return shapes
|
||||
|
||||
|
||||
def _validate(data_ttl, shapes_graph):
|
||||
data = Graph()
|
||||
data.parse(data=data_ttl, format="turtle")
|
||||
conforms, _, text = pyshacl.validate(
|
||||
data, shacl_graph=shapes_graph, inference="none", advanced=True
|
||||
)
|
||||
return conforms, text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("declare_namespace,carry_class_uris", SHAPES)
|
||||
def test_target_class_names_a_class_the_data_can_instantiate(declare_namespace, carry_class_uris):
|
||||
shapes = _shapes_graph(_ontology(
|
||||
declare_namespace=declare_namespace, carry_class_uris=carry_class_uris
|
||||
))
|
||||
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
|
||||
|
||||
assert targets == {ONTOLOGY_NS + "Person", ONTOLOGY_NS + "Organization"}, targets
|
||||
assert not any(t.startswith(SHAPES_NS) for t in targets), (
|
||||
f"shapes still target their own shapes namespace: {targets}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("declare_namespace,carry_class_uris", SHAPES)
|
||||
def test_property_path_names_a_predicate_the_data_uses(declare_namespace, carry_class_uris):
|
||||
shapes = _shapes_graph(_ontology(
|
||||
declare_namespace=declare_namespace, carry_class_uris=carry_class_uris
|
||||
))
|
||||
paths = {str(o) for o in shapes.objects(None, SH.path)}
|
||||
|
||||
assert paths, "no sh:path emitted at all"
|
||||
for path in paths:
|
||||
assert path.startswith(ONTOLOGY_NS), path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("declare_namespace,carry_class_uris", SHAPES)
|
||||
def test_a_real_violation_is_actually_reported(declare_namespace, carry_class_uris):
|
||||
"""The killer case. Shapes that match nothing make pySHACL return conforms=True."""
|
||||
shapes = _shapes_graph(_ontology(
|
||||
declare_namespace=declare_namespace, carry_class_uris=carry_class_uris
|
||||
))
|
||||
conforms, text = _validate(VIOLATING_DATA, shapes)
|
||||
|
||||
assert not conforms, (
|
||||
"a Person with no fullName was reported as conforming, which means the "
|
||||
f"shapes matched no focus nodes:\n{text}"
|
||||
)
|
||||
assert "fullName" in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("declare_namespace,carry_class_uris", SHAPES)
|
||||
def test_conforming_data_still_conforms(declare_namespace, carry_class_uris):
|
||||
shapes = _shapes_graph(_ontology(
|
||||
declare_namespace=declare_namespace, carry_class_uris=carry_class_uris
|
||||
))
|
||||
conforms, text = _validate(CONFORMING_DATA, shapes)
|
||||
assert conforms, text
|
||||
|
||||
|
||||
def test_default_target_namespace_is_the_vocabulary_not_the_shapes_namespace():
|
||||
"""With nothing declared anywhere, targets must not land in the shapes namespace."""
|
||||
ontology = {
|
||||
"classes": [{"name": "Person", "label": "Person"}],
|
||||
"properties": [{"name": "fullName", "type": "datatype", "range": "string",
|
||||
"domain": "Person", "required": True}],
|
||||
}
|
||||
shapes = _shapes_graph(ontology)
|
||||
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
|
||||
|
||||
assert targets, "no sh:targetClass emitted at all"
|
||||
for target in targets:
|
||||
assert not target.startswith(SHAPES_NS), target
|
||||
|
||||
|
||||
def test_shape_resources_are_distinct_from_the_classes_they_target():
|
||||
shapes = _shapes_graph(_ontology(declare_namespace=True, carry_class_uris=True))
|
||||
node_shapes = {str(s) for s in shapes.subjects(RDF.type, SH.NodeShape)}
|
||||
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
|
||||
|
||||
assert node_shapes, "no node shapes emitted"
|
||||
assert node_shapes.isdisjoint(targets), (
|
||||
f"a shape and the class it targets are the same resource: {node_shapes & targets}"
|
||||
)
|
||||
|
||||
|
||||
# ── #1105 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_domainless_property_is_not_asserted_on_every_class():
|
||||
"""minCount 1 on a domain-less property invalidates every instance of every class."""
|
||||
generator = SHACLGenerator()
|
||||
graph = generator.generate(_ontology(declare_namespace=True, carry_class_uris=True))
|
||||
|
||||
carriers = [
|
||||
shape.target_class
|
||||
for shape in graph.node_shapes
|
||||
for prop in shape.property_shapes
|
||||
if prop.path.endswith("sourceDocument")
|
||||
]
|
||||
assert carriers == [], f"a property with no declared domain was attached to {carriers}"
|
||||
|
||||
|
||||
def test_domainless_property_does_not_invalidate_conforming_data():
|
||||
shapes = _shapes_graph(_ontology(declare_namespace=True, carry_class_uris=True))
|
||||
conforms, text = _validate(CONFORMING_DATA, shapes)
|
||||
|
||||
assert conforms, f"invented a constraint the ontology never declared:\n{text}"
|
||||
assert "sourceDocument" not in text
|
||||
|
||||
|
||||
def test_domainless_attachment_is_available_as_an_explicit_opt_in():
|
||||
"""The old behaviour stays reachable for anyone who relied on it."""
|
||||
generator = SHACLGenerator(attach_domainless_properties=True)
|
||||
graph = generator.generate(_ontology(declare_namespace=True, carry_class_uris=True))
|
||||
|
||||
carriers = {
|
||||
shape.target_class
|
||||
for shape in graph.node_shapes
|
||||
for prop in shape.property_shapes
|
||||
if prop.path.endswith("sourceDocument")
|
||||
}
|
||||
assert len(carriers) == len(graph.node_shapes)
|
||||
|
||||
|
||||
# ── Review findings on the first revision of this fix ────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("fmt,parse_as", [
|
||||
("turtle", "turtle"), ("n-triples", "nt"), ("json-ld", "json-ld"),
|
||||
])
|
||||
def test_every_format_targets_the_ontology_namespace(fmt, parse_as):
|
||||
"""
|
||||
The first revision fixed Turtle alone. JSON-LD and N-Triples went on
|
||||
pasting names onto the shapes namespace, so two of the three formats still
|
||||
produced shapes that matched nothing.
|
||||
"""
|
||||
generator = SHACLGenerator()
|
||||
graph = generator.generate(_ontology(declare_namespace=False, carry_class_uris=True))
|
||||
|
||||
shapes = Graph()
|
||||
shapes.parse(data=generator.serialize(graph, fmt), format=parse_as)
|
||||
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
|
||||
|
||||
assert targets == {ONTOLOGY_NS + "Person", ONTOLOGY_NS + "Organization"}, targets
|
||||
assert not any(t.startswith(SHAPES_NS) for t in targets), targets
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fmt,parse_as", [
|
||||
("turtle", "turtle"), ("n-triples", "nt"), ("json-ld", "json-ld"),
|
||||
])
|
||||
def test_every_format_reports_a_real_violation(fmt, parse_as):
|
||||
generator = SHACLGenerator()
|
||||
graph = generator.generate(_ontology(declare_namespace=False, carry_class_uris=True))
|
||||
|
||||
shapes = Graph()
|
||||
shapes.parse(data=generator.serialize(graph, fmt), format=parse_as)
|
||||
conforms, text = _validate(VIOLATING_DATA, shapes)
|
||||
|
||||
assert not conforms, f"{fmt} shapes matched no focus nodes:\n{text}"
|
||||
|
||||
|
||||
def test_a_property_sharing_a_class_name_keeps_its_own_iri():
|
||||
"""One name-keyed map gave the property the class's IRI, so sh:path validated
|
||||
the wrong predicate."""
|
||||
ontology = {
|
||||
"classes": [{"name": "Account", "uri": ONTOLOGY_NS + "Account"}],
|
||||
"properties": [
|
||||
{
|
||||
"name": "Account",
|
||||
"uri": ONTOLOGY_NS + "accountNumber",
|
||||
"type": "datatype",
|
||||
"range": "string",
|
||||
"domain": "Account",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
shapes = _shapes_graph(ontology)
|
||||
|
||||
paths = {str(o) for o in shapes.objects(None, SH.path)}
|
||||
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
|
||||
|
||||
assert paths == {ONTOLOGY_NS + "accountNumber"}, paths
|
||||
assert targets == {ONTOLOGY_NS + "Account"}, targets
|
||||
|
||||
|
||||
def test_sh_class_resolves_to_the_class_namespace():
|
||||
ontology = {
|
||||
"classes": [
|
||||
{"name": "Person", "uri": ONTOLOGY_NS + "Person"},
|
||||
{"name": "Organization", "uri": ONTOLOGY_NS + "Organization"},
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "worksAt",
|
||||
"uri": ONTOLOGY_NS + "worksAt",
|
||||
"type": "object",
|
||||
"range": "Organization",
|
||||
"domain": "Person",
|
||||
}
|
||||
],
|
||||
}
|
||||
shapes = _shapes_graph(ontology)
|
||||
classes = {str(o) for o in shapes.objects(None, SH["class"])}
|
||||
|
||||
assert classes == {ONTOLOGY_NS + "Organization"}, classes
|
||||
|
||||
|
||||
def test_the_engine_forwards_the_new_options():
|
||||
"""to_shacl passed them through generate(**options), which never reads them."""
|
||||
from semantica.ontology.engine import OntologyEngine
|
||||
|
||||
ontology = _ontology(declare_namespace=False, carry_class_uris=False)
|
||||
|
||||
turtle = OntologyEngine().to_shacl(ontology, target_namespace="https://forwarded.example/ns#")
|
||||
shapes = Graph()
|
||||
shapes.parse(data=turtle, format="turtle")
|
||||
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
|
||||
assert all(t.startswith("https://forwarded.example/ns#") for t in targets), targets
|
||||
|
||||
attached = OntologyEngine().to_shacl(ontology, attach_domainless_properties=True)
|
||||
assert "sourceDocument" in attached
|
||||
|
||||
default = OntologyEngine().to_shacl(ontology)
|
||||
assert "sourceDocument" not in default
|
||||
|
||||
|
||||
def test_the_opt_in_warns_rather_than_whispering(caplog):
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
SHACLGenerator(attach_domainless_properties=True).generate(
|
||||
_ontology(declare_namespace=True, carry_class_uris=True)
|
||||
)
|
||||
|
||||
messages = " ".join(record.getMessage() for record in caplog.records)
|
||||
assert "sourceDocument" in messages
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Regression tests for #1108.
|
||||
|
||||
Every module supporting custom methods wrapped the registered callable in a
|
||||
bare `except Exception`, logged a warning, and continued into the built-in
|
||||
implementation. That makes a registered method advisory: it can add behaviour,
|
||||
but it cannot decline.
|
||||
|
||||
For a gate, a validator or a policy check, declining is the entire purpose.
|
||||
The demonstration below is the one from the issue: a verifier rejects invalid
|
||||
RDF and deletes the file, and the swallowed exception lets the default write it
|
||||
straight back.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from semantica.export import methods as export_methods
|
||||
from semantica.export.registry import method_registry
|
||||
from semantica.utils.custom_methods import (
|
||||
CUSTOM_METHOD_FELL_BACK,
|
||||
call_custom_method,
|
||||
)
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""Raised by a gate that declines to produce output."""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
method_registry.clear("rdf")
|
||||
yield
|
||||
method_registry.clear("rdf")
|
||||
|
||||
|
||||
KG = {"entities": [{"id": "e1", "text": "Acme", "type": "ORG"}], "relationships": []}
|
||||
|
||||
|
||||
def test_a_registered_gate_can_refuse(tmp_path):
|
||||
"""The exception must reach the caller instead of being logged and dropped."""
|
||||
def gate(data, file_path, **kwargs):
|
||||
raise Refused("this graph does not pass validation")
|
||||
|
||||
method_registry.register("rdf", "gate", gate)
|
||||
|
||||
with pytest.raises(Refused):
|
||||
export_methods.export_rdf(KG, str(tmp_path / "out.ttl"), method="gate")
|
||||
|
||||
|
||||
def test_a_refusal_leaves_no_output_behind(tmp_path):
|
||||
"""The issue's demonstration: the default used to write the file back."""
|
||||
target = tmp_path / "out.ttl"
|
||||
|
||||
def gate(data, file_path, **kwargs):
|
||||
Path(file_path).unlink(missing_ok=True)
|
||||
raise Refused("rejected by the verifier")
|
||||
|
||||
method_registry.register("rdf", "gate", gate)
|
||||
|
||||
with pytest.raises(Refused):
|
||||
export_methods.export_rdf(KG, str(target), method="gate")
|
||||
|
||||
assert not target.exists(), (
|
||||
"the default implementation wrote the file the gate refused to produce"
|
||||
)
|
||||
|
||||
|
||||
def test_a_custom_method_that_succeeds_is_unaffected(tmp_path):
|
||||
target = tmp_path / "out.ttl"
|
||||
|
||||
def writer(data, file_path, **kwargs):
|
||||
Path(file_path).write_text("# written by the custom method\n")
|
||||
return {"written_by": "custom"}
|
||||
|
||||
method_registry.register("rdf", "writer", writer)
|
||||
result = export_methods.export_rdf(KG, str(target), method="writer")
|
||||
|
||||
assert result == {"written_by": "custom"}
|
||||
assert target.read_text().startswith("# written by the custom method")
|
||||
|
||||
|
||||
def test_the_old_behaviour_is_available_as_an_explicit_opt_in(tmp_path):
|
||||
target = tmp_path / "out.ttl"
|
||||
|
||||
def gate(data, file_path, **kwargs):
|
||||
raise Refused("rejected")
|
||||
|
||||
method_registry.register("rdf", "gate", gate)
|
||||
export_methods.export_rdf(
|
||||
KG, str(target), method="gate", fallback_on_custom_error=True
|
||||
)
|
||||
|
||||
assert target.exists(), "opting in should still fall through to the default"
|
||||
|
||||
|
||||
def test_the_reserved_keyword_is_never_forwarded():
|
||||
"""`fallback_on_custom_error` is consumed by the policy, not by the method."""
|
||||
seen = {}
|
||||
|
||||
def recorder(**kwargs):
|
||||
seen.update(kwargs)
|
||||
return "ok"
|
||||
|
||||
result = call_custom_method(
|
||||
_NullLogger(), "recorder", recorder, alpha=1, fallback_on_custom_error=True
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
assert seen == {"alpha": 1}
|
||||
|
||||
|
||||
class _NullLogger:
|
||||
def warning(self, *args, **kwargs):
|
||||
self.last = args
|
||||
|
||||
|
||||
def test_the_sentinel_is_returned_only_on_the_opt_in_path():
|
||||
def boom():
|
||||
raise Refused("no")
|
||||
|
||||
logger = _NullLogger()
|
||||
assert call_custom_method(
|
||||
logger, "boom", boom, fallback_on_custom_error=True
|
||||
) is CUSTOM_METHOD_FELL_BACK
|
||||
|
||||
with pytest.raises(Refused):
|
||||
call_custom_method(logger, "boom", boom)
|
||||
|
||||
|
||||
def test_a_falsy_return_value_is_not_mistaken_for_a_failure():
|
||||
"""`is not CUSTOM_METHOD_FELL_BACK` matters: None and 0 are real results."""
|
||||
for value in (None, 0, "", False, []):
|
||||
assert call_custom_method(_NullLogger(), "m", lambda: value) is value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module_name",
|
||||
["export", "ingest", "parse", "normalize", "embeddings", "kg"],
|
||||
)
|
||||
def test_no_module_still_swallows_custom_method_failures(module_name):
|
||||
"""The swallow was repeated across six modules, not just the one filed."""
|
||||
import semantica
|
||||
|
||||
# Read the file rather than import it: some of these modules pull in
|
||||
# optional third-party dependencies that need not be installed to check
|
||||
# that the swallow is gone.
|
||||
source = (
|
||||
Path(semantica.__file__).parent / module_name / "methods.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "falling back to default" not in source, (
|
||||
f"semantica/{module_name}/methods.py still swallows custom method failures"
|
||||
)
|
||||
|
||||
|
||||
# ── Review findings on the first revision of this fix ────────────────────────
|
||||
|
||||
def test_the_reserved_flag_never_reaches_the_default_implementation(monkeypatch, tmp_path):
|
||||
"""
|
||||
`**kwargs` unpacking builds a fresh dict inside the helper, so popping there
|
||||
left the caller's own kwargs untouched and the flag was forwarded on to the
|
||||
default path. The helper documents the flag as never forwarded, so that
|
||||
promise was false for exactly the case the flag exists for.
|
||||
"""
|
||||
seen = {}
|
||||
|
||||
class Spy:
|
||||
def __init__(self, **config):
|
||||
seen.update(config)
|
||||
|
||||
def export(self, *args, **kwargs):
|
||||
(tmp_path / "written").write_text("default ran")
|
||||
|
||||
monkeypatch.setattr(export_methods, "RDFExporter", Spy)
|
||||
|
||||
def gate(data, file_path, **kwargs):
|
||||
raise Refused("rejected")
|
||||
|
||||
method_registry.register("rdf", "gate", gate)
|
||||
export_methods.export_rdf(
|
||||
KG, str(tmp_path / "out.ttl"), method="gate", fallback_on_custom_error=True
|
||||
)
|
||||
|
||||
assert (tmp_path / "written").exists(), "the default path did not run"
|
||||
assert "fallback_on_custom_error" not in seen, (
|
||||
f"the reserved flag was forwarded to the default implementation: {seen}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_reserved_flag_never_reaches_a_successful_custom_method(tmp_path):
|
||||
seen = {}
|
||||
|
||||
def writer(data, file_path, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return "ok"
|
||||
|
||||
method_registry.register("rdf", "writer", writer)
|
||||
result = export_methods.export_rdf(
|
||||
KG, str(tmp_path / "out.ttl"), method="writer", fallback_on_custom_error=True
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
assert "fallback_on_custom_error" not in seen, seen
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module_name", ["export", "ingest", "parse", "normalize", "embeddings", "kg"],
|
||||
)
|
||||
def test_every_site_consumes_the_flag_before_forwarding(module_name):
|
||||
"""A site that forgets the pop reintroduces the leak silently."""
|
||||
import semantica
|
||||
|
||||
source = (
|
||||
Path(semantica.__file__).parent / module_name / "methods.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
calls = source.count("result = call_custom_method(")
|
||||
pops = source.count('.pop("fallback_on_custom_error", False)')
|
||||
assert calls == pops, (
|
||||
f"semantica/{module_name}/methods.py has {calls} call site(s) but "
|
||||
f"{pops} consume the reserved flag"
|
||||
)
|
||||
Reference in New Issue
Block a user