dcmspec 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. dcmspec/__init__.py +0 -0
  2. dcmspec/apps/__init__.py +1 -0
  3. dcmspec/apps/cli/__init__.py +0 -0
  4. dcmspec/apps/cli/dataelements.py +89 -0
  5. dcmspec/apps/cli/iodattributes.py +125 -0
  6. dcmspec/apps/cli/iodmodules.py +92 -0
  7. dcmspec/apps/cli/modattributes.py +265 -0
  8. dcmspec/apps/cli/tdwiicontent.py +365 -0
  9. dcmspec/apps/cli/uidvalues.py +84 -0
  10. dcmspec/apps/cli/upsdimseattributes.py +109 -0
  11. dcmspec/apps/cli/upsioddimseattributes.py +330 -0
  12. dcmspec/apps/ui/iod_explorer/README.md +33 -0
  13. dcmspec/apps/ui/iod_explorer/__init__.py +9 -0
  14. dcmspec/apps/ui/iod_explorer/config/README.md +171 -0
  15. dcmspec/apps/ui/iod_explorer/config/iod_explorer_config.json +4 -0
  16. dcmspec/apps/ui/iod_explorer/config/iod_explorer_config_debug.json +4 -0
  17. dcmspec/apps/ui/iod_explorer/config/iod_explorer_config_example.json +4 -0
  18. dcmspec/apps/ui/iod_explorer/config/iod_explorer_config_minimal_logging.json +4 -0
  19. dcmspec/apps/ui/iod_explorer/iod_explorer.py +989 -0
  20. dcmspec/config.py +90 -0
  21. dcmspec/csv_table_spec_parser.py +85 -0
  22. dcmspec/doc_handler.py +214 -0
  23. dcmspec/dom_table_spec_parser.py +831 -0
  24. dcmspec/dom_utils.py +116 -0
  25. dcmspec/iod_spec_builder.py +444 -0
  26. dcmspec/iod_spec_printer.py +59 -0
  27. dcmspec/json_spec_store.py +110 -0
  28. dcmspec/module_registry.py +51 -0
  29. dcmspec/pdf_doc_handler.py +451 -0
  30. dcmspec/progress.py +232 -0
  31. dcmspec/service_attribute_defaults.py +124 -0
  32. dcmspec/service_attribute_model.py +231 -0
  33. dcmspec/spec_factory.py +451 -0
  34. dcmspec/spec_merger.py +536 -0
  35. dcmspec/spec_model.py +461 -0
  36. dcmspec/spec_parser.py +37 -0
  37. dcmspec/spec_printer.py +131 -0
  38. dcmspec/spec_store.py +50 -0
  39. dcmspec/ups_xhtml_doc_handler.py +117 -0
  40. dcmspec/xhtml_doc_handler.py +182 -0
  41. dcmspec-0.2.1.dist-info/METADATA +139 -0
  42. dcmspec-0.2.1.dist-info/RECORD +45 -0
  43. dcmspec-0.2.1.dist-info/WHEEL +4 -0
  44. dcmspec-0.2.1.dist-info/entry_points.txt +11 -0
  45. dcmspec-0.2.1.dist-info/licenses/LICENSE +201 -0
dcmspec/dom_utils.py ADDED
@@ -0,0 +1,116 @@
1
+ """DOM utility functions for DICOM XHTML document processing in dcmspec.
2
+
3
+ Provides the DOMUtils class, which offers methods for navigating and extracting
4
+ tables and table IDs from parsed BeautifulSoup DOMs of DICOM standard documents.
5
+
6
+ Typical usage:
7
+ dom_utils = DOMUtils(logger=logger)
8
+ table = dom_utils.get_table(dom, table_id)
9
+ table_id = dom_utils.get_table_id_from_section(dom, section_id)
10
+ """
11
+ import logging
12
+ from typing import Optional
13
+
14
+ from bs4 import BeautifulSoup, Tag
15
+
16
+ class DOMUtils:
17
+ """Utility class for DOM navigation and extraction in DICOM XHTML documents.
18
+
19
+ Provides methods for locating tables and table IDs within a parsed BeautifulSoup DOM,
20
+ with optional logging for warnings and debug information.
21
+
22
+ Typical usage:
23
+ ```python
24
+ dom_utils = DOMUtils(logger=logger)
25
+ table = dom_utils.get_table(dom, table_id)
26
+ table_id = dom_utils.get_table_id_from_section(dom, section_id)
27
+ ```
28
+
29
+ """
30
+
31
+ def __init__(self, logger: Optional[logging.Logger] = None):
32
+ """Initialize DOMUtils with an optional logger.
33
+
34
+ Args:
35
+ logger (Optional[logging.Logger]): Logger instance to use for warnings and debug messages.
36
+ If None, a default logger is created.
37
+
38
+ """
39
+ if logger is not None and not isinstance(logger, logging.Logger):
40
+ raise TypeError("logger must be an instance of logging.Logger or None")
41
+ self.logger = logger or logging.getLogger(self.__class__.__name__)
42
+
43
+ def get_table(self, dom: BeautifulSoup, table_id: str) -> Optional[Tag]:
44
+ """Retrieve the table element with the specified ID from the DOM.
45
+
46
+ DocBook XML to XHTML conversion stylesheets enclose tables in a
47
+ <div class="table"> with the table identifier in <a id="table_ID"></a>
48
+
49
+ Searches for an anchor tag with the given ID and then finds the next
50
+ table element.
51
+
52
+ Args:
53
+ dom: The BeautifulSoup DOM object.
54
+ table_id: The ID of the table to retrieve.
55
+
56
+ Returns:
57
+ The table element if found, otherwise None.
58
+
59
+ """
60
+ anchor = dom.find("a", {"id": table_id})
61
+ if anchor is None:
62
+ self.logger.warning(f"Table Id {table_id} not found.")
63
+ return None
64
+ table_div = anchor.find_parent("div", class_="table")
65
+ if not table_div:
66
+ self.logger.warning(f"Parent <div class='table'> for Table Id {table_id} not found.")
67
+ return None
68
+ table = table_div.find("table")
69
+ if not table:
70
+ self.logger.warning(f"Table for Table Id {table_id} not found inside its <div class='table'>.")
71
+ return None
72
+ return table
73
+
74
+ def get_table_id_from_section(self, dom: BeautifulSoup, section_id: str) -> Optional[str]:
75
+ """Get the id of the first table in a section.
76
+
77
+ Retrieve the first table_id (anchor id) of a `<div class="table">` inside a `<div class="section">`
78
+ that contains an anchor tag with the given section id, e.g.:
79
+
80
+ ```html
81
+ <a id="table_C.7-1" shape="rect"></a>
82
+ ```
83
+
84
+ Args:
85
+ dom (BeautifulSoup): The parsed XHTML DOM object.
86
+ section_id (str): The id of the section to search for the table_id.
87
+
88
+ Returns:
89
+ Optional[str]: The id of the first table anchor found, or None if not found.
90
+
91
+ """
92
+ # Find the anchor with the given id
93
+ anchor = dom.find("a", {"id": section_id})
94
+ if not anchor:
95
+ self.logger.warning(f"Section with id '{section_id}' not found.")
96
+ return None
97
+
98
+ # Find the parent section div
99
+ section_div = anchor.find_parent("div", class_="section")
100
+ if not section_div:
101
+ self.logger.warning(f"No parent <div class='section'> found for section id '{section_id}'.")
102
+ return None
103
+
104
+ # Find the first <div class="table"> inside the section
105
+ table_div = section_div.find("div", class_="table")
106
+ if not table_div:
107
+ self.logger.warning(f"No <div class='table'> found in section for section id '{section_id}'.")
108
+ return None
109
+
110
+ # Find the first anchor with an id inside the table div (the table id)
111
+ table_anchor = table_div.find("a", id=True)
112
+ if table_anchor and table_anchor.get("id"):
113
+ return table_anchor["id"]
114
+
115
+ self.logger.warning(f"No table id found in <div class='table'> for section id '{section_id}'.")
116
+ return None
@@ -0,0 +1,444 @@
1
+ """Builder for expanded DICOM IOD specification models in dcmspec.
2
+
3
+ This module provides the IODSpecBuilder class, which coordinates the construction of a
4
+ DICOM IOD model, combining the IOD Modules and Module Attributes models.
5
+ """
6
+ import logging
7
+ import os
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from anytree import Node
11
+ from bs4 import BeautifulSoup
12
+
13
+ from dcmspec.dom_utils import DOMUtils
14
+ from dcmspec.spec_factory import SpecFactory
15
+ from dcmspec.spec_model import SpecModel
16
+ from dcmspec.module_registry import ModuleRegistry
17
+
18
+ # BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
19
+ from typing import Callable
20
+ from dcmspec.progress import (
21
+ Progress,
22
+ ProgressStatus,
23
+ ProgressObserver,
24
+ add_progress_step,
25
+ calculate_percent,
26
+ handle_legacy_callback,
27
+ )
28
+ # END LEGACY SUPPORT
29
+
30
+ class IODSpecBuilder:
31
+ """Orchestrates the construction of DICOM IOD specification models supporting two modes.
32
+
33
+ - Expanded (legacy) mode: Produces a single expanded IOD model, where each IOD node has its referenced
34
+ Module's content nodes attached as children. This is the default if no module_registry is provided.
35
+
36
+ - Registry/reference mode: If a ModuleRegistry is provided, Module models are shared by reference via the registry,
37
+ enabling efficient reuse and reduced memory usage when building many IODs.
38
+
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ iod_factory: SpecFactory = None,
44
+ module_factory: SpecFactory = None,
45
+ logger: logging.Logger = None,
46
+ ref_attr: str = None,
47
+ module_registry: Optional[ModuleRegistry] = None,
48
+ ):
49
+ """Initialize the IODSpecBuilder.
50
+
51
+ If no factory is provided, a default SpecFactory is used for both IOD and module models.
52
+
53
+ Args:
54
+ iod_factory (Optional[SpecFactory]): Factory for building the IOD model. If None, uses SpecFactory().
55
+ module_factory (Optional[SpecFactory]): Factory for building module models. If None, uses iod_factory.
56
+ logger (Optional[logging.Logger]): Logger instance to use. If None, a default logger is created.
57
+ ref_attr (Optional[str]): Attribute name to use for Module references. If None, defaults to "ref".
58
+ module_registry (Optional[ModuleRegistry]): Registry for sharing Module models by table_id.
59
+ If provided, Module models are shared by reference across IODs.
60
+
61
+ Raises:
62
+ ValueError: If `ref_attr` is not a non-empty string.
63
+
64
+ Note:
65
+ The builder is initialized with factories for the IOD and module models. By default, the same
66
+ factory is used for both, but a different factory can be provided for modules if needed.
67
+
68
+ """
69
+ self.logger = logger or logging.getLogger(self.__class__.__name__)
70
+
71
+ self.iod_factory = iod_factory or SpecFactory(logger=self.logger)
72
+ self.module_factory = module_factory or self.iod_factory
73
+ self.dom_utils = DOMUtils(logger=self.logger)
74
+ self.ref_attr = ref_attr or "ref"
75
+ self.module_registry = module_registry
76
+ # Set expand flag: expand=True for legacy (expanded/copy) mode, False for registry/reference mode
77
+ self.expand = self.module_registry is None
78
+ if not isinstance(self.ref_attr, str) or not self.ref_attr.strip():
79
+ raise ValueError("ref_attr must be a non-empty string.")
80
+
81
+
82
+ def build_from_url(
83
+ self,
84
+ url: str,
85
+ cache_file_name: str,
86
+ table_id: str,
87
+ force_download: bool = False,
88
+ progress_observer: 'Optional[ProgressObserver]' = None,
89
+ # BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
90
+ progress_callback: 'Optional[Callable[[int], None]]' = None,
91
+ # END LEGACY SUPPORT
92
+ json_file_name: str = None,
93
+ **kwargs: object,
94
+ ) -> tuple[SpecModel, Optional[Dict[str, SpecModel]]]:
95
+ """Build and cache a DICOM IOD specification model from a URL.
96
+
97
+ Warning::
98
+ This method now returns a tuple ``(iod_model, module_models)``, which is a **breaking change** from
99
+ previous versions that returned only the IOD model. All callers must be updated to unpack the tuple,
100
+ or use backward-compatible wrappers if needed.
101
+
102
+ This method orchestrates the full workflow:
103
+ - Loads or downloads the IOD table and builds/caches the IOD model using the iod_factory.
104
+ - Finds all nodes in the IOD model with a "ref" attribute, indicating a referenced module.
105
+ - For each referenced module, loads or parses and caches the module model using the module_factory.
106
+ - Assembles a new expanded model, where each IOD node has its referenced module's content node as a child.
107
+ - Uses the first module's metadata header and version for the expanded model's metadata.
108
+ - Caches the expanded model if a json_file_name is provided.
109
+
110
+ Args:
111
+ url (str): The URL to download the input file from.
112
+ cache_file_name (str): Filename of the cached input file.
113
+ table_id (str): The ID of the IOD table to parse.
114
+ force_download (bool): If True, always download the input file and generate the model even if cached.
115
+ progress_observer (Optional[ProgressObserver]): Optional observer to report download progress.
116
+ See the Note below for details on the progress events and their properties.
117
+ progress_callback (Optional[Callable[[int], None]]): [LEGACY, Deprecated] Optional callback to
118
+ report progress as an integer percent (0-100, or -1 if indeterminate). Use progress_observer
119
+ instead. Will be removed in a future release.
120
+ json_file_name (str, optional): Filename to save the cached expanded JSON model.
121
+ **kwargs: Additional arguments for model construction.
122
+
123
+ Returns:
124
+ tuple: (iod_model, module_models)
125
+ - iod_model (SpecModel): The expanded IOD model (if expand=True) or the IOD model (if expand=False).
126
+ - module_models (dict or None): The Module models dict (None if expand=True).
127
+
128
+ Note:
129
+ The returned `module_models` dict contains only the modules referenced by the current IOD.
130
+ If you provided a `ModuleRegistry`, it may contain additional modules from previous or future builds.
131
+ The registry enables sharing and reuse, but the returned dict is always specific to the current IOD.
132
+
133
+
134
+ Note:
135
+ If a progress observer accepting a Progress object is provided, progress events are as follows:
136
+
137
+ - **Step 1 (DOWNLOADING_IOD):** Events include `status=DOWNLOADING_IOD`, `step=1`,
138
+ `total_steps=4`, and a meaningful `percent` value.
139
+ - **Step 2 (PARSING_IOD_MODULE_LIST):** Events include `status=PARSING_IOD_MODULE_LIST`, `step=2`,
140
+ `total_steps=4`, and `percent == -1` (indeterminate).
141
+ - **Step 3 (PARSING_IOD_MODULES):** Events include `status=PARSING_IOD_MODULES`, `step=3`,
142
+ `total_steps=4`, and a meaningful `percent` value.
143
+ - **Step 4 (SAVING_IOD_MODEL):** Events include `status=SAVING_IOD_MODEL`, `step=4`,
144
+ `total_steps=4`, and `percent == -1` (indeterminate).
145
+
146
+ For example usage in a client application,
147
+ see [`ProgressStatus`](progress.md#dcmspec.progress.ProgressStatus).
148
+
149
+ """
150
+ # BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
151
+ progress_observer = handle_legacy_callback(progress_observer, progress_callback)
152
+ # END LEGACY SUPPORT
153
+
154
+ # Load from cache if available and not force_download
155
+ cache_dir = self.iod_factory.config.get_param("cache_dir")
156
+ iod_model_path = self._get_model_cache_path(json_file_name, cache_dir)
157
+ iod_model = self._load_iod_model_from_cache(iod_model_path, force_download)
158
+ module_models = None
159
+ if not self.expand and iod_model is not None:
160
+ module_models = self._load_module_models_from_cache(iod_model, cache_dir)
161
+ if iod_model is not None and (self.expand or module_models is not None):
162
+ self.logger.info(f"Loaded IOD model from cache: {iod_model_path}")
163
+ return iod_model, module_models
164
+
165
+ total_steps = 4 # 1=download, 2=parse IOD, 3=build modules, 4=save
166
+
167
+ # --- Step 1: Load the DOM from cache file or download and cache DOM in memory ---
168
+ if progress_observer:
169
+ @add_progress_step(step=1, total_steps=total_steps, status=ProgressStatus.DOWNLOADING_IOD)
170
+ def step1_progress_observer(progress):
171
+ progress_observer(progress)
172
+ else:
173
+ step1_progress_observer = None
174
+ dom = self.iod_factory.load_document(
175
+ url=url,
176
+ cache_file_name=cache_file_name,
177
+ force_download=force_download,
178
+ progress_observer=step1_progress_observer,
179
+ # BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
180
+ progress_callback=progress_observer,
181
+ # END LEGACY SUPPORT
182
+ )
183
+
184
+ # --- Step 2: Build the IOD model from the DOM ---
185
+ if progress_observer:
186
+ progress_observer(
187
+ Progress(-1, status=ProgressStatus.PARSING_IOD_MODULE_LIST, step=2, total_steps=total_steps)
188
+ )
189
+ iod_model = self._build_iod_model(dom, table_id, url, json_file_name)
190
+
191
+ # --- Step 3: Build or load model for each module in the IOD ---
192
+ if progress_observer:
193
+ progress_observer(
194
+ Progress(-1, status=ProgressStatus.PARSING_IOD_MODULES, step=3, total_steps=total_steps)
195
+ )
196
+
197
+ # Find all nodes with a reference attribute in the IOD Modules model
198
+ nodes_with_ref = [node for node in iod_model.content.children if hasattr(node, self.ref_attr)]
199
+
200
+ # Build or load module models for each referenced section
201
+ module_models = self._build_module_models(
202
+ nodes_with_ref, dom, url, step=3, total_steps=total_steps, progress_observer=progress_observer
203
+ )
204
+ # Fail if no module models were found.
205
+ if not module_models:
206
+ raise RuntimeError("No module models were found for the referenced modules in the IOD table.")
207
+
208
+ # --- Step 4: Create and store the iod expanded or enriched model ---
209
+ if progress_observer:
210
+ progress_observer(Progress(-1, status=ProgressStatus.SAVING_IOD_MODEL, step=4, total_steps=total_steps))
211
+
212
+ # Create the expanded model from the IOD modules and module models
213
+ if self.expand:
214
+ expanded_iod_model = self._create_expanded_model(iod_model, module_models)
215
+
216
+ # Cache the expanded or enriched model if a json_file_name is provided
217
+ if json_file_name:
218
+ iod_json_file_path = self._get_model_cache_path(
219
+ json_file_name, self.iod_factory.config.get_param("cache_dir")
220
+ )
221
+ try:
222
+ self.iod_factory.model_store.save(expanded_iod_model, iod_json_file_path)
223
+ except Exception as e:
224
+ self.logger.warning(f"Failed to cache model to {iod_json_file_path}: {e}")
225
+ else:
226
+ self.logger.info("No json_file_name specified; IOD model not cached.")
227
+
228
+ if self.expand:
229
+ return expanded_iod_model, None
230
+ else:
231
+ return iod_model, module_models
232
+
233
+ def _get_model_cache_path(self, json_file_name: Optional[str], cache_dir: str) -> Optional[str]:
234
+ """Return the full path to the model cache file for the given cache_dir and json_file_name."""
235
+ return self._get_cache_path(json_file_name, cache_dir)
236
+
237
+ def _get_module_model_cache_path(self, module_json_file_name: str) -> Optional[str]:
238
+ """Return the full path to the module model cache file for the current module_factory."""
239
+ cache_dir = self.module_factory.config.get_param("cache_dir")
240
+ return self._get_cache_path(module_json_file_name, cache_dir)
241
+
242
+ def _get_cache_path(self, json_file_name: Optional[str], cache_dir: Optional[str]) -> Optional[str]:
243
+ """Return the full path to a cache file in the model cache directory."""
244
+ if json_file_name and cache_dir:
245
+ return os.path.join(cache_dir, "model", json_file_name)
246
+ return None
247
+
248
+ def _load_iod_model_from_cache(self, iod_model_path: Optional[str], force_download: bool) -> Optional[SpecModel]:
249
+ """Return the cached IOD model if available and not force_download, else None."""
250
+ if iod_model_path and os.path.exists(iod_model_path) and not force_download:
251
+ try:
252
+ return self.iod_factory.model_store.load(iod_model_path)
253
+ except Exception as e:
254
+ self.logger.warning(
255
+ f"Failed to load IOD model from cache {iod_model_path}: {e}"
256
+ )
257
+ return None
258
+
259
+ def _load_module_models_from_cache(self, iod_model: SpecModel, cache_dir: str) -> Optional[Dict[str, SpecModel]]:
260
+ """Return a dict of module models loaded from cache, keyed by table_id, for the given IOD model.
261
+
262
+ If a module is already present in the registry, it is reused and not loaded from cache again.
263
+ """
264
+ module_models = {}
265
+ nodes_with_ref = [node for node in iod_model.content.children if hasattr(node, self.ref_attr)]
266
+ for node in nodes_with_ref:
267
+ table_id = getattr(node, "table_id", None)
268
+ if table_id:
269
+ # Use registry if available and module already present
270
+ if self.module_registry is not None and table_id in self.module_registry:
271
+ module_model = self.module_registry[table_id]
272
+ else:
273
+ module_json_file_name = f"{table_id}.json"
274
+ module_json_file_path = self._get_module_model_cache_path(module_json_file_name)
275
+ if module_json_file_path and os.path.exists(module_json_file_path):
276
+ try:
277
+ module_model = self.module_factory.model_store.load(module_json_file_path)
278
+ # Optionally, also populate the registry
279
+ if self.module_registry is not None:
280
+ self.module_registry[table_id] = module_model
281
+ except Exception as e:
282
+ self.logger.warning(
283
+ f"Failed to load module model from cache {module_json_file_path}: {e}"
284
+ )
285
+ continue
286
+ else:
287
+ continue # If not in registry and not in cache, skip (or could trigger build if desired)
288
+ module_models[table_id] = module_model
289
+ return module_models or None
290
+
291
+ def _build_iod_model(self, dom, table_id, url, json_file_name):
292
+ """Build the IOD Module List model, without caching."""
293
+ # Build IOD model without caching as iod enriched or expanded model will be cached separately
294
+ return self.iod_factory.build_model(
295
+ doc_object=dom,
296
+ table_id=table_id,
297
+ url=url,
298
+ json_file_name=None,
299
+ )
300
+
301
+ def _build_module_models(
302
+ self,
303
+ nodes_with_ref: List[Any],
304
+ dom: Any,
305
+ url: str,
306
+ step: int,
307
+ total_steps: int,
308
+ progress_observer: Optional['ProgressObserver'] = None
309
+ ) -> Dict[str, Any]:
310
+ """Build or load module models for each referenced section, reporting progress.
311
+
312
+ If a module is already present in the registry, it is reused and not loaded from cache again.
313
+ """
314
+ module_models: Dict[str, Any] = {}
315
+
316
+ # Initialize progress tracking
317
+ total_modules = len(nodes_with_ref)
318
+ if progress_observer and total_modules > 0:
319
+ progress_observer(
320
+ Progress(0, status=ProgressStatus.PARSING_IOD_MODULES, step=step, total_steps=total_steps)
321
+ )
322
+ # Iterate over nodes with references to modules
323
+ for idx, node in enumerate(nodes_with_ref):
324
+ ref_value = getattr(node, self.ref_attr, None)
325
+ section_id = self._get_section_id_from_ref(ref_value)
326
+ if not section_id:
327
+ continue
328
+
329
+ module_table_id = self.dom_utils.get_table_id_from_section(dom, section_id)
330
+ self.logger.debug(f"First Module table_id for section_id={repr(section_id)}: {repr(module_table_id)}")
331
+ if not module_table_id:
332
+ self.logger.warning(f"No table found for section id {section_id}")
333
+ continue
334
+
335
+ # Enrich iod module node with the module's table_id for reference to registry.
336
+ setattr(node, "table_id", module_table_id)
337
+
338
+ # Load the module model from cache or registry, or build it if not found
339
+ module_model = self._get_or_build_module_model(
340
+ module_table_id, dom, url, progress_observer
341
+ )
342
+ # Add Module model to dict
343
+ if module_model is not None:
344
+ module_models[module_table_id] = module_model
345
+
346
+ # Update progress
347
+ if progress_observer and total_modules > 0:
348
+ percent = calculate_percent(idx + 1, total_modules)
349
+ progress_observer(Progress(
350
+ percent,
351
+ status=ProgressStatus.PARSING_IOD_MODULES,
352
+ step=step,
353
+ total_steps=total_steps
354
+ ))
355
+ return module_models
356
+
357
+ def _get_or_build_module_model(
358
+ self,
359
+ module_table_id: str,
360
+ dom: Any,
361
+ url: str,
362
+ progress_observer: Optional['ProgressObserver'] = None
363
+ ) -> Optional[Any]:
364
+ """Get or build a module model for the given table_id, using registry and cache as appropriate."""
365
+ # Use registry if available and module already present
366
+ if self.module_registry is not None and module_table_id in self.module_registry:
367
+ return self.module_registry[module_table_id]
368
+
369
+ # Attempt to load from cache
370
+ module_json_file_name = f"{module_table_id}.json"
371
+ module_json_file_path = self._get_module_model_cache_path(module_json_file_name)
372
+ if module_json_file_path and os.path.exists(module_json_file_path):
373
+ try:
374
+ module_model = self.module_factory.model_store.load(module_json_file_path)
375
+ except Exception as e:
376
+ self.logger.warning(f"Failed to load module model from cache {module_json_file_path}: {e}")
377
+ module_model = self.module_factory.build_model(
378
+ doc_object=dom,
379
+ table_id=module_table_id,
380
+ url=url,
381
+ json_file_name=module_json_file_name,
382
+ progress_observer=progress_observer,
383
+ )
384
+ else:
385
+ # Build the module model without caching (it will be cached when building the IOD model)
386
+ module_model = self.module_factory.build_model(
387
+ doc_object=dom,
388
+ table_id=module_table_id,
389
+ url=url,
390
+ json_file_name=module_json_file_name,
391
+ progress_observer=progress_observer,
392
+ )
393
+ # Store in registry if using reference mode
394
+ if self.module_registry is not None:
395
+ self.module_registry[module_table_id] = module_model
396
+ return module_model
397
+
398
+ def _get_section_id_from_ref(self, ref_value: str) -> Optional[str]:
399
+ """Normalize a ref_value (plain text or HTML anchor) to a section_id.
400
+
401
+ For HTML, extract the href after '#'. For plain text, always prepend 'sect_'.
402
+ Strips whitespace for robust lookup.
403
+ (Do NOT lowercase: DICOM IDs are mixed case and BeautifulSoup search is case-sensitive.)
404
+ """
405
+ if not ref_value:
406
+ return None
407
+ if "<a " not in ref_value:
408
+ # Always prepend 'sect_' for plain text references, strip only
409
+ section_id = f"sect_{ref_value.strip()}"
410
+ self.logger.debug(f"Extracted section_id from plain text reference: {repr(section_id)}")
411
+ return section_id
412
+ soup = BeautifulSoup(ref_value, "lxml-xml")
413
+ # Find the anchor with class "xref" (the actual module reference)
414
+ anchor = soup.find("a", class_="xref")
415
+ if anchor and anchor.has_attr("href"):
416
+ href = anchor["href"].strip()
417
+ section_id = href.split("#", 1)[-1] if "#" in href else href
418
+ section_id = section_id.strip()
419
+ self.logger.debug(f"Extracted section_id from HTML reference: {repr(section_id)}")
420
+ return section_id
421
+ else:
422
+ self.logger.debug(f"No section_id could be extracted from ref_value={repr(ref_value)}")
423
+ return None
424
+
425
+ def _create_expanded_model(self, iodmodules_model: SpecModel, module_models: dict) -> SpecModel:
426
+ """Create the expanded model by attaching Module nodes content to IOD nodes."""
427
+ # Use the first module's metadata node for the expanded model
428
+ first_module = next(iter(module_models.values()))
429
+ iod_metadata = first_module.metadata
430
+ iod_metadata.table_id = iodmodules_model.metadata.table_id
431
+
432
+ # The content node will have as children the IOD model's nodes,
433
+ # and for each referenced module, its content's children will be attached directly under the iod node
434
+ iod_content = Node("content")
435
+ for iod_node in iodmodules_model.content.children:
436
+ table_id = getattr(iod_node, "table_id", None)
437
+ if table_id and table_id in module_models:
438
+ module_content = module_models[table_id].content
439
+ for child in list(module_content.children):
440
+ child.parent = iod_node
441
+ iod_node.parent = iod_content
442
+
443
+ # Create and return the expanded model
444
+ return SpecModel(metadata=iod_metadata, content=iod_content)
@@ -0,0 +1,59 @@
1
+ """Printer class for DICOM IOD specification model in dcmspec.
2
+
3
+ Provides the SpecPrinter class for printing DICOM IOD specification models (SpecModel)
4
+ to standard output, either as a hierarchical tree or as a flat table, using rich formatting.
5
+ """
6
+ from anytree import PreOrderIter
7
+ from rich.table import Table, box
8
+
9
+ from dcmspec.spec_printer import LEVEL_COLORS, SpecPrinter
10
+
11
+ class IODSpecPrinter(SpecPrinter):
12
+ """Printer for DICOM IOD specification models with mixed node types.
13
+
14
+ Overrides print_table to display IOD Modules nodes as a one-cell title row (spanning all columns)
15
+ The table columns are those of the Module Attributes nodes.
16
+ """
17
+
18
+ def print_table(self, colorize: bool = False):
19
+ """Print the specification model as a flat table with module title rows.
20
+
21
+ Args:
22
+ colorize (bool): Whether to colorize the output by node depth.
23
+
24
+ """
25
+ table = Table(show_header=True, header_style="bold magenta", show_lines=True, box=box.ASCII_DOUBLE_HEAD)
26
+
27
+ attr_headers = list(self.model.metadata.header)
28
+ for header in attr_headers:
29
+ table.add_column(header, width=20)
30
+
31
+ # Traverse the tree in PreOrder (as in the base class)
32
+ for node in PreOrderIter(self.model.content):
33
+ # skip the root node
34
+ if node.name == "content":
35
+ continue # Print IOD module nodes as a title row (one cell, spanning all columns)
36
+ if hasattr(node, "module"):
37
+ iod_title = getattr(node, "module", getattr(node, "name", ""))
38
+ iod_usage = getattr(node, "usage", "")
39
+ iod_title_text = f"{iod_title} Module ({iod_usage})" if iod_usage else iod_title
40
+ # Set title style
41
+ row_style = (
42
+ "magenta" if colorize else None
43
+ )
44
+ table.add_row(iod_title_text, *[""] * (len(attr_headers) - 1), style=row_style)
45
+ # Print module attribute nodes as regular rows
46
+ else:
47
+ row = [getattr(node, attr, "") for attr in self.model.metadata.column_to_attr.values()]
48
+ row_style = None
49
+ if colorize:
50
+ row_style = (
51
+ "yellow"
52
+ if self.model._is_include(node)
53
+ else "magenta"
54
+ if self.model._is_title(node)
55
+ else LEVEL_COLORS[(node.depth - 1) % len(LEVEL_COLORS)]
56
+ )
57
+ table.add_row(*row, style=row_style)
58
+
59
+ self.console.print(table)