Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,19 @@ elem = kc.element("req-001")
elem.id # "req-001"
elem.type # "requirement"
elem.attrs # {"title": "Boot time < 5s"}
```

`attrs` follows the schema: an attribute declared `multiple=True` comes back as a sorted
list of its values — even when it currently holds only one — and every other attribute
comes back as a single string. The list is sorted because the triple store has no
inherent order, so repeated reads would otherwise be free to disagree.

```python
sb.add_vertex_type("doc", attributes={"tag": text(multiple=True), "title": text()})
kc.add_vertex("d1", type="doc", title="Guide", tag=["b", "a"])

kc.element("d1").attrs # {"title": "Guide", "tag": ["a", "b"]}
sb.multivalued_attributes("doc") # frozenset({"tag"})

kc.element_ids(type="test_case") # ["tc-001", "tc-002"]
kc.elements(type="test_case") # [Element('tc-001', ...), Element('tc-002', ...)]
Expand Down
30 changes: 28 additions & 2 deletions knowledgecomplex/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,39 @@ def uri(self) -> str | None:

@property
def attrs(self) -> dict[str, Any]:
"""
Attributes as the schema declares them.

An attribute declared ``multiple=True`` comes back as a sorted list of its
values, even when it currently holds one; every other attribute comes back
as a single string. Sorting matters because the underlying triple store has
no inherent order, so without it repeated reads could disagree.
"""
ns_str = self._kc._schema._base_iri
try:
multivalued = self._kc._schema.multivalued_attributes(self.type)
except (ValueError, SchemaError):
# Only two things are expected here: ValueError from .type when the
# element carries no user type, and SchemaError when that type is not
# registered. Either way its attributes are still worth reading, so
# fall back to treating them all as single-valued. Anything else is a
# real bug and should surface rather than be swallowed.
multivalued = frozenset()

attrs: dict[str, Any] = {}
for _, p, o in self._kc._instance_graph.triples((self._iri, None, None)):
p_str = str(p)
if p_str.startswith(ns_str):
attr_name = p_str[len(ns_str):]
if not p_str.startswith(ns_str):
continue
attr_name = p_str[len(ns_str):]
if attr_name in multivalued:
attrs.setdefault(attr_name, []).append(str(o))
else:
attrs[attr_name] = str(o)

for attr_name in multivalued:
if attr_name in attrs:
attrs[attr_name].sort()
return attrs

def compile(self) -> None:
Expand Down
40 changes: 40 additions & 0 deletions knowledgecomplex/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,46 @@ def _validate_parent(self, parent: str | None, expected_kind: str) -> None:
f"expected '{expected_kind}'"
)

def multivalued_attributes(self, type_name: str) -> frozenset[str]:
"""
Names of the attributes on type_name that may hold more than one value.

Includes inherited attributes. Callers need this to read an element back
faithfully: an attribute declared ``multiple=True`` is a set of values, and
collapsing it to one silently loses data.

Parameters
----------
type_name : str
A registered type name.

Returns
-------
frozenset[str]

Example
-------
>>> sb = SchemaBuilder(namespace="demo")
>>> _ = sb.add_vertex_type("Doc", {"tag": text(multiple=True), "title": text()})
>>> sorted(sb.multivalued_attributes("Doc"))
['tag']
"""
from knowledgecomplex.exceptions import SchemaError
if type_name not in self._types:
raise SchemaError(f"Type '{type_name}' is not registered")

specs = {
**self._collect_inherited_attributes(type_name),
**self._types[type_name].get("attributes", {}),
}
names = set()
for name, spec in specs.items():
if isinstance(spec, dict):
spec = spec.get("vocab") or spec.get("text")
if getattr(spec, "multiple", False):
names.add(name)
return frozenset(names)

def _collect_inherited_attributes(self, type_name: str) -> dict:
"""Walk the parent chain and collect all inherited attributes."""
inherited = {}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "knowledgecomplex"
version = "0.1.0"
version = "0.2.0"
description = "Typed simplicial complexes backed by OWL, SHACL, and SPARQL"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
63 changes: 63 additions & 0 deletions tests/test_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,66 @@ def test_element_attrs_correct(self, qa_kc):
assert len(elems) == 1
assert elems[0].attrs["criteria"] == "Accuracy"
assert elems[0].attrs["title"] == "Guide A"


class TestMultivaluedAttrs:
"""An attribute declared multiple=True is a set of values, not one of them.

Before this, Element.attrs overwrote each value with the next as it walked the
triples, so a caller got an arbitrary single value and no indication that the
others existed.
"""

@pytest.fixture
def kc(self):
sb = SchemaBuilder(namespace="demo")
sb.add_vertex_type(
"Doc",
attributes={"tag": text(multiple=True), "title": text()},
)
kc = KnowledgeComplex(schema=sb)
kc.add_vertex("d1", type="Doc", title="Guide", tag=["b", "a", "c"])
kc.add_vertex("d2", type="Doc", title="Note", tag=["only"])
return kc

def test_all_values_are_returned(self, kc):
assert kc.element("d1").attrs["tag"] == ["a", "b", "c"]

def test_a_single_value_is_still_a_list(self, kc):
"""The type does not depend on how many values happen to be recorded."""
assert kc.element("d2").attrs["tag"] == ["only"]

def test_single_valued_attributes_are_unchanged(self, kc):
assert kc.element("d1").attrs["title"] == "Guide"

def test_repeated_reads_agree(self, kc):
"""The triple store has no inherent order, so the list is sorted."""
assert kc.element("d1").attrs == kc.element("d1").attrs

def test_schema_reports_which_attributes_are_multivalued(self):
sb = SchemaBuilder(namespace="demo")
sb.add_vertex_type("Doc", attributes={"tag": text(multiple=True), "title": text()})
assert sb.multivalued_attributes("Doc") == {"tag"}

def test_an_element_with_no_registered_type_still_reads(self):
"""The fallback the narrowed except clause exists for.

An element whose type is absent or unregistered has attributes worth
reading; it simply cannot be told which of them are multivalued.
"""
sb = SchemaBuilder(namespace="demo")
sb.add_vertex_type("Doc", attributes={"tag": text(multiple=True)})
kc = KnowledgeComplex(schema=sb)
kc.add_vertex("d1", type="Doc", tag=["a"])

stranger = Element(kc, "not-an-element")
assert stranger.attrs == {}

def test_an_unexpected_error_is_not_swallowed(self, kc, monkeypatch):
"""Narrow, not bare: a real bug in the schema lookup must surface."""
def boom(_type_name):
raise RuntimeError("something genuinely broken")

monkeypatch.setattr(kc._schema, "multivalued_attributes", boom)
with pytest.raises(RuntimeError, match="genuinely broken"):
_ = kc.element("d1").attrs
Loading