Skip to content

Commit f9e84e5

Browse files
Merge pull request #1025 from strr/strr/better-namespace-prefixes
2 parents d47798a + cc92fe6 commit f9e84e5

File tree

2 files changed

+111
-16
lines changed

2 files changed

+111
-16
lines changed

iXBRLViewerPlugin/iXBRLViewer.py

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,15 @@ class NamespaceMap:
6363
"""
6464

6565
def __init__(self) -> None:
66-
self.nsmap: dict[str, str] = {}
67-
self.prefixmap: dict[str, str] = {}
66+
self._nsmap: dict[str, str] = {}
67+
self._reportNsMap: dict[str, str] = {}
68+
69+
@property
70+
def prefixmap(self) -> dict[str, str]:
71+
"""
72+
Get the current prefix map (prefix to namespace URI).
73+
"""
74+
return {p: ns for ns, p in self._nsmap.items()}
6875

6976
def getPrefix(self, ns: str, preferredPrefix: str | None = None) -> str:
7077
"""
@@ -75,27 +82,46 @@ def getPrefix(self, ns: str, preferredPrefix: str | None = None) -> str:
7582
prefix (or the string "ns")
7683
"""
7784

78-
prefix = self.nsmap.get(ns, None)
79-
if not prefix:
80-
if preferredPrefix and preferredPrefix not in self.prefixmap:
81-
prefix = preferredPrefix
82-
else:
83-
p = preferredPrefix if preferredPrefix else "ns"
84-
n = 0
85-
while f"{p}{n}" in self.prefixmap:
86-
n += 1
85+
if (boundPrefix := self._nsmap.get(ns, None)) is not None:
86+
return boundPrefix
8787

88-
prefix = f"{p}{n}"
88+
if preferredPrefix is None:
89+
# For example, EE2.0 fact values (expanded names) get turned in to
90+
# QNames without a prefix but the report will know the prefix
91+
preferredPrefix = self._reportNsMap.get(ns, None)
92+
93+
all_bound_prefixes = self._nsmap.values()
94+
95+
if preferredPrefix and preferredPrefix not in all_bound_prefixes:
96+
chosenPrefix = preferredPrefix
97+
else:
98+
p = preferredPrefix if preferredPrefix else "ns"
99+
n = 0
100+
while (chosenPrefix := f"{p}{n}") in all_bound_prefixes:
101+
n += 1
89102

90-
self.prefixmap[prefix] = ns
91-
self.nsmap[ns] = prefix
92-
return prefix
103+
self._nsmap[ns] = chosenPrefix
104+
return chosenPrefix
93105

94106
def qname(self, qname: QName) -> str:
95107
if qname.namespaceURI is None:
96108
return qname.localName
97109
return f"{self.getPrefix(qname.namespaceURI, qname.prefix)}:{qname.localName}"
98110

111+
def clear(self) -> None:
112+
"""
113+
Clear the namespace map.
114+
"""
115+
self._nsmap.clear()
116+
self._reportNsMap.clear()
117+
118+
def stashReportNSMap(self, report: ModelXbrl) -> None:
119+
"""
120+
Stash a copy of Arelle's prefix/namespace map in case it can be used
121+
later to find a preferred prefix.
122+
"""
123+
self._reportNsMap = {ns: p for p, ns in report.prefixedNamespaces.items()}
124+
99125

100126
class IXBRLViewerBuilderError(Exception):
101127
pass
@@ -501,6 +527,7 @@ def addSourceReport(self) -> dict[str, list[Any]]:
501527
return sourceReport
502528

503529
def processModel(self, report: ModelXbrl) -> None:
530+
self.nsmap.stashReportNSMap(report)
504531
self.footnoteRelationshipSet = ModelRelationshipSet(report, "XBRL-footnotes") # type: ignore[no-untyped-call]
505532
self.currentTargetReport = self.newTargetReport(getattr(report, "ixdsTarget", None))
506533
softwareCredits = set()
@@ -600,7 +627,7 @@ def createViewer(
600627
scriptUrl: str = DEFAULT_JS_FILENAME,
601628
showValidations: bool = True,
602629
packageDownloadURL: str | None = None,
603-
) -> 'iXBRLViewer' | None:
630+
) -> iXBRLViewer | None:
604631
"""
605632
Create an iXBRL file with XBRL data as a JSON blob, and script tags added.
606633
:param scriptUrl: The `src` value of the script tag that loads the viewer script.

tests/unit_tests/iXBRLViewerPlugin/test_iXBRLViewer.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,67 @@ def test_getPrefix_subsequent_call_with_two_namespaces_and_prefix(self):
9595
result_2 = ns_map.getPrefix(namespace_2)
9696
assert result_2 == 'ns1'
9797

98+
def test_stashReportNSMap_uses_report_prefix(self):
99+
"""
100+
After stashing, getPrefix should use the prefix from the report's
101+
prefixedNamespaces map as the preferred prefix.
102+
"""
103+
ns_map = NamespaceMap()
104+
report = Mock()
105+
report.prefixedNamespaces = {"myprefix": "http://example.com"}
106+
ns_map.stashReportNSMap(report)
107+
assert ns_map.getPrefix("http://example.com") == "myprefix"
108+
109+
def test_stashReportNSMap_without_stash_uses_generated_prefix(self):
110+
"""
111+
Without stashing, getPrefix should fall back to a generated prefix.
112+
"""
113+
ns_map = NamespaceMap()
114+
assert ns_map.getPrefix("http://example.com") == "ns0"
115+
116+
def test_stashReportNSMap_prefix_differs_from_unstashed(self):
117+
"""
118+
Stashing should produce a different prefix than without stashing for
119+
the same namespace.
120+
"""
121+
report = Mock()
122+
report.prefixedNamespaces = {"myprefix": "http://example.com"}
123+
124+
stashed = NamespaceMap()
125+
stashed.stashReportNSMap(report)
126+
127+
unstashed = NamespaceMap()
128+
129+
assert stashed.getPrefix("http://example.com") != unstashed.getPrefix("http://example.com")
130+
131+
def test_stashReportNSMap_stashed_prefix_already_taken(self):
132+
"""
133+
If the stashed prefix is already bound to another namespace, getPrefix
134+
should fall back to a numbered suffix rather than silently reusing it.
135+
"""
136+
ns_map = NamespaceMap()
137+
report = Mock()
138+
report.prefixedNamespaces = {"myprefix": "http://example.com"}
139+
ns_map.stashReportNSMap(report)
140+
# Bind "myprefix" to a different namespace first
141+
ns_map.getPrefix("http://other.com", "myprefix")
142+
# Now the stashed preferred prefix is taken; should number-suffix
143+
assert ns_map.getPrefix("http://example.com") == "myprefix0"
144+
145+
def test_stashReportNSMap_does_not_affect_already_bound_namespace(self):
146+
"""
147+
If getPrefix was called before stashReportNSMap, the already-bound
148+
prefix should be returned unchanged on subsequent calls.
149+
"""
150+
ns_map = NamespaceMap()
151+
# Bind the namespace before stashing
152+
assert ns_map.getPrefix("http://example.com") == "ns0"
153+
report = Mock()
154+
report.prefixedNamespaces = {"myprefix": "http://example.com"}
155+
ns_map.stashReportNSMap(report)
156+
# Stash should not retroactively change the bound prefix
157+
assert ns_map.getPrefix("http://example.com") == "ns0"
158+
98159

99160
class TestIXBRLViewer:
100161

@@ -444,6 +505,11 @@ def urlDocEntry(path, docType, linkQName=None):
444505
info=info_effect,
445506
modelDocument=self.modelDocument,
446507
ixdsTarget=None,
508+
prefixedNamespaces={
509+
'snake': 'http://example.com/snake',
510+
'badger': 'http://example.com/badger',
511+
'mushroom': 'http://example.com/mushroom'
512+
},
447513
urlDocs=dict((
448514
urlDocEntry('/filesystem/local-inline.htm', Type.INLINEXBRL),
449515
urlDocEntry('https://example.com/remote-inline.htm', Type.INLINEXBRL),
@@ -474,6 +540,7 @@ def urlDocEntry(path, docType, linkQName=None):
474540
fileSource=file_source,
475541
info=info_effect,
476542
modelDocument=self.modelDocument,
543+
prefixedNamespaces={},
477544
ixdsTarget=None,
478545
urlDocs={}
479546
)
@@ -486,6 +553,7 @@ def urlDocEntry(path, docType, linkQName=None):
486553
fileSource=file_source,
487554
info=info_effect,
488555
modelDocument=self.modelDocumentInlineSet,
556+
prefixedNamespaces={},
489557
ixdsTarget=None,
490558
urlDocs={}
491559
)

0 commit comments

Comments
 (0)