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/config.py ADDED
@@ -0,0 +1,90 @@
1
+ """Configuration management for the dcmspec library.
2
+
3
+ This module provides the Config class for managing application configuration,
4
+ including cache directory handling and user-defined parameters.
5
+ """
6
+
7
+ import os
8
+ import json
9
+ from typing import Any, Optional, Dict
10
+ from platformdirs import user_cache_dir, user_config_dir
11
+
12
+
13
+ class Config:
14
+ """Manages application configuration.
15
+
16
+ Reserved configuration keys:
17
+ - cache_dir: Cache Directory path used by the library. If not set by the user, OS-specific default is used.
18
+
19
+ Users may add their own keys, but should not overwrite reserved keys unless they intend to change library behavior.
20
+ """
21
+
22
+ def __init__(self, app_name: str = "dcmspec", config_file: Optional[str] = None):
23
+ """Initialize the Config object.
24
+
25
+ Args:
26
+ app_name: The application name used for determining default config/cache directories.
27
+ config_file: Optional path to a specific config file. If not provided, a default location is used.
28
+
29
+ """
30
+ self.app_name: str = app_name
31
+ self.config_file: str = config_file or os.path.join(user_config_dir(app_name), "config.json")
32
+
33
+ # Check if config_file is a directory; if so, warn and fall back to default config
34
+ if os.path.isdir(self.config_file):
35
+ print(f"Warning: The config_file path '{self.config_file}' is a directory, not a file. Using default.")
36
+ self._data: Dict[str, Any] = {"cache_dir": user_cache_dir(app_name)}
37
+ return
38
+
39
+ # Initialize config with OS-specific default value for cache directory
40
+ self._data: Dict[str, Any] = {"cache_dir": user_cache_dir(app_name)}
41
+
42
+ self.load_config()
43
+
44
+ def load_config(self) -> None:
45
+ """Load configuration from the config file if it exists.
46
+
47
+ Creates the cache directory if it does not exist.
48
+ """
49
+ try:
50
+ if os.path.exists(self.config_file):
51
+ with open(self.config_file, "r", encoding="utf-8") as f:
52
+ config: Dict[str, Any] = json.load(f)
53
+ self._data.update(config)
54
+ except (OSError, json.JSONDecodeError) as e:
55
+ print(f"Failed to load configuration file {self.config_file}: {e}")
56
+
57
+ cache_dir = self.get_param("cache_dir")
58
+ try:
59
+ os.makedirs(cache_dir, exist_ok=True)
60
+ except FileExistsError:
61
+ print(f"Error: The cache_dir path '{cache_dir}' exists and is not a directory.")
62
+ return
63
+
64
+ # Handle rare case where the path may not be a directory and, for any reason, os.makedirs did not fail.
65
+ if not os.path.isdir(cache_dir):
66
+ print(f"Error: The cache_dir path '{cache_dir}' is not a directory.")
67
+ return
68
+
69
+ def save_config(self) -> None:
70
+ """Save the current configuration to the config file."""
71
+ try:
72
+ os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
73
+ with open(self.config_file, "w", encoding="utf-8") as f:
74
+ json.dump(self._data, f, indent=4)
75
+ except OSError as e:
76
+ print(f"Failed to save configuration file {self.config_file}: {e}")
77
+
78
+ def set_param(self, key: str, value: Any) -> None:
79
+ """Set a configuration parameter."""
80
+ self._data[key] = value
81
+
82
+ def get_param(self, key: str) -> Optional[Any]:
83
+ """Get a configuration parameter by key."""
84
+ return self._data.get(key)
85
+
86
+ @property
87
+ def cache_dir(self) -> str:
88
+ """Access the cache directory used by the library."""
89
+ return self.get_param("cache_dir")
90
+
@@ -0,0 +1,85 @@
1
+ """CSV specification parser class for IHE profiles tables in dcmspec.
2
+
3
+ Provides the CSVTableSpecParser class for parsing DICOM specification tables in CSV format,
4
+ converting them into structured in-memory representations using anytree.
5
+ """
6
+ from typing import Tuple
7
+ from anytree import Node
8
+
9
+ from dcmspec.spec_parser import SpecParser
10
+
11
+ class CSVTableSpecParser(SpecParser):
12
+ """Base parser for DICOM Specification IHE tables in CSV-like format."""
13
+
14
+ def parse(
15
+ self,
16
+ table: dict,
17
+ column_to_attr,
18
+ name_attr="elem_name",
19
+ table_id=None,
20
+ include_depth=None,
21
+ ) -> Tuple[Node, Node]:
22
+ """Parse specification metadata and content from a single table dict.
23
+
24
+ Args:
25
+ table (dict): A table dict as output by PDFDocHandler.concat_tables, with 'header' and 'data' keys.
26
+ column_to_attr (dict): Mapping from column indices to node attribute names.
27
+ name_attr (str): The attribute to use for node names.
28
+ table_id (str, optional): Table identifier for model parsing.
29
+ include_depth (int, optional): The depth to which included tables should be parsed.
30
+
31
+ Returns:
32
+ tuple: (metadata_node, content_node)
33
+
34
+ """
35
+ # Use the header and data from the grouped table dict
36
+ header = table.get("header", [])
37
+ data = table.get("data", [])
38
+
39
+ metadata = Node("metadata")
40
+ metadata.header = header
41
+ metadata.column_to_attr = column_to_attr
42
+ metadata.table_id = table_id
43
+ if include_depth is not None:
44
+ metadata.include_depth = int(include_depth)
45
+ content = self.parse_table([data], column_to_attr, name_attr)
46
+ return metadata, content
47
+
48
+ def parse_table(
49
+ self,
50
+ tables: list, # List of tables, each a list of rows (list of str)
51
+ column_to_attr: dict,
52
+ name_attr: str = "elem_name",
53
+ ) -> Node:
54
+ """Build a tree from tables using column mapping and '>' nesting logic.
55
+
56
+ Args:
57
+ tables (list): List of tables, each a list of rows (list of str).
58
+ column_to_attr (dict): Mapping from column indices to node attribute names.
59
+ name_attr (str): The attribute to use for node names.
60
+
61
+ Returns:
62
+ Node: The root node of the tree.
63
+
64
+ """
65
+ root = Node("content")
66
+ parent_nodes = {0: root}
67
+ for table in tables:
68
+ for row in table:
69
+ row_data = {}
70
+ for col_idx, attr in column_to_attr.items():
71
+ value = row[col_idx] if col_idx < len(row) else ""
72
+ # Clean up newlines in the cell to be used as node name
73
+ if attr == name_attr:
74
+ value = value.replace("\n", " ")
75
+ row_data[attr] = value
76
+ node_name = row_data[name_attr]
77
+ level = node_name.count(">") + 1
78
+ # Ensure all parent levels exist
79
+ if (level - 1) not in parent_nodes:
80
+ # If a parent is missing, attach to root
81
+ parent_nodes[level - 1] = root
82
+ parent = parent_nodes[level - 1]
83
+ child = Node(node_name, parent=parent, **row_data)
84
+ parent_nodes[level] = child
85
+ return root
dcmspec/doc_handler.py ADDED
@@ -0,0 +1,214 @@
1
+ """Base class for handling DICOM specification documents in dcmspec.
2
+
3
+ Provides the DocHandler class for reading, parsing, and downloading DICOM documents
4
+ in various formats (e.g., XHTML, PDF). The base class supplies a generic download
5
+ method for both text and binary files, and defines the interface for document parsing.
6
+ Subclasses should implement the `load_document` method for their specific format.
7
+ """
8
+ import os
9
+ from typing import Any, Optional
10
+ import logging
11
+ import requests
12
+
13
+ from dcmspec.config import Config
14
+ from dcmspec.progress import Progress, ProgressObserver, calculate_percent
15
+ # BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
16
+ from dcmspec.progress import ProgressStatus, handle_legacy_callback
17
+ from typing import Callable
18
+ # END LEGACY SUPPORT
19
+
20
+ class DocHandler:
21
+ """Base class for DICOM document handlers.
22
+
23
+ Handles DICOM documents in various formats (e.g., XHTML, PDF).
24
+ Subclasses must implement the `load_document` method to handle
25
+ reading/parsing input files. The base class provides a generic
26
+ download method for both text and binary files.
27
+
28
+ Progress Reporting:
29
+ The observer pattern is used for progress reporting. Subclasses may extend
30
+ the Progress class and use the progress_observer to report additional information
31
+ (e.g., status, errors, or other context) beyond percent complete, enabling future
32
+ extensibility for richer progress tracking.
33
+ """
34
+
35
+ def __init__(self, config: Optional[Config] = None, logger: Optional[logging.Logger] = None):
36
+ """Initialize the document handler with an optional logger.
37
+
38
+ Args:
39
+ config (Optional[Config]): Config instance to use. If None, a default Config is created.
40
+ logger (Optional[logging.Logger]): Logger instance to use. If None, a default logger is created.
41
+
42
+ Logging:
43
+ A logger may be passed for custom logging control. If no logger is provided,
44
+ a default logger for this class is used. In both cases, no logging handlers
45
+ are added by default. To see log output, logging should be configured in the
46
+ application (e.g., with logging.basicConfig()).
47
+
48
+ """
49
+ if logger is not None and not isinstance(logger, logging.Logger):
50
+ raise TypeError("logger must be an instance of logging.Logger or None")
51
+ self.logger = logger or logging.getLogger(self.__class__.__name__)
52
+
53
+ if config is not None and not isinstance(config, Config):
54
+ raise TypeError("config must be an instance of Config or None")
55
+ self.config = config or Config()
56
+
57
+ def download(
58
+ self,
59
+ url: str,
60
+ file_path: str,
61
+ binary: bool = False,
62
+ progress_observer: 'Optional[ProgressObserver]' = None,
63
+ # BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
64
+ progress_callback: 'Optional[Callable[[int], None]]' = None
65
+ # END LEGACY SUPPORT
66
+ ) -> str:
67
+ """Download a file from a URL and save it to the specified path.
68
+
69
+ Args:
70
+ url (str): The URL to download the file from.
71
+ file_path (str): The path to save the downloaded file.
72
+ binary (bool): If True, save as binary. If False, save as UTF-8 text.
73
+ progress_observer (Optional[ProgressObserver]): Optional observer to report download progress.
74
+ progress_callback (Optional[Callable[[int], None]]): [LEGACY, Deprecated] Optional callback to
75
+ report progress as an integer percent (0-100, or -1 if indeterminate). Use progress_observer
76
+ instead. Will be removed in a future release.
77
+
78
+ Returns:
79
+ str: The file path where the document was saved.
80
+
81
+ Raises:
82
+ RuntimeError: If the download or save fails.
83
+
84
+ """
85
+ # BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
86
+ progress_observer = handle_legacy_callback(progress_observer, progress_callback)
87
+ # END LEGACY SUPPORT
88
+ self.logger.info(f"Downloading document from {url} to {file_path}")
89
+ try:
90
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
91
+ except OSError as e:
92
+ self.logger.error(f"Failed to create directory for {file_path}: {e}")
93
+ raise RuntimeError(f"Failed to create directory for {file_path}: {e}") from e
94
+ try:
95
+ with requests.get(url, timeout=30, stream=True, headers={"Accept-Encoding": "identity"}) as response:
96
+ response.raise_for_status()
97
+ total = int(response.headers.get('content-length', 0))
98
+ chunk_size = 8192
99
+ if binary:
100
+ self._download_binary(response, file_path, total, chunk_size, progress_observer)
101
+ else:
102
+ self._download_text(response, file_path, total, chunk_size, progress_observer)
103
+ self.logger.info(f"Document downloaded to {file_path}")
104
+ return file_path
105
+ except requests.exceptions.RequestException as e:
106
+ self.logger.error(f"Failed to download {url}: {e}")
107
+ raise RuntimeError(f"Failed to download {url}: {e}") from e
108
+ except OSError as e:
109
+ self.logger.error(f"Failed to save file {file_path}: {e}")
110
+ raise RuntimeError(f"Failed to save file {file_path}: {e}") from e
111
+
112
+ def _report_progress(self, downloaded, total, progress_observer, last_percent):
113
+ """Report progress if percent changed.
114
+
115
+ If the total file size is unknown or invalid, calls the observer with -1 to indicate
116
+ indeterminate progress. Otherwise, calls the observer with the integer percent (0-100).
117
+ Adds status=ProgressStatus.DOWNLOADING to all progress events.
118
+ """
119
+ if not progress_observer:
120
+ return
121
+
122
+ percent = calculate_percent(downloaded, total)
123
+ if percent != last_percent[0]:
124
+ progress_observer(Progress(percent, status=ProgressStatus.DOWNLOADING))
125
+ last_percent[0] = percent
126
+
127
+ def _download_binary(self, response, file_path, total, chunk_size, progress_observer):
128
+ """Download and save a binary file with progress reporting.
129
+
130
+ Streams each chunk directly to the file to avoid high memory usage.
131
+ Reports progress using the provided observer.
132
+ """
133
+ downloaded = 0
134
+ last_percent = [None]
135
+ with open(file_path, "wb") as f:
136
+ for chunk in response.iter_content(chunk_size=chunk_size):
137
+ if chunk:
138
+ # For binary, no cleaning is needed
139
+ f.write(chunk)
140
+ downloaded += len(chunk)
141
+ self._report_progress(downloaded, total, progress_observer, last_percent)
142
+
143
+ def _download_text(self, response, file_path, total, chunk_size, progress_observer):
144
+ """Download and save a text file with progress reporting.
145
+
146
+ Streams cleaned chunks directly to the file to avoid high memory usage.
147
+ Reports progress using the provided observer using response.encoding if available
148
+ for accurate byte counting.
149
+ """
150
+ downloaded = 0
151
+ last_percent = [None]
152
+ encoding = response.encoding or "utf-8"
153
+ with open(file_path, "w", encoding="utf-8") as f:
154
+ for chunk in response.iter_content(chunk_size=chunk_size, decode_unicode=True):
155
+ if chunk:
156
+ cleaned_chunk = self.clean_text(chunk)
157
+ f.write(cleaned_chunk)
158
+ chunk_bytes = cleaned_chunk.encode(encoding)
159
+ downloaded += len(chunk_bytes)
160
+ self._report_progress(downloaded, total, progress_observer, last_percent)
161
+
162
+ def clean_text(self, text: str) -> str:
163
+ """Clean text content before saving.
164
+
165
+ Subclasses can override this to perform format-specific cleaning (e.g., remove ZWSP/NBSP for XHTML).
166
+ By default, returns the text unchanged.
167
+
168
+ Args:
169
+ text (str): The text content to clean.
170
+
171
+ Returns:
172
+ str: The cleaned text.
173
+
174
+ """
175
+ return text
176
+
177
+ def load_document(
178
+ self,
179
+ cache_file_name: str,
180
+ url: Optional[str] = None,
181
+ force_download: bool = False,
182
+ progress_observer: 'Optional[ProgressObserver]' = None,
183
+ # BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
184
+ progress_callback: 'Optional[Callable[[int], None]]' = None,
185
+ # END LEGACY SUPPORT
186
+ *args: Any,
187
+ **kwargs: Any
188
+ ) -> Any:
189
+ """Implement this method to read and parse the document file, returning a parsed object.
190
+
191
+ Subclasses should implement this method to load and parse a document file
192
+ (e.g., XHTML, PDF, CSV) and return a format-specific parsed object.
193
+ The exact type of the returned object depends on the subclass
194
+ (e.g., BeautifulSoup for XHTML, pdfplumber.PDF for PDF).
195
+
196
+ Args:
197
+ cache_file_name (str): Path or name of the local cached file.
198
+ url (str, optional): URL to download the file from if not cached or if force_download is True.
199
+ force_download (bool, optional): If True, download the file even if it exists locally.
200
+ progress_observer (Optional[ProgressObserver]): Optional observer to report download progress.
201
+ progress_callback (Optional[Callable[[int], None]]): [LEGACY, Deprecated] Optional callback to
202
+ report progress as an integer percent (0-100, or -1 if indeterminate). Use progress_observer
203
+ instead. Will be removed in a future release.
204
+ *args: Additional positional arguments for format-specific loading.
205
+ **kwargs: Additional keyword arguments for format-specific loading.
206
+
207
+ Returns:
208
+ Any: The parsed document object (type depends on subclass).
209
+
210
+ """
211
+ # BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
212
+ progress_observer = handle_legacy_callback(progress_observer, progress_callback)
213
+ # END LEGACY SUPPORT
214
+ raise NotImplementedError("Subclasses must implement load_document()")