Skip to content

SpecModel

dcmspec.spec_model.SpecModel

Represent a hierarchical information model from any table of DICOM documents.

This class holds the DICOM specification model, structured into a hierarchical tree of DICOM components such as Data Elements, UIDs, Attributes, and others.

The model contains two main parts
  • metadata: a node holding table and document metadata
  • content: a node holding the hierarchical content tree

The model can be filtered.

Source code in src/dcmspec/spec_model.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
class SpecModel:
    """Represent a hierarchical information model from any table of DICOM documents.

    This class holds the DICOM specification model, structured into a hierarchical tree
    of DICOM components such as Data Elements, UIDs, Attributes, and others.

    The model contains two main parts:
        - metadata: a node holding table and document metadata
        - content: a node holding the hierarchical content tree

    The model can be filtered.
    """

    def __init__(
        self,
        metadata: Node,
        content: Node,
        logger: logging.Logger = None,
    ):
        """Initialize the SpecModel.

        Sets up the logger and initializes the specification model.

        Args:
            metadata (Node): Node holding table and document metadata, such as headers, version, and table ID.
            content (Node): Node holding the hierarchical content tree of the DICOM specification.
            logger (logging.Logger, optional): A pre-configured logger instance to use.
                If None, a default logger will be created.

        """
        self.logger = logger or logging.getLogger(self.__class__.__name__)
        self.metadata = metadata
        self.content = content

    def exclude_titles(self) -> None:
        """Remove nodes corresponding to title rows from the content tree.

        Title rows are typically found in some DICOM tables and represent section headers
        rather than actual data elements (such as Module titles in PS3.4). 
        This method traverses the content tree and removes any node identified as a title,
        cleaning up the model for further processing.

        The method operates on the content tree and does not affect the metadata node.

        Returns:
            None

        """
        # Traverse the tree and remove nodes where is_title is True
        for node in list(PreOrderIter(self.content)):
            if self._is_title(node):
                self.logger.debug(f"Removing title node: {node.name}")
                node.parent = None

    def filter_required(
        self,
        type_attr_name: str,
        keep: Optional[list[str]] = None,
        remove: Optional[list[str]] = None
    ) -> None:
        """Remove nodes that are considered optional according to DICOM requirements.

        This method traverses the content tree and removes nodes whose requirement
        (e.g., "Type", "Matching", or "Return Key") indicates that they are optional. 
        Nodes with conditional or required types (e.g., "1", "1C", "2", "2C")
        are retained. The method can be customized by specifying which types to keep or remove.

        Additionally, for nodes representing Sequences (node names containing "_sequence"), 
        this method removes all subelements if the sequence itself is not required or can be empty
        (e.g., type "3", "2", "2C", "-", "O", or "Not allowed").

        Args:
            type_attr_name (str): Name of the node attribute holding the optionality requirement,
                for example "Type" of an attribute, "Matching", or "Return Key".
            keep (Optional[list[str]]): List of type values to keep (default: ["1", "1C", "2", "2C"]).
            remove (Optional[list[str]]): List of type values to remove (default: ["3"]).

        Returns:
            None

        """
        if keep is None:
            keep = ["1", "1C", "2", "2C"]
        if remove is None:
            remove = ["3"]
        types_to_keep = keep
        types_to_remove = remove
        attribute_name = type_attr_name

        for node in PreOrderIter(self.content):
            if hasattr(node, attribute_name):
                dcmtype = getattr(node, attribute_name)
                if dcmtype in types_to_remove and dcmtype not in types_to_keep:
                    self.logger.debug(f"[{dcmtype.rjust(3)}] : Removing {node.name} element")
                    node.parent = None
                # Remove nodes under "Sequence" nodes which are not required or which can be empty
                if "_sequence" in node.name and dcmtype in ["3", "2", "2C", "-", "O", "Not allowed"]:
                    self.logger.debug(f"[{dcmtype.rjust(3)}] : Removing {node.name} subelements")
                    for descendant in node.descendants:
                        descendant.parent = None

    def merge_matching_path(
        self,
        other: "SpecModel",
        match_by: str = "name",
        attribute_name: Optional[str] = None,
        merge_attrs: Optional[list[str]] = None,
        ignore_module_level: bool = False,
    ) -> "SpecModel":
        """Merge with another SpecModel, producing a new model with attributes merged for nodes with matching paths.

        The path for matching is constructed at each level using either the node's `name`
        (if match_by="name") or a specified attribute (if match_by="attribute" and attribute_name is given).
        Only nodes whose full path matches (by the chosen key) will be merged.

        This method is useful for combining DICOM specification models from different parts of the standard.
        For example, it can be used to merge a PS3.3 model of a normalized IOD specification with a PS3.4 model of a
        SOP class specification.

        Args:
            other (SpecModel): The other model to merge with the current model.
            match_by (str): "name" to match by node.name path, "attribute" to match by a specific attribute path.
            attribute_name (str, optional): The attribute name to use for matching if match_by="attribute".
            merge_attrs (list[str], optional): List of attribute names to merge from the other model's node.
            ignore_module_level (bool, optional): If True, skip the module level in the path for matching.

        Returns:
            SpecModel: A new merged SpecModel.

        """        
        return self._merge_nodes(
            other,
            match_by=match_by,
            attribute_name=attribute_name,
            merge_attrs=merge_attrs,
            is_path_based=True,
            ignore_module_level=ignore_module_level
        )

    def merge_matching_node(
        self,
        other: "SpecModel",
        match_by: str = "name",
        attribute_name: Optional[str] = None,
        merge_attrs: Optional[list[str]] = None,
    ) -> "SpecModel":
        """Merge two SpecModel trees by matching nodes at any level using a single key (name or attribute).

        For each node in the current model, this method finds a matching node in the other model
        using either the node's name (if match_by="name") or a specified attribute (if match_by="attribute").
        If a match is found, the specified attributes from the other model's node are merged into the current node.

        This is useful for enrichment scenarios, such as adding VR/VM/Keyword from the Part 6 dictionary
        to a Part 3 module, where nodes are matched by a unique attribute like elem_tag.

        - Matching is performed globally (not by path): any node in the current model is matched to any node
          in the other model with the same key value, regardless of their position in the tree.
        - It is expected that there is only one matching node per key in the other model.
        - If multiple nodes in the other model have the same key, a warning is logged and only the last one
          found in pre-order traversal is used for merging.

        Example use cases:
            - Enrich a PS3.3 module attribute specification with VR/VM from the PS3.6 data elements dictionary.
            - Merge any two models where a unique key (name or attribute) can be used for node correspondence.

        Args:
            other (SpecModel): The other model to merge with the current model.
            match_by (str): "name" to match by node.name (stripped of leading '>' and whitespace),
                or "attribute" to match by a specific attribute value.
            attribute_name (str, optional): The attribute name to use for matching if match_by="attribute".
            merge_attrs (list[str], optional): List of attribute names to merge from the other model's node.

        Returns:
            SpecModel: A new merged SpecModel with attributes from the other model merged in.

        Raises:
            ValueError: If match_by is invalid or attribute_name is missing when required.

        """        
        return self._merge_nodes(
            other,
            match_by=match_by,
            attribute_name=attribute_name,
            merge_attrs=merge_attrs,
            is_path_based=False
        )
    def _strip_leading_gt(self, name):
        """Strip leading '>' and whitespace from a node name for matching."""
        return name.lstrip(">").lstrip().rstrip() if isinstance(name, str) else name

    def _is_include(self, node: Node) -> bool:
        """Determine if a node represents an 'Include' of a Macro table.

        Args:
            node: The node to check.

        Returns:
            True if the node represents an 'Include' of a Macro table, False otherwise.

        """
        return "include_table" in node.name

    def _is_title(self, node: Node) -> bool:
        """Determine if a node is a title.

        Args:
            node: The node to check.

        Returns:
            True if the node is a title, False otherwise.

        """
        return (
            self._has_only_key_0_attr(node, self.metadata.column_to_attr)
            and not self._is_include(node)
            and node.name != "content"
        )

    def _has_only_key_0_attr(self, node: Node, column_to_attr: Dict[int, str]) -> bool:
        # sourcery skip: merge-duplicate-blocks, use-any
        """Check that only the key 0 attribute is present.

        Determines if a node has only the attribute specified by the item with key "0"
        in column_to_attr, corresponding to the first column of the table.

        Args:
            node: The node to check.
            column_to_attr: Mapping between column number and attribute name.

        Returns:
            True if the node has only the key "0" attribute, False otherwise.

        """
        # Irrelevant if columns 0 not extracted
        if 0 not in column_to_attr:
            return False

        key_0_attr = column_to_attr[0]
        # key 0 must be present and not None
        if not hasattr(node, key_0_attr) or getattr(node, key_0_attr) is None:
            return False

        # all other keys must be absent or None
        for key, attr_name in column_to_attr.items():
            if key == 0:
                continue
            if hasattr(node, attr_name) and getattr(node, attr_name) is not None:
                return False
        return True


    @staticmethod
    def _get_node_path(node: Node, attr: str = "name") -> tuple:
        """Return a tuple representing the path of the node using the given attribute."""
        return tuple(getattr(n, attr, None) for n in node.path)


    @staticmethod
    def _get_path_by_name(node: Node) -> tuple:
        """Return the path of the node using node.name at each level."""
        return SpecModel._get_node_path(node, "name")

    @staticmethod
    def _get_path_by_attr(node: Node, attr: str) -> tuple:
        """Return the path of the node using the given attribute at each level."""
        return SpecModel._get_node_path(node, attr)

    def _build_node_map(
        self,
        other: "SpecModel",
        match_by: str,
        attribute_name: Optional[str] = None,
        is_path_based: bool = False
    ) -> tuple[dict, callable]:
        """Construct a mapping from keys to nodes in the other model, and a key function for matching.

        This method prepares the data structures needed for merging two SpecModel trees. It builds a mapping
        from a key (either a node's name, a specified attribute, or a path of such values) to nodes in the
        `other` model, and returns a function that computes the same key for nodes in the current model.

        Args:
            other (SpecModel): The other model to merge with.
            match_by (str): "name" to match by node name, or "attribute" to match by a specific attribute.
            attribute_name (str, optional): The attribute name to use for matching if match_by="attribute".
            is_path_based (bool): If True, use the full path of names/attributes as the key; if False, 
                use only the value.

        Returns:
            tuple: (node_map, key_func)
                node_map (dict): Mapping from key to node in the other model.
                key_func (callable): Function that computes the key for a node in the current model.

        Raises:
            ValueError: If match_by is invalid or attribute_name is missing when required.

        """
        if match_by == "name":
            self.logger.debug("Matching models by node name.")
            if is_path_based:
                node_map = {
                    self._get_path_by_name(node): node
                    for node in PreOrderIter(other.content)
                }
                def key_func(node):
                    return self._get_path_by_name(node)
            else:
                def key_func(node):
                    return self._strip_leading_gt(node.name)
                # Build mapping with handling of duplicates
                key_to_nodes = defaultdict(list)
                for node in PreOrderIter(other.content):
                    key = key_func(node)
                    key_to_nodes[key].append(node)

                self._warn_multiple_matches(key_to_nodes)
                node_map = {key: nodes[-1] for key, nodes in key_to_nodes.items()}

        elif match_by == "attribute" and attribute_name:
            self.logger.debug(f"Matching models by attribute: {attribute_name}")
            if is_path_based:
                node_map = {
                    self._get_path_by_attr(node, attribute_name): node
                    for node in PreOrderIter(other.content)
                }
                def key_func(node):
                    return self._get_path_by_attr(node, attribute_name)
            else:
                def key_func(node):
                    return getattr(node, attribute_name, None)
                # Build mapping with handling of duplicates
                key_to_nodes = defaultdict(list)
                for node in PreOrderIter(other.content):
                    key = key_func(node)
                    key_to_nodes[key].append(node)

                self._warn_multiple_matches(key_to_nodes)
                node_map = {key: nodes[-1] for key, nodes in key_to_nodes.items()}
        else:
            raise ValueError("Invalid match_by or missing attribute_name")

        return node_map, key_func

    def _warn_multiple_matches(self, key_to_nodes: dict):
        """Log a warning if any key in the mapping corresponds to multiple nodes.

        Args:
            key_to_nodes (dict): A mapping from key to a list of nodes with that key.

        Returns:
            None

        """
        for key, nodes in key_to_nodes.items():
            if key is not None and len(nodes) > 1:
                self.logger.warning(
                    f"Multiple nodes found for key '{key}': "
                    f"{[getattr(n, 'name', None) for n in nodes]}. "
                    "Only the last one will be used for merging."
                )

    def _merge_nodes(
        self,
        other: "SpecModel",
        match_by: str,
        attribute_name: Optional[str] = None,
        merge_attrs: Optional[list[str]] = None,
        is_path_based: bool = False,
        ignore_module_level: bool = False,
    ) -> "SpecModel":
        """Merge this SpecModel with another, enriching nodes by matching keys.

        This is the core logic for merging two SpecModel trees. For each node in the current model,
        it attempts to find a matching node in the other model using the specified matching strategy.
        If a match is found, the specified attributes from the other node are copied into the current node.

        Args:
            other (SpecModel): The other model to merge from.
            match_by (str): "name" to match by node name, or "attribute" to match by a specific attribute.
            attribute_name (str, optional): The attribute name to use for matching if match_by="attribute".
            merge_attrs (list[str], optional): List of attribute names to copy from the matching node.
            is_path_based (bool): If True, match nodes by their full path; if False, match globally by key.
            ignore_module_level (bool): If True, skip the module level in the path for matching.

        Returns:
            SpecModel: A deep copy of this model, with attributes merged from the other model where matches are found.

        Notes:
            - If multiple nodes in the other model have the same key, only the last one is used (a warning is logged).
            - If a node in this model has no match in the other model, it is left unchanged.
            - The merge is non-destructive: a new SpecModel is returned.

        """
        merged = copy.deepcopy(self)
        merged.logger = self.logger 

        if is_path_based and ignore_module_level:
            # Build node_map with stripped paths
            if match_by == "name":
                node_map = {
                    self._strip_module_level(self._get_path_by_name(node)): node
                    for node in PreOrderIter(other.content)
                }
                def key_func(node):
                    return self._strip_module_level(self._get_path_by_name(node))
            elif match_by == "attribute" and attribute_name:
                node_map = {
                    self._strip_module_level(self._get_path_by_attr(node, attribute_name)): node
                    for node in PreOrderIter(other.content)
                }
                def key_func(node):
                    return self._strip_module_level(self._get_path_by_attr(node, attribute_name))
            else:
                raise ValueError("Invalid match_by or missing attribute_name")
        else:
            node_map, key_func = self._build_node_map(
                other, match_by, attribute_name, is_path_based
            )

        enriched_count = 0
        total_nodes = 0
        for node in PreOrderIter(merged.content):
            total_nodes += 1
            key = key_func(node)

            if key in node_map and key is not None:
                other_node = node_map[key]
                enriched_this_node = False
                for attr in (merge_attrs or []):
                    if attr is not None and hasattr(other_node, attr):
                        setattr(node, attr, getattr(other_node, attr))
                        attr_val = getattr(other_node, attr)
                        self.logger.debug(
                            f"Enriched node {getattr(node, 'name', None)} "
                            f"(key={key}) with {attr}={str(attr_val)[:10]}"
                        )
                        enriched_this_node = True
                if enriched_this_node:
                    enriched_count += 1

        self.logger.info(f"Total nodes enriched during merge: {enriched_count} / {total_nodes}")
        return merged

    def _strip_module_level(self, path_tuple):
        # Remove all but the last leading None or the module level for path matching
        # This ensures (None, None, '(0010,0010)') and (None, '(0010,0010)') both become (None, '(0010,0010)')
        path = list(path_tuple)
        while len(path) > 2 and path[0] is None:
            path.pop(0)
        return tuple(path)

__init__(metadata, content, logger=None)

Initialize the SpecModel.

Sets up the logger and initializes the specification model.

PARAMETER DESCRIPTION
metadata

Node holding table and document metadata, such as headers, version, and table ID.

TYPE: Node

content

Node holding the hierarchical content tree of the DICOM specification.

TYPE: Node

logger

A pre-configured logger instance to use. If None, a default logger will be created.

TYPE: Logger DEFAULT: None

Source code in src/dcmspec/spec_model.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def __init__(
    self,
    metadata: Node,
    content: Node,
    logger: logging.Logger = None,
):
    """Initialize the SpecModel.

    Sets up the logger and initializes the specification model.

    Args:
        metadata (Node): Node holding table and document metadata, such as headers, version, and table ID.
        content (Node): Node holding the hierarchical content tree of the DICOM specification.
        logger (logging.Logger, optional): A pre-configured logger instance to use.
            If None, a default logger will be created.

    """
    self.logger = logger or logging.getLogger(self.__class__.__name__)
    self.metadata = metadata
    self.content = content

exclude_titles()

Remove nodes corresponding to title rows from the content tree.

Title rows are typically found in some DICOM tables and represent section headers rather than actual data elements (such as Module titles in PS3.4). This method traverses the content tree and removes any node identified as a title, cleaning up the model for further processing.

The method operates on the content tree and does not affect the metadata node.

RETURNS DESCRIPTION
None

None

Source code in src/dcmspec/spec_model.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def exclude_titles(self) -> None:
    """Remove nodes corresponding to title rows from the content tree.

    Title rows are typically found in some DICOM tables and represent section headers
    rather than actual data elements (such as Module titles in PS3.4). 
    This method traverses the content tree and removes any node identified as a title,
    cleaning up the model for further processing.

    The method operates on the content tree and does not affect the metadata node.

    Returns:
        None

    """
    # Traverse the tree and remove nodes where is_title is True
    for node in list(PreOrderIter(self.content)):
        if self._is_title(node):
            self.logger.debug(f"Removing title node: {node.name}")
            node.parent = None

filter_required(type_attr_name, keep=None, remove=None)

Remove nodes that are considered optional according to DICOM requirements.

This method traverses the content tree and removes nodes whose requirement (e.g., "Type", "Matching", or "Return Key") indicates that they are optional. Nodes with conditional or required types (e.g., "1", "1C", "2", "2C") are retained. The method can be customized by specifying which types to keep or remove.

Additionally, for nodes representing Sequences (node names containing "_sequence"), this method removes all subelements if the sequence itself is not required or can be empty (e.g., type "3", "2", "2C", "-", "O", or "Not allowed").

PARAMETER DESCRIPTION
type_attr_name

Name of the node attribute holding the optionality requirement, for example "Type" of an attribute, "Matching", or "Return Key".

TYPE: str

keep

List of type values to keep (default: ["1", "1C", "2", "2C"]).

TYPE: Optional[list[str]] DEFAULT: None

remove

List of type values to remove (default: ["3"]).

TYPE: Optional[list[str]] DEFAULT: None

RETURNS DESCRIPTION
None

None

Source code in src/dcmspec/spec_model.py
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def filter_required(
    self,
    type_attr_name: str,
    keep: Optional[list[str]] = None,
    remove: Optional[list[str]] = None
) -> None:
    """Remove nodes that are considered optional according to DICOM requirements.

    This method traverses the content tree and removes nodes whose requirement
    (e.g., "Type", "Matching", or "Return Key") indicates that they are optional. 
    Nodes with conditional or required types (e.g., "1", "1C", "2", "2C")
    are retained. The method can be customized by specifying which types to keep or remove.

    Additionally, for nodes representing Sequences (node names containing "_sequence"), 
    this method removes all subelements if the sequence itself is not required or can be empty
    (e.g., type "3", "2", "2C", "-", "O", or "Not allowed").

    Args:
        type_attr_name (str): Name of the node attribute holding the optionality requirement,
            for example "Type" of an attribute, "Matching", or "Return Key".
        keep (Optional[list[str]]): List of type values to keep (default: ["1", "1C", "2", "2C"]).
        remove (Optional[list[str]]): List of type values to remove (default: ["3"]).

    Returns:
        None

    """
    if keep is None:
        keep = ["1", "1C", "2", "2C"]
    if remove is None:
        remove = ["3"]
    types_to_keep = keep
    types_to_remove = remove
    attribute_name = type_attr_name

    for node in PreOrderIter(self.content):
        if hasattr(node, attribute_name):
            dcmtype = getattr(node, attribute_name)
            if dcmtype in types_to_remove and dcmtype not in types_to_keep:
                self.logger.debug(f"[{dcmtype.rjust(3)}] : Removing {node.name} element")
                node.parent = None
            # Remove nodes under "Sequence" nodes which are not required or which can be empty
            if "_sequence" in node.name and dcmtype in ["3", "2", "2C", "-", "O", "Not allowed"]:
                self.logger.debug(f"[{dcmtype.rjust(3)}] : Removing {node.name} subelements")
                for descendant in node.descendants:
                    descendant.parent = None

merge_matching_node(other, match_by='name', attribute_name=None, merge_attrs=None)

Merge two SpecModel trees by matching nodes at any level using a single key (name or attribute).

For each node in the current model, this method finds a matching node in the other model using either the node's name (if match_by="name") or a specified attribute (if match_by="attribute"). If a match is found, the specified attributes from the other model's node are merged into the current node.

This is useful for enrichment scenarios, such as adding VR/VM/Keyword from the Part 6 dictionary to a Part 3 module, where nodes are matched by a unique attribute like elem_tag.

  • Matching is performed globally (not by path): any node in the current model is matched to any node in the other model with the same key value, regardless of their position in the tree.
  • It is expected that there is only one matching node per key in the other model.
  • If multiple nodes in the other model have the same key, a warning is logged and only the last one found in pre-order traversal is used for merging.
Example use cases
  • Enrich a PS3.3 module attribute specification with VR/VM from the PS3.6 data elements dictionary.
  • Merge any two models where a unique key (name or attribute) can be used for node correspondence.
PARAMETER DESCRIPTION
other

The other model to merge with the current model.

TYPE: SpecModel

match_by

"name" to match by node.name (stripped of leading '>' and whitespace), or "attribute" to match by a specific attribute value.

TYPE: str DEFAULT: 'name'

attribute_name

The attribute name to use for matching if match_by="attribute".

TYPE: str DEFAULT: None

merge_attrs

List of attribute names to merge from the other model's node.

TYPE: list[str] DEFAULT: None

RETURNS DESCRIPTION
SpecModel

A new merged SpecModel with attributes from the other model merged in.

TYPE: SpecModel

RAISES DESCRIPTION
ValueError

If match_by is invalid or attribute_name is missing when required.

Source code in src/dcmspec/spec_model.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def merge_matching_node(
    self,
    other: "SpecModel",
    match_by: str = "name",
    attribute_name: Optional[str] = None,
    merge_attrs: Optional[list[str]] = None,
) -> "SpecModel":
    """Merge two SpecModel trees by matching nodes at any level using a single key (name or attribute).

    For each node in the current model, this method finds a matching node in the other model
    using either the node's name (if match_by="name") or a specified attribute (if match_by="attribute").
    If a match is found, the specified attributes from the other model's node are merged into the current node.

    This is useful for enrichment scenarios, such as adding VR/VM/Keyword from the Part 6 dictionary
    to a Part 3 module, where nodes are matched by a unique attribute like elem_tag.

    - Matching is performed globally (not by path): any node in the current model is matched to any node
      in the other model with the same key value, regardless of their position in the tree.
    - It is expected that there is only one matching node per key in the other model.
    - If multiple nodes in the other model have the same key, a warning is logged and only the last one
      found in pre-order traversal is used for merging.

    Example use cases:
        - Enrich a PS3.3 module attribute specification with VR/VM from the PS3.6 data elements dictionary.
        - Merge any two models where a unique key (name or attribute) can be used for node correspondence.

    Args:
        other (SpecModel): The other model to merge with the current model.
        match_by (str): "name" to match by node.name (stripped of leading '>' and whitespace),
            or "attribute" to match by a specific attribute value.
        attribute_name (str, optional): The attribute name to use for matching if match_by="attribute".
        merge_attrs (list[str], optional): List of attribute names to merge from the other model's node.

    Returns:
        SpecModel: A new merged SpecModel with attributes from the other model merged in.

    Raises:
        ValueError: If match_by is invalid or attribute_name is missing when required.

    """        
    return self._merge_nodes(
        other,
        match_by=match_by,
        attribute_name=attribute_name,
        merge_attrs=merge_attrs,
        is_path_based=False
    )

merge_matching_path(other, match_by='name', attribute_name=None, merge_attrs=None, ignore_module_level=False)

Merge with another SpecModel, producing a new model with attributes merged for nodes with matching paths.

The path for matching is constructed at each level using either the node's name (if match_by="name") or a specified attribute (if match_by="attribute" and attribute_name is given). Only nodes whose full path matches (by the chosen key) will be merged.

This method is useful for combining DICOM specification models from different parts of the standard. For example, it can be used to merge a PS3.3 model of a normalized IOD specification with a PS3.4 model of a SOP class specification.

PARAMETER DESCRIPTION
other

The other model to merge with the current model.

TYPE: SpecModel

match_by

"name" to match by node.name path, "attribute" to match by a specific attribute path.

TYPE: str DEFAULT: 'name'

attribute_name

The attribute name to use for matching if match_by="attribute".

TYPE: str DEFAULT: None

merge_attrs

List of attribute names to merge from the other model's node.

TYPE: list[str] DEFAULT: None

ignore_module_level

If True, skip the module level in the path for matching.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
SpecModel

A new merged SpecModel.

TYPE: SpecModel

Source code in src/dcmspec/spec_model.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def merge_matching_path(
    self,
    other: "SpecModel",
    match_by: str = "name",
    attribute_name: Optional[str] = None,
    merge_attrs: Optional[list[str]] = None,
    ignore_module_level: bool = False,
) -> "SpecModel":
    """Merge with another SpecModel, producing a new model with attributes merged for nodes with matching paths.

    The path for matching is constructed at each level using either the node's `name`
    (if match_by="name") or a specified attribute (if match_by="attribute" and attribute_name is given).
    Only nodes whose full path matches (by the chosen key) will be merged.

    This method is useful for combining DICOM specification models from different parts of the standard.
    For example, it can be used to merge a PS3.3 model of a normalized IOD specification with a PS3.4 model of a
    SOP class specification.

    Args:
        other (SpecModel): The other model to merge with the current model.
        match_by (str): "name" to match by node.name path, "attribute" to match by a specific attribute path.
        attribute_name (str, optional): The attribute name to use for matching if match_by="attribute".
        merge_attrs (list[str], optional): List of attribute names to merge from the other model's node.
        ignore_module_level (bool, optional): If True, skip the module level in the path for matching.

    Returns:
        SpecModel: A new merged SpecModel.

    """        
    return self._merge_nodes(
        other,
        match_by=match_by,
        attribute_name=attribute_name,
        merge_attrs=merge_attrs,
        is_path_based=True,
        ignore_module_level=ignore_module_level
    )