tts-dictionary-interface 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,244 @@
1
+ import pdb
2
+ from pathlib import Path
3
+ import os
4
+ from lxml import etree
5
+
6
+ from tts_utilities.logger import create_logger
7
+ logger = create_logger('semantic_dictionary')
8
+
9
+ #Requirements:
10
+ # Commands
11
+ # Every project needs to have a command dictioanry
12
+ # Every command class needs a stem attr
13
+ # Every command class needs an opcode attr
14
+ # Every command class needs an opscat attr
15
+ # Every command class needs an args attr that is an ArgumentList
16
+ # Every command argument needs a name attr
17
+ # Every command argument needs a units attr
18
+ # Every command argument needs a way to understand length
19
+ # Every command argument needs a min attr (is this true for all? or just for numeric?)
20
+ # Every command argument needs a max attr (is this true for all? or just for numeric?)
21
+ # Channels
22
+ # Every project needs to have a channel dictioanary
23
+ # Every channel class needs an measurement_id attr
24
+ # Every channel class needs a channel_id attr
25
+ # Every channel class needs a channel_name attr
26
+ # Every channel class needs an opscat attr
27
+ # Every channel class needs to have a type attr
28
+ # Needs to be translated to chill type (e.g. naive dn/eu/status/dnString)
29
+
30
+ class SemanticDictionary:
31
+ """
32
+ A base reading AMPCS dictionaries (or really any XML file) more Pythonically.
33
+
34
+ This class is a bit obtuse, so please take a look at DemoSat's implementation in
35
+ tts-demosat/demosat_dictionary_interface for an example of how this looks when
36
+ realized against an actual space mission.
37
+
38
+ This class provides a dictionary-like interface to an XML tree. It allows retrieval of
39
+ items via specific unique IDs defined in the subclass configuration, and dynamic
40
+ attribute access via configured XPaths.
41
+
42
+ Attributes:
43
+ ITEM_XPATHS (list): A list of XPath strings used to find items. An item in this context is
44
+ the primary human-parseable artifact in the XML. (e.g. for command.xml, it's a command). If
45
+ you extend SemanticDictionary and do "for x in SemanticDictionaryExtension: x", you will
46
+ iterate through these "items""
47
+ ITEM_HUMAN_UNIQUE_IDS (list): The expected human-readable ID for items
48
+ ITEM_CLASSES (list): The class to pass items ETREE elements to.
49
+ ATTR_XPATHS (dict): A mapping of XML xpaths to Pyhton attributes
50
+ """
51
+ ITEM_XPATHS = []
52
+ ITEM_HUMAN_UNIQUE_IDS = []
53
+ ITEM_CLASSES = []
54
+ ATTR_XPATHS = {}
55
+
56
+ def __init__(self, source):
57
+ """
58
+ Initialize the SemanticDictionary with a source XML.
59
+
60
+ Args:
61
+ source (str, os.PathLike, etree._ElementTree, or etree.Element): The source of the
62
+ XML data. This can be a file path string, a Path object, a full ElementTree,
63
+ or a specific Element node. This library is meant to be called recursively,
64
+ so this might be the file passed by the user or it might be an ETREE Element
65
+ passed by another SemanticDictionary
66
+ """
67
+ # Check if source is a string OR a Path object (os.PathLike covers pathlib)
68
+ if isinstance(source, (str, os.PathLike)):
69
+ # Force conversion to string for lxml.parse, just to be safe
70
+ tree = etree.parse(str(source))
71
+ self.etree = tree.getroot()
72
+ elif isinstance(source, etree._ElementTree):
73
+ self.etree = source.getroot()
74
+ else:
75
+ # Assume it is already an lxml Element
76
+ self.etree = source
77
+
78
+ def xpath(self, xpath):
79
+ """
80
+ Run a raw XPath query against the root element of this dictionary. Literally just
81
+ a passthrough of etree.xpath so you can get to any part of the XML you want below
82
+ this node.
83
+
84
+ Args:
85
+ xpath (str): The XPath query string to execute.
86
+
87
+ Returns:
88
+ list: A list of lxml elements or values matching the query.
89
+ """
90
+ return self.etree.xpath(xpath)
91
+
92
+ def __getitem__(self, item):
93
+ """
94
+ Retrieve a specific item from the dictionary by its unique identifier.
95
+
96
+ This iterates through the configured ITEM_XPATHS and ITEM_HUMAN_UNIQUE_IDS. It constructs
97
+ an XPath query to find the element where the unique ID attribute matches the provided item.
98
+
99
+ Args:
100
+ item (str): The unique identifier value (e.g., a command stem or channel ID) to search for.
101
+
102
+ Returns:
103
+ object: An instance of the corresponding class from ITEM_CLASSES wrapping the found element.
104
+
105
+ Raises:
106
+ Exception: If no elements are found matching the identifier.
107
+ Exception: If more than one element is found matching the identifier.
108
+ """
109
+ elements = []
110
+ xpaths = []
111
+
112
+ for itemxpath, itemid, itemclass in zip(self.ITEM_XPATHS, self.ITEM_HUMAN_UNIQUE_IDS, self.ITEM_CLASSES):
113
+ # 1. Build the condition list (Handles keys like 'name|abbreviation')
114
+ conditions = []
115
+ for attr in itemid.split('|'):
116
+ attr = attr.strip()
117
+
118
+ # Special handling: Look at text content vs attributes
119
+ if attr == 'text()':
120
+ conditions.append(f'text()="{item}"')
121
+ else:
122
+ conditions.append(f'@{attr}="{item}"')
123
+
124
+ # 2. Join with OR and wrap in brackets: [name="X" or abbreviation="X"]
125
+ attr_def = '[' + ' or '.join(conditions) + ']'
126
+
127
+ # 3. Execute Query
128
+ xpath_query = f'{itemxpath}{attr_def}'
129
+ xpaths.append(xpath_query)
130
+ elements += [itemclass(x) for x in self.etree.xpath(xpath_query)]
131
+
132
+ if len(elements) == 0:
133
+ raise Exception(f'No elements found with value "{item}" on xpath(s): {xpaths}')
134
+ if len(elements) > 1:
135
+ raise Exception(f'More than one element found with value "{item}" on xpath(s): {xpaths}')
136
+
137
+ return elements[0]
138
+
139
+ def __getattr__(self, attr):
140
+ """
141
+ Retrieve a dynamic attribute or a standard XML attribute.
142
+
143
+ If the attribute is defined in ATTR_XPATHS, the configured XPath is executed.
144
+ Otherwise, it attempts to get the value as a standard attribute of the XML root.
145
+
146
+ Args:
147
+ attr (str): The name of the attribute to retrieve.
148
+
149
+ Returns:
150
+ Any: The wrapped object, text content, or raw element if defined in ATTR_XPATHS;
151
+ otherwise, the string value of the XML attribute on the root element.
152
+ """
153
+ if attr in self.ATTR_XPATHS.keys():
154
+ config = self.ATTR_XPATHS[attr]
155
+ xpath = config[0]
156
+ is_text = config[1]
157
+ attr_class = config[2]
158
+ # Safely get the 4th argument, default to False
159
+ return_list = config[3] if len(config) > 3 else False
160
+
161
+ element_list = self.etree.xpath(xpath)
162
+
163
+ # Helper to safely extract value/text from a single result
164
+ def resolve_value(item):
165
+ if attr_class is not None:
166
+ return attr_class(item)
167
+
168
+ # If we asked for text, but got an Element, get .text
169
+ if is_text and hasattr(item, 'text'):
170
+ return item.text
171
+
172
+ # If we asked for text, but item is already a string (because xpath ended in /text()), return it
173
+ if is_text and isinstance(item, str):
174
+ return item
175
+
176
+ # Otherwise return the raw item (Element or String)
177
+ return item
178
+
179
+ # --- 1. Nothing Found ---
180
+ if len(element_list) == 0:
181
+ # If expecting a list, return empty list. Otherwise None.
182
+ return [] if return_list else None
183
+
184
+ # --- 2. List Requested ---
185
+ if return_list:
186
+ return [resolve_value(e) for e in element_list]
187
+
188
+ # --- 3. Single Item Requested ---
189
+ return resolve_value(element_list[0])
190
+
191
+ else:
192
+ return self.etree.get(attr)
193
+
194
+ def __iter__(self):
195
+ """
196
+ Iterate over all configured items in the dictionary.
197
+
198
+ This method chains together all elements found by the configured ITEM_XPATHS,
199
+ wrapping them in their respective ITEM_CLASSES.
200
+
201
+ Yields:
202
+ object: Instances of the item classes defined in the configuration.
203
+ """
204
+ elements = []
205
+ for itemxpath, itemclass in zip(self.ITEM_XPATHS, self.ITEM_CLASSES):
206
+ elements += [itemclass(x) for x in self.etree.xpath(itemxpath)]
207
+ for e in elements: yield e
208
+
209
+ def __len__(self):
210
+ # Return the number of items in the members list
211
+ return len([x for x in self])
212
+
213
+ def __contains__(self, key):
214
+ """
215
+ Enables 'if "KEY" in dictionary' syntax.
216
+
217
+ Iterates through the configured ITEM_XPATHS and ITEM_HUMAN_UNIQUE_IDS
218
+ to checks if an element with the given key exists.
219
+ """
220
+ # Zip together the XPath locations and the Attribute IDs defining "uniqueness"
221
+ for itemxpath, itemid in zip(self.ITEM_XPATHS, self.ITEM_HUMAN_UNIQUE_IDS):
222
+
223
+ # 1. Build the condition list (Handles keys like 'name|abbreviation')
224
+ conditions = []
225
+ for attr in itemid.split('|'):
226
+ attr = attr.strip()
227
+
228
+ # Special handling: Look at text content vs attributes
229
+ if attr == 'text()':
230
+ conditions.append(f'text()="{key}"')
231
+ else:
232
+ conditions.append(f'@{attr}="{key}"')
233
+
234
+ # 2. Join with OR: [name="X" or abbreviation="X"]
235
+ predicate = " or ".join(conditions)
236
+
237
+ # 3. Construct full query: e.g. telemetry_definitions/telemetry[@name="X"]
238
+ query = f"{itemxpath}[{predicate}]"
239
+
240
+ # 4. Check existence (xpath returns a list; empty list is False)
241
+ if self.etree.xpath(query):
242
+ return True
243
+
244
+ return False
File without changes
File without changes
@@ -0,0 +1,172 @@
1
+ import sys
2
+ import pytest
3
+ from unittest.mock import MagicMock, patch
4
+ from lxml import etree
5
+
6
+ # --- MOCKING EXTERNAL DEPENDENCIES ---
7
+ # We mock tts_utilities before importing the module to avoid ModuleNotFoundError
8
+ sys.modules['tts_utilities'] = MagicMock()
9
+ sys.modules['tts_utilities.logger'] = MagicMock()
10
+
11
+ # Assuming the user's code is in a file named `semantic_dictionary.py`
12
+ # If you are pasting this into a single file, put the class definition above this line.
13
+ from tts_dictionary_interface.base import SemanticDictionary
14
+
15
+ # --- FIXTURES AND SETUP ---
16
+
17
+ @pytest.fixture
18
+ def sample_xml():
19
+ """Creates a temporary sample XML string for testing."""
20
+ return b"""
21
+ <Project name="TestProject" version="1.0">
22
+ <Commands>
23
+ <Command stem="CMD_A" opcode="0x01" opscat="flight">
24
+ <Args>
25
+ <Arg name="arg1" units="sec" min="0" max="10"/>
26
+ </Args>
27
+ </Command>
28
+ <Command stem="CMD_B" opcode="0x02" opscat="ground">
29
+ <Args>
30
+ <Arg name="arg2" units="m/s"/>
31
+ </Args>
32
+ </Command>
33
+ <Command stem="CMD_DUP" opcode="0x03" />
34
+ <Command stem="CMD_DUP" opcode="0x04" />
35
+ </Commands>
36
+ <Channels>
37
+ <Channel id="100" name="ChanA" type="float"/>
38
+ </Channels>
39
+ <Metadata>
40
+ <Description>A test dictionary</Description>
41
+ </Metadata>
42
+ </Project>
43
+ """
44
+
45
+ class CommandWrapper:
46
+ """A dummy wrapper class to test ITEM_CLASSES instantiation."""
47
+ def __init__(self, element):
48
+ self.element = element
49
+ self.stem = element.get('stem')
50
+
51
+ class ConcreteDictionary(SemanticDictionary):
52
+ """
53
+ A concrete implementation of the base class.
54
+ We must populate the configuration constants to test the logic.
55
+ """
56
+ # Look for Commands, identify them by 'stem' or 'opcode', wrap them in CommandWrapper
57
+ ITEM_XPATHS = ['.//Command']
58
+ ITEM_HUMAN_UNIQUE_IDS = ['stem|opcode']
59
+ ITEM_CLASSES = [CommandWrapper]
60
+
61
+ # Map 'description' to the text of the Description tag
62
+ # Map 'project_name' to the 'name' attribute of the root (via fallback or XPath)
63
+ ATTR_XPATHS = {
64
+ 'description': ('.//Description', True, None), # (xpath, is_text, class)
65
+ 'all_args': ('.//Arg', False, None) # Return raw element list (or first element)
66
+ }
67
+
68
+ @pytest.fixture
69
+ def concrete_dict(tmp_path, sample_xml):
70
+ """Creates an instance of the ConcreteDictionary using a temporary file."""
71
+ f = tmp_path / "test_dict.xml"
72
+ f.write_bytes(sample_xml)
73
+ return ConcreteDictionary(str(f))
74
+
75
+ # --- TEST CASES ---
76
+
77
+ class TestInitialization:
78
+ def test_init_with_path(self, tmp_path, sample_xml):
79
+ f = tmp_path / "test.xml"
80
+ f.write_bytes(sample_xml)
81
+ sd = SemanticDictionary(str(f))
82
+ assert isinstance(sd.etree, etree._Element)
83
+
84
+ def test_init_with_element(self, sample_xml):
85
+ root = etree.fromstring(sample_xml)
86
+ sd = SemanticDictionary(root)
87
+ assert sd.etree == root
88
+
89
+ def test_init_invalid_path(self):
90
+ with pytest.raises(OSError):
91
+ SemanticDictionary("non_existent_file.xml")
92
+
93
+ class TestGetItem:
94
+ """Tests for __getitem__ (e.g., dict['key'])"""
95
+
96
+ def test_get_item_by_stem_success(self, concrete_dict):
97
+ # Should find CMD_A using the 'stem' attribute defined in ITEM_HUMAN_UNIQUE_IDS
98
+ item = concrete_dict['CMD_A']
99
+ assert isinstance(item, CommandWrapper)
100
+ assert item.element.get('opcode') == '0x01'
101
+
102
+ def test_get_item_by_opcode_success(self, concrete_dict):
103
+ # Should find CMD_B using the 'opcode' attribute (second part of the split)
104
+ item = concrete_dict['0x02']
105
+ assert item.stem == 'CMD_B'
106
+
107
+ def test_get_item_not_found(self, concrete_dict):
108
+ with pytest.raises(Exception) as excinfo:
109
+ _ = concrete_dict['NON_EXISTENT']
110
+ assert "No elements found" in str(excinfo.value)
111
+
112
+ def test_get_item_multiple_found(self, concrete_dict):
113
+ # CMD_DUP exists twice in the XML
114
+ with pytest.raises(Exception) as excinfo:
115
+ _ = concrete_dict['CMD_DUP']
116
+ assert "More than one element found" in str(excinfo.value)
117
+
118
+ class TestGetAttr:
119
+ """Tests for __getattr__ (e.g., dict.attr)"""
120
+
121
+ def test_getattr_configured_xpath_text(self, concrete_dict):
122
+ # 'description' is mapped to .//Description text
123
+ assert concrete_dict.description == "A test dictionary"
124
+
125
+ def test_getattr_configured_xpath_element(self, concrete_dict):
126
+ # 'all_args' is mapped to .//Arg, is_text=False
127
+ # It should return the first matching element
128
+ arg = concrete_dict.all_args
129
+ assert isinstance(arg, etree._Element)
130
+ assert arg.get('name') == 'arg1'
131
+
132
+ def test_getattr_fallback_to_root_attr(self, concrete_dict):
133
+ # 'version' is NOT in ATTR_XPATHS, so it should look at root attributes
134
+ assert concrete_dict.version == "1.0"
135
+
136
+ def test_getattr_not_found_in_config(self, concrete_dict):
137
+ # If xpath exists in config but finds nothing in XML
138
+ # We need to temporarily modify the ATTR_XPATHS for this specific instance/class test
139
+ # or rely on a specific test case.
140
+ # Let's try accessing a root attribute that doesn't exist
141
+ assert concrete_dict.non_existent_attr is None
142
+
143
+ def test_getattr_xpath_no_elements_returns_none(self, concrete_dict):
144
+ # We temporarily add a broken configuration to the class
145
+ ConcreteDictionary.ATTR_XPATHS['broken'] = ('.//NonExistentTag', True, None)
146
+
147
+ # Access it
148
+ val = concrete_dict.broken
149
+
150
+ # ASSERT: It should just return None gracefully (no crash)
151
+ # We removed the logger check because your implementation doesn't log anymore.
152
+ assert val is None
153
+
154
+ class TestIteration:
155
+ """Tests for __iter__"""
156
+
157
+ def test_iter_yields_wrappers(self, concrete_dict):
158
+ items = list(concrete_dict)
159
+ # We expect 4 commands total in the sample XML (CMD_A, CMD_B, and 2x CMD_DUP)
160
+ assert len(items) == 4
161
+ assert all(isinstance(x, CommandWrapper) for x in items)
162
+
163
+ stems = [x.stem for x in items]
164
+ assert "CMD_A" in stems
165
+ assert "CMD_B" in stems
166
+
167
+ class TestXPathHelper:
168
+ def test_xpath_method(self, concrete_dict):
169
+ # Testing the wrapper method .xpath()
170
+ results = concrete_dict.xpath('.//Command')
171
+ assert len(results) == 4
172
+ assert isinstance(results[0], etree._Element)
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: tts-dictionary-interface
3
+ Version: 0.2.3
4
+ Summary: Core code for developing Python AMPCS dictionary interfaces.
5
+ Author: muszynsk
6
+ Author-email: muszynsk <matt.muszynski@jpl.nasa.gov>
7
+ License: Apache-2.0
8
+ Project-URL: Homepage, https://github.com/NASA-JPL-Teamtools-Studio/tts_dictionary_interface
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Requires-Python: >=3.6
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: lxml
14
+ Requires-Dist: pytest
15
+ Requires-Dist: tomli
16
+ Requires-Dist: tts-utilities<=1.0.0,>=0.2.2
17
+ Dynamic: author
18
+ Dynamic: requires-python
19
+
20
+ # Teamtools Dictionary Interface
21
+
22
+ ![Project logo](https://github.com/NASA-JPL-Teamtools-Studio/teamtools_documentation/blob/main/docs/images/tts_image_artifacts/tts_dictionary_interface.png)
23
+
24
+ ## About Teamtools Studio
25
+
26
+ Teamtools Studio Utilities is part of JPL's Teamtools Studio (TTS).
27
+
28
+ TTS is an effort originated in JPL's Planning and Execution section to centralize shared repositories across missions. This benefits JPL by reducing cost through reducing duplicated code, collaborating across missions, and unifying standards for development and design across JPL.
29
+
30
+ Although Planning and Execution is primarily concerned with flight operations, the TTS suite has been generalized and atomized to the point where many of these tools are applicable during other mission phases and even in non-spaceflight contexts. Through our work flying space missions, we hope to provide tools to the open source community that have utility in data analysis or planning for any complex system where failure is not an option.
31
+
32
+ For more infomation on how to contribute, and how these libraries form a complete ecosystem for high reliability data analysis, see the [Full TTS Documentation](https://nasa-jpl-teamtools-studio.github.io/teamtools_documentation/).
33
+
34
+ ## What is Teamtools Dictionary Interface?
35
+
36
+ ### Overview
37
+
38
+ The TTS Dicitonary Interface is an interoperability solution to ensure that other TTS libraries (Dexter, Tower, Dante, TTS Seq, FRESH) can
39
+ make common assumptions about how to retreive the metadata on spacraft commands and telemetry they need in order to appropriately interpret it.
40
+ This helps them answer questions like "is this arugment value valid?" and "what are the units for this telemetry point?".
41
+
42
+ Dictionaries are a key part of the Flight Ground Interface for any spacecraft. They containing the information needed to translate human-readable commands and arugments to binary files that make sense to a spacecraft's command and sequencing flight software and the to take the stream of
43
+ ones and zeros returned from the spacecraft back into human-readable telemetry back on the ground. They are the Rosetta Stone of any space mission.
44
+
45
+ While TTS Dictionary Interface is built around AMPCS's XML dictionary schema, it can ostensibly be used with any format of dictionary so long
46
+ as it can be made into a Pyhton class that behaves the same way that the SemanticDictionary does. More than anything this is an interoperability
47
+ layer.
48
+
49
+ For projects that do not use AMPCS, the easiest way to leverage this library is likely to write some middleware to turn your dictionary format
50
+ into the kind of XML that would be required if you were using AMPCS. Short of that, it is likely that the only other solution is to write
51
+ a completely new class that looks to downstream TTS modules as if it were generated by TTS Dictionary Interface. This is likely much harder
52
+ to validate than simply converting to XML and validating against the multimisison AMPCS schema shared in the DemoSat extension of this library.
53
+
54
+ ### Projects Currently Supported
55
+
56
+ * DemoSat
57
+ * Orbiting Carbon Observatory 2 (OCO-2)
58
+ * NISAR
59
+
60
+ ## Architecture
61
+
62
+ ### TTS dependencies
63
+
64
+ * TTS Utilities
@@ -0,0 +1,9 @@
1
+ tts_dictionary_interface/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ tts_dictionary_interface/base.py,sha256=wSlfvspI4aNqlCabaFF417LG-ss0YM_7nIUsVy07CyQ,10172
3
+ tts_dictionary_interface/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ tts_dictionary_interface/test/conftest.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ tts_dictionary_interface/test/test_base.py,sha256=qs7BK9_xMDJSJOhVG1tOfQ4AgUzSNiqkVK-KQhNfEeU,6557
6
+ tts_dictionary_interface-0.2.3.dist-info/METADATA,sha256=ucgYDLUclmA8OT4ogYUI2TrkmqaGIq7yCvbTWC3QE6M,3801
7
+ tts_dictionary_interface-0.2.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
8
+ tts_dictionary_interface-0.2.3.dist-info/top_level.txt,sha256=irz1oGRGtuTqgAI3v5XIQNQ_HcrqNQ46G3atDGfpG48,25
9
+ tts_dictionary_interface-0.2.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tts_dictionary_interface