tts-dictionary-interface 0.3.0__tar.gz → 0.4.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/src/tts_dictionary_interface.egg-info → tts_dictionary_interface-0.4.0}/PKG-INFO +1 -1
- tts_dictionary_interface-0.4.0/docs/agents/check_docstring_coverage.py +166 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/index.rst +1 -1
- tts_dictionary_interface-0.4.0/docs/narrative.rst +100 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/pyproject.toml +4 -1
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/ait/loader.py +22 -16
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/base.py +130 -27
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/contracts.py +135 -0
- tts_dictionary_interface-0.4.0/src/tts_dictionary_interface/test/test_cache.py +166 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/test_contracts.py +123 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/tree.py +2 -2
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0/src/tts_dictionary_interface.egg-info}/PKG-INFO +1 -1
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface.egg-info/SOURCES.txt +3 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/.github/workflows/run_tests_on_pr.yml +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/.gitignore +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/README.md +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/ci/sphinx_ignore_tags.txt +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/_static/bugfix.css +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/agents/README.md +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/conf.py +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/foo.rst +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/semantic_dictionary_cache_spec.md +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/license.md +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/setup.cfg +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/setup.py +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/__init__.py +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/__init__.py +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/ait/__init__.py +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/__init__.py +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/conftest.py +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/fixtures/ait_yaml/demosat_includes/ccsds_header.yaml +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/fixtures/ait_yaml/demosat_includes/demosat_channels.yaml +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/fixtures/ait_yaml/demosat_includes/tlm.yaml +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/fixtures/ait_yaml/generic_includes/mid/leaf/leaf.yaml +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/fixtures/ait_yaml/generic_includes/mid/mid.yaml +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/fixtures/ait_yaml/generic_includes/top.yaml +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/test_ait_loader.py +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/test_base.py +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/test_tree.py +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/tree.py.bk +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface.egg-info/dependency_links.txt +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface.egg-info/requires.txt +0 -0
- {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
AST-based docstring coverage check for tts_dictionary_interface.
|
|
4
|
+
|
|
5
|
+
Prints coverage percentage and exits 0 iff >=80% and no previously-missing
|
|
6
|
+
symbols remain undocumented.
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import ast
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
SKIP_DIRS = {"test", "build", ".venv", "__pycache__"}
|
|
14
|
+
SKIP_FILES = {"sandbox.py", "conftest.py"}
|
|
15
|
+
|
|
16
|
+
PREVIOUSLY_MISSING = {
|
|
17
|
+
"tts_dictionary_interface.base.SemanticDictionary.resolve_value",
|
|
18
|
+
"tts_dictionary_interface.tree.TreeSemanticDictionary.resolve",
|
|
19
|
+
"tts_dictionary_interface.contracts.ArgumentContract.name",
|
|
20
|
+
"tts_dictionary_interface.contracts.ArgumentContract.length",
|
|
21
|
+
"tts_dictionary_interface.contracts.ArgumentContract.units",
|
|
22
|
+
"tts_dictionary_interface.contracts.ArgumentContract.min",
|
|
23
|
+
"tts_dictionary_interface.contracts.ArgumentContract.max",
|
|
24
|
+
"tts_dictionary_interface.contracts.CommandContract.stem",
|
|
25
|
+
"tts_dictionary_interface.contracts.CommandContract.opcode",
|
|
26
|
+
"tts_dictionary_interface.contracts.CommandContract.opscat",
|
|
27
|
+
"tts_dictionary_interface.contracts.CommandContract.args",
|
|
28
|
+
"tts_dictionary_interface.contracts.ChannelContract.measurement_id",
|
|
29
|
+
"tts_dictionary_interface.contracts.ChannelContract.channel_id",
|
|
30
|
+
"tts_dictionary_interface.contracts.ChannelContract.channel_name",
|
|
31
|
+
"tts_dictionary_interface.contracts.ChannelContract.type",
|
|
32
|
+
"tts_dictionary_interface.contracts.ChannelContract.module",
|
|
33
|
+
"tts_dictionary_interface.contracts.EvrContract.message",
|
|
34
|
+
"tts_dictionary_interface.contracts.EvrContract.severity",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def is_public(name):
|
|
38
|
+
# Public if not private (single leading underscore). Dunder names are public.
|
|
39
|
+
return not (name.startswith("_") and not name.startswith("__"))
|
|
40
|
+
|
|
41
|
+
def module_name_from_path(py_path, src_root):
|
|
42
|
+
rel = py_path.relative_to(src_root)
|
|
43
|
+
parts = list(rel.with_suffix("").parts)
|
|
44
|
+
# strip leading src if present
|
|
45
|
+
if parts[0] == "src":
|
|
46
|
+
parts = parts[1:]
|
|
47
|
+
return ".".join(parts)
|
|
48
|
+
|
|
49
|
+
def collect_symbols(src_root):
|
|
50
|
+
symbols = [] # list of (fqname, node)
|
|
51
|
+
documented = set()
|
|
52
|
+
for py_path in src_root.rglob("*.py"):
|
|
53
|
+
# skip
|
|
54
|
+
if any(part in SKIP_DIRS for part in py_path.parts):
|
|
55
|
+
continue
|
|
56
|
+
if py_path.name in SKIP_FILES:
|
|
57
|
+
continue
|
|
58
|
+
# only src/tts_dictionary_interface
|
|
59
|
+
if "tts_dictionary_interface" not in str(py_path):
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
source = py_path.read_text(encoding="utf-8")
|
|
63
|
+
except Exception:
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
tree = ast.parse(source, filename=str(py_path))
|
|
67
|
+
except SyntaxError:
|
|
68
|
+
continue
|
|
69
|
+
mod_name = module_name_from_path(py_path, src_root.parent if src_root.name == "src" else src_root)
|
|
70
|
+
# fallback
|
|
71
|
+
# walk
|
|
72
|
+
for node in ast.walk(tree):
|
|
73
|
+
# We need parent context to avoid double counting
|
|
74
|
+
pass
|
|
75
|
+
# Simpler: manual walk with stack
|
|
76
|
+
symbols_info = []
|
|
77
|
+
for py_path in src_root.rglob("*.py"):
|
|
78
|
+
if any(part in SKIP_DIRS for part in py_path.parts):
|
|
79
|
+
continue
|
|
80
|
+
if py_path.name in SKIP_FILES:
|
|
81
|
+
continue
|
|
82
|
+
if "tts_dictionary_interface" not in str(py_path):
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
source = py_path.read_text(encoding="utf-8")
|
|
86
|
+
except Exception:
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
tree = ast.parse(source, filename=str(py_path))
|
|
90
|
+
except SyntaxError:
|
|
91
|
+
continue
|
|
92
|
+
mod_name = module_name_from_path(py_path, src_root.parent if src_root.name == "src" else src_root)
|
|
93
|
+
# process module level nodes
|
|
94
|
+
for node in tree.body:
|
|
95
|
+
if isinstance(node, ast.ClassDef) and is_public(node.name):
|
|
96
|
+
fq_class = f"{mod_name}.{node.name}"
|
|
97
|
+
symbols_info.append((fq_class, node))
|
|
98
|
+
# methods
|
|
99
|
+
for item in node.body:
|
|
100
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and is_public(item.name):
|
|
101
|
+
fq = f"{fq_class}.{item.name}"
|
|
102
|
+
symbols_info.append((fq, item))
|
|
103
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and is_public(node.name):
|
|
104
|
+
fq = f"{mod_name}.{node.name}"
|
|
105
|
+
symbols_info.append((fq, node))
|
|
106
|
+
total = 0
|
|
107
|
+
documented_set = set()
|
|
108
|
+
for fq, node in symbols_info:
|
|
109
|
+
total += 1
|
|
110
|
+
if ast.get_docstring(node):
|
|
111
|
+
documented_set.add(fq)
|
|
112
|
+
return total, documented_set, symbols_info
|
|
113
|
+
|
|
114
|
+
def main():
|
|
115
|
+
parser = argparse.ArgumentParser()
|
|
116
|
+
parser.add_argument('--strict-sentences', action='store_true', help='Enforce summary sentence ends with period')
|
|
117
|
+
args = parser.parse_args()
|
|
118
|
+
# repo root is parent of src
|
|
119
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
120
|
+
src_root = repo_root / "src"
|
|
121
|
+
total, documented_set, symbols_info = collect_symbols(src_root)
|
|
122
|
+
documented_count = len(documented_set)
|
|
123
|
+
pct = (documented_count / total * 100) if total else 0.0
|
|
124
|
+
print(f"Coverage: {documented_count}/{total} = {pct:.1f}%")
|
|
125
|
+
# list symbols
|
|
126
|
+
print("\nSymbols:")
|
|
127
|
+
for fq, node in symbols_info:
|
|
128
|
+
status = "DOC" if fq in documented_set else "MISSING"
|
|
129
|
+
print(f" {status} {fq}")
|
|
130
|
+
# strict sentence check
|
|
131
|
+
sentence_failures = []
|
|
132
|
+
if args.strict_sentences:
|
|
133
|
+
for fq, node in symbols_info:
|
|
134
|
+
doc = ast.get_docstring(node)
|
|
135
|
+
if not doc:
|
|
136
|
+
continue
|
|
137
|
+
first_line = doc.strip().splitlines()[0].strip()
|
|
138
|
+
if not first_line.endswith('.'):
|
|
139
|
+
sentence_failures.append((fq, first_line))
|
|
140
|
+
# check previously missing
|
|
141
|
+
missing_prev = []
|
|
142
|
+
for sym in PREVIOUSLY_MISSING:
|
|
143
|
+
# if symbol not found, we consider it not applicable
|
|
144
|
+
found = any(fq == sym for fq, _ in symbols_info)
|
|
145
|
+
if not found:
|
|
146
|
+
continue
|
|
147
|
+
if sym not in documented_set:
|
|
148
|
+
missing_prev.append(sym)
|
|
149
|
+
if pct < 80:
|
|
150
|
+
print(f"\nFAIL: coverage {pct:.1f}% < 80%", file=sys.stderr)
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
if sentence_failures:
|
|
153
|
+
print("\nFAIL: docstring summary sentences must end with period:", file=sys.stderr)
|
|
154
|
+
for fq, line in sentence_failures:
|
|
155
|
+
print(f" {fq}: {line}", file=sys.stderr)
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
if missing_prev:
|
|
158
|
+
print("\nFAIL: previously missing symbols still undocumented:", file=sys.stderr)
|
|
159
|
+
for s in missing_prev:
|
|
160
|
+
print(f" {s}", file=sys.stderr)
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
print("\nPASS")
|
|
163
|
+
sys.exit(0)
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Semantic Dictionary Interface Narrative
|
|
2
|
+
=======================================
|
|
3
|
+
|
|
4
|
+
Purpose
|
|
5
|
+
-------
|
|
6
|
+
|
|
7
|
+
The ``tts_dictionary_interface`` package provides a format-agnostic interoperability layer for spacecraft dictionary data. It defines a small set of contracts and two core engine base classes – ``SemanticDictionary`` for XML/AMPCS dictionaries and ``TreeSemanticDictionary`` for dict/list-tree dictionaries such as AIT YAML – so that downstream Teamtools libraries can assume a common way to look up commands, telemetry channels, and event records.
|
|
8
|
+
|
|
9
|
+
The package does not implement mission-specific knowledge. Mission teams subclass the base classes and declare ``ITEM_PATHS``, ``ITEM_CLASSES``, ``ITEM_HUMAN_UNIQUE_IDS`` and ``ATTR_PATHS`` to map their dictionary schema onto the shared contracts.
|
|
10
|
+
|
|
11
|
+
Architecture Overview
|
|
12
|
+
--------------------
|
|
13
|
+
|
|
14
|
+
ASCII class inheritance diagram::
|
|
15
|
+
|
|
16
|
+
SemanticDictionary
|
|
17
|
+
|
|
|
18
|
+
+-- MissionXMLDictionary (e.g., DemoSat command dict)
|
|
19
|
+
|
|
20
|
+
TreeSemanticDictionary
|
|
21
|
+
|
|
|
22
|
+
+-- AitYamlDictionary
|
|
23
|
+
|
|
|
24
|
+
+-- MissionAITDictionary
|
|
25
|
+
|
|
26
|
+
Contracts
|
|
27
|
+
ArgumentContract
|
|
28
|
+
CommandContract
|
|
29
|
+
ChannelContract
|
|
30
|
+
EvrContract
|
|
31
|
+
|
|
32
|
+
Module Guide
|
|
33
|
+
------------
|
|
34
|
+
|
|
35
|
+
``tts_dictionary_interface.base``
|
|
36
|
+
``SemanticDictionary`` – dictionary-like wrapper around lxml trees.
|
|
37
|
+
``DictionaryAttributeError`` – raised when a single-valued attribute resolves to zero elements.
|
|
38
|
+
|
|
39
|
+
``tts_dictionary_interface.tree``
|
|
40
|
+
``TreeSemanticDictionary`` – analog of ``SemanticDictionary`` for dict/list trees.
|
|
41
|
+
``select`` – path query helper used by ``TreeSemanticDictionary``.
|
|
42
|
+
|
|
43
|
+
``tts_dictionary_interface.contracts``
|
|
44
|
+
Format-agnostic contracts for arguments, commands, channels, and EVRs. Concrete classes inherit from these contracts via multiple inheritance.
|
|
45
|
+
|
|
46
|
+
``tts_dictionary_interface.ait.loader``
|
|
47
|
+
``AitYamlDictionary`` – ``TreeSemanticDictionary`` subclass that loads AIT-flavored YAML with ``!include`` resolution.
|
|
48
|
+
``IncludeLoader`` – PyYAML loader that resolves ``!include`` relative to the including file and ignores other custom tags.
|
|
49
|
+
``load_yaml`` – public helper to load AIT YAML files.
|
|
50
|
+
|
|
51
|
+
Key Classes
|
|
52
|
+
-----------
|
|
53
|
+
|
|
54
|
+
``SemanticDictionary``
|
|
55
|
+
Provides ``__getitem__``, ``__iter__``, ``__len__``, ``__contains__`` and dynamic attribute access via ``ATTR_XPATHS``. Subclasses configure ``ITEM_XPATHS``, ``ITEM_HUMAN_UNIQUE_IDS``, ``ITEM_CLASSES`` and ``ATTR_XPATHS``.
|
|
56
|
+
|
|
57
|
+
``TreeSemanticDictionary``
|
|
58
|
+
Same concepts as ``SemanticDictionary`` but for dict/list trees. Uses a small path language ``a/b[k="v"]`` and supports cross-document resolution via the ``.document`` attribute.
|
|
59
|
+
|
|
60
|
+
``ArgumentContract``, ``CommandContract``, ``ChannelContract``, ``EvrContract``
|
|
61
|
+
Define the required and optional attributes that mission classes must expose. Required attributes raise ``NotImplementedError`` if missing; optional attributes resolve to ``None``.
|
|
62
|
+
|
|
63
|
+
Extension Guide
|
|
64
|
+
---------------
|
|
65
|
+
|
|
66
|
+
To add a mission-specific dictionary interface:
|
|
67
|
+
|
|
68
|
+
1. Subclass ``SemanticDictionary`` for XML or ``TreeSemanticDictionary`` for YAML/JSON.
|
|
69
|
+
2. Set ``DICTIONARY_MODULE`` and ``DICTIONARY_FILENAME`` for versioned package loading.
|
|
70
|
+
3. Declare ``ITEM_PATHS`` / ``ITEM_XPATHS``, ``ITEM_HUMAN_UNIQUE_IDS``, ``ITEM_CLASSES`` and ``ATTR_PATHS`` / ``ATTR_XPATHS``.
|
|
71
|
+
4. Make item classes inherit from the appropriate contract, e.g.:
|
|
72
|
+
|
|
73
|
+
.. code-block:: python
|
|
74
|
+
|
|
75
|
+
class CommandItem(SemanticDictionary, CommandContract):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
See ``demosat_dictionary_interface`` for a concrete example of how a mission plugs in.
|
|
79
|
+
|
|
80
|
+
Usage Example
|
|
81
|
+
-------------
|
|
82
|
+
|
|
83
|
+
Load a command dictionary and inspect a command:
|
|
84
|
+
|
|
85
|
+
.. code-block:: python
|
|
86
|
+
|
|
87
|
+
from my_mission_dict import CommandDictionary
|
|
88
|
+
|
|
89
|
+
cmd_dict = CommandDictionary('current')
|
|
90
|
+
cmd = cmd_dict['MY_CMD']
|
|
91
|
+
print(cmd.stem) # command stem
|
|
92
|
+
print(cmd.opcode) # command opcode
|
|
93
|
+
print([a.name for a in cmd.args]) # argument names
|
|
94
|
+
|
|
95
|
+
The same pattern works for ``TreeSemanticDictionary``-based YAML dictionaries via ``AitYamlDictionary``.
|
|
96
|
+
|
|
97
|
+
Relationships
|
|
98
|
+
-------------
|
|
99
|
+
|
|
100
|
+
The package uses ``tts_utilities.logger.create_logger`` for consistent logging. No other runtime dependencies are required beyond ``lxml``, ``PyYAML`` and ``importlib_resources`` for Python 3.6 compatibility.
|
|
@@ -28,12 +28,19 @@ from tts_dictionary_interface.tree import TreeSemanticDictionary, _flatten
|
|
|
28
28
|
|
|
29
29
|
class IncludeLoader(yaml.SafeLoader):
|
|
30
30
|
"""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
tag as a no-op
|
|
31
|
+
YAML loader that resolves !include relative to the including file.
|
|
32
|
+
|
|
33
|
+
It treats every other custom tag as a no-op, loading nodes as plain
|
|
34
|
+
dict/list/scalar values.
|
|
34
35
|
"""
|
|
35
36
|
|
|
36
37
|
def __init__(self, stream):
|
|
38
|
+
"""
|
|
39
|
+
Initialize the IncludeLoader with the current file's directory.
|
|
40
|
+
|
|
41
|
+
The root directory is used to resolve `!include` paths relative
|
|
42
|
+
to the including file.
|
|
43
|
+
"""
|
|
37
44
|
stream_name = getattr(stream, 'name', None)
|
|
38
45
|
self._root_dir = os.path.dirname(stream_name) if stream_name else '.'
|
|
39
46
|
super().__init__(stream)
|
|
@@ -66,9 +73,10 @@ IncludeLoader.add_constructor(None, _construct_ignore_tag)
|
|
|
66
73
|
|
|
67
74
|
def load_yaml(path):
|
|
68
75
|
"""
|
|
69
|
-
Load an AIT-core-style YAML dictionary file
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
Load an AIT-core-style YAML dictionary file.
|
|
77
|
+
|
|
78
|
+
Includes are resolved recursively relative to each including file's
|
|
79
|
+
own directory, and all other custom tags are ignored.
|
|
72
80
|
"""
|
|
73
81
|
with open(path, 'r') as f:
|
|
74
82
|
return yaml.load(f, IncludeLoader)
|
|
@@ -76,16 +84,14 @@ def load_yaml(path):
|
|
|
76
84
|
|
|
77
85
|
class AitYamlDictionary(TreeSemanticDictionary):
|
|
78
86
|
"""
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
one flat list of items
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
that `source=<a directory>` or `source=<a version string>` (resolved
|
|
88
|
-
against `DICTIONARY_MODULE`) both know which file to load.
|
|
87
|
+
TreeSemanticDictionary for AIT-flavored YAML files.
|
|
88
|
+
|
|
89
|
+
It resolves !include directives via load_yaml and flattens the result
|
|
90
|
+
into a single flat list, so documents assembled from nested includes
|
|
91
|
+
behave like one flat list of items.
|
|
92
|
+
|
|
93
|
+
Concrete subclasses set DICTIONARY_FILENAME so that source arguments
|
|
94
|
+
resolved against DICTIONARY_MODULE know which file to load.
|
|
89
95
|
"""
|
|
90
96
|
|
|
91
97
|
@classmethod
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
import os
|
|
3
|
+
from collections import OrderedDict
|
|
3
4
|
from lxml import etree
|
|
4
5
|
|
|
5
6
|
try:
|
|
@@ -19,7 +20,7 @@ class DictionaryAttributeError(AttributeError):
|
|
|
19
20
|
|
|
20
21
|
def _accepts_document_arg(func):
|
|
21
22
|
"""
|
|
22
|
-
Whether `func` is written to accept a second positional argument -- the
|
|
23
|
+
Whether `func` is written to accept a second positional argument -- the
|
|
23
24
|
constructed item itself, which exposes `.document`.
|
|
24
25
|
"""
|
|
25
26
|
try:
|
|
@@ -49,6 +50,7 @@ class BaseSemanticDictionary:
|
|
|
49
50
|
|
|
50
51
|
DICTIONARY_MODULE = None
|
|
51
52
|
DICTIONARY_FILENAME = None
|
|
53
|
+
CACHE_CAPACITY = 100
|
|
52
54
|
|
|
53
55
|
def __init__(self, source=None, document=None):
|
|
54
56
|
"""
|
|
@@ -58,12 +60,83 @@ class BaseSemanticDictionary:
|
|
|
58
60
|
"""
|
|
59
61
|
if source is None:
|
|
60
62
|
raise ValueError(f"Must specify a source for {self.__class__.__name__}")
|
|
61
|
-
|
|
63
|
+
|
|
62
64
|
# Load the root node
|
|
63
65
|
self.node = self._load_source(source)
|
|
64
|
-
# The document is the root node. If we are an item of another dictionary,
|
|
66
|
+
# The document is the root node. If we are an item of another dictionary,
|
|
65
67
|
# we are passed the root of that dictionary.
|
|
66
68
|
self.document = document if document is not None else self.node
|
|
69
|
+
self._cache = OrderedDict()
|
|
70
|
+
self._node_cache = OrderedDict()
|
|
71
|
+
self._item_count = None
|
|
72
|
+
|
|
73
|
+
_CACHE_MISS = object()
|
|
74
|
+
|
|
75
|
+
def _cache_get(self, key):
|
|
76
|
+
value = self._cache.get(key, self._CACHE_MISS)
|
|
77
|
+
if value is not self._CACHE_MISS:
|
|
78
|
+
self._cache.move_to_end(key)
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
def _cache_put(self, key, value):
|
|
82
|
+
self._cache[key] = value
|
|
83
|
+
self._cache.move_to_end(key)
|
|
84
|
+
while len(self._cache) > self.CACHE_CAPACITY:
|
|
85
|
+
self._cache.popitem(last=False)
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
def _node_cache_get(self, node):
|
|
89
|
+
key = id(node)
|
|
90
|
+
value = self._node_cache.get(key, self._CACHE_MISS)
|
|
91
|
+
if value is not self._CACHE_MISS:
|
|
92
|
+
self._node_cache.move_to_end(key)
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
def _node_cache_put(self, node, value):
|
|
96
|
+
key = id(node)
|
|
97
|
+
self._node_cache[key] = value
|
|
98
|
+
self._node_cache.move_to_end(key)
|
|
99
|
+
while len(self._node_cache) > self.CACHE_CAPACITY:
|
|
100
|
+
self._node_cache.popitem(last=False)
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
def _item_cache_key(self, node, id_spec):
|
|
104
|
+
for identifier in id_spec.split('|'):
|
|
105
|
+
identifier = identifier.strip()
|
|
106
|
+
if hasattr(node, 'get'):
|
|
107
|
+
value = node.get(identifier)
|
|
108
|
+
elif isinstance(node, dict):
|
|
109
|
+
value = node.get(identifier)
|
|
110
|
+
else:
|
|
111
|
+
value = None
|
|
112
|
+
if value is not None:
|
|
113
|
+
return ('getitem', value)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def _wrap_item(self, node, itemclass, id_spec=None, lookup_key=None):
|
|
117
|
+
if lookup_key is not None:
|
|
118
|
+
cached = self._cache_get(('getitem', lookup_key))
|
|
119
|
+
if cached is not self._CACHE_MISS:
|
|
120
|
+
return cached
|
|
121
|
+
|
|
122
|
+
item = self._node_cache_get(node)
|
|
123
|
+
if item is self._CACHE_MISS:
|
|
124
|
+
item = itemclass(node, document=self.document)
|
|
125
|
+
self._node_cache_put(node, item)
|
|
126
|
+
|
|
127
|
+
if lookup_key is not None:
|
|
128
|
+
self._cache_put(('getitem', lookup_key), item)
|
|
129
|
+
else:
|
|
130
|
+
key = self._item_cache_key(node, id_spec) if id_spec else None
|
|
131
|
+
if key is not None:
|
|
132
|
+
existing = self._cache.get(key, self._CACHE_MISS)
|
|
133
|
+
if existing is self._CACHE_MISS:
|
|
134
|
+
self._cache_put(key, item)
|
|
135
|
+
elif getattr(existing, 'node', existing) is not getattr(item, 'node', item):
|
|
136
|
+
# An identifier shared by multiple nodes is ambiguous and
|
|
137
|
+
# must remain discoverable by __getitem__.
|
|
138
|
+
self._cache.pop(key, None)
|
|
139
|
+
return item
|
|
67
140
|
|
|
68
141
|
def _load_source(self, source):
|
|
69
142
|
"""Load the source into self.node. Implemented by subclasses."""
|
|
@@ -91,35 +164,44 @@ class BaseSemanticDictionary:
|
|
|
91
164
|
return getattr(type(self), 'ITEM_PATHS', None) or getattr(type(self), 'ITEM_XPATHS', [])
|
|
92
165
|
|
|
93
166
|
def __getitem__(self, item):
|
|
167
|
+
cached = self._cache_get(('getitem', item))
|
|
168
|
+
if cached is not self._CACHE_MISS:
|
|
169
|
+
return cached
|
|
170
|
+
|
|
94
171
|
elements = []
|
|
95
172
|
item_paths = self._effective_item_paths()
|
|
96
173
|
item_ids = getattr(type(self), 'ITEM_HUMAN_UNIQUE_IDS', [])
|
|
97
174
|
item_classes = getattr(type(self), 'ITEM_CLASSES', [])
|
|
98
|
-
|
|
175
|
+
|
|
99
176
|
for path, id_spec, itemclass in zip(item_paths, item_ids, item_classes):
|
|
100
177
|
identifiers = [id_.strip() for id_ in id_spec.split('|')]
|
|
101
178
|
for id_attr in identifiers:
|
|
102
179
|
matches = self.select(path, {id_attr: item})
|
|
103
|
-
elements.extend([
|
|
180
|
+
elements.extend([
|
|
181
|
+
(node, itemclass, id_spec) for node in matches
|
|
182
|
+
])
|
|
104
183
|
|
|
105
184
|
# Deduplicate by node identity
|
|
106
185
|
unique_elements = []
|
|
107
186
|
seen_nodes = set()
|
|
108
|
-
for
|
|
109
|
-
# We assume the wrapped item has a .node attribute or is the node itself
|
|
110
|
-
node = getattr(e, 'node', e)
|
|
187
|
+
for node, itemclass, id_spec in elements:
|
|
111
188
|
if id(node) not in seen_nodes:
|
|
112
|
-
unique_elements.append(
|
|
189
|
+
unique_elements.append((node, itemclass, id_spec))
|
|
113
190
|
seen_nodes.add(id(node))
|
|
114
191
|
|
|
115
192
|
if not unique_elements:
|
|
116
193
|
raise KeyError(f'No elements found with value "{item}"')
|
|
117
194
|
if len(unique_elements) > 1:
|
|
118
195
|
raise KeyError(f'More than one element found with value "{item}"')
|
|
119
|
-
|
|
120
|
-
|
|
196
|
+
|
|
197
|
+
node, itemclass, id_spec = unique_elements[0]
|
|
198
|
+
return self._wrap_item(node, itemclass, id_spec, lookup_key=item)
|
|
121
199
|
|
|
122
200
|
def __getattr__(self, attr):
|
|
201
|
+
cached = self._cache_get(('getattr', attr))
|
|
202
|
+
if cached is not self._CACHE_MISS:
|
|
203
|
+
return cached
|
|
204
|
+
|
|
123
205
|
# Try ATTR_PATHS (new 3-tuple: path, attr_class, return_list) first,
|
|
124
206
|
# then fall back to legacy ATTR_XPATHS (4-tuple: xpath, is_text, attr_class, return_list).
|
|
125
207
|
attr_paths = getattr(type(self), 'ATTR_PATHS', None)
|
|
@@ -135,7 +217,8 @@ class BaseSemanticDictionary:
|
|
|
135
217
|
# Fall back to root XML attribute (returns None if not present,
|
|
136
218
|
# matching the legacy SemanticDictionary behavior)
|
|
137
219
|
if hasattr(self.node, 'get'):
|
|
138
|
-
|
|
220
|
+
value = self.node.get(attr)
|
|
221
|
+
return self._cache_put(('getattr', attr), value)
|
|
139
222
|
raise AttributeError(f"'{self.__class__.__name__}' has no attribute '{attr}'")
|
|
140
223
|
config = attr_xpaths[attr]
|
|
141
224
|
path = config[0]
|
|
@@ -144,7 +227,7 @@ class BaseSemanticDictionary:
|
|
|
144
227
|
return_list = config[3] if len(config) > 3 else False
|
|
145
228
|
|
|
146
229
|
matches = self.select(path)
|
|
147
|
-
|
|
230
|
+
|
|
148
231
|
def resolve(m):
|
|
149
232
|
if is_text and hasattr(m, 'text'):
|
|
150
233
|
m = m.text
|
|
@@ -156,29 +239,49 @@ class BaseSemanticDictionary:
|
|
|
156
239
|
raise DictionaryAttributeError(f"Attribute '{attr}' (path '{path}') matched zero elements")
|
|
157
240
|
|
|
158
241
|
if return_list:
|
|
159
|
-
|
|
160
|
-
|
|
242
|
+
value = [resolve(m) for m in matches]
|
|
243
|
+
else:
|
|
244
|
+
value = resolve(matches[0])
|
|
245
|
+
return self._cache_put(('getattr', attr), value)
|
|
161
246
|
|
|
162
247
|
def __iter__(self):
|
|
163
|
-
elements = []
|
|
164
248
|
# Use type(self) to avoid triggering __getattr__
|
|
165
249
|
item_paths = self._effective_item_paths()
|
|
166
250
|
item_classes = getattr(type(self), 'ITEM_CLASSES', [])
|
|
167
|
-
|
|
251
|
+
item_ids = getattr(type(self), 'ITEM_HUMAN_UNIQUE_IDS', [])
|
|
252
|
+
count = 0
|
|
253
|
+
for index, (path, itemclass) in enumerate(zip(item_paths, item_classes)):
|
|
254
|
+
id_spec = item_ids[index] if index < len(item_ids) else None
|
|
168
255
|
matches = self.select(path)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
256
|
+
for node in matches:
|
|
257
|
+
count += 1
|
|
258
|
+
yield self._wrap_item(node, itemclass, id_spec)
|
|
259
|
+
self._item_count = count
|
|
172
260
|
|
|
173
261
|
def __len__(self):
|
|
174
|
-
|
|
262
|
+
if self._item_count is None:
|
|
263
|
+
for _ in self:
|
|
264
|
+
pass
|
|
265
|
+
return self._item_count
|
|
175
266
|
|
|
176
267
|
def __contains__(self, key):
|
|
268
|
+
cached = self._cache_get(('getitem', key))
|
|
269
|
+
if cached is not self._CACHE_MISS:
|
|
270
|
+
return True
|
|
271
|
+
|
|
177
272
|
item_paths = self._effective_item_paths()
|
|
178
|
-
for path, id_spec in zip(
|
|
273
|
+
for path, id_spec, itemclass in zip(
|
|
274
|
+
item_paths, self.ITEM_HUMAN_UNIQUE_IDS, self.ITEM_CLASSES
|
|
275
|
+
):
|
|
179
276
|
identifiers = [id_.strip() for id_ in id_spec.split('|')]
|
|
180
277
|
for id_attr in identifiers:
|
|
181
|
-
|
|
278
|
+
matches = self.select(path, {id_attr: key})
|
|
279
|
+
if matches:
|
|
280
|
+
if len(matches) == 1:
|
|
281
|
+
self._wrap_item(matches[0], itemclass, id_spec, lookup_key=key)
|
|
282
|
+
# Preserve the existing boolean semantics for ambiguous
|
|
283
|
+
# identifiers; __getitem__ remains responsible for
|
|
284
|
+
# rejecting multiple matches.
|
|
182
285
|
return True
|
|
183
286
|
return False
|
|
184
287
|
|
|
@@ -200,7 +303,7 @@ class XMLSemanticDictionary(BaseSemanticDictionary):
|
|
|
200
303
|
def _load_source(self, source):
|
|
201
304
|
if not isinstance(source, (str, os.PathLike)):
|
|
202
305
|
return source.getroot() if hasattr(source, 'getroot') else source
|
|
203
|
-
|
|
306
|
+
|
|
204
307
|
source_str = str(source)
|
|
205
308
|
if not source_str.lower().endswith('.xml'):
|
|
206
309
|
if not self.DICTIONARY_MODULE or not self.DICTIONARY_FILENAME:
|
|
@@ -212,7 +315,7 @@ class XMLSemanticDictionary(BaseSemanticDictionary):
|
|
|
212
315
|
return etree.parse(str(xml_path)).getroot()
|
|
213
316
|
except Exception as e:
|
|
214
317
|
raise FileNotFoundError(f"Failed to resolve {source_str}: {e}")
|
|
215
|
-
|
|
318
|
+
|
|
216
319
|
return etree.parse(source_str).getroot()
|
|
217
320
|
|
|
218
321
|
def select(self, path, filters=None):
|
|
@@ -224,11 +327,11 @@ class XMLSemanticDictionary(BaseSemanticDictionary):
|
|
|
224
327
|
preds.append(f'text()="{val}"')
|
|
225
328
|
else:
|
|
226
329
|
preds.append(f'@{attr}="{val}"')
|
|
227
|
-
|
|
330
|
+
|
|
228
331
|
xpath = f"{path}[{' or '.join(preds)}]"
|
|
229
332
|
else:
|
|
230
333
|
xpath = path
|
|
231
|
-
|
|
334
|
+
|
|
232
335
|
return self.node.xpath(xpath)
|
|
233
336
|
|
|
234
337
|
# Alias for backward compatibility
|
|
@@ -104,24 +104,41 @@ class ArgumentContract:
|
|
|
104
104
|
|
|
105
105
|
@property
|
|
106
106
|
def name(self):
|
|
107
|
+
"""Return the argument name."""
|
|
107
108
|
return _resolve_required(self, 'name')
|
|
108
109
|
|
|
109
110
|
@property
|
|
110
111
|
def length(self):
|
|
112
|
+
"""Return the argument length."""
|
|
111
113
|
return _resolve_required(self, 'length')
|
|
112
114
|
|
|
113
115
|
@property
|
|
114
116
|
def units(self):
|
|
117
|
+
"""Return the argument units if defined."""
|
|
115
118
|
return _resolve_optional(self, 'units')
|
|
116
119
|
|
|
117
120
|
@property
|
|
118
121
|
def min(self):
|
|
122
|
+
"""Return the minimum value if defined."""
|
|
119
123
|
return _resolve_optional(self, 'min')
|
|
120
124
|
|
|
121
125
|
@property
|
|
122
126
|
def max(self):
|
|
127
|
+
"""Return the maximum value if defined."""
|
|
123
128
|
return _resolve_optional(self, 'max')
|
|
124
129
|
|
|
130
|
+
@property
|
|
131
|
+
def type(self):
|
|
132
|
+
return _resolve_optional(self, 'type')
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def description(self):
|
|
136
|
+
return _resolve_optional(self, 'description')
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def enum(self):
|
|
140
|
+
return _resolve_optional(self, 'enum')
|
|
141
|
+
|
|
125
142
|
|
|
126
143
|
class CommandContract:
|
|
127
144
|
"""
|
|
@@ -134,18 +151,22 @@ class CommandContract:
|
|
|
134
151
|
|
|
135
152
|
@property
|
|
136
153
|
def stem(self):
|
|
154
|
+
"""Return the command stem."""
|
|
137
155
|
return _resolve_required(self, 'stem')
|
|
138
156
|
|
|
139
157
|
@property
|
|
140
158
|
def opcode(self):
|
|
159
|
+
"""Return the command opcode."""
|
|
141
160
|
return _resolve_required(self, 'opcode')
|
|
142
161
|
|
|
143
162
|
@property
|
|
144
163
|
def opscat(self):
|
|
164
|
+
"""Return the command opscat."""
|
|
145
165
|
return _resolve_required(self, 'opscat')
|
|
146
166
|
|
|
147
167
|
@property
|
|
148
168
|
def args(self):
|
|
169
|
+
"""Return the command arguments."""
|
|
149
170
|
return _resolve_required(self, 'args')
|
|
150
171
|
|
|
151
172
|
|
|
@@ -163,29 +184,140 @@ class ChannelContract:
|
|
|
163
184
|
|
|
164
185
|
@property
|
|
165
186
|
def channel_id(self):
|
|
187
|
+
"""Return the channel ID."""
|
|
166
188
|
return _resolve_required(self, 'channel_id')
|
|
167
189
|
|
|
168
190
|
@property
|
|
169
191
|
def channel_name(self):
|
|
192
|
+
"""Return the channel name."""
|
|
170
193
|
return _resolve_required(self, 'channel_name')
|
|
171
194
|
|
|
172
195
|
@property
|
|
173
196
|
def opscat(self):
|
|
197
|
+
"""Return the channel opscat."""
|
|
174
198
|
return _resolve_required(self, 'opscat')
|
|
175
199
|
|
|
176
200
|
@property
|
|
177
201
|
def type(self):
|
|
202
|
+
"""Return the channel type."""
|
|
178
203
|
return _resolve_required(self, 'type')
|
|
179
204
|
|
|
180
205
|
@property
|
|
181
206
|
def module(self):
|
|
207
|
+
"""Return the channel module if defined."""
|
|
182
208
|
return _resolve_optional(self, 'module')
|
|
183
209
|
|
|
184
210
|
@property
|
|
185
211
|
def measurement_id(self):
|
|
212
|
+
"""Return the measurement ID if defined."""
|
|
186
213
|
return _resolve_optional(self, 'measurement_id')
|
|
187
214
|
|
|
188
215
|
|
|
216
|
+
class ApidContract:
|
|
217
|
+
"""
|
|
218
|
+
Contract for an application packet identifier definition.
|
|
219
|
+
|
|
220
|
+
Required: apid, name, format, title, description, module, opscat.
|
|
221
|
+
Optional: recorded, downlink_info.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def apid(self):
|
|
226
|
+
return _resolve_required(self, 'apid')
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def name(self):
|
|
230
|
+
return _resolve_required(self, 'name')
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def format(self):
|
|
234
|
+
return _resolve_required(self, 'format')
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def title(self):
|
|
238
|
+
return _resolve_required(self, 'title')
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def description(self):
|
|
242
|
+
return _resolve_required(self, 'description')
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def module(self):
|
|
246
|
+
return _resolve_required(self, 'module')
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def opscat(self):
|
|
250
|
+
return _resolve_required(self, 'opscat')
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def recorded(self):
|
|
254
|
+
return _resolve_optional(self, 'recorded')
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def downlink_info(self):
|
|
258
|
+
return _resolve_optional(self, 'downlink_info')
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class EvrArgumentContract:
|
|
262
|
+
"""
|
|
263
|
+
Contract for an argument interpolated into an EVR message.
|
|
264
|
+
|
|
265
|
+
Required: name.
|
|
266
|
+
Optional: type, length, enum.
|
|
267
|
+
|
|
268
|
+
EVR arguments are deliberately separate from command arguments because
|
|
269
|
+
their metadata describes message interpolation, not command encoding.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def name(self):
|
|
274
|
+
return _resolve_required(self, 'name')
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def type(self):
|
|
278
|
+
return _resolve_optional(self, 'type')
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def length(self):
|
|
282
|
+
return _resolve_optional(self, 'length')
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def enum(self):
|
|
286
|
+
return _resolve_optional(self, 'enum')
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class EnumTableContract:
|
|
290
|
+
"""
|
|
291
|
+
Contract for a named enumeration table.
|
|
292
|
+
|
|
293
|
+
Required: name, values.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def name(self):
|
|
298
|
+
return _resolve_required(self, 'name')
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def values(self):
|
|
302
|
+
return _resolve_required(self, 'values')
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class EnumValueContract:
|
|
306
|
+
"""
|
|
307
|
+
Contract for one value in an enumeration table.
|
|
308
|
+
|
|
309
|
+
Required: name, value.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def name(self):
|
|
314
|
+
return _resolve_required(self, 'name')
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def value(self):
|
|
318
|
+
return _resolve_required(self, 'value')
|
|
319
|
+
|
|
320
|
+
|
|
189
321
|
class EvrContract:
|
|
190
322
|
"""
|
|
191
323
|
Contract for a single EVR (event record) definition.
|
|
@@ -205,12 +337,15 @@ class EvrContract:
|
|
|
205
337
|
|
|
206
338
|
@property
|
|
207
339
|
def name(self):
|
|
340
|
+
"""Return the EVR name."""
|
|
208
341
|
return _resolve_required(self, 'name')
|
|
209
342
|
|
|
210
343
|
@property
|
|
211
344
|
def message(self):
|
|
345
|
+
"""Return the EVR message template."""
|
|
212
346
|
return _resolve_required(self, 'message')
|
|
213
347
|
|
|
214
348
|
@property
|
|
215
349
|
def severity(self):
|
|
350
|
+
"""Return the EVR severity."""
|
|
216
351
|
return _resolve_required(self, 'severity')
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from lxml import etree
|
|
2
|
+
|
|
3
|
+
from tts_dictionary_interface.base import SemanticDictionary
|
|
4
|
+
from tts_dictionary_interface.tree import TreeSemanticDictionary
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CachedItem(TreeSemanticDictionary):
|
|
8
|
+
ATTR_PATHS = {
|
|
9
|
+
'name': ('.', lambda node: node['name']),
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CachedDocument(TreeSemanticDictionary):
|
|
14
|
+
ITEM_PATHS = ['.']
|
|
15
|
+
ITEM_HUMAN_UNIQUE_IDS = ['name']
|
|
16
|
+
ITEM_CLASSES = [CachedItem]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_xml_contains_populates_the_same_item_cache():
|
|
20
|
+
class XMLItem(SemanticDictionary):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
class XMLDocument(SemanticDictionary):
|
|
24
|
+
ITEM_XPATHS = ['.//Item']
|
|
25
|
+
ITEM_HUMAN_UNIQUE_IDS = ['name']
|
|
26
|
+
ITEM_CLASSES = [XMLItem]
|
|
27
|
+
|
|
28
|
+
document = XMLDocument(etree.fromstring(b'<Root><Item name="one"/></Root>'))
|
|
29
|
+
|
|
30
|
+
assert 'one' in document
|
|
31
|
+
first = document['one']
|
|
32
|
+
assert document['one'] is first
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_xml_alternate_identifier_reuses_the_wrapped_node():
|
|
36
|
+
class XMLItem(SemanticDictionary):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
class XMLDocument(SemanticDictionary):
|
|
40
|
+
ITEM_XPATHS = ['.//Item']
|
|
41
|
+
ITEM_HUMAN_UNIQUE_IDS = ['name|opcode']
|
|
42
|
+
ITEM_CLASSES = [XMLItem]
|
|
43
|
+
|
|
44
|
+
document = XMLDocument(
|
|
45
|
+
etree.fromstring(b'<Root><Item name="one" opcode="0x1"/></Root>')
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
first = document['one']
|
|
49
|
+
assert document['0x1'] is first
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_xml_getattr_caches_resolved_values():
|
|
53
|
+
calls = []
|
|
54
|
+
|
|
55
|
+
def count_value(value):
|
|
56
|
+
calls.append(value)
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
class XMLDocument(SemanticDictionary):
|
|
60
|
+
ATTR_XPATHS = {
|
|
61
|
+
'description': ('.//Description', True, count_value),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
document = XMLDocument(
|
|
65
|
+
etree.fromstring(b'<Root><Description>cached</Description></Root>')
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert document.description == 'cached'
|
|
69
|
+
assert document.description == 'cached'
|
|
70
|
+
assert calls == ['cached']
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_xml_iteration_populates_getitem_cache_and_preserves_document():
|
|
74
|
+
class XMLItem(SemanticDictionary):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
class XMLDocument(SemanticDictionary):
|
|
78
|
+
ITEM_XPATHS = ['.//Item']
|
|
79
|
+
ITEM_HUMAN_UNIQUE_IDS = ['name']
|
|
80
|
+
ITEM_CLASSES = [XMLItem]
|
|
81
|
+
|
|
82
|
+
root = etree.fromstring(
|
|
83
|
+
b'<Root><Item name="one"/><Item name="two"/></Root>'
|
|
84
|
+
)
|
|
85
|
+
document = XMLDocument(root)
|
|
86
|
+
|
|
87
|
+
items = list(document)
|
|
88
|
+
|
|
89
|
+
assert len(document) == 2
|
|
90
|
+
assert document['one'] is items[0]
|
|
91
|
+
assert all(item.document is root for item in items)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_getitem_returns_the_cached_wrapped_item():
|
|
95
|
+
document = CachedDocument([{'name': 'one'}])
|
|
96
|
+
|
|
97
|
+
first = document['one']
|
|
98
|
+
second = document['one']
|
|
99
|
+
|
|
100
|
+
assert second is first
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_contains_populates_the_same_item_cache():
|
|
104
|
+
document = CachedDocument([{'name': 'one'}])
|
|
105
|
+
|
|
106
|
+
assert 'one' in document
|
|
107
|
+
first = document['one']
|
|
108
|
+
assert document['one'] is first
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_iteration_reuses_wrapped_items_and_len_is_not_lru_size():
|
|
112
|
+
document = CachedDocument([{'name': str(i)} for i in range(3)])
|
|
113
|
+
|
|
114
|
+
first_iteration = list(document)
|
|
115
|
+
second_iteration = list(document)
|
|
116
|
+
|
|
117
|
+
assert len(document) == 3
|
|
118
|
+
assert second_iteration == first_iteration
|
|
119
|
+
assert all(left is right for left, right in zip(first_iteration, second_iteration))
|
|
120
|
+
|
|
121
|
+
large_document = CachedDocument([{'name': str(i)} for i in range(101)])
|
|
122
|
+
list(large_document)
|
|
123
|
+
assert len(large_document) == 101
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_iteration_wraps_items_as_the_iterator_advances():
|
|
127
|
+
class CountingDocument(CachedDocument):
|
|
128
|
+
def __init__(self, source):
|
|
129
|
+
super().__init__(source)
|
|
130
|
+
self.selected = 0
|
|
131
|
+
|
|
132
|
+
def select(self, path, filters=None):
|
|
133
|
+
self.selected += 1
|
|
134
|
+
return super().select(path, filters)
|
|
135
|
+
|
|
136
|
+
document = CountingDocument([{'name': str(i)} for i in range(3)])
|
|
137
|
+
iterator = iter(document)
|
|
138
|
+
|
|
139
|
+
assert document.selected == 0
|
|
140
|
+
next(iterator)
|
|
141
|
+
assert document.selected == 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_lru_eviction_rebuilds_the_oldest_item():
|
|
145
|
+
document = CachedDocument([{'name': str(i)} for i in range(101)])
|
|
146
|
+
|
|
147
|
+
first = document['0']
|
|
148
|
+
for i in range(1, 101):
|
|
149
|
+
document[str(i)]
|
|
150
|
+
|
|
151
|
+
assert document['0'] is not first
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_getattr_returns_the_cached_value():
|
|
155
|
+
calls = []
|
|
156
|
+
|
|
157
|
+
class CountingItem(TreeSemanticDictionary):
|
|
158
|
+
ATTR_PATHS = {
|
|
159
|
+
'value': ('.', lambda node: calls.append(node['value']) or node['value']),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
item = CountingItem({'value': 'cached'})
|
|
163
|
+
|
|
164
|
+
assert item.value == 'cached'
|
|
165
|
+
assert item.value == 'cached'
|
|
166
|
+
assert calls == ['cached']
|
|
@@ -9,9 +9,13 @@ from lxml import etree
|
|
|
9
9
|
|
|
10
10
|
from tts_dictionary_interface.base import SemanticDictionary
|
|
11
11
|
from tts_dictionary_interface.contracts import (
|
|
12
|
+
ApidContract,
|
|
12
13
|
ChannelContract,
|
|
13
14
|
CommandContract,
|
|
14
15
|
ArgumentContract,
|
|
16
|
+
EnumTableContract,
|
|
17
|
+
EnumValueContract,
|
|
18
|
+
EvrArgumentContract,
|
|
15
19
|
EvrContract,
|
|
16
20
|
)
|
|
17
21
|
|
|
@@ -165,3 +169,122 @@ def test_incomplete_evr_raises_only_for_missing_attrs():
|
|
|
165
169
|
evr.message
|
|
166
170
|
with pytest.raises(NotImplementedError):
|
|
167
171
|
evr.severity
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class CompleteApid(ApidContract):
|
|
175
|
+
@property
|
|
176
|
+
def apid(self):
|
|
177
|
+
return 42
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def name(self):
|
|
181
|
+
return 'CORE'
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def format(self):
|
|
185
|
+
return '0x%04X'
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def title(self):
|
|
189
|
+
return 'Core packet'
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def description(self):
|
|
193
|
+
return 'Core application packet'
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def module(self):
|
|
197
|
+
return 'core'
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def opscat(self):
|
|
201
|
+
return 'CORE'
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_apid_contract_required_attributes_and_optional_defaults():
|
|
205
|
+
apid = CompleteApid()
|
|
206
|
+
assert isinstance(apid, ApidContract)
|
|
207
|
+
assert apid.apid == 42
|
|
208
|
+
assert apid.name == 'CORE'
|
|
209
|
+
assert apid.format == '0x%04X'
|
|
210
|
+
assert apid.title == 'Core packet'
|
|
211
|
+
assert apid.description == 'Core application packet'
|
|
212
|
+
assert apid.module == 'core'
|
|
213
|
+
assert apid.opscat == 'CORE'
|
|
214
|
+
assert apid.recorded is None
|
|
215
|
+
assert apid.downlink_info is None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class IncompleteApid(ApidContract):
|
|
219
|
+
@property
|
|
220
|
+
def apid(self):
|
|
221
|
+
return 42
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_apid_contract_required_attributes_fail_lazily():
|
|
225
|
+
apid = IncompleteApid()
|
|
226
|
+
with pytest.raises(NotImplementedError):
|
|
227
|
+
apid.name
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class CompleteEvrArgument(EvrArgumentContract):
|
|
231
|
+
@property
|
|
232
|
+
def name(self):
|
|
233
|
+
return 'count'
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_evr_argument_contract_optional_attributes_default_to_none():
|
|
237
|
+
argument = CompleteEvrArgument()
|
|
238
|
+
assert isinstance(argument, EvrArgumentContract)
|
|
239
|
+
assert argument.name == 'count'
|
|
240
|
+
assert argument.type is None
|
|
241
|
+
assert argument.length is None
|
|
242
|
+
assert argument.enum is None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class CompleteEnumTable(EnumTableContract):
|
|
246
|
+
@property
|
|
247
|
+
def name(self):
|
|
248
|
+
return 'modes'
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def values(self):
|
|
252
|
+
return []
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class CompleteEnumValue(EnumValueContract):
|
|
256
|
+
@property
|
|
257
|
+
def name(self):
|
|
258
|
+
return 'OFF'
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def value(self):
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_enum_contracts_expose_required_attributes():
|
|
266
|
+
table = CompleteEnumTable()
|
|
267
|
+
value = CompleteEnumValue()
|
|
268
|
+
assert isinstance(table, EnumTableContract)
|
|
269
|
+
assert isinstance(value, EnumValueContract)
|
|
270
|
+
assert table.name == 'modes'
|
|
271
|
+
assert table.values == []
|
|
272
|
+
assert value.name == 'OFF'
|
|
273
|
+
assert value.value == 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class ExtendedArgument(ArgumentContract):
|
|
277
|
+
@property
|
|
278
|
+
def name(self):
|
|
279
|
+
return 'mode'
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def length(self):
|
|
283
|
+
return 8
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_argument_contract_extended_attributes_default_to_none():
|
|
287
|
+
argument = ExtendedArgument()
|
|
288
|
+
assert argument.type is None
|
|
289
|
+
assert argument.description is None
|
|
290
|
+
assert argument.enum is None
|
|
@@ -204,11 +204,11 @@ class TreeSemanticDictionary(BaseSemanticDictionary):
|
|
|
204
204
|
"""
|
|
205
205
|
if filters:
|
|
206
206
|
# For TreeSemanticDictionary, we append the filter to the path.
|
|
207
|
-
# Note: we use ' and ' as per the original implementation,
|
|
207
|
+
# Note: we use ' and ' as per the original implementation,
|
|
208
208
|
# but BaseSemanticDictionary calls select() in a loop for OR.
|
|
209
209
|
filter_str = ' and '.join(f'{attr.strip()}="{val}"' for attr, val in filters.items())
|
|
210
210
|
path = f"{path}[{filter_str}]"
|
|
211
|
-
|
|
211
|
+
|
|
212
212
|
return select(self.node, path)
|
|
213
213
|
|
|
214
214
|
def path(self, path):
|
|
@@ -8,9 +8,11 @@ ci/sphinx_ignore_tags.txt
|
|
|
8
8
|
docs/conf.py
|
|
9
9
|
docs/foo.rst
|
|
10
10
|
docs/index.rst
|
|
11
|
+
docs/narrative.rst
|
|
11
12
|
docs/semantic_dictionary_cache_spec.md
|
|
12
13
|
docs/_static/bugfix.css
|
|
13
14
|
docs/agents/README.md
|
|
15
|
+
docs/agents/check_docstring_coverage.py
|
|
14
16
|
src/__init__.py
|
|
15
17
|
src/tts_dictionary_interface/__init__.py
|
|
16
18
|
src/tts_dictionary_interface/base.py
|
|
@@ -28,6 +30,7 @@ src/tts_dictionary_interface/test/__init__.py
|
|
|
28
30
|
src/tts_dictionary_interface/test/conftest.py
|
|
29
31
|
src/tts_dictionary_interface/test/test_ait_loader.py
|
|
30
32
|
src/tts_dictionary_interface/test/test_base.py
|
|
33
|
+
src/tts_dictionary_interface/test/test_cache.py
|
|
31
34
|
src/tts_dictionary_interface/test/test_contracts.py
|
|
32
35
|
src/tts_dictionary_interface/test/test_tree.py
|
|
33
36
|
src/tts_dictionary_interface/test/fixtures/ait_yaml/demosat_includes/ccsds_header.yaml
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|