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.
- tts_dictionary_interface-0.3.0/.github/workflows/run_tests_on_pr.yml +14 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/.gitignore +3 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/PKG-INFO +2 -1
- tts_dictionary_interface-0.3.0/docs/agents/README.md +13 -0
- tts_dictionary_interface-0.3.0/docs/semantic_dictionary_cache_spec.md +47 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/pyproject.toml +1 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/ait/__init__.py +3 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/ait/loader.py +100 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/base.py +235 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/contracts.py +216 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/demosat_includes/ccsds_header.yaml +8 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/demosat_includes/demosat_channels.yaml +16 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/demosat_includes/tlm.yaml +2 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/generic_includes/mid/leaf/leaf.yaml +10 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/generic_includes/mid/mid.yaml +8 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/fixtures/ait_yaml/generic_includes/top.yaml +8 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/test_ait_loader.py +62 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface/test/test_base.py +24 -11
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/test_contracts.py +167 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/test/test_tree.py +331 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/tree.py +216 -0
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface/tree.py.bk +181 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface.egg-info/PKG-INFO +2 -1
- tts_dictionary_interface-0.3.0/src/tts_dictionary_interface.egg-info/SOURCES.txt +38 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface.egg-info/requires.txt +1 -0
- tts_dictionary_interface-0.2.3/src/tts_dictionary_interface/base.py +0 -244
- tts_dictionary_interface-0.2.3/src/tts_dictionary_interface.egg-info/SOURCES.txt +0 -21
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/README.md +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/ci/sphinx_ignore_tags.txt +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/docs/_static/bugfix.css +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/docs/conf.py +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/docs/foo.rst +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/docs/index.rst +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/license.md +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/setup.cfg +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/setup.py +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/__init__.py +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface/__init__.py +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface/test/__init__.py +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface/test/conftest.py +0 -0
- {tts_dictionary_interface-0.2.3 → tts_dictionary_interface-0.3.0}/src/tts_dictionary_interface.egg-info/dependency_links.txt +0 -0
- {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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tts-dictionary-interface
|
|
3
|
-
Version: 0.
|
|
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.
|
|
@@ -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')
|