tts-dictionary-interface 0.2.3__tar.gz → 0.3.0__tar.gz

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 (42) hide show
  1. tts_dictionary_interface-0.3.0/.github/workflows/run_tests_on_pr.yml +14 -0
  2. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/.gitignore +3 -0
  3. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/PKG-INFO +2 -1
  4. tts_dictionary_interface-0.3.0/docs/agents/README.md +13 -0
  5. tts_dictionary_interface-0.3.0/docs/semantic_dictionary_cache_spec.md +47 -0
  6. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/pyproject.toml +1 -0
  7. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/ait/__init__.py +3 -0
  8. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/ait/loader.py +100 -0
  9. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/base.py +235 -0
  10. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/contracts.py +216 -0
  11. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/demosat_includes/ccsds_header.yaml +8 -0
  12. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/demosat_includes/demosat_channels.yaml +16 -0
  13. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/demosat_includes/tlm.yaml +2 -0
  14. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/generic_includes/mid/leaf/leaf.yaml +10 -0
  15. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/generic_includes/mid/mid.yaml +8 -0
  16. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/generic_includes/top.yaml +8 -0
  17. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/test_ait_loader.py +62 -0
  18. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface/test/test_base.py +24 -11
  19. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/test_contracts.py +167 -0
  20. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/test_tree.py +331 -0
  21. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/tree.py +216 -0
  22. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/tree.py.bk +181 -0
  23. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface.egg-info/PKG-INFO +2 -1
  24. tts_dictionary_interface-0.3.0/src/tts_dictionary_interface.egg-info/SOURCES.txt +38 -0
  25. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface.egg-info/requires.txt +1 -0
  26. tts_dictionary_interface-0.2.3/src/tts_dictionary_interface/base.py +0 -244
  27. tts_dictionary_interface-0.2.3/src/tts_dictionary_interface.egg-info/SOURCES.txt +0 -21
  28. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/README.md +0 -0
  29. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/ci/sphinx_ignore_tags.txt +0 -0
  30. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/docs/_static/bugfix.css +0 -0
  31. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/docs/conf.py +0 -0
  32. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/docs/foo.rst +0 -0
  33. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/docs/index.rst +0 -0
  34. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/license.md +0 -0
  35. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/setup.cfg +0 -0
  36. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/setup.py +0 -0
  37. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/__init__.py +0 -0
  38. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface/__init__.py +0 -0
  39. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface/test/__init__.py +0 -0
  40. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface/test/conftest.py +0 -0
  41. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface.egg-info/dependency_links.txt +0 -0
  42. {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface.egg-info/top_level.txt +0 -0
@@ -0,0 +1,14 @@
1
+ name: PR Multi-Version Tests
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [ main ]
6
+ types: [opened, synchronize, reopened]
7
+
8
+ permissions:
9
+ pull-requests: write
10
+ contents: read
11
+
12
+ jobs:
13
+ call-shared-tests:
14
+ uses: NASA-JPL-Teamtools-Studio/tts_ci_cd/.github/workflows/run_tests_on_pr.yml@main
@@ -186,3 +186,6 @@ Thumbs.db
186
186
 
187
187
  #nfs temp files
188
188
  .nfs*
189
+
190
+ #other
191
+ tmp_dict_examples/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tts-dictionary-interface
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Core code for developing Python AMPCS dictionary interfaces.
5
5
  Author: muszynsk
6
6
  Author-email: muszynsk <matt.muszynski@jpl.nasa.gov>
@@ -13,6 +13,7 @@ Description-Content-Type: text/markdown
13
13
  Requires-Dist: lxml
14
14
  Requires-Dist: pytest
15
15
  Requires-Dist: tomli
16
+ Requires-Dist: pyyaml
16
17
  Requires-Dist: tts-utilities<=1.0.0,>=0.2.2
17
18
  Dynamic: author
18
19
  Dynamic: requires-python
@@ -0,0 +1,13 @@
1
+ # Agent skills
2
+
3
+ Canonical engineering-skill conventions and the cross-repo dependency map live in
4
+ `tts_core/teamtools_documentation/docs/agents/`. This repo holds only the
5
+ per-repo instance data a skill needs locally.
6
+
7
+ - **Issue tracker:** GitHub Issues at `github.com/NASA-JPL-Teamtools-Studio/tts_dictionary_interface`
8
+ (host `github.com`, `-R NASA-JPL-Teamtools-Studio/tts_dictionary_interface`).
9
+ *If a skill needs more, see `teamtools_documentation/docs/agents/issue-tracker-github-ghe.md`.*
10
+ - **Adapts:** `unknown`
11
+ - **Triage labels:** default vocabulary (see `teamtools_documentation/docs/agents/triage-labels.md`)
12
+ - **Domain context:** repo-specific domain language is in this repo's `CONTEXT.md`.
13
+ - **Ownership:** public
@@ -0,0 +1,47 @@
1
+ # Implementation Spec: SemanticDictionary Node Caching
2
+ ## Overview
3
+ This specification outlines the implementation of a caching mechanism for `SemanticDictionary` to resolve performance issues caused by repeated XML parsing and XPath lookups.
4
+ ## Design Decisions
5
+ ### 1. Cache Scope and Lifecycle
6
+ - **Scope**: Session-scoped. Each `SemanticDictionary` instance will maintain its own private cache.
7
+ - **Lifecycle**: The cache is created upon instantiation and lives for the duration of the object.
8
+ ### 2. Cache Mechanism
9
+ - **Type**: A Python dictionary used as an LRU (Least Recently Used) cache.
10
+ - **Capacity**: Bounded to 100 entries to prevent unbounded memory growth.
11
+ - **Concurrency**: Non-thread-safe (to minimize overhead, per developer requirement).
12
+ ### 3. Keying Strategy
13
+ To avoid collisions between different lookup methods, the cache will use a composite key: `(lookup_type, lookup_value)`.
14
+ | Method | `lookup_type` | `lookup_value` |
15
+ |---|---|---|
16
+ | `__getitem__` | `'getitem'` | The item ID (e.g., command stem) |
17
+ | `__contains__` | `'getitem'` | The key being checked |
18
+ | `__getattr__` | `'getattr'` | The attribute name |
19
+ | `__iter__` | `'getitem'` | The item ID (from `ITEM_HUMAN_UNIQUE_IDS`) |
20
+ ### 4. Detailed Implementation Plan
21
+ #### A. Initialization
22
+ Add `self._cache = collections.OrderedDict()` to `SemanticDictionary.__init__`.
23
+ #### B. `__getitem__` and `__contains__`
24
+ - Check `self._cache.get(('getitem', item))`.
25
+ - If present, return/confirm.
26
+ - If absent:
27
+ - Perform the existing XPath logic.
28
+ - If a result is found, wrap it in the appropriate `ITEM_CLASS`.
29
+ - Add to `self._cache` with key `('getitem', item)`.
30
+ - Maintain LRU order by moving the key to the end if it exists, or removing the oldest if size > 100.
31
+ #### C. `__getattr__`
32
+ - Check `self._cache.get(('getattr', attr))`.
33
+ - If present, return.
34
+ - If absent:
35
+ - Perform existing logic.
36
+ - Store result in `self._cache` with key `('getattr', attr)`.
37
+ - Maintain LRU order.
38
+ #### D. `__iter__` and `__len__`
39
+ - `__iter__` will implement "lazy population":
40
+ - Iterate through `ITEM_XPATHS` and `ITEM_HUMAN_UNIQUE_IDS`.
41
+ - For each item, perform the wrap and store in `self._cache` using `('getitem', identifier)`.
42
+ - `__len__` will return the count of items in the `'getitem'` cache after the first iteration has completed, or a cached count.
43
+ #### E. Invalidation
44
+ - No explicit `refresh()` method will be implemented at this time, as per developer request.
45
+ ## Verification Plan
46
+ 1. **Profiling**: Run `profile_test.py` to confirm the speedup factor (expected >10x for subsequent accesses).
47
+ 2. **Correctness**: Ensure `__getitem__`, `__getattr__`, `__contains__`, and `__iter__` all return correct, consistent data.
@@ -15,6 +15,7 @@ dependencies = [
15
15
  "lxml",
16
16
  "pytest",
17
17
  "tomli",
18
+ "pyyaml",
18
19
  "tts-utilities>=0.2.2,<=1.0.0"
19
20
  ]
20
21
  dynamic = ["version"]
@@ -0,0 +1,3 @@
1
+ from tts_dictionary_interface.ait.loader import AitYamlDictionary, load_yaml
2
+
3
+ __all__ = ['load_yaml', 'AitYamlDictionary']
@@ -0,0 +1,100 @@
1
+ """
2
+ PyYAML loading support for AIT-core-style dictionaries (`!Packet`/`!Field`/
3
+ `!Command`/`!Argument`/`!EVR`, and similar mission-specific tags such as
4
+ `!Fixed`).
5
+
6
+ Two responsibilities, per design decisions made while grilling issue #21:
7
+
8
+ 1. Every AIT-core tag is treated as a no-op: a tagged mapping/sequence/
9
+ scalar loads as a plain dict/list/str, ignoring AIT-core's own Python
10
+ tag semantics entirely. This keeps the engine free of any dependency
11
+ on the `ait-core` package.
12
+
13
+ 2. `!include <relative-path>` is a real, resolved directive: the
14
+ referenced file is loaded (relative to the *including* file's own
15
+ directory, never a shared root or the process's cwd) and spliced in
16
+ as if its contents had been written inline. This mirrors the real
17
+ oco3mos dictionaries, where each FSW-version directory's `tlm.yaml` is
18
+ just a manifest of `!include` pointers at its own sibling files.
19
+ Includes may nest to any depth -- resolution is always relative to
20
+ whichever file directly contains the `!include`.
21
+ """
22
+ import os
23
+
24
+ import yaml
25
+
26
+ from tts_dictionary_interface.tree import TreeSemanticDictionary, _flatten
27
+
28
+
29
+ class IncludeLoader(yaml.SafeLoader):
30
+ """
31
+ A yaml.SafeLoader that resolves `!include` relative to the directory
32
+ of the file currently being loaded, and treats every other custom
33
+ tag as a no-op (plain dict/list/scalar).
34
+ """
35
+
36
+ def __init__(self, stream):
37
+ stream_name = getattr(stream, 'name', None)
38
+ self._root_dir = os.path.dirname(stream_name) if stream_name else '.'
39
+ super().__init__(stream)
40
+
41
+
42
+ def _construct_include(loader, node):
43
+ relative_path = loader.construct_scalar(node)
44
+ path = os.path.join(loader._root_dir, relative_path)
45
+ with open(path, 'r') as f:
46
+ return yaml.load(f, IncludeLoader)
47
+
48
+
49
+ def _construct_ignore_tag(loader, node):
50
+ """
51
+ Load any tagged node as if it were untagged, per its underlying YAML
52
+ kind (mapping/sequence/scalar) -- ignores the tag itself.
53
+ """
54
+ if isinstance(node, yaml.MappingNode):
55
+ return loader.construct_mapping(node, deep=True)
56
+ if isinstance(node, yaml.SequenceNode):
57
+ return loader.construct_sequence(node, deep=True)
58
+ return loader.construct_scalar(node)
59
+
60
+
61
+ IncludeLoader.add_constructor('!include', _construct_include)
62
+ # `None` registers a fallback constructor for any tag with no exact or
63
+ # multi-constructor match -- i.e. every AIT-core tag except `!include`.
64
+ IncludeLoader.add_constructor(None, _construct_ignore_tag)
65
+
66
+
67
+ def load_yaml(path):
68
+ """
69
+ Load an AIT-core-style YAML dictionary file, resolving any `!include`
70
+ directives (recursively, relative to each including file's own
71
+ directory) and ignoring all other custom tags.
72
+ """
73
+ with open(path, 'r') as f:
74
+ return yaml.load(f, IncludeLoader)
75
+
76
+
77
+ class AitYamlDictionary(TreeSemanticDictionary):
78
+ """
79
+ A `TreeSemanticDictionary` sourced from an AIT-flavored YAML file:
80
+ resolves `!include` directives via `load_yaml` and flattens the
81
+ result into a single flat list, so a document assembled from nested
82
+ includes (like the real oco3mos `tlm.yaml` manifests) behaves like
83
+ one flat list of items regardless of how many files it's split
84
+ across.
85
+
86
+ Concrete subclasses set `DICTIONARY_FILENAME` (e.g. 'cmd.yaml') so
87
+ that `source=<a directory>` or `source=<a version string>` (resolved
88
+ against `DICTIONARY_MODULE`) both know which file to load.
89
+ """
90
+
91
+ @classmethod
92
+ def _load_file(cls, path):
93
+ if os.path.isdir(path):
94
+ if not cls.DICTIONARY_FILENAME:
95
+ raise ValueError(
96
+ f"{cls.__name__} must define DICTIONARY_FILENAME to load from a directory."
97
+ )
98
+ path = os.path.join(path, cls.DICTIONARY_FILENAME)
99
+ loaded = load_yaml(path)
100
+ return _flatten(loaded) if isinstance(loaded, list) else loaded
@@ -0,0 +1,235 @@
1
+ import inspect
2
+ import os
3
+ from lxml import etree
4
+
5
+ try:
6
+ from importlib.resources import files, as_file
7
+ except ImportError:
8
+ from importlib_resources import files, as_file
9
+
10
+ from tts_utilities.logger import create_logger
11
+ logger = create_logger('semantic_dictionary')
12
+
13
+ class DictionaryAttributeError(AttributeError):
14
+ """
15
+ Raised when a single-valued attribute configured in ATTR_PATHS resolves
16
+ to zero elements against the underlying dictionary.
17
+ """
18
+ pass
19
+
20
+ def _accepts_document_arg(func):
21
+ """
22
+ Whether `func` is written to accept a second positional argument -- the
23
+ constructed item itself, which exposes `.document`.
24
+ """
25
+ try:
26
+ signature = inspect.signature(func)
27
+ except (TypeError, ValueError):
28
+ return False
29
+
30
+ parameters = list(signature.parameters.values())
31
+ if any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in parameters):
32
+ return True
33
+
34
+ positional = [
35
+ p for p in parameters
36
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
37
+ ]
38
+ return len(positional) >= 2
39
+
40
+ class BaseSemanticDictionary:
41
+ """
42
+ A generic base for reading structured dictionaries (XML, YAML, JSON)
43
+ using a declarative mapping of paths to attributes and items.
44
+ """
45
+ ITEM_PATHS = []
46
+ ITEM_HUMAN_UNIQUE_IDS = []
47
+ ITEM_CLASSES = []
48
+ ATTR_PATHS = {}
49
+
50
+ DICTIONARY_MODULE = None
51
+ DICTIONARY_FILENAME = None
52
+
53
+ def __init__(self, source=None, document=None):
54
+ """
55
+ Args:
56
+ source: Version string, file path, or pre-parsed node.
57
+ document: Root node of the parent document (for cross-document resolution).
58
+ """
59
+ if source is None:
60
+ raise ValueError(f"Must specify a source for {self.__class__.__name__}")
61
+
62
+ # Load the root node
63
+ self.node = self._load_source(source)
64
+ # The document is the root node. If we are an item of another dictionary,
65
+ # we are passed the root of that dictionary.
66
+ self.document = document if document is not None else self.node
67
+
68
+ def _load_source(self, source):
69
+ """Load the source into self.node. Implemented by subclasses."""
70
+ raise NotImplementedError()
71
+
72
+ def select(self, path, filters=None):
73
+ """
74
+ Return a list of nodes matching the path and optional filters.
75
+ filters: {attr_name: value}
76
+ """
77
+ raise NotImplementedError()
78
+
79
+ def resolve_attr_value(self, value, attr_class, item_instance):
80
+ """
81
+ Convert a raw node/value into the final attribute value.
82
+ """
83
+ if attr_class is None:
84
+ return value
85
+ if _accepts_document_arg(attr_class):
86
+ return attr_class(value, item_instance)
87
+ return attr_class(value)
88
+
89
+ def _effective_item_paths(self):
90
+ """Return ITEM_PATHS if non-empty, else fall back to legacy ITEM_XPATHS."""
91
+ return getattr(type(self), 'ITEM_PATHS', None) or getattr(type(self), 'ITEM_XPATHS', [])
92
+
93
+ def __getitem__(self, item):
94
+ elements = []
95
+ item_paths = self._effective_item_paths()
96
+ item_ids = getattr(type(self), 'ITEM_HUMAN_UNIQUE_IDS', [])
97
+ item_classes = getattr(type(self), 'ITEM_CLASSES', [])
98
+
99
+ for path, id_spec, itemclass in zip(item_paths, item_ids, item_classes):
100
+ identifiers = [id_.strip() for id_ in id_spec.split('|')]
101
+ for id_attr in identifiers:
102
+ matches = self.select(path, {id_attr: item})
103
+ elements.extend([itemclass(node, document=self.document) for node in matches])
104
+
105
+ # Deduplicate by node identity
106
+ unique_elements = []
107
+ seen_nodes = set()
108
+ for e in elements:
109
+ # We assume the wrapped item has a .node attribute or is the node itself
110
+ node = getattr(e, 'node', e)
111
+ if id(node) not in seen_nodes:
112
+ unique_elements.append(e)
113
+ seen_nodes.add(id(node))
114
+
115
+ if not unique_elements:
116
+ raise KeyError(f'No elements found with value "{item}"')
117
+ if len(unique_elements) > 1:
118
+ raise KeyError(f'More than one element found with value "{item}"')
119
+
120
+ return unique_elements[0]
121
+
122
+ def __getattr__(self, attr):
123
+ # Try ATTR_PATHS (new 3-tuple: path, attr_class, return_list) first,
124
+ # then fall back to legacy ATTR_XPATHS (4-tuple: xpath, is_text, attr_class, return_list).
125
+ attr_paths = getattr(type(self), 'ATTR_PATHS', None)
126
+ if attr_paths and attr in attr_paths:
127
+ config = attr_paths[attr]
128
+ path = config[0]
129
+ attr_class = config[1] if len(config) > 1 else None
130
+ return_list = config[2] if len(config) > 2 else False
131
+ is_text = False
132
+ else:
133
+ attr_xpaths = getattr(type(self), 'ATTR_XPATHS', None)
134
+ if not attr_xpaths or attr not in attr_xpaths:
135
+ # Fall back to root XML attribute (returns None if not present,
136
+ # matching the legacy SemanticDictionary behavior)
137
+ if hasattr(self.node, 'get'):
138
+ return self.node.get(attr)
139
+ raise AttributeError(f"'{self.__class__.__name__}' has no attribute '{attr}'")
140
+ config = attr_xpaths[attr]
141
+ path = config[0]
142
+ is_text = config[1] if len(config) > 1 else False
143
+ attr_class = config[2] if len(config) > 2 else None
144
+ return_list = config[3] if len(config) > 3 else False
145
+
146
+ matches = self.select(path)
147
+
148
+ def resolve(m):
149
+ if is_text and hasattr(m, 'text'):
150
+ m = m.text
151
+ return self.resolve_attr_value(m, attr_class, self)
152
+
153
+ if not matches:
154
+ if return_list:
155
+ return []
156
+ raise DictionaryAttributeError(f"Attribute '{attr}' (path '{path}') matched zero elements")
157
+
158
+ if return_list:
159
+ return [resolve(m) for m in matches]
160
+ return resolve(matches[0])
161
+
162
+ def __iter__(self):
163
+ elements = []
164
+ # Use type(self) to avoid triggering __getattr__
165
+ item_paths = self._effective_item_paths()
166
+ item_classes = getattr(type(self), 'ITEM_CLASSES', [])
167
+ for path, itemclass in zip(item_paths, item_classes):
168
+ matches = self.select(path)
169
+ elements.extend([itemclass(node, document=self.document) for node in matches])
170
+ for e in elements:
171
+ yield e
172
+
173
+ def __len__(self):
174
+ return sum(1 for _ in self)
175
+
176
+ def __contains__(self, key):
177
+ item_paths = self._effective_item_paths()
178
+ for path, id_spec in zip(item_paths, self.ITEM_HUMAN_UNIQUE_IDS):
179
+ identifiers = [id_.strip() for id_ in id_spec.split('|')]
180
+ for id_attr in identifiers:
181
+ if self.select(path, {id_attr: key}):
182
+ return True
183
+ return False
184
+
185
+ class XMLSemanticDictionary(BaseSemanticDictionary):
186
+ """
187
+ XML implementation of the Semantic Interface.
188
+ """
189
+ @property
190
+ def etree(self):
191
+ return self.node
192
+
193
+ def xpath(self, xpath):
194
+ """
195
+ Run a raw XPath query against the root element of the dictionary.
196
+ A passthrough to ``self.node.xpath()``.
197
+ """
198
+ return self.node.xpath(xpath)
199
+
200
+ def _load_source(self, source):
201
+ if not isinstance(source, (str, os.PathLike)):
202
+ return source.getroot() if hasattr(source, 'getroot') else source
203
+
204
+ source_str = str(source)
205
+ if not source_str.lower().endswith('.xml'):
206
+ if not self.DICTIONARY_MODULE or not self.DICTIONARY_FILENAME:
207
+ raise ValueError("XMLSemanticDictionary requires DICTIONARY_MODULE/FILENAME for versioned loading")
208
+ package_target = f"{self.DICTIONARY_MODULE}.{source_str}"
209
+ try:
210
+ traversable_path = files(package_target).joinpath(self.DICTIONARY_FILENAME)
211
+ with as_file(traversable_path) as xml_path:
212
+ return etree.parse(str(xml_path)).getroot()
213
+ except Exception as e:
214
+ raise FileNotFoundError(f"Failed to resolve {source_str}: {e}")
215
+
216
+ return etree.parse(source_str).getroot()
217
+
218
+ def select(self, path, filters=None):
219
+ if filters:
220
+ # Build XPath predicate: [ @attr1="val1" or @attr2="val2" ]
221
+ preds = []
222
+ for attr, val in filters.items():
223
+ if attr == 'text()':
224
+ preds.append(f'text()="{val}"')
225
+ else:
226
+ preds.append(f'@{attr}="{val}"')
227
+
228
+ xpath = f"{path}[{' or '.join(preds)}]"
229
+ else:
230
+ xpath = path
231
+
232
+ return self.node.xpath(xpath)
233
+
234
+ # Alias for backward compatibility
235
+ SemanticDictionary = XMLSemanticDictionary
@@ -0,0 +1,216 @@
1
+ """
2
+ Shared, format-agnostic attribute contracts for Command/Argument/Channel/EVR
3
+ objects, satisfied via multiple inheritance by any dictionary engine's
4
+ concrete classes (XML/AMPCS, JSON/F-Prime, YAML/AIT, ...).
5
+
6
+ Why this exists: without a shared contract, each engine's Command/Channel/EVR
7
+ classes could drift into incompatible shapes with nothing to stop it.
8
+
9
+ How "required but not yet implemented" works:
10
+
11
+ A contract-required attribute that a concrete class doesn't actually
12
+ implement must raise NotImplementedError -- but only when that specific
13
+ attribute is accessed, never at class definition or instantiation time.
14
+
15
+ For engines that implement attributes as *real* properties (e.g. a
16
+ hand-written JSON engine), this falls out for free: Python's normal
17
+ attribute lookup finds the concrete class's own property in its __dict__
18
+ before it ever consults a mixin base class, so the contract's
19
+ placeholder is simply never reached.
20
+
21
+ For engines that resolve attributes *dynamically* (e.g. AMPCS's
22
+ SemanticDictionary.__getattr__, which resolves attributes from
23
+ ATTR_XPATHS at access time rather than declaring them as properties),
24
+ there's nothing in the class's __dict__ to shadow the contract's
25
+ placeholder property -- so the placeholder would normally win and raise
26
+ immediately, without ever giving the dynamic resolver a chance. To avoid
27
+ that, every contract placeholder explicitly defers to the instance's own
28
+ __getattr__ (if it defines one) before concluding the attribute isn't
29
+ implemented at all.
30
+ """
31
+
32
+ _UNSET = object()
33
+
34
+
35
+ def _resolve_required(instance, name):
36
+ """
37
+ Resolve a contract-required attribute.
38
+
39
+ 1. If the concrete class exposes a declarative attribute-config dict
40
+ (e.g. AMPCS's SemanticDictionary subclasses' ATTR_XPATHS, or the
41
+ dict/list-tree analog ATTR_PATHS used by YAML/AIT-flavored
42
+ engines) and the attribute isn't a key in ANY of them, the
43
+ attribute is definitely not implemented -- raise NotImplementedError
44
+ without even trying, since the dynamic __getattr__ would otherwise
45
+ silently fall back to a raw attribute lookup and return None
46
+ instead of signaling "unimplemented".
47
+ 2. Otherwise, if the concrete class defines a real __getattr__, try it
48
+ explicitly.
49
+ 3. If that raises AttributeError (including DictionaryAttributeError)
50
+ -- or there's no __getattr__ to try at all -- the attribute is not
51
+ implemented; raise NotImplementedError.
52
+ """
53
+ attr_paths = getattr(type(instance), 'ATTR_PATHS', None) or {}
54
+ attr_xpaths = getattr(type(instance), 'ATTR_XPATHS', None) or {}
55
+ if attr_paths or attr_xpaths:
56
+ if name not in attr_paths and name not in attr_xpaths:
57
+ raise NotImplementedError(
58
+ f"{type(instance).__name__} does not implement required "
59
+ f"contract attribute '{name}'"
60
+ )
61
+
62
+ dynamic_getattr = getattr(type(instance), '__getattr__', None)
63
+ if dynamic_getattr is not None:
64
+ try:
65
+ return dynamic_getattr(instance, name)
66
+ except AttributeError as e:
67
+ raise NotImplementedError(
68
+ f"{type(instance).__name__} does not implement required "
69
+ f"contract attribute '{name}'"
70
+ ) from e
71
+ raise NotImplementedError(
72
+ f"{type(instance).__name__} does not implement required contract "
73
+ f"attribute '{name}'"
74
+ )
75
+
76
+
77
+ def _resolve_optional(instance, name):
78
+ """
79
+ Resolve a contract-optional attribute.
80
+
81
+ Optional attributes represent concepts that some formats simply don't
82
+ have (e.g. F-Prime has no per-channel packet ID). Unlike required
83
+ attributes, an unimplemented optional attribute is not an error -- it
84
+ just resolves to None.
85
+ """
86
+ dynamic_getattr = getattr(type(instance), '__getattr__', None)
87
+ if dynamic_getattr is None:
88
+ return None
89
+ try:
90
+ return dynamic_getattr(instance, name)
91
+ except AttributeError:
92
+ return None
93
+
94
+
95
+ class ArgumentContract:
96
+ """
97
+ Contract for a single Command argument.
98
+
99
+ Required: name, length.
100
+ Optional: units, min, max -- some argument types (enumerated, string,
101
+ repeat-block arguments) have no native concept of engineering units or
102
+ numeric range; those resolve to None rather than raising.
103
+ """
104
+
105
+ @property
106
+ def name(self):
107
+ return _resolve_required(self, 'name')
108
+
109
+ @property
110
+ def length(self):
111
+ return _resolve_required(self, 'length')
112
+
113
+ @property
114
+ def units(self):
115
+ return _resolve_optional(self, 'units')
116
+
117
+ @property
118
+ def min(self):
119
+ return _resolve_optional(self, 'min')
120
+
121
+ @property
122
+ def max(self):
123
+ return _resolve_optional(self, 'max')
124
+
125
+
126
+ class CommandContract:
127
+ """
128
+ Contract for a single Command definition.
129
+
130
+ Required: stem (also usable as the dictionary's __getitem__ key),
131
+ opcode, opscat, args (an iterable of ArgumentContract-satisfying
132
+ objects).
133
+ """
134
+
135
+ @property
136
+ def stem(self):
137
+ return _resolve_required(self, 'stem')
138
+
139
+ @property
140
+ def opcode(self):
141
+ return _resolve_required(self, 'opcode')
142
+
143
+ @property
144
+ def opscat(self):
145
+ return _resolve_required(self, 'opscat')
146
+
147
+ @property
148
+ def args(self):
149
+ return _resolve_required(self, 'args')
150
+
151
+
152
+ class ChannelContract:
153
+ """
154
+ Contract for a single Channel (telemetry point) definition.
155
+
156
+ Required: channel_id and/or channel_name (either usable as the
157
+ dictionary's __getitem__ key), opscat, type.
158
+ Optional: module, measurement_id -- not every format has a distinct
159
+ module concept separate from opscat (F-Prime, AIT), or a per-channel
160
+ packet/measurement ID (F-Prime); those resolve to None rather than
161
+ raising.
162
+ """
163
+
164
+ @property
165
+ def channel_id(self):
166
+ return _resolve_required(self, 'channel_id')
167
+
168
+ @property
169
+ def channel_name(self):
170
+ return _resolve_required(self, 'channel_name')
171
+
172
+ @property
173
+ def opscat(self):
174
+ return _resolve_required(self, 'opscat')
175
+
176
+ @property
177
+ def type(self):
178
+ return _resolve_required(self, 'type')
179
+
180
+ @property
181
+ def module(self):
182
+ return _resolve_optional(self, 'module')
183
+
184
+ @property
185
+ def measurement_id(self):
186
+ return _resolve_optional(self, 'measurement_id')
187
+
188
+
189
+ class EvrContract:
190
+ """
191
+ Contract for a single EVR (event record) definition.
192
+
193
+ Required: name, message, severity.
194
+
195
+ severity is required at the contract level even though some formats
196
+ (AIT) have no native severity concept at all -- severity is too
197
+ important a concept on missions that do use it to quietly downgrade
198
+ contract-wide. Formats without it are expected to explicitly implement
199
+ `severity` returning None and document that as a known limitation,
200
+ rather than being contractually exempted from having it.
201
+
202
+ message returns the raw, unformatted template string for every format
203
+ -- no parameter substitution against live telemetry values.
204
+ """
205
+
206
+ @property
207
+ def name(self):
208
+ return _resolve_required(self, 'name')
209
+
210
+ @property
211
+ def message(self):
212
+ return _resolve_required(self, 'message')
213
+
214
+ @property
215
+ def severity(self):
216
+ return _resolve_required(self, 'severity')
@@ -0,0 +1,8 @@
1
+ - !Packet
2
+ name: CCSDS_HEADER
3
+ fields:
4
+ - !Field
5
+ name: apid
6
+ bytes: [0, 1]
7
+ type: MSB_U16
8
+ mask: 0x07FF