Skip to content

Commit 3fd8bde

Browse files
committed
feat(loader): Selectively parse AIRD fragments
This commit changes the MelodyLoader to selectively parse only the metadata section of AIRD files, and not keep the representation data in memory all the time. This dramatically reduces the runtime memory footprint and loading times for Capella models, especially when there are lots of representations in a given model.
1 parent a0b5521 commit 3fd8bde

3 files changed

Lines changed: 117 additions & 10 deletions

File tree

src/capellambse/loader/core.py

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,12 @@ def __missing__(self, key: str) -> t.NoReturn:
185185

186186

187187
class ModelFile:
188-
"""Represents a single file in the model (i.e. a fragment)."""
188+
"""Represents a single file in the model (i.e. a fragment).
189+
190+
This class loads the entire XML tree into memory. This makes it
191+
unsuitable for large trees with only small interesting segments,
192+
like ``.aird`` files. See :class:`VisualFile` for an alternative.
193+
"""
189194

190195
__qtypecache: dict[etree.QName, dict[int, etree._Element]]
191196
__xtypecache: dict[str, dict[int, etree._Element]]
@@ -468,6 +473,99 @@ def unfollow_href(self, element_id: str) -> etree._Element:
468473
return self.__hrefsources[element_id]
469474

470475

476+
class VisualFile:
477+
"""Represents a visual (AIRD) fragment.
478+
479+
Visual fragments can rapidly grow very large, which makes it
480+
impractical to hold them in memory entirely all the time. This
481+
specialized class works similar to :class:`ModelFile`. However, it
482+
only keeps the central index in memory, and only loads and parses
483+
other data on request.
484+
"""
485+
486+
fragment_type: t.Final = FragmentType.VISUAL
487+
488+
def __init__(
489+
self,
490+
filename: pathlib.PurePosixPath,
491+
handler: filehandler.FileHandler,
492+
) -> None:
493+
self.filename = filename
494+
self.filehandler = handler
495+
if filename.suffix not in VISUAL_EXTS:
496+
raise ValueError(f"Bad filename for visual fragment: {filename}")
497+
498+
with handler.open(filename) as f:
499+
parser = etree.iterparse(f)
500+
for _, element in parser:
501+
parent = element.getparent()
502+
if parent is None or parent.getparent() is not None:
503+
continue
504+
505+
if element.tag == f"{{{_n.NAMESPACES['viewpoint']}}}DAnalysis":
506+
self.__analysis = element
507+
break
508+
parent.remove(element)
509+
else:
510+
raise RuntimeError(
511+
"Broken XML: No 'viewpoint:DAnalysis' element found"
512+
)
513+
parent = self.__analysis.getparent()
514+
assert parent is not None
515+
parent.remove(self.__analysis)
516+
517+
def __getitem__(self, key: str) -> etree._Element:
518+
# TODO Return a diagram root element if it's found in this fragment
519+
raise KeyError(key)
520+
521+
def referenced_files(self) -> cabc.Iterator[str]:
522+
for i in self.__analysis:
523+
if i.tag == "semanticResources" and i.text:
524+
yield i.text
525+
elif i.tag == "referencedAnalysis" and (href := i.get("href")):
526+
yield href.split("#", maxsplit=1)[0]
527+
528+
def enumerate_uuids(self) -> set[str]:
529+
"""Enumerate all UUIDs used in this fragment."""
530+
return set()
531+
532+
def idcache_index(self, subtree: etree._Element) -> None:
533+
"""Index the IDs of ``subtree``."""
534+
raise NotImplementedError("Cannot modify visual fragments")
535+
536+
def idcache_remove(self, source: str | etree._Element) -> None:
537+
"""Remove the ID or all IDs below the source from the ID cache."""
538+
raise NotImplementedError("Cannot modify visual fragments")
539+
540+
def idcache_rebuild(self) -> None:
541+
"""Invalidate and rebuild this file's ID cache."""
542+
# Nothing to do
543+
544+
def idcache_reserve(self, new_id: str) -> None:
545+
"""Reserve the given ID for an element to be inserted later."""
546+
raise NotImplementedError("Cannot modify visual fragments")
547+
548+
def iterall_xt(
549+
self, xtypes: cabc.Container[str]
550+
) -> cabc.Iterator[etree._Element]:
551+
"""Iterate over all elements in this tree by ``xsi:type``."""
552+
del xtypes
553+
yield from ()
554+
555+
def write_xml(
556+
self,
557+
filename: pathlib.PurePosixPath,
558+
encoding: str = "utf-8",
559+
) -> None:
560+
"""Do nothing."""
561+
del filename, encoding
562+
563+
# pylint: disable-next=useless-return
564+
def unfollow_href(self, element_id: str) -> etree._Element | None:
565+
del element_id
566+
return None
567+
568+
471569
class MelodyLoader:
472570
"""Facilitates extensive access to Polarsys / Capella projects."""
473571

@@ -551,7 +649,7 @@ def __init__(
551649
else:
552650
self.resources[resname] = reshdl
553651

554-
self.trees: dict[pathlib.PurePosixPath, ModelFile] = {}
652+
self.trees: dict[pathlib.PurePosixPath, ModelFile | VisualFile] = {}
555653
self.__load_referenced_files(
556654
pathlib.PurePosixPath("\0", self.entrypoint)
557655
)
@@ -604,11 +702,17 @@ def __load_referenced_files(
604702

605703
handler = self.resources[resource_path.parts[0]]
606704
filename = pathlib.PurePosixPath(*resource_path.parts[1:])
607-
frag = ModelFile(
608-
filename, handler, ignore_uuid_dups=self.__ignore_uuid_dups
609-
)
705+
frag: VisualFile | ModelFile
706+
if filename.suffix in VISUAL_EXTS:
707+
frag = VisualFile(filename, handler)
708+
refs = list(frag.referenced_files())
709+
else:
710+
frag = ModelFile(
711+
filename, handler, ignore_uuid_dups=self.__ignore_uuid_dups
712+
)
713+
refs = []
610714
self.trees[resource_path] = frag
611-
for ref in _find_refs(frag.root):
715+
for ref in refs:
612716
ref_name = helpers.normalize_pure_path(
613717
_unquote_ref(ref), base=resource_path.parent
614718
)
@@ -679,6 +783,7 @@ def update_namespaces(self) -> None:
679783
if fragment.fragment_type != FragmentType.SEMANTIC:
680784
continue
681785

786+
assert isinstance(fragment, ModelFile)
682787
LOGGER.debug("Updating namespaces on fragment %s", fname)
683788
fragment.update_namespaces(vp)
684789

@@ -955,7 +1060,7 @@ def iterall_xt(
9551060
"""
9561061
xtset = self._nonempty_hashset(xtypes)
9571062
if trees is None:
958-
files: cabc.Iterable[ModelFile] = self.trees.values()
1063+
files: cabc.Iterable[ModelFile | VisualFile] = self.trees.values()
9591064
else:
9601065
files = (v for k, v in self.trees.items() if k in trees)
9611066
return itertools.chain.from_iterable(
@@ -1290,7 +1395,7 @@ def follow_links(
12901395

12911396
def _find_fragment(
12921397
self, element: etree._Element
1293-
) -> tuple[pathlib.PurePosixPath, ModelFile]:
1398+
) -> tuple[pathlib.PurePosixPath, ModelFile | VisualFile]:
12941399
root = collections.deque(
12951400
itertools.chain([element], element.iterancestors()), 1
12961401
)[0]

src/capellambse/model/_model.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,8 @@ class as the superclass of every concrete model element
416416
trees = [
417417
t
418418
for t in self._loader.trees.values()
419-
if t.fragment_type is loader.FragmentType.SEMANTIC
419+
if isinstance(t, loader.ModelFile)
420+
and t.fragment_type is loader.FragmentType.SEMANTIC
420421
]
421422
matches: cabc.Iterable[etree._Element]
422423
if not classes:

src/capellambse/model/_obj.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
from lxml import etree
4949

5050
import capellambse
51-
from capellambse import helpers
51+
from capellambse import helpers, loader
5252

5353
from . import VIRTUAL_NAMESPACE_PREFIX, T, U, _descriptors, _pods, _styleclass
5454

@@ -736,6 +736,7 @@ def __init__(
736736
ns = self.__capella_namespace__
737737
qtype = model.qualify_classname((ns, type(self).__name__))
738738
assert qtype.namespace is not None
739+
assert isinstance(fragment, loader.ModelFile)
739740
fragment.add_namespace(qtype.namespace, ns.alias)
740741
self._element.set(helpers.ATT_XT, qtype)
741742
for key, val in kw.items():

0 commit comments

Comments
 (0)