mycorrhizal 0.1.0__py3-none-any.whl → 0.1.2__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/_version.py +1 -0
- mycorrhizal/hypha/core/builder.py +45 -7
- mycorrhizal/hypha/core/specs.py +19 -3
- mycorrhizal/rhizomorph/core.py +62 -4
- mycorrhizal/spores/__init__.py +69 -16
- mycorrhizal/spores/core.py +786 -75
- mycorrhizal/spores/models.py +22 -0
- mycorrhizal/spores/transport/__init__.py +9 -2
- mycorrhizal/spores/transport/base.py +36 -17
- mycorrhizal/spores/transport/file.py +126 -0
- {mycorrhizal-0.1.0.dist-info → mycorrhizal-0.1.2.dist-info}/METADATA +1 -1
- {mycorrhizal-0.1.0.dist-info → mycorrhizal-0.1.2.dist-info}/RECORD +13 -11
- {mycorrhizal-0.1.0.dist-info → mycorrhizal-0.1.2.dist-info}/WHEEL +0 -0
mycorrhizal/spores/core.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Spores Core API
|
|
4
4
|
|
|
5
|
-
Main spores module with configuration and
|
|
5
|
+
Main spores module with configuration and logger functionality.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -11,17 +11,19 @@ import asyncio
|
|
|
11
11
|
import inspect
|
|
12
12
|
import functools
|
|
13
13
|
import logging
|
|
14
|
+
import threading
|
|
14
15
|
from datetime import datetime
|
|
15
16
|
from typing import (
|
|
16
17
|
Any, Callable, Optional, Union, Dict, List,
|
|
17
|
-
ParamSpec, TypeVar, overload
|
|
18
|
+
ParamSpec, TypeVar, overload, get_origin, get_args, Annotated
|
|
18
19
|
)
|
|
19
20
|
from dataclasses import dataclass
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
20
22
|
|
|
21
23
|
from .models import (
|
|
22
24
|
Event, Object, LogRecord, Relationship,
|
|
23
25
|
EventAttributeValue, ObjectAttributeValue,
|
|
24
|
-
ObjectScope, ObjectRef, EventAttr,
|
|
26
|
+
ObjectScope, ObjectRef, EventAttr, SporesAttr,
|
|
25
27
|
generate_event_id, generate_object_id,
|
|
26
28
|
attribute_value_from_python, object_attribute_from_python
|
|
27
29
|
)
|
|
@@ -51,7 +53,7 @@ class SporesConfig:
|
|
|
51
53
|
enabled: Whether spores logging is enabled
|
|
52
54
|
object_cache_size: Maximum objects in LRU cache
|
|
53
55
|
encoder: Encoder instance to use
|
|
54
|
-
transport: Transport instance to use (required for logging)
|
|
56
|
+
transport: Transport instance to use (required for logging to work)
|
|
55
57
|
"""
|
|
56
58
|
enabled: bool = True
|
|
57
59
|
object_cache_size: int = 128
|
|
@@ -160,7 +162,7 @@ def get_object_cache() -> ObjectLRUCache[str, Object]:
|
|
|
160
162
|
|
|
161
163
|
async def _send_log_record(record: LogRecord) -> None:
|
|
162
164
|
"""
|
|
163
|
-
Send a log record via the configured transport.
|
|
165
|
+
Send a log record via the configured transport (async).
|
|
164
166
|
|
|
165
167
|
Args:
|
|
166
168
|
record: The LogRecord to send
|
|
@@ -179,15 +181,707 @@ async def _send_log_record(record: LogRecord) -> None:
|
|
|
179
181
|
data = config.encoder.encode(record)
|
|
180
182
|
content_type = config.encoder.content_type()
|
|
181
183
|
|
|
182
|
-
# Send via transport
|
|
184
|
+
# Send via transport (async)
|
|
183
185
|
await config.transport.send(data, content_type)
|
|
184
186
|
|
|
185
187
|
except Exception as e:
|
|
186
188
|
logger.error(f"Failed to send log record: {e}")
|
|
187
189
|
|
|
188
190
|
|
|
191
|
+
def _send_log_record_sync(record: LogRecord) -> None:
|
|
192
|
+
"""
|
|
193
|
+
Send a log record via the configured transport (sync).
|
|
194
|
+
|
|
195
|
+
This is a synchronous version that uses blocking I/O.
|
|
196
|
+
Used by SyncEventLogger in daemon threads.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
record: The LogRecord to send
|
|
200
|
+
"""
|
|
201
|
+
config = get_config()
|
|
202
|
+
|
|
203
|
+
if not config.enabled:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
if config.transport is None or config.encoder is None:
|
|
207
|
+
logger.warning("Spores not properly configured (missing transport or encoder)")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
# Encode the record
|
|
212
|
+
data = config.encoder.encode(record)
|
|
213
|
+
content_type = config.encoder.content_type()
|
|
214
|
+
|
|
215
|
+
# Send via transport (blocking call)
|
|
216
|
+
config.transport.send(data, content_type)
|
|
217
|
+
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.error(f"Failed to send log record: {e}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ============================================================================
|
|
223
|
+
# Logger Interface
|
|
224
|
+
# ============================================================================
|
|
225
|
+
|
|
226
|
+
class EventLogger(ABC):
|
|
227
|
+
"""
|
|
228
|
+
Abstract base class for event loggers.
|
|
229
|
+
|
|
230
|
+
Implementations can be sync or async, but the interface is the same.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
@abstractmethod
|
|
234
|
+
def event(self, event_type: str, **kwargs):
|
|
235
|
+
"""Log an event."""
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
@abstractmethod
|
|
239
|
+
def log_object(self, obj_type: str, obj_id: str, **kwargs):
|
|
240
|
+
"""Log an object."""
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class AsyncEventLogger(EventLogger):
|
|
245
|
+
"""
|
|
246
|
+
Async event logger for use in async contexts.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def __init__(self, name: str):
|
|
250
|
+
self.name = name
|
|
251
|
+
|
|
252
|
+
async def event(self, event_type: str, relationships: Dict[str, Relationship] | None = None, **kwargs) -> None:
|
|
253
|
+
"""
|
|
254
|
+
Log an event asynchronously.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
event_type: The type of event
|
|
258
|
+
relationships: Optional dict of qualifier -> Relationship for OCEL object relationships
|
|
259
|
+
**kwargs: Event attributes
|
|
260
|
+
"""
|
|
261
|
+
config = get_config()
|
|
262
|
+
if not config.enabled:
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
timestamp = datetime.now()
|
|
266
|
+
|
|
267
|
+
attr_values = {}
|
|
268
|
+
for key, value in kwargs.items():
|
|
269
|
+
attr_values[key] = EventAttributeValue(
|
|
270
|
+
name=key,
|
|
271
|
+
value=str(value),
|
|
272
|
+
time=timestamp
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
event = Event(
|
|
276
|
+
id=generate_event_id(),
|
|
277
|
+
type=event_type,
|
|
278
|
+
time=timestamp,
|
|
279
|
+
attributes=attr_values,
|
|
280
|
+
relationships=relationships or {}
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
record = LogRecord(event=event)
|
|
284
|
+
await _send_log_record(record)
|
|
285
|
+
|
|
286
|
+
async def log_object(self, obj_type: str, obj_id: str, **kwargs) -> None:
|
|
287
|
+
"""Log an object asynchronously."""
|
|
288
|
+
config = get_config()
|
|
289
|
+
if not config.enabled:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
timestamp = datetime.now()
|
|
293
|
+
|
|
294
|
+
attr_values = {}
|
|
295
|
+
for key, value in kwargs.items():
|
|
296
|
+
attr_values[key] = ObjectAttributeValue(
|
|
297
|
+
name=key,
|
|
298
|
+
value=str(value),
|
|
299
|
+
time=timestamp
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
obj = Object(
|
|
303
|
+
id=obj_id,
|
|
304
|
+
type=obj_type,
|
|
305
|
+
attributes=attr_values
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
record = LogRecord(object=obj)
|
|
309
|
+
await _send_log_record(record)
|
|
310
|
+
|
|
311
|
+
def log_event(self, event_type: str, relationships: Dict[str, tuple] | None = None, attributes: Dict[str, Any] | None = None):
|
|
312
|
+
"""
|
|
313
|
+
Decorator factory that logs events with auto-logged object relationships (async version).
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
event_type: The type of event to log
|
|
317
|
+
relationships: Dict mapping qualifiers to relationship specs:
|
|
318
|
+
- qualifier: Relationship qualifier (e.g., "order", "customer")
|
|
319
|
+
- spec: 2-tuple (source, obj_type) or 3-tuple (source, obj_type, attrs)
|
|
320
|
+
- source: Where to get object ("return", "ret", param name, "self")
|
|
321
|
+
- obj_type: OCEL object type
|
|
322
|
+
- attrs: Optional list of attribute names to log (omitted = auto-detect SporesAttr)
|
|
323
|
+
attributes: Dict mapping event attribute names to values or callables
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Decorator function
|
|
327
|
+
"""
|
|
328
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
329
|
+
@functools.wraps(func)
|
|
330
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
331
|
+
# Execute the function
|
|
332
|
+
result = await func(*args, **kwargs)
|
|
333
|
+
|
|
334
|
+
# Build context for resolving objects and attributes
|
|
335
|
+
context = self._build_context(func, args, kwargs, result)
|
|
336
|
+
|
|
337
|
+
# Process relationships - log objects and build event relationships
|
|
338
|
+
event_relationships = {}
|
|
339
|
+
if relationships:
|
|
340
|
+
for qualifier, rel_spec in relationships.items():
|
|
341
|
+
# Handle both 2-tuple and 3-tuple formats
|
|
342
|
+
if len(rel_spec) == 2:
|
|
343
|
+
source, obj_type = rel_spec
|
|
344
|
+
attrs = None # Auto-detect SporesAttr
|
|
345
|
+
else:
|
|
346
|
+
source, obj_type, attrs = rel_spec
|
|
347
|
+
|
|
348
|
+
# Resolve object from source
|
|
349
|
+
obj = self._resolve_source(source, context)
|
|
350
|
+
if obj is None:
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
# Extract object ID
|
|
354
|
+
obj_id = self._get_object_id(obj)
|
|
355
|
+
if obj_id is None:
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
# Extract and log object attributes
|
|
359
|
+
obj_attrs = self._extract_object_attrs(obj, attrs)
|
|
360
|
+
await self.log_object(obj_type, obj_id, **obj_attrs)
|
|
361
|
+
|
|
362
|
+
# Add to event relationships
|
|
363
|
+
event_relationships[qualifier] = Relationship(object_id=obj_id, qualifier=qualifier)
|
|
364
|
+
|
|
365
|
+
# Extract event attributes
|
|
366
|
+
event_attrs = {}
|
|
367
|
+
if attributes:
|
|
368
|
+
for attr_name, attr_value in attributes.items():
|
|
369
|
+
event_attrs[attr_name] = self._evaluate_expression(attr_value, context)
|
|
370
|
+
|
|
371
|
+
# Log the event with relationships
|
|
372
|
+
await self.event(event_type, relationships=event_relationships, **event_attrs)
|
|
373
|
+
|
|
374
|
+
return result
|
|
375
|
+
|
|
376
|
+
return wrapper # type: ignore
|
|
377
|
+
return decorator
|
|
378
|
+
|
|
379
|
+
def _build_context(self, func: Callable, args: tuple, kwargs: dict, result: Any) -> Dict[str, Any]:
|
|
380
|
+
"""Build context dict for async version."""
|
|
381
|
+
context = {
|
|
382
|
+
'return': result,
|
|
383
|
+
'ret': result,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
sig = inspect.signature(func)
|
|
387
|
+
bound = sig.bind(*args, **kwargs)
|
|
388
|
+
bound.apply_defaults()
|
|
389
|
+
|
|
390
|
+
for param_name, param_value in bound.arguments.items():
|
|
391
|
+
context[param_name] = param_value
|
|
392
|
+
|
|
393
|
+
return context
|
|
394
|
+
|
|
395
|
+
def _resolve_source(self, source: str, context: Dict[str, Any]) -> Any:
|
|
396
|
+
"""Resolve source for async version."""
|
|
397
|
+
if source == "return" or source == "ret":
|
|
398
|
+
return context.get("return")
|
|
399
|
+
elif source == "self":
|
|
400
|
+
return context.get("self")
|
|
401
|
+
else:
|
|
402
|
+
return context.get(source)
|
|
403
|
+
|
|
404
|
+
def _get_object_id(self, obj: Any) -> str | None:
|
|
405
|
+
"""Extract object ID for async version."""
|
|
406
|
+
if obj is None:
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
if hasattr(obj, 'id'):
|
|
410
|
+
return str(getattr(obj, 'id'))
|
|
411
|
+
else:
|
|
412
|
+
return str(obj)
|
|
413
|
+
|
|
414
|
+
def _extract_object_attrs(self, obj: Any, attrs_spec: list | dict) -> Dict[str, Any]:
|
|
415
|
+
"""Extract attributes for async version."""
|
|
416
|
+
if attrs_spec is None:
|
|
417
|
+
return self._extract_spores_attrs(obj)
|
|
418
|
+
|
|
419
|
+
if isinstance(attrs_spec, list):
|
|
420
|
+
result = {}
|
|
421
|
+
for attr_name in attrs_spec:
|
|
422
|
+
if hasattr(obj, attr_name):
|
|
423
|
+
result[attr_name] = getattr(obj, attr_name)
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
elif isinstance(attrs_spec, dict):
|
|
427
|
+
result = {}
|
|
428
|
+
for attr_name, expr in attrs_spec.items():
|
|
429
|
+
if isinstance(expr, str) and expr.startswith(("return.", "ret.", "self.")):
|
|
430
|
+
parts = expr.split(".", 1)
|
|
431
|
+
source = self._resolve_source(parts[0], {})
|
|
432
|
+
if source and len(parts) > 1:
|
|
433
|
+
result[attr_name] = getattr(source, parts[1])
|
|
434
|
+
elif isinstance(expr, str) and hasattr(obj, expr):
|
|
435
|
+
result[attr_name] = getattr(obj, expr)
|
|
436
|
+
elif callable(expr):
|
|
437
|
+
result[attr_name] = expr(obj)
|
|
438
|
+
else:
|
|
439
|
+
result[attr_name] = expr
|
|
440
|
+
return result
|
|
441
|
+
|
|
442
|
+
return {}
|
|
443
|
+
|
|
444
|
+
def _extract_spores_attrs(self, obj: Any) -> Dict[str, Any]:
|
|
445
|
+
"""Extract SporesAttr-marked fields for async version."""
|
|
446
|
+
result = {}
|
|
447
|
+
obj_class = obj if isinstance(obj, type) else type(obj)
|
|
448
|
+
|
|
449
|
+
if hasattr(obj_class, '__annotations__'):
|
|
450
|
+
for field_name, field_type in obj_class.__annotations__.items():
|
|
451
|
+
if get_origin(field_type) is Annotated:
|
|
452
|
+
args = get_args(field_type)
|
|
453
|
+
for arg in args:
|
|
454
|
+
if arg is SporesAttr:
|
|
455
|
+
if hasattr(obj, field_name):
|
|
456
|
+
value = getattr(obj, field_name)
|
|
457
|
+
result[field_name] = value
|
|
458
|
+
break
|
|
459
|
+
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
def _evaluate_expression(self, expr: Any, context: Dict[str, Any]) -> Any:
|
|
463
|
+
"""Evaluate expression for async version."""
|
|
464
|
+
if callable(expr):
|
|
465
|
+
sig = inspect.signature(expr)
|
|
466
|
+
params = sig.parameters
|
|
467
|
+
|
|
468
|
+
if len(params) == 1 and list(params.values())[0].kind == inspect.Parameter.VAR_POSITIONAL:
|
|
469
|
+
return expr(**context)
|
|
470
|
+
elif len(params) == 0:
|
|
471
|
+
return expr()
|
|
472
|
+
else:
|
|
473
|
+
kwargs = {}
|
|
474
|
+
for param_name in params:
|
|
475
|
+
if param_name in context:
|
|
476
|
+
kwargs[param_name] = context[param_name]
|
|
477
|
+
return expr(**kwargs)
|
|
478
|
+
elif isinstance(expr, str):
|
|
479
|
+
# Check if it's a simple parameter reference
|
|
480
|
+
if expr in context:
|
|
481
|
+
return context[expr]
|
|
482
|
+
# Check if it's a dotted attribute access
|
|
483
|
+
elif "." in expr:
|
|
484
|
+
parts = expr.split(".", 1)
|
|
485
|
+
if parts[0] in context:
|
|
486
|
+
obj = context[parts[0]]
|
|
487
|
+
if hasattr(obj, parts[1]):
|
|
488
|
+
return getattr(obj, parts[1])
|
|
489
|
+
return expr
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class SyncEventLogger(EventLogger):
|
|
493
|
+
"""
|
|
494
|
+
Sync event logger for use in synchronous contexts.
|
|
495
|
+
|
|
496
|
+
Uses daemon threads for fire-and-forget logging - business logic never blocks.
|
|
497
|
+
Logs are written via sync transport's blocking send() (no event loop needed).
|
|
498
|
+
"""
|
|
499
|
+
|
|
500
|
+
def __init__(self, name: str):
|
|
501
|
+
self.name = name
|
|
502
|
+
|
|
503
|
+
def event(self, event_type: str, relationships: Dict[str, Relationship] | None = None, **kwargs) -> None:
|
|
504
|
+
"""
|
|
505
|
+
Log an event in background daemon thread (fire-and-forget).
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
event_type: The type of event
|
|
509
|
+
relationships: Optional dict of qualifier -> Relationship for OCEL object relationships
|
|
510
|
+
**kwargs: Event attributes
|
|
511
|
+
"""
|
|
512
|
+
config = get_config()
|
|
513
|
+
if not config.enabled:
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
def log_in_thread():
|
|
517
|
+
timestamp = datetime.now()
|
|
518
|
+
|
|
519
|
+
attr_values = {}
|
|
520
|
+
for key, value in kwargs.items():
|
|
521
|
+
attr_values[key] = EventAttributeValue(
|
|
522
|
+
name=key,
|
|
523
|
+
value=str(value),
|
|
524
|
+
time=timestamp
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
event = Event(
|
|
528
|
+
id=generate_event_id(),
|
|
529
|
+
type=event_type,
|
|
530
|
+
time=timestamp,
|
|
531
|
+
attributes=attr_values,
|
|
532
|
+
relationships=relationships or {}
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
record = LogRecord(event=event)
|
|
536
|
+
# Blocking send - no event loop needed
|
|
537
|
+
_send_log_record_sync(record)
|
|
538
|
+
|
|
539
|
+
thread = threading.Thread(target=log_in_thread, daemon=True)
|
|
540
|
+
thread.start()
|
|
541
|
+
|
|
542
|
+
def log_object(self, obj_type: str, obj_id: str, **kwargs) -> None:
|
|
543
|
+
"""Log an object in background daemon thread (fire-and-forget)."""
|
|
544
|
+
config = get_config()
|
|
545
|
+
if not config.enabled:
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
def log_in_thread():
|
|
549
|
+
timestamp = datetime.now()
|
|
550
|
+
|
|
551
|
+
attr_values = {}
|
|
552
|
+
for key, value in kwargs.items():
|
|
553
|
+
attr_values[key] = ObjectAttributeValue(
|
|
554
|
+
name=key,
|
|
555
|
+
value=str(value),
|
|
556
|
+
time=timestamp
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
obj = Object(
|
|
560
|
+
id=obj_id,
|
|
561
|
+
type=obj_type,
|
|
562
|
+
attributes=attr_values
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
record = LogRecord(object=obj)
|
|
566
|
+
# Blocking send - no event loop needed
|
|
567
|
+
_send_log_record_sync(record)
|
|
568
|
+
|
|
569
|
+
thread = threading.Thread(target=log_in_thread, daemon=True)
|
|
570
|
+
thread.start()
|
|
571
|
+
|
|
572
|
+
def log_event(self, event_type: str, relationships: Dict[str, tuple] | None = None, attributes: Dict[str, Any] | None = None):
|
|
573
|
+
"""
|
|
574
|
+
Decorator factory that logs events with auto-logged object relationships.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
event_type: The type of event to log
|
|
578
|
+
relationships: Dict mapping qualifiers to relationship specs:
|
|
579
|
+
- qualifier: Relationship qualifier (e.g., "order", "customer")
|
|
580
|
+
- spec: 2-tuple (source, obj_type) or 3-tuple (source, obj_type, attrs)
|
|
581
|
+
- source: Where to get object ("return", "ret", param name, "self")
|
|
582
|
+
- obj_type: OCEL object type
|
|
583
|
+
- attrs: Optional list of attribute names to log (omitted = auto-detect SporesAttr)
|
|
584
|
+
attributes: Dict mapping event attribute names to values or callables
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
Decorator function
|
|
588
|
+
|
|
589
|
+
Example:
|
|
590
|
+
```python
|
|
591
|
+
@spore.log_event(
|
|
592
|
+
event_type="OrderCreated",
|
|
593
|
+
relationships={
|
|
594
|
+
"order": ("return", "Order"), # 2-tuple: auto-detect SporesAttr
|
|
595
|
+
"customer": ("customer", "Customer"), # 2-tuple: auto-detect SporesAttr
|
|
596
|
+
},
|
|
597
|
+
attributes={
|
|
598
|
+
"item_count": lambda items: len(items),
|
|
599
|
+
},
|
|
600
|
+
)
|
|
601
|
+
def create_order(customer: Customer, items: list) -> Order:
|
|
602
|
+
order = Order(...)
|
|
603
|
+
return order
|
|
604
|
+
```
|
|
605
|
+
"""
|
|
606
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
607
|
+
@functools.wraps(func)
|
|
608
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
609
|
+
# Execute the function
|
|
610
|
+
result = func(*args, **kwargs)
|
|
611
|
+
|
|
612
|
+
# Build context for resolving objects and attributes
|
|
613
|
+
context = self._build_context(func, args, kwargs, result)
|
|
614
|
+
|
|
615
|
+
# Process relationships - log objects and build event relationships
|
|
616
|
+
event_relationships = {}
|
|
617
|
+
if relationships:
|
|
618
|
+
for qualifier, rel_spec in relationships.items():
|
|
619
|
+
# Handle both 2-tuple and 3-tuple formats
|
|
620
|
+
if len(rel_spec) == 2:
|
|
621
|
+
source, obj_type = rel_spec
|
|
622
|
+
attrs = None # Auto-detect SporesAttr
|
|
623
|
+
else:
|
|
624
|
+
source, obj_type, attrs = rel_spec
|
|
625
|
+
|
|
626
|
+
# Resolve object from source
|
|
627
|
+
obj = self._resolve_source(source, context)
|
|
628
|
+
if obj is None:
|
|
629
|
+
continue
|
|
630
|
+
|
|
631
|
+
# Extract object ID
|
|
632
|
+
obj_id = self._get_object_id(obj)
|
|
633
|
+
if obj_id is None:
|
|
634
|
+
continue
|
|
635
|
+
|
|
636
|
+
# Extract and log object attributes
|
|
637
|
+
obj_attrs = self._extract_object_attrs(obj, attrs)
|
|
638
|
+
self.log_object(obj_type, obj_id, **obj_attrs)
|
|
639
|
+
|
|
640
|
+
# Add to event relationships
|
|
641
|
+
event_relationships[qualifier] = Relationship(object_id=obj_id, qualifier=qualifier)
|
|
642
|
+
|
|
643
|
+
# Extract event attributes
|
|
644
|
+
event_attrs = {}
|
|
645
|
+
if attributes:
|
|
646
|
+
for attr_name, attr_value in attributes.items():
|
|
647
|
+
event_attrs[attr_name] = self._evaluate_expression(attr_value, context)
|
|
648
|
+
|
|
649
|
+
# Log the event with relationships
|
|
650
|
+
self.event(event_type, relationships=event_relationships, **event_attrs)
|
|
651
|
+
|
|
652
|
+
return result
|
|
653
|
+
|
|
654
|
+
return wrapper # type: ignore
|
|
655
|
+
return decorator
|
|
656
|
+
|
|
657
|
+
def _build_context(self, func: Callable, args: tuple, kwargs: dict, result: Any) -> Dict[str, Any]:
|
|
658
|
+
"""
|
|
659
|
+
Build a context dict for resolving sources and evaluating expressions.
|
|
660
|
+
|
|
661
|
+
Returns dict with:
|
|
662
|
+
- 'return' and 'ret': The return value
|
|
663
|
+
- 'self': For methods, the self parameter
|
|
664
|
+
- All function parameters by name
|
|
665
|
+
"""
|
|
666
|
+
context = {
|
|
667
|
+
'return': result,
|
|
668
|
+
'ret': result,
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
# Bind arguments to parameter names
|
|
672
|
+
sig = inspect.signature(func)
|
|
673
|
+
bound = sig.bind(*args, **kwargs)
|
|
674
|
+
bound.apply_defaults()
|
|
675
|
+
|
|
676
|
+
for param_name, param_value in bound.arguments.items():
|
|
677
|
+
context[param_name] = param_value
|
|
678
|
+
|
|
679
|
+
return context
|
|
680
|
+
|
|
681
|
+
def _resolve_source(self, source: str, context: Dict[str, Any]) -> Any:
|
|
682
|
+
"""
|
|
683
|
+
Resolve an object from a source expression.
|
|
684
|
+
|
|
685
|
+
Sources:
|
|
686
|
+
- "return" or "ret": Return value
|
|
687
|
+
- "self": For methods
|
|
688
|
+
- Any other string: Parameter name from context
|
|
689
|
+
"""
|
|
690
|
+
if source == "return" or source == "ret":
|
|
691
|
+
return context.get("return")
|
|
692
|
+
elif source == "self":
|
|
693
|
+
return context.get("self")
|
|
694
|
+
else:
|
|
695
|
+
return context.get(source)
|
|
696
|
+
|
|
697
|
+
def _get_object_id(self, obj: Any) -> str | None:
|
|
698
|
+
"""
|
|
699
|
+
Extract object ID from an object.
|
|
700
|
+
|
|
701
|
+
Tries:
|
|
702
|
+
1. obj.id attribute
|
|
703
|
+
2. str(obj)
|
|
704
|
+
"""
|
|
705
|
+
if obj is None:
|
|
706
|
+
return None
|
|
707
|
+
|
|
708
|
+
if hasattr(obj, 'id'):
|
|
709
|
+
return str(getattr(obj, 'id'))
|
|
710
|
+
else:
|
|
711
|
+
return str(obj)
|
|
712
|
+
|
|
713
|
+
def _extract_object_attrs(self, obj: Any, attrs_spec: list | dict) -> Dict[str, Any]:
|
|
714
|
+
"""
|
|
715
|
+
Extract attributes from an object for logging.
|
|
716
|
+
|
|
717
|
+
Args:
|
|
718
|
+
obj: The object to extract from
|
|
719
|
+
attrs_spec: Either a list of attribute names, or a dict of {name: expression}
|
|
720
|
+
|
|
721
|
+
Returns:
|
|
722
|
+
Dict of attribute name -> value
|
|
723
|
+
"""
|
|
724
|
+
if attrs_spec is None:
|
|
725
|
+
# Check for SporesAttr annotations on the object's class
|
|
726
|
+
return self._extract_spores_attrs(obj)
|
|
727
|
+
|
|
728
|
+
if isinstance(attrs_spec, list):
|
|
729
|
+
# Simple list of attribute names
|
|
730
|
+
result = {}
|
|
731
|
+
for attr_name in attrs_spec:
|
|
732
|
+
if hasattr(obj, attr_name):
|
|
733
|
+
result[attr_name] = getattr(obj, attr_name)
|
|
734
|
+
return result
|
|
735
|
+
|
|
736
|
+
elif isinstance(attrs_spec, dict):
|
|
737
|
+
# Dict with custom names or callables
|
|
738
|
+
result = {}
|
|
739
|
+
for attr_name, expr in attrs_spec.items():
|
|
740
|
+
if isinstance(expr, str) and expr.startswith(("return.", "ret.", "self.")):
|
|
741
|
+
# Special handling for return/ret/self references
|
|
742
|
+
parts = expr.split(".", 1)
|
|
743
|
+
source = self._resolve_source(parts[0], {})
|
|
744
|
+
if source and len(parts) > 1:
|
|
745
|
+
result[attr_name] = getattr(source, parts[1])
|
|
746
|
+
elif isinstance(expr, str) and hasattr(obj, expr):
|
|
747
|
+
# Simple attribute access
|
|
748
|
+
result[attr_name] = getattr(obj, expr)
|
|
749
|
+
elif callable(expr):
|
|
750
|
+
# Callable expression
|
|
751
|
+
result[attr_name] = expr(obj)
|
|
752
|
+
else:
|
|
753
|
+
# Static value
|
|
754
|
+
result[attr_name] = expr
|
|
755
|
+
return result
|
|
756
|
+
|
|
757
|
+
return {}
|
|
758
|
+
|
|
759
|
+
def _extract_spores_attrs(self, obj: Any) -> Dict[str, Any]:
|
|
760
|
+
"""
|
|
761
|
+
Extract attributes marked with SporesAttr from a Pydantic model.
|
|
762
|
+
|
|
763
|
+
Checks the object's class __annotations__ for Annotated[type, SporesAttr] fields.
|
|
764
|
+
"""
|
|
765
|
+
result = {}
|
|
766
|
+
|
|
767
|
+
# Get the class (handle both instances and classes)
|
|
768
|
+
obj_class = obj if isinstance(obj, type) else type(obj)
|
|
769
|
+
|
|
770
|
+
if hasattr(obj_class, '__annotations__'):
|
|
771
|
+
for field_name, field_type in obj_class.__annotations__.items():
|
|
772
|
+
# Check if this is an Annotated type with SporesAttr
|
|
773
|
+
if get_origin(field_type) is Annotated:
|
|
774
|
+
args = get_args(field_type)
|
|
775
|
+
for arg in args:
|
|
776
|
+
if arg is SporesAttr:
|
|
777
|
+
# Found a SporesAttr marker - log this field
|
|
778
|
+
if hasattr(obj, field_name):
|
|
779
|
+
value = getattr(obj, field_name)
|
|
780
|
+
result[field_name] = value
|
|
781
|
+
break
|
|
782
|
+
|
|
783
|
+
return result
|
|
784
|
+
|
|
785
|
+
def _evaluate_expression(self, expr: Any, context: Dict[str, Any]) -> Any:
|
|
786
|
+
"""
|
|
787
|
+
Evaluate an expression for event or object attributes.
|
|
788
|
+
|
|
789
|
+
Supports:
|
|
790
|
+
- Static values (strings, numbers, etc.)
|
|
791
|
+
- Callables (called with context)
|
|
792
|
+
- Strings that are parameter references or attribute accesses
|
|
793
|
+
"""
|
|
794
|
+
if callable(expr):
|
|
795
|
+
# Callable - try to detect what params it wants
|
|
796
|
+
sig = inspect.signature(expr)
|
|
797
|
+
params = sig.parameters
|
|
798
|
+
|
|
799
|
+
if len(params) == 1 and list(params.values())[0].kind == inspect.Parameter.VAR_POSITIONAL:
|
|
800
|
+
# **kwargs style
|
|
801
|
+
return expr(**context)
|
|
802
|
+
elif len(params) == 0:
|
|
803
|
+
# No params
|
|
804
|
+
return expr()
|
|
805
|
+
else:
|
|
806
|
+
# Named params - pass relevant context
|
|
807
|
+
kwargs = {}
|
|
808
|
+
for param_name in params:
|
|
809
|
+
if param_name in context:
|
|
810
|
+
kwargs[param_name] = context[param_name]
|
|
811
|
+
return expr(**kwargs)
|
|
812
|
+
elif isinstance(expr, str):
|
|
813
|
+
# Check if it's a simple parameter reference
|
|
814
|
+
if expr in context:
|
|
815
|
+
return context[expr]
|
|
816
|
+
# Check if it's an attribute access like "order.id"
|
|
817
|
+
elif "." in expr:
|
|
818
|
+
parts = expr.split(".", 1)
|
|
819
|
+
if parts[0] in context:
|
|
820
|
+
obj = context[parts[0]]
|
|
821
|
+
if hasattr(obj, parts[1]):
|
|
822
|
+
return getattr(obj, parts[1])
|
|
823
|
+
return expr
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def get_spore_sync(name: str) -> SyncEventLogger:
|
|
827
|
+
"""
|
|
828
|
+
Get a synchronous spore logger.
|
|
829
|
+
|
|
830
|
+
Use this in synchronous code. The logger uses daemon threads for
|
|
831
|
+
fire-and-forget logging - business logic never blocks.
|
|
832
|
+
|
|
833
|
+
Args:
|
|
834
|
+
name: Spore name (typically __module__ or __name__)
|
|
835
|
+
|
|
836
|
+
Returns:
|
|
837
|
+
A SyncEventLogger instance
|
|
838
|
+
|
|
839
|
+
Example:
|
|
840
|
+
```python
|
|
841
|
+
from mycorrhizal.spores import configure, get_spore_sync
|
|
842
|
+
from mycorrhizal.spores.transport import SyncFileTransport
|
|
843
|
+
|
|
844
|
+
configure(transport=SyncFileTransport("logs/ocel.jsonl"))
|
|
845
|
+
spore = get_spore_sync(__name__)
|
|
846
|
+
|
|
847
|
+
@spore.log_event(event_type="OrderCreated", order_id="order.id")
|
|
848
|
+
def create_order(order: Order) -> Order:
|
|
849
|
+
return order
|
|
850
|
+
```
|
|
851
|
+
"""
|
|
852
|
+
return SyncEventLogger(name)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def get_spore_async(name: str) -> AsyncEventLogger:
|
|
856
|
+
"""
|
|
857
|
+
Get an asynchronous spore logger.
|
|
858
|
+
|
|
859
|
+
Use this in asynchronous code. The logger uses async I/O.
|
|
860
|
+
|
|
861
|
+
Args:
|
|
862
|
+
name: Spore name (typically __module__ or __name__)
|
|
863
|
+
|
|
864
|
+
Returns:
|
|
865
|
+
An AsyncEventLogger instance
|
|
866
|
+
|
|
867
|
+
Example:
|
|
868
|
+
```python
|
|
869
|
+
from mycorrhizal.spores import configure, get_spore_async
|
|
870
|
+
from mycorrhizal.spores.transport import AsyncFileTransport
|
|
871
|
+
|
|
872
|
+
configure(transport=AsyncFileTransport("logs/ocel.jsonl"))
|
|
873
|
+
spore = get_spore_async(__name__)
|
|
874
|
+
|
|
875
|
+
@spore.log_event(event_type="OrderCreated", order_id="order.id")
|
|
876
|
+
async def create_order(order: Order) -> Order:
|
|
877
|
+
return order
|
|
878
|
+
```
|
|
879
|
+
"""
|
|
880
|
+
return AsyncEventLogger(name)
|
|
881
|
+
|
|
882
|
+
|
|
189
883
|
# ============================================================================
|
|
190
|
-
#
|
|
884
|
+
# Legacy API (deprecated)
|
|
191
885
|
# ============================================================================
|
|
192
886
|
|
|
193
887
|
class SporeDecorator:
|
|
@@ -237,10 +931,20 @@ class SporeDecorator:
|
|
|
237
931
|
# Call the original function
|
|
238
932
|
result = func(*args, **kwargs)
|
|
239
933
|
|
|
240
|
-
# Log
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
934
|
+
# Log in background thread since we're not in async context
|
|
935
|
+
import threading
|
|
936
|
+
def log_in_thread():
|
|
937
|
+
new_loop = asyncio.new_event_loop()
|
|
938
|
+
asyncio.set_event_loop(new_loop)
|
|
939
|
+
try:
|
|
940
|
+
new_loop.run_until_complete(self._log_event(
|
|
941
|
+
func, args, kwargs, event_type, attributes, objects
|
|
942
|
+
))
|
|
943
|
+
finally:
|
|
944
|
+
new_loop.close()
|
|
945
|
+
|
|
946
|
+
thread = threading.Thread(target=log_in_thread, daemon=True)
|
|
947
|
+
thread.start()
|
|
244
948
|
|
|
245
949
|
return result
|
|
246
950
|
|
|
@@ -297,61 +1001,73 @@ class SporeDecorator:
|
|
|
297
1001
|
# Extract objects
|
|
298
1002
|
event_objects = []
|
|
299
1003
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
1004
|
+
if objects is not None and bb is not None:
|
|
1005
|
+
# Extract objects from interface using proper Annotated type handling
|
|
1006
|
+
for obj_field in objects:
|
|
1007
|
+
if hasattr(bb, obj_field):
|
|
1008
|
+
obj = getattr(bb, obj_field)
|
|
1009
|
+
|
|
1010
|
+
# Get the field type from annotations
|
|
1011
|
+
field_type = type(bb).__annotations__.get(obj_field)
|
|
1012
|
+
if field_type is not None:
|
|
1013
|
+
# Check if it's an Annotated type
|
|
1014
|
+
if get_origin(field_type) is Annotated:
|
|
1015
|
+
args = get_args(field_type)
|
|
1016
|
+
if len(args) >= 2:
|
|
1017
|
+
# args[0] is the actual type, args[1:] are metadata
|
|
1018
|
+
for metadata in args[1:]:
|
|
1019
|
+
# Check if it's an ObjectRef
|
|
1020
|
+
if isinstance(metadata, ObjectRef):
|
|
1021
|
+
rel = Relationship(
|
|
1022
|
+
object_id=obj.id,
|
|
1023
|
+
qualifier=metadata.qualifier
|
|
1024
|
+
)
|
|
1025
|
+
event_objects.append((obj, rel))
|
|
1026
|
+
break
|
|
1027
|
+
|
|
1028
|
+
# Create event
|
|
320
1029
|
event = Event(
|
|
321
1030
|
id=generate_event_id(),
|
|
322
1031
|
type=event_type,
|
|
323
1032
|
time=timestamp,
|
|
324
|
-
attributes=
|
|
325
|
-
|
|
1033
|
+
attributes={
|
|
1034
|
+
k: EventAttributeValue(name=k, value=str(v), time=timestamp)
|
|
1035
|
+
for k, v in event_attrs.items()
|
|
1036
|
+
},
|
|
1037
|
+
relationships={
|
|
1038
|
+
r.qualifier: r
|
|
1039
|
+
for _, r in event_objects
|
|
1040
|
+
}
|
|
326
1041
|
)
|
|
327
1042
|
|
|
328
1043
|
# Send event
|
|
329
1044
|
await _send_log_record(LogRecord(event=event))
|
|
330
1045
|
|
|
331
|
-
# Send
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
1046
|
+
# Send object records (convert Pydantic models to OCEL Objects)
|
|
1047
|
+
for obj, _ in event_objects:
|
|
1048
|
+
ocel_obj = extraction.convert_to_ocel_object(obj)
|
|
1049
|
+
if ocel_obj:
|
|
1050
|
+
obj_record = LogRecord(object=ocel_obj)
|
|
1051
|
+
await _send_log_record(obj_record)
|
|
335
1052
|
|
|
336
1053
|
except Exception as e:
|
|
337
1054
|
logger.error(f"Failed to log event: {e}")
|
|
338
1055
|
|
|
339
1056
|
def _find_blackboard(self, args: tuple, kwargs: dict) -> Optional[Any]:
|
|
340
|
-
"""Find blackboard in
|
|
341
|
-
|
|
342
|
-
for
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if 'blackboard' in type_name or 'context' in type_name:
|
|
1057
|
+
"""Find blackboard in arguments.
|
|
1058
|
+
|
|
1059
|
+
Looks for objects marked with _is_blackboard, or falls back to
|
|
1060
|
+
the first argument that looks like a Pydantic model (has __annotations__).
|
|
1061
|
+
"""
|
|
1062
|
+
# First, look for explicitly marked blackboard
|
|
1063
|
+
for arg in args:
|
|
1064
|
+
if hasattr(arg, '_is_blackboard'):
|
|
1065
|
+
return arg
|
|
1066
|
+
|
|
1067
|
+
# Fallback: look for Pydantic BaseModel or dataclass with annotations
|
|
1068
|
+
for arg in args:
|
|
1069
|
+
if hasattr(arg, '__annotations__') and hasattr(arg, '__dict__'):
|
|
1070
|
+
# Looks like a data container (Pydantic model or dataclass)
|
|
355
1071
|
return arg
|
|
356
1072
|
|
|
357
1073
|
return None
|
|
@@ -377,43 +1093,38 @@ class SporeDecorator:
|
|
|
377
1093
|
def decorator(cls: type) -> type:
|
|
378
1094
|
# Store metadata on the class
|
|
379
1095
|
cls._spores_object_type = object_type
|
|
1096
|
+
# Store in global registry
|
|
1097
|
+
if not hasattr(self, '_object_types'):
|
|
1098
|
+
self._object_types = {}
|
|
1099
|
+
self._object_types[object_type] = cls
|
|
380
1100
|
return cls
|
|
381
1101
|
|
|
382
1102
|
return decorator
|
|
383
1103
|
|
|
384
1104
|
|
|
385
|
-
#
|
|
1105
|
+
# Create global spore instance for backward compatibility
|
|
386
1106
|
spore = SporeDecorator()
|
|
387
1107
|
|
|
388
1108
|
|
|
389
1109
|
# ============================================================================
|
|
390
|
-
#
|
|
1110
|
+
# Public API
|
|
391
1111
|
# ============================================================================
|
|
392
1112
|
|
|
393
1113
|
__all__ = [
|
|
1114
|
+
# Configuration
|
|
394
1115
|
'configure',
|
|
395
1116
|
'get_config',
|
|
396
1117
|
'get_object_cache',
|
|
1118
|
+
|
|
1119
|
+
# Spore getters (explicit)
|
|
1120
|
+
'get_spore_sync',
|
|
1121
|
+
'get_spore_async',
|
|
1122
|
+
|
|
1123
|
+
# Logger types
|
|
1124
|
+
'EventLogger',
|
|
1125
|
+
'AsyncEventLogger',
|
|
1126
|
+
'SyncEventLogger',
|
|
1127
|
+
|
|
1128
|
+
# Legacy (deprecated)
|
|
397
1129
|
'spore',
|
|
398
|
-
'SporesConfig',
|
|
399
|
-
# Models
|
|
400
|
-
'Event',
|
|
401
|
-
'Object',
|
|
402
|
-
'LogRecord',
|
|
403
|
-
'Relationship',
|
|
404
|
-
'EventAttributeValue',
|
|
405
|
-
'ObjectAttributeValue',
|
|
406
|
-
'ObjectRef',
|
|
407
|
-
'ObjectScope',
|
|
408
|
-
'EventAttr',
|
|
409
|
-
'generate_event_id',
|
|
410
|
-
'generate_object_id',
|
|
411
|
-
# Cache
|
|
412
|
-
'ObjectLRUCache',
|
|
413
|
-
# Encoder
|
|
414
|
-
'Encoder',
|
|
415
|
-
'JSONEncoder',
|
|
416
|
-
# Transport
|
|
417
|
-
'Transport',
|
|
418
|
-
'HTTPTransport',
|
|
419
1130
|
]
|