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
47 changes: 46 additions & 1 deletion tests/test_deserialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
from xbrlkit.parse.ids import unit_id
from xbrlkit.periods import duration_period, instant_period, period_from_interval
from xbrlkit.deserialize.holon import _scheme_for
from xbrlkit.namespaces import ENTITY_SCHEME
from xbrlkit.namespaces import ENTITY_SCHEME, HOLON_VOCAB
from xbrlkit.serialize import to_clawdog, to_clawdog_report, to_holon, to_tavi
from xbrlkit.serialize._values import CIK_SCHEME
from xbrlkit.serve import tools
Expand Down Expand Up @@ -601,6 +601,51 @@ def test_round_trip_keeps_the_facts(model: XbrlModel, fmt: str) -> None:
assert cash.unit_id == next(u.id for u in got.units if u.measure == "iso4217:USD")


def test_tavi_round_trips_an_authored_reports_own_fields(model: XbrlModel) -> None:
"""An authored report's style, legal name, and each network's structure,
block type, fact set and order, ride as `rs:` properties and come back."""
model.filing.reporting_style = "BSC-CORP-IS02-CF1"
model.entity.name = "Acme"
model.entity.legal_name = "Acme Holdings, Inc."
for network in model.networks:
if network.role_uri == BALANCE_SHEET:
network.block_type = "balance_sheet"
network.structure_id = "struct_01"
network.fact_set_id = "fs_01"
network.structure_order = 100
for fact in model.facts:
fact.structure_id = "struct_01"
text = to_tavi(model)
assert json.loads(text)["documentInfo"]["namespaces"]["rs"] == HOLON_VOCAB

got, gaps = from_tavi_report(text)
assert got.filing.reporting_style == "BSC-CORP-IS02-CF1"
assert got.entity.legal_name == "Acme Holdings, Inc."
presentation = next(
n for n in got.networks if n.role_uri == BALANCE_SHEET and n.kind == "presentation"
)
assert (
presentation.block_type,
presentation.structure_id,
presentation.fact_set_id,
presentation.structure_order,
) == ("balance_sheet", "struct_01", "fs_01", 100)
assert all(f.structure_id == "struct_01" for f in got.facts)
assert gaps.unmapped_fact_properties == {}


def test_a_filings_tavi_carries_no_report_properties(model: XbrlModel) -> None:
"""A filing has none of those fields, so its document is spec-shaped only:
no `rs` binding, no property types. A legal name equal to the name, which
the parse always sets, writes nothing."""
model.entity.legal_name = model.entity.name
model.filing.report_uri = "https://www.sec.gov/Archives/edgar/data/1/0001-24-1.htm"
document = json.loads(to_tavi(model))
assert "rs" not in document["documentInfo"]["namespaces"]
assert "propertyTypes" not in document["xbrlModel"]
assert "rs:" not in json.dumps(document)


def test_tavi_reads_an_exact_fact_as_infinitely_precise(model: XbrlModel) -> None:
"""TAVI writes INF by leaving `decimals` out; the reader must not read that
as unknown, or an exact fact loses to a rounded duplicate of itself."""
Expand Down
67 changes: 62 additions & 5 deletions xbrlkit/deserialize/tavi.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
XbrlModel,
)
from ..namespaces import HOLON_VOCAB, PROV_VOCAB, TAVI_REPORT_BASE
from ..serialize.tavi import PROVENANCE_PROPERTIES, REPORT_PREFIX
from ..serialize.tavi import PROVENANCE_PROPERTIES, REPORT_PREFIX, REPORT_PROPERTIES
from ..parse.ids import unit_id
from ..periods import period_from_interval
from ..serialize._values import CIK_SCHEME
Expand Down Expand Up @@ -213,7 +213,7 @@ def _read(document: Mapping[str, Any]) -> tuple[XbrlModel, ImportGaps]:
entity = _entity(xbrl_model, namespaces)
concepts = _concepts(xbrl_model, namespaces, gaps)
_apply_labels(xbrl_model, concepts, entity, _entity_sqname(xbrl_model), gaps)
networks = _networks(xbrl_model)
networks = _networks(xbrl_model, namespaces)
networks.extend(_definition_networks(xbrl_model, networks, concepts))
facts, periods, units = _facts(xbrl_model, concepts, entity, namespaces, gaps)
_mark_text_facts(concepts, facts)
Expand Down Expand Up @@ -251,7 +251,17 @@ def _entity(
if not identifier:
prefix, identifier = "", name
scheme = namespaces.get(prefix) or (CIK_SCHEME if prefix == "cik" else prefix)
return EntityIdentity(cik=identifier or "", scheme=scheme or CIK_SCHEME)
entities = _sequence(xbrl_model.get("entities"))
legal_name = (
_report_property(_mapping(entities[0]), "rs:legalName", namespaces)
if entities
else None
)
return EntityIdentity(
cik=identifier or "",
scheme=scheme or CIK_SCHEME,
legal_name=None if legal_name is None else str(legal_name),
)


def _filing(
Expand Down Expand Up @@ -282,8 +292,10 @@ def _filing(
fiscal_end = cover.get(_DEI_FISCAL_YEAR_END, "")
if entity.name is None:
entity.name = cover.get(_DEI_REGISTRANT_NAME)
reporting_style = _report_property(xbrl_model, "rs:reportingStyle", namespaces)
return FilingMeta(
accession=accession or "unknown",
reporting_style=None if reporting_style is None else str(reporting_style),
cik=entity.cik,
form=cover.get(_DEI_DOCUMENT_TYPE),
is_inline_xbrl=None,
Expand Down Expand Up @@ -550,7 +562,9 @@ def _roles(xbrl_model: Mapping[str, Any]) -> _Roles:
return _Roles(network_roles, definitions, documentations)


def _networks(xbrl_model: Mapping[str, Any]) -> list[Network]:
def _networks(
xbrl_model: Mapping[str, Any], namespaces: Mapping[str, str]
) -> list[Network]:
"""Networks, rejoined to the extended link roles their groups stand for."""
roles = _roles(xbrl_model)
networks: list[Network] = []
Expand Down Expand Up @@ -578,11 +592,43 @@ def _networks(xbrl_model: Mapping[str, Any]) -> list[Network]:
documentation=roles.documentations.get(role_uri),
kind=kind,
arcs=_arcs(_sequence(obj.get("relationships")), kind),
block_type=_optional_str(_report_property(obj, "rs:blockType", namespaces)),
structure_id=_optional_str(_report_property(obj, "rs:structureId", namespaces)),
fact_set_id=_optional_str(_report_property(obj, "rs:factSetId", namespaces)),
structure_order=_optional_int(
_report_property(obj, "rs:structureOrder", namespaces)
),
)
)
return networks


def _report_property(
obj: Mapping[str, Any], qname: str, namespaces: Mapping[str, str]
) -> Any:
"""The value an object's ``properties`` give one report property, if any."""
for entry in _sequence(obj.get("properties")):
prop = _mapping(entry)
iri = _expand(str(prop.get("property", "")), namespaces)
if _REPORT_PROPERTY_IRIS.get(iri) == qname:
value = prop.get("value")
if isinstance(value, list) and len(value) == 1:
value = value[0]
return value
return None


def _optional_str(value: Any) -> str | None:
return None if value is None else str(value)


def _optional_int(value: Any) -> int | None:
try:
return None if value is None else int(value)
except (TypeError, ValueError):
return None


# The dimensions every reconstructed cube declares that are not taxonomy axes.
_CORE_DIMENSIONS = frozenset(("xbrl:concept", *OPTIONAL_CORE_DIMENSIONS))

Expand Down Expand Up @@ -856,6 +902,7 @@ def _facts(
is_nil=not values,
language=str(language) if isinstance(language, str) else None,
provenance=_fact_provenance(obj, namespaces, gaps),
structure_id=_optional_str(_report_property(obj, "rs:structureId", namespaces)),
)
)

Expand Down Expand Up @@ -894,6 +941,13 @@ def _expand(qname: str, namespaces: Mapping[str, str]) -> str:
return f"{uri}{local}" if uri.endswith(("#", "/")) else f"{uri}#{local}"


# Expanded IRI of each report property, so a document is matched on the IRI
# whatever prefix it bound.
_REPORT_PROPERTY_IRIS: dict[str, str] = {
_expand(qname, {"rs": HOLON_VOCAB}): qname for qname, _, _ in REPORT_PROPERTIES
}


# Expanded property IRI -> ``FactProvenance`` field, for the four provenance
# properties: a document is matched on the IRI, whatever prefix it bound.
_PROVENANCE_IRIS: dict[str, str] = {
Expand All @@ -913,7 +967,10 @@ def _fact_provenance(
value = prop.get("value")
if isinstance(value, list) and len(value) == 1:
value = value[0]
attr = _PROVENANCE_IRIS.get(_expand(qname, namespaces))
iri = _expand(qname, namespaces)
if iri in _REPORT_PROPERTY_IRIS:
continue # read where the fact is built
attr = _PROVENANCE_IRIS.get(iri)
if attr is None or value is None:
if qname:
gaps.unmapped_fact_properties[qname] = (
Expand Down
126 changes: 105 additions & 21 deletions xbrlkit/serialize/tavi.py
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,8 @@ def _namespaces(model: XbrlModel, report_id: str) -> dict[str, str]:
if any(fact.provenance is not None for fact in model.facts):
namespaces["prov"] = PROV_VOCAB
namespaces["rs"] = HOLON_VOCAB
if _report_properties_used(model):
namespaces["rs"] = HOLON_VOCAB

by_uri = {uri: prefix for prefix, uri in namespaces.items()}
for concept in model.concepts.values():
Expand Down Expand Up @@ -781,6 +783,10 @@ def _model_properties(model: XbrlModel) -> list[dict[str, object]]:
"value": model.filing.filing_date.isoformat(),
}
)
if model.filing.reporting_style:
properties.append(
{"property": "rs:reportingStyle", "value": model.filing.reporting_style}
)
return properties


Expand Down Expand Up @@ -808,7 +814,22 @@ def _entities(model: XbrlModel) -> list[dict[str, object]]:
under this report's own namespace, which dropped the scheme and gave two
converters of one filing two different entities.
"""
return [{"name": entity_sqname(model.entity)}]
entity: dict[str, object] = {"name": entity_sqname(model.entity)}
legal_name = _distinct_legal_name(model)
if legal_name:
entity["properties"] = [{"property": "rs:legalName", "value": legal_name}]
return [entity]


def _distinct_legal_name(model: XbrlModel) -> str | None:
"""The legal name, when it says something the entity's name label does not.

The parse sets the legal name to the name when the filing gives only one;
writing that copy would put our vocabulary into every filing's document,
so only a legal name that differs from the name is written.
"""
legal_name = model.entity.legal_name
return legal_name if legal_name and legal_name != model.entity.name else None


def _entity_labels(model: XbrlModel, default_language: str) -> list[dict[str, object]]:
Expand Down Expand Up @@ -1111,13 +1132,15 @@ def group_for(role_uri: str) -> str:
for position, root in enumerate(roots, start=1)
]
relationships.extend(_relationship(arc, network) for arc in network.arcs)
networks.append(
{
"name": name,
"relationshipTypeName": relationship_type,
"relationships": relationships,
}
)
network_object: dict[str, object] = {
"name": name,
"relationshipTypeName": relationship_type,
"relationships": relationships,
}
properties = _network_properties(network)
if properties:
network_object["properties"] = properties
networks.append(network_object)
group_contents.append({"groupName": group_for(network.role_uri), "forObject": name})

# A cube joins the section whose definition linkbase declared its hypercube
Expand Down Expand Up @@ -1219,10 +1242,13 @@ def _facts(
if decimals is not None:
fact_value["decimals"] = decimals
entry["factValues"] = [fact_value]
if fact.provenance is not None:
properties = _provenance_properties(fact.provenance)
if properties:
entry["properties"] = properties
properties = (
_provenance_properties(fact.provenance) if fact.provenance is not None else []
)
if fact.structure_id:
properties.append({"property": "rs:structureId", "value": fact.structure_id})
if properties:
entry["properties"] = properties
facts.append(entry)
return facts

Expand Down Expand Up @@ -1271,25 +1297,83 @@ def _fact_name(fact: XbrlFact, index: int, names: set[str], gaps: GapReport) ->
)


# What an authored report carries that TAVI has no object for, as model-defined
# properties under the RoboSystems vocabulary (sections 5.13 / 11.6): the
# report's style, the entity's legal name, each network's structure, block
# type, fact set and order, and each fact's structure. A filing carries none
# of them, so its document declares and binds nothing new. The report's own IRI
# is not among them: the report namespace already names the report, and a
# filing's `report_uri` (its EDGAR document) would otherwise be written too.
REPORT_PROPERTIES: tuple[tuple[str, str, tuple[str, ...]], ...] = (
("rs:reportingStyle", "xs:string", ("xbrl:xbrlModelObject",)),
("rs:legalName", "xs:string", ("xbrl:entityObject",)),
("rs:blockType", "xs:string", ("xbrl:networkObject",)),
("rs:structureId", "xs:string", ("xbrl:networkObject", "xbrl:factObject")),
("rs:factSetId", "xs:string", ("xbrl:networkObject",)),
("rs:structureOrder", "xs:integer", ("xbrl:networkObject",)),
)


def _network_properties(network: Network) -> list[dict[str, object]]:
properties: list[dict[str, object]] = []
for qname, value in (
("rs:blockType", network.block_type),
("rs:structureId", network.structure_id),
("rs:factSetId", network.fact_set_id),
("rs:structureOrder", network.structure_order),
):
if value is not None and value != "":
properties.append({"property": qname, "value": value})
return properties


def _report_properties_used(model: XbrlModel) -> set[str]:
"""The report properties this model has a value for."""
used: set[str] = set()
if model.filing.reporting_style:
used.add("rs:reportingStyle")
if _distinct_legal_name(model):
used.add("rs:legalName")
for network in model.networks:
if network.kind != "definition":
used.update(str(entry["property"]) for entry in _network_properties(network))
if any(fact.structure_id for fact in model.facts):
used.add("rs:structureId")
return used


def _property_types(model: XbrlModel) -> list[dict[str, object]]:
"""The property type objects (section 11.6) the facts' provenance needs.
"""The property type objects (section 11.6) the model's properties need.

Declared only when a fact carries provenance: section 3.1 forbids a
Declared only when something carries the property: section 3.1 forbids a
property no type declares, and the type is what makes the property mean
the same thing to the next reader. ``definitional`` is false — where a fact
came from is contextual metadata, not part of its identity (11.6.1).
came from, or which structure a network renders, is contextual metadata,
not part of the object's identity (11.6.1).
"""
if not any(fact.provenance is not None for fact in model.facts):
return []
return [
types: list[dict[str, object]] = []
if any(fact.provenance is not None for fact in model.facts):
types.extend(
{
"name": qname,
"dataType": datatype,
"definitional": False,
"allowedObjects": ["xbrl:factObject"],
}
for _, qname, datatype in PROVENANCE_PROPERTIES
)
used = _report_properties_used(model)
types.extend(
{
"name": qname,
"dataType": datatype,
"definitional": False,
"allowedObjects": ["xbrl:factObject"],
"allowedObjects": list(allowed),
}
for _, qname, datatype in PROVENANCE_PROPERTIES
]
for qname, datatype, allowed in REPORT_PROPERTIES
if qname in used
)
return types


def _provenance_properties(provenance: FactProvenance) -> list[dict[str, object]]:
Expand Down
Loading