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.
Files changed (37) hide show
  1. mycorrhizal/__init__.py +3 -0
  2. mycorrhizal/common/__init__.py +68 -0
  3. mycorrhizal/common/interface_builder.py +203 -0
  4. mycorrhizal/common/interfaces.py +412 -0
  5. mycorrhizal/common/timebase.py +99 -0
  6. mycorrhizal/common/wrappers.py +532 -0
  7. mycorrhizal/enoki/__init__.py +0 -0
  8. mycorrhizal/enoki/core.py +1545 -0
  9. mycorrhizal/enoki/testing_utils.py +529 -0
  10. mycorrhizal/enoki/util.py +220 -0
  11. mycorrhizal/hypha/__init__.py +0 -0
  12. mycorrhizal/hypha/core/__init__.py +107 -0
  13. mycorrhizal/hypha/core/builder.py +404 -0
  14. mycorrhizal/hypha/core/runtime.py +890 -0
  15. mycorrhizal/hypha/core/specs.py +234 -0
  16. mycorrhizal/hypha/util.py +38 -0
  17. mycorrhizal/rhizomorph/README.md +220 -0
  18. mycorrhizal/rhizomorph/__init__.py +0 -0
  19. mycorrhizal/rhizomorph/core.py +1729 -0
  20. mycorrhizal/rhizomorph/util.py +45 -0
  21. mycorrhizal/spores/__init__.py +124 -0
  22. mycorrhizal/spores/cache.py +208 -0
  23. mycorrhizal/spores/core.py +419 -0
  24. mycorrhizal/spores/dsl/__init__.py +48 -0
  25. mycorrhizal/spores/dsl/enoki.py +514 -0
  26. mycorrhizal/spores/dsl/hypha.py +399 -0
  27. mycorrhizal/spores/dsl/rhizomorph.py +351 -0
  28. mycorrhizal/spores/encoder/__init__.py +11 -0
  29. mycorrhizal/spores/encoder/base.py +42 -0
  30. mycorrhizal/spores/encoder/json.py +159 -0
  31. mycorrhizal/spores/extraction.py +484 -0
  32. mycorrhizal/spores/models.py +288 -0
  33. mycorrhizal/spores/transport/__init__.py +10 -0
  34. mycorrhizal/spores/transport/base.py +46 -0
  35. mycorrhizal-0.1.0.dist-info/METADATA +198 -0
  36. mycorrhizal-0.1.0.dist-info/RECORD +37 -0
  37. 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,11 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Spores Encoders
4
+
5
+ Encoders for serializing OCEL LogRecords to various formats.
6
+ """
7
+
8
+ from .base import Encoder
9
+ from .json import JSONEncoder
10
+
11
+ __all__ = ['Encoder', 'JSONEncoder']
@@ -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