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.
Files changed (43) hide show
  1. {tts_dictionary_interface-0.3.0/src/tts_dictionary_interface.egg-info → tts_dictionary_interface-0.4.0}/PKG-INFO +1 -1
  2. tts_dictionary_interface-0.4.0/docs/agents/check_docstring_coverage.py +166 -0
  3. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/index.rst +1 -1
  4. tts_dictionary_interface-0.4.0/docs/narrative.rst +100 -0
  5. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/pyproject.toml +4 -1
  6. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/ait/loader.py +22 -16
  7. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/base.py +130 -27
  8. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/contracts.py +135 -0
  9. tts_dictionary_interface-0.4.0/src/tts_dictionary_interface/test/test_cache.py +166 -0
  10. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/test_contracts.py +123 -0
  11. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/tree.py +2 -2
  12. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0/src/tts_dictionary_interface.egg-info}/PKG-INFO +1 -1
  13. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface.egg-info/SOURCES.txt +3 -0
  14. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/.github/workflows/run_tests_on_pr.yml +0 -0
  15. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/.gitignore +0 -0
  16. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/README.md +0 -0
  17. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/ci/sphinx_ignore_tags.txt +0 -0
  18. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/_static/bugfix.css +0 -0
  19. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/agents/README.md +0 -0
  20. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/conf.py +0 -0
  21. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/foo.rst +0 -0
  22. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/docs/semantic_dictionary_cache_spec.md +0 -0
  23. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/license.md +0 -0
  24. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/setup.cfg +0 -0
  25. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/setup.py +0 -0
  26. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/__init__.py +0 -0
  27. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/__init__.py +0 -0
  28. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/ait/__init__.py +0 -0
  29. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/__init__.py +0 -0
  30. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/conftest.py +0 -0
  31. {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
  32. {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
  33. {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
  34. {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
  35. {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
  36. {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
  37. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/test_ait_loader.py +0 -0
  38. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/test_base.py +0 -0
  39. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/test/test_tree.py +0 -0
  40. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface/tree.py.bk +0 -0
  41. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface.egg-info/dependency_links.txt +0 -0
  42. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface.egg-info/requires.txt +0 -0
  43. {tts_dictionary_interface-0.3.0 → tts_dictionary_interface-0.4.0}/src/tts_dictionary_interface.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tts-dictionary-interface
3
- Version: 0.3.0
3
+ Version: 0.4.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>
@@ -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()
@@ -5,7 +5,7 @@ Welcome to the Teamtools Starter Template documentation!
5
5
  :maxdepth: 2
6
6
  :caption: Contents:
7
7
 
8
- foo
8
+ narrative
9
9
 
10
10
  Indices and tables
11
11
  ==================
@@ -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.
@@ -38,4 +38,7 @@ requires = [
38
38
  "tts-utilities>=0.2.1,<1.0.0",
39
39
  "setuptools-scm"
40
40
  ]
41
- build-backend = "setuptools.build_meta"
41
+ build-backend = "setuptools.build_meta"
42
+
43
+ [tool.tts-ctx]
44
+ type = "core"
@@ -28,12 +28,19 @@ from tts_dictionary_interface.tree import TreeSemanticDictionary, _flatten
28
28
 
29
29
  class IncludeLoader(yaml.SafeLoader):
30
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).
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, resolving any `!include`
70
- directives (recursively, relative to each including file's own
71
- directory) and ignoring all other custom tags.
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
- 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.
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([itemclass(node, document=self.document) for node in matches])
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 e in elements:
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(e)
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
- return unique_elements[0]
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
- return self.node.get(attr)
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
- return [resolve(m) for m in matches]
160
- return resolve(matches[0])
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
- for path, itemclass in zip(item_paths, item_classes):
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
- elements.extend([itemclass(node, document=self.document) for node in matches])
170
- for e in elements:
171
- yield e
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
- return sum(1 for _ in self)
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(item_paths, self.ITEM_HUMAN_UNIQUE_IDS):
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
- if self.select(path, {id_attr: key}):
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):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tts-dictionary-interface
3
- Version: 0.3.0
3
+ Version: 0.4.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>
@@ -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