mycorrhizal 0.1.0__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.
- mycorrhizal/__init__.py +3 -0
- mycorrhizal/common/__init__.py +68 -0
- mycorrhizal/common/interface_builder.py +203 -0
- mycorrhizal/common/interfaces.py +412 -0
- mycorrhizal/common/timebase.py +99 -0
- mycorrhizal/common/wrappers.py +532 -0
- mycorrhizal/enoki/__init__.py +0 -0
- mycorrhizal/enoki/core.py +1545 -0
- mycorrhizal/enoki/testing_utils.py +529 -0
- mycorrhizal/enoki/util.py +220 -0
- mycorrhizal/hypha/__init__.py +0 -0
- mycorrhizal/hypha/core/__init__.py +107 -0
- mycorrhizal/hypha/core/builder.py +404 -0
- mycorrhizal/hypha/core/runtime.py +890 -0
- mycorrhizal/hypha/core/specs.py +234 -0
- mycorrhizal/hypha/util.py +38 -0
- mycorrhizal/rhizomorph/README.md +220 -0
- mycorrhizal/rhizomorph/__init__.py +0 -0
- mycorrhizal/rhizomorph/core.py +1729 -0
- mycorrhizal/rhizomorph/util.py +45 -0
- mycorrhizal/spores/__init__.py +124 -0
- mycorrhizal/spores/cache.py +208 -0
- mycorrhizal/spores/core.py +419 -0
- mycorrhizal/spores/dsl/__init__.py +48 -0
- mycorrhizal/spores/dsl/enoki.py +514 -0
- mycorrhizal/spores/dsl/hypha.py +399 -0
- mycorrhizal/spores/dsl/rhizomorph.py +351 -0
- mycorrhizal/spores/encoder/__init__.py +11 -0
- mycorrhizal/spores/encoder/base.py +42 -0
- mycorrhizal/spores/encoder/json.py +159 -0
- mycorrhizal/spores/extraction.py +484 -0
- mycorrhizal/spores/models.py +288 -0
- mycorrhizal/spores/transport/__init__.py +10 -0
- mycorrhizal/spores/transport/base.py +46 -0
- mycorrhizal-0.1.0.dist-info/METADATA +198 -0
- mycorrhizal-0.1.0.dist-info/RECORD +37 -0
- mycorrhizal-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Spores Adapter for Rhizomorph (Behavior Trees)
|
|
4
|
+
|
|
5
|
+
Provides logging integration for Rhizomorph nodes and trees.
|
|
6
|
+
Extracts blackboard information and automatically creates event/object logs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import functools
|
|
13
|
+
import inspect
|
|
14
|
+
import logging
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
17
|
+
|
|
18
|
+
from ...spores import (
|
|
19
|
+
get_config,
|
|
20
|
+
Event,
|
|
21
|
+
LogRecord,
|
|
22
|
+
Relationship,
|
|
23
|
+
EventAttributeValue,
|
|
24
|
+
generate_event_id,
|
|
25
|
+
)
|
|
26
|
+
from ...spores.extraction import (
|
|
27
|
+
extract_attributes_from_blackboard,
|
|
28
|
+
extract_objects_from_blackboard,
|
|
29
|
+
)
|
|
30
|
+
from ...spores.core import _send_log_record, get_object_cache
|
|
31
|
+
from ...rhizomorph.core import Status
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _supports_timebase(func: Callable) -> bool:
|
|
38
|
+
"""Check if function accepts a timebase parameter."""
|
|
39
|
+
try:
|
|
40
|
+
sig = inspect.signature(func)
|
|
41
|
+
return 'tb' in sig.parameters
|
|
42
|
+
except (ValueError, TypeError):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RhizomorphAdapter:
|
|
47
|
+
"""
|
|
48
|
+
Adapter for Rhizomorph behavior tree logging.
|
|
49
|
+
|
|
50
|
+
Provides decorators and helpers for logging:
|
|
51
|
+
- Node tick execution with status results
|
|
52
|
+
- Tree-level events
|
|
53
|
+
- Blackboard object lifecycle tracking
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
```python
|
|
57
|
+
from mycorrhizal.spores.dsl import RhizomorphAdapter
|
|
58
|
+
|
|
59
|
+
adapter = RhizomorphAdapter()
|
|
60
|
+
|
|
61
|
+
@bt.tree
|
|
62
|
+
def MyTree():
|
|
63
|
+
@bt.action
|
|
64
|
+
@adapter.log_node(event_type="check_threat")
|
|
65
|
+
async def check_threat(bb: MissionContext) -> Status:
|
|
66
|
+
# Event automatically logged with:
|
|
67
|
+
# - status result attribute
|
|
68
|
+
# - attributes from bb (with EventAttr annotations)
|
|
69
|
+
# - relationships to objects (with ObjectRef annotations)
|
|
70
|
+
return Status.SUCCESS
|
|
71
|
+
```
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self):
|
|
75
|
+
"""Initialize the Rhizomorph adapter."""
|
|
76
|
+
self._enabled = True
|
|
77
|
+
|
|
78
|
+
def enable(self):
|
|
79
|
+
"""Enable logging for this adapter."""
|
|
80
|
+
self._enabled = True
|
|
81
|
+
|
|
82
|
+
def disable(self):
|
|
83
|
+
"""Disable logging for this adapter."""
|
|
84
|
+
self._enabled = False
|
|
85
|
+
|
|
86
|
+
def log_node(
|
|
87
|
+
self,
|
|
88
|
+
event_type: str,
|
|
89
|
+
attributes: Optional[Union[Dict[str, Any], List[str]]] = None,
|
|
90
|
+
log_status: bool = True,
|
|
91
|
+
) -> Callable:
|
|
92
|
+
"""
|
|
93
|
+
Decorator to log Rhizomorph node execution.
|
|
94
|
+
|
|
95
|
+
Automatically captures:
|
|
96
|
+
- Node execution status (SUCCESS, FAILURE, RUNNING, etc.)
|
|
97
|
+
- Attributes from blackboard (with EventAttr annotations)
|
|
98
|
+
- Objects from blackboard (with ObjectRef annotations)
|
|
99
|
+
- Node name
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
event_type: Type of event to log
|
|
103
|
+
attributes: Static attributes or param names to extract
|
|
104
|
+
log_status: Whether to include status in event attributes
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Decorator function
|
|
108
|
+
"""
|
|
109
|
+
def decorator(func: Callable) -> Callable:
|
|
110
|
+
@functools.wraps(func)
|
|
111
|
+
async def async_wrapper(bb: Any, tb: Any = None):
|
|
112
|
+
# Call original node function
|
|
113
|
+
if _supports_timebase(func):
|
|
114
|
+
result = await func(bb=bb, tb=tb)
|
|
115
|
+
else:
|
|
116
|
+
result = await func(bb=bb)
|
|
117
|
+
|
|
118
|
+
# Log event
|
|
119
|
+
await _log_node_event(
|
|
120
|
+
func, bb, tb, event_type, attributes, log_status, result
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
@functools.wraps(func)
|
|
126
|
+
def sync_wrapper(bb: Any, tb: Any = None):
|
|
127
|
+
# Call original node function
|
|
128
|
+
if _supports_timebase(func):
|
|
129
|
+
result = func(bb=bb, tb=tb)
|
|
130
|
+
else:
|
|
131
|
+
result = func(bb=bb)
|
|
132
|
+
|
|
133
|
+
# Schedule logging
|
|
134
|
+
asyncio.create_task(_log_node_event(
|
|
135
|
+
func, bb, tb, event_type, attributes, log_status, result
|
|
136
|
+
))
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
# Return appropriate wrapper
|
|
141
|
+
if asyncio.iscoroutinefunction(func):
|
|
142
|
+
return async_wrapper # type: ignore
|
|
143
|
+
else:
|
|
144
|
+
return sync_wrapper # type: ignore
|
|
145
|
+
|
|
146
|
+
return decorator
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _log_node_event(
|
|
150
|
+
func: Callable,
|
|
151
|
+
bb: Any,
|
|
152
|
+
tb: Any,
|
|
153
|
+
event_type: str,
|
|
154
|
+
attributes: Optional[Union[Dict[str, Any], List[str]]],
|
|
155
|
+
log_status: bool,
|
|
156
|
+
result: Any,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Log a node tick event."""
|
|
159
|
+
config = get_config()
|
|
160
|
+
if not config.enabled:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
timestamp = datetime.now()
|
|
165
|
+
|
|
166
|
+
# Build event attributes
|
|
167
|
+
event_attrs = {}
|
|
168
|
+
|
|
169
|
+
# Add node name
|
|
170
|
+
event_attrs["node_name"] = EventAttributeValue(
|
|
171
|
+
name="node_name",
|
|
172
|
+
value=func.__name__,
|
|
173
|
+
time=timestamp
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Add status if requested and result is a Status
|
|
177
|
+
if log_status and isinstance(result, Status):
|
|
178
|
+
event_attrs["status"] = EventAttributeValue(
|
|
179
|
+
name="status",
|
|
180
|
+
value=result.name,
|
|
181
|
+
time=timestamp
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Extract from blackboard
|
|
185
|
+
bb_attrs = extract_attributes_from_blackboard(bb, timestamp)
|
|
186
|
+
event_attrs.update(bb_attrs)
|
|
187
|
+
|
|
188
|
+
# Extract objects from blackboard
|
|
189
|
+
bb_objects = extract_objects_from_blackboard(bb)
|
|
190
|
+
|
|
191
|
+
# Build relationships
|
|
192
|
+
relationships = {}
|
|
193
|
+
for obj in bb_objects:
|
|
194
|
+
relationships[obj.id] = Relationship(
|
|
195
|
+
object_id=obj.id,
|
|
196
|
+
qualifier="context"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Build event
|
|
200
|
+
event = Event(
|
|
201
|
+
id=generate_event_id(),
|
|
202
|
+
type=event_type,
|
|
203
|
+
time=timestamp,
|
|
204
|
+
attributes=event_attrs,
|
|
205
|
+
relationships=relationships
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Send event
|
|
209
|
+
await _send_log_record(LogRecord(event=event))
|
|
210
|
+
|
|
211
|
+
# Send objects to cache
|
|
212
|
+
cache = get_object_cache()
|
|
213
|
+
for obj in bb_objects:
|
|
214
|
+
cache.contains_or_add(obj.id, obj)
|
|
215
|
+
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error(f"Failed to log node event: {e}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def log_tree_event(
|
|
221
|
+
event_type: str,
|
|
222
|
+
tree_name: str,
|
|
223
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
224
|
+
) -> Callable:
|
|
225
|
+
"""
|
|
226
|
+
Decorator to log tree-level events.
|
|
227
|
+
|
|
228
|
+
Use this for logging events at the tree level rather than individual nodes.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
event_type: Type of event to log
|
|
232
|
+
tree_name: Name of the tree
|
|
233
|
+
attributes: Static attributes to include
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Decorator function
|
|
237
|
+
"""
|
|
238
|
+
def decorator(func: Callable) -> Callable:
|
|
239
|
+
@functools.wraps(func)
|
|
240
|
+
async def async_wrapper(*args, **kwargs):
|
|
241
|
+
result = await func(*args, **kwargs)
|
|
242
|
+
|
|
243
|
+
# Log event
|
|
244
|
+
config = get_config()
|
|
245
|
+
if config.enabled:
|
|
246
|
+
await _log_tree_event(func, tree_name, event_type, attributes, args, kwargs)
|
|
247
|
+
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
@functools.wraps(func)
|
|
251
|
+
def sync_wrapper(*args, **kwargs):
|
|
252
|
+
result = func(*args, **kwargs)
|
|
253
|
+
|
|
254
|
+
# Schedule logging
|
|
255
|
+
config = get_config()
|
|
256
|
+
if config.enabled:
|
|
257
|
+
asyncio.create_task(_log_tree_event(
|
|
258
|
+
func, tree_name, event_type, attributes, args, kwargs
|
|
259
|
+
))
|
|
260
|
+
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
if asyncio.iscoroutinefunction(func):
|
|
264
|
+
return async_wrapper # type: ignore
|
|
265
|
+
else:
|
|
266
|
+
return sync_wrapper # type: ignore
|
|
267
|
+
|
|
268
|
+
return decorator
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def _log_tree_event(
|
|
272
|
+
func: Callable,
|
|
273
|
+
tree_name: str,
|
|
274
|
+
event_type: str,
|
|
275
|
+
attributes: Optional[Dict[str, Any]],
|
|
276
|
+
args: tuple,
|
|
277
|
+
kwargs: dict,
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Log a tree-level event."""
|
|
280
|
+
config = get_config()
|
|
281
|
+
if not config.enabled:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
timestamp = datetime.now()
|
|
286
|
+
|
|
287
|
+
# Build event attributes
|
|
288
|
+
event_attrs = {
|
|
289
|
+
"tree_name": EventAttributeValue(
|
|
290
|
+
name="tree_name",
|
|
291
|
+
value=tree_name,
|
|
292
|
+
time=timestamp
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
# Add static attributes
|
|
297
|
+
if attributes:
|
|
298
|
+
for key, value in attributes.items():
|
|
299
|
+
if callable(value):
|
|
300
|
+
# Extract bb from kwargs or args
|
|
301
|
+
bb = kwargs.get('bb') or (args[0] if args else None)
|
|
302
|
+
if bb:
|
|
303
|
+
result = value(bb)
|
|
304
|
+
event_attrs[key] = EventAttributeValue(
|
|
305
|
+
name=key,
|
|
306
|
+
value=str(result),
|
|
307
|
+
time=timestamp
|
|
308
|
+
)
|
|
309
|
+
else:
|
|
310
|
+
event_attrs[key] = EventAttributeValue(
|
|
311
|
+
name=key,
|
|
312
|
+
value=str(value),
|
|
313
|
+
time=timestamp
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Extract bb for object logging
|
|
317
|
+
bb = kwargs.get('bb') or (args[0] if args else None)
|
|
318
|
+
|
|
319
|
+
# Extract objects from blackboard
|
|
320
|
+
relationships = {}
|
|
321
|
+
event_objects = []
|
|
322
|
+
|
|
323
|
+
if bb:
|
|
324
|
+
bb_objects = extract_objects_from_blackboard(bb)
|
|
325
|
+
event_objects.extend(bb_objects)
|
|
326
|
+
|
|
327
|
+
for obj in bb_objects:
|
|
328
|
+
relationships[obj.id] = Relationship(
|
|
329
|
+
object_id=obj.id,
|
|
330
|
+
qualifier="context"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Build event
|
|
334
|
+
event = Event(
|
|
335
|
+
id=generate_event_id(),
|
|
336
|
+
type=event_type,
|
|
337
|
+
time=timestamp,
|
|
338
|
+
attributes=event_attrs,
|
|
339
|
+
relationships=relationships
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Send event
|
|
343
|
+
await _send_log_record(LogRecord(event=event))
|
|
344
|
+
|
|
345
|
+
# Send objects to cache
|
|
346
|
+
cache = get_object_cache()
|
|
347
|
+
for obj in event_objects:
|
|
348
|
+
cache.contains_or_add(obj.id, obj)
|
|
349
|
+
|
|
350
|
+
except Exception as e:
|
|
351
|
+
logger.error(f"Failed to log tree event: {e}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Spores Encoder Interface
|
|
4
|
+
|
|
5
|
+
Abstract base class for encoding LogRecords to bytes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from ..models import LogRecord
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Encoder(Protocol):
|
|
17
|
+
"""
|
|
18
|
+
Protocol for encoders that convert LogRecords to bytes.
|
|
19
|
+
|
|
20
|
+
Encoders serialize OCEL LogRecords for transport to OCEL consumers.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def encode(self, record: LogRecord) -> bytes:
|
|
24
|
+
"""
|
|
25
|
+
Encode a LogRecord to bytes.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
record: The LogRecord to encode
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Serialized bytes representation
|
|
32
|
+
"""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def content_type(self) -> str:
|
|
36
|
+
"""
|
|
37
|
+
Return the MIME content type for this encoding.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Content type string (e.g., "application/json")
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Spores JSON Encoder
|
|
4
|
+
|
|
5
|
+
JSON encoder for OCEL LogRecords.
|
|
6
|
+
Compatible with the Go OCEL library format.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
from .base import Encoder
|
|
16
|
+
from ..models import LogRecord, Event, Object, Relationship, EventAttributeValue, ObjectAttributeValue
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JSONEncoder(Encoder):
|
|
20
|
+
"""
|
|
21
|
+
JSON encoder for OCEL LogRecords.
|
|
22
|
+
|
|
23
|
+
Produces OCEL-compatible JSON.
|
|
24
|
+
|
|
25
|
+
Example output:
|
|
26
|
+
{
|
|
27
|
+
"event": {
|
|
28
|
+
"id": "evt-123",
|
|
29
|
+
"type": "process_item",
|
|
30
|
+
"time": "2025-01-11T12:34:56.789Z",
|
|
31
|
+
"attributes": [
|
|
32
|
+
{"name": "priority", "value": "high", "time": "2025-01-11T12:34:56.789Z"}
|
|
33
|
+
],
|
|
34
|
+
"relationships": [
|
|
35
|
+
{"objectId": "obj-456", "qualifier": "input"}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Or for objects:
|
|
41
|
+
{
|
|
42
|
+
"object": {
|
|
43
|
+
"id": "obj-456",
|
|
44
|
+
"type": "WorkItem",
|
|
45
|
+
"attributes": [
|
|
46
|
+
{"name": "status", "value": "pending", "time": "2025-01-11T12:34:56.789Z"}
|
|
47
|
+
],
|
|
48
|
+
"relationships": []
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def encode(self, record: LogRecord) -> bytes:
|
|
54
|
+
"""
|
|
55
|
+
Encode a LogRecord to JSON bytes.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
record: The LogRecord to encode
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
JSON-encoded bytes
|
|
62
|
+
"""
|
|
63
|
+
# Convert LogRecord to dict
|
|
64
|
+
if record.event is not None:
|
|
65
|
+
data = {"event": self._event_to_dict(record.event)}
|
|
66
|
+
else:
|
|
67
|
+
data = {"object": self._object_to_dict(record.object)}
|
|
68
|
+
|
|
69
|
+
# Encode to JSON
|
|
70
|
+
return json.dumps(data, separators=(',', ':')).encode('utf-8')
|
|
71
|
+
|
|
72
|
+
def content_type(self) -> str:
|
|
73
|
+
"""Return the JSON content type."""
|
|
74
|
+
return "application/json"
|
|
75
|
+
|
|
76
|
+
def _event_to_dict(self, event: Event) -> Dict[str, Any]:
|
|
77
|
+
"""Convert an Event to dict for JSON serialization."""
|
|
78
|
+
return {
|
|
79
|
+
"id": event.id,
|
|
80
|
+
"type": event.type,
|
|
81
|
+
"time": self._format_datetime(event.time),
|
|
82
|
+
"attributes": [
|
|
83
|
+
self._event_attr_to_dict(name, attr)
|
|
84
|
+
for name, attr in event.attributes.items()
|
|
85
|
+
],
|
|
86
|
+
"relationships": [
|
|
87
|
+
self._relationship_to_dict(qualifier, rel)
|
|
88
|
+
for qualifier, rel in event.relationships.items()
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def _object_to_dict(self, obj: Object) -> Dict[str, Any]:
|
|
93
|
+
"""Convert an Object to dict for JSON serialization."""
|
|
94
|
+
return {
|
|
95
|
+
"id": obj.id,
|
|
96
|
+
"type": obj.type,
|
|
97
|
+
"attributes": [
|
|
98
|
+
self._object_attr_to_dict(name, attr)
|
|
99
|
+
for name, attr in obj.attributes.items()
|
|
100
|
+
],
|
|
101
|
+
"relationships": [
|
|
102
|
+
self._relationship_to_dict(qualifier, rel)
|
|
103
|
+
for qualifier, rel in obj.relationships.items()
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def _event_attr_to_dict(self, name: str, attr: EventAttributeValue) -> Dict[str, str]:
|
|
108
|
+
"""Convert EventAttributeValue to dict."""
|
|
109
|
+
return {
|
|
110
|
+
"name": attr.name or name,
|
|
111
|
+
"value": attr.value,
|
|
112
|
+
"time": self._format_datetime(attr.time)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def _object_attr_to_dict(self, name: str, attr: ObjectAttributeValue) -> Dict[str, str]:
|
|
116
|
+
"""Convert ObjectAttributeValue to dict."""
|
|
117
|
+
return {
|
|
118
|
+
"name": attr.name or name,
|
|
119
|
+
"value": attr.value,
|
|
120
|
+
"time": self._format_datetime(attr.time)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def _relationship_to_dict(self, qualifier: str, rel: Relationship) -> Dict[str, str]:
|
|
124
|
+
"""Convert Relationship to dict."""
|
|
125
|
+
return {
|
|
126
|
+
"objectId": rel.object_id,
|
|
127
|
+
"qualifier": qualifier
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def _format_datetime(self, dt: datetime) -> str:
|
|
131
|
+
"""
|
|
132
|
+
Format datetime to RFC3339Nano.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
dt: The datetime to format
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
ISO format string with nanosecond precision
|
|
139
|
+
"""
|
|
140
|
+
# Python's isoformat() produces RFC3339-compatible output
|
|
141
|
+
# For nanosecond precision, we need to ensure it has 9 digits
|
|
142
|
+
iso = dt.isoformat()
|
|
143
|
+
|
|
144
|
+
# Add microseconds if not present (Python 3.11+ has timespec='nanoseconds')
|
|
145
|
+
if '.' not in iso:
|
|
146
|
+
iso += '.000000000'
|
|
147
|
+
|
|
148
|
+
# Ensure we have 9 digits of fractional seconds
|
|
149
|
+
if '.' in iso:
|
|
150
|
+
main, frac = iso.split('.')
|
|
151
|
+
# Pad or truncate to 9 digits
|
|
152
|
+
frac = (frac + '0' * 9)[:9]
|
|
153
|
+
iso = f"{main}.{frac}"
|
|
154
|
+
|
|
155
|
+
# Ensure timezone is Z (UTC) or offset
|
|
156
|
+
if iso.endswith('+00:00'):
|
|
157
|
+
iso = iso[:-6] + 'Z'
|
|
158
|
+
|
|
159
|
+
return iso
|