mycorrhizal 0.1.0__py3-none-any.whl → 0.2.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 (45) hide show
  1. mycorrhizal/_version.py +1 -0
  2. mycorrhizal/common/__init__.py +15 -3
  3. mycorrhizal/common/cache.py +114 -0
  4. mycorrhizal/common/compilation.py +263 -0
  5. mycorrhizal/common/interface_detection.py +159 -0
  6. mycorrhizal/common/interfaces.py +3 -50
  7. mycorrhizal/common/mermaid.py +124 -0
  8. mycorrhizal/common/wrappers.py +1 -1
  9. mycorrhizal/hypha/core/builder.py +56 -8
  10. mycorrhizal/hypha/core/runtime.py +242 -107
  11. mycorrhizal/hypha/core/specs.py +19 -3
  12. mycorrhizal/mycelium/__init__.py +174 -0
  13. mycorrhizal/mycelium/core.py +619 -0
  14. mycorrhizal/mycelium/exceptions.py +30 -0
  15. mycorrhizal/mycelium/hypha_bridge.py +1143 -0
  16. mycorrhizal/mycelium/instance.py +440 -0
  17. mycorrhizal/mycelium/pn_context.py +276 -0
  18. mycorrhizal/mycelium/runner.py +165 -0
  19. mycorrhizal/mycelium/spores_integration.py +655 -0
  20. mycorrhizal/mycelium/tree_builder.py +102 -0
  21. mycorrhizal/mycelium/tree_spec.py +197 -0
  22. mycorrhizal/rhizomorph/README.md +82 -33
  23. mycorrhizal/rhizomorph/core.py +308 -82
  24. mycorrhizal/septum/TRANSITION_REFERENCE.md +385 -0
  25. mycorrhizal/{enoki → septum}/core.py +326 -100
  26. mycorrhizal/{enoki → septum}/testing_utils.py +7 -7
  27. mycorrhizal/{enoki → septum}/util.py +44 -21
  28. mycorrhizal/spores/__init__.py +72 -19
  29. mycorrhizal/spores/core.py +907 -75
  30. mycorrhizal/spores/dsl/__init__.py +8 -8
  31. mycorrhizal/spores/dsl/hypha.py +3 -15
  32. mycorrhizal/spores/dsl/rhizomorph.py +3 -11
  33. mycorrhizal/spores/dsl/{enoki.py → septum.py} +26 -77
  34. mycorrhizal/spores/encoder/json.py +21 -12
  35. mycorrhizal/spores/extraction.py +14 -11
  36. mycorrhizal/spores/models.py +75 -20
  37. mycorrhizal/spores/transport/__init__.py +9 -2
  38. mycorrhizal/spores/transport/base.py +36 -17
  39. mycorrhizal/spores/transport/file.py +126 -0
  40. mycorrhizal-0.2.0.dist-info/METADATA +335 -0
  41. mycorrhizal-0.2.0.dist-info/RECORD +54 -0
  42. mycorrhizal-0.1.0.dist-info/METADATA +0 -198
  43. mycorrhizal-0.1.0.dist-info/RECORD +0 -37
  44. /mycorrhizal/{enoki → septum}/__init__.py +0 -0
  45. {mycorrhizal-0.1.0.dist-info → mycorrhizal-0.2.0.dist-info}/WHEEL +0 -0
@@ -2,7 +2,7 @@
2
2
  """
3
3
  Spores Core API
4
4
 
5
- Main spores module with configuration and decorator functionality.
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,828 @@ 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] = attribute_value_from_python(value)
270
+
271
+ event = Event(
272
+ id=generate_event_id(),
273
+ type=event_type,
274
+ time=timestamp,
275
+ attributes=attr_values,
276
+ relationships=relationships or {}
277
+ )
278
+
279
+ record = LogRecord(event=event)
280
+ await _send_log_record(record)
281
+
282
+ async def log_object(self, obj_type: str, obj_id: str, **kwargs) -> None:
283
+ """Log an object asynchronously."""
284
+ config = get_config()
285
+ if not config.enabled:
286
+ return
287
+
288
+ timestamp = datetime.now()
289
+
290
+ attr_values = {}
291
+ for key, value in kwargs.items():
292
+ attr_values[key] = object_attribute_from_python(value, time=timestamp)
293
+
294
+ obj = Object(
295
+ id=obj_id,
296
+ type=obj_type,
297
+ attributes=attr_values
298
+ )
299
+
300
+ record = LogRecord(object=obj)
301
+ await _send_log_record(record)
302
+
303
+ def log_event(self, event_type: str, relationships: Dict[str, tuple] | None = None, attributes: Dict[str, Any] | None = None):
304
+ """
305
+ Decorator factory that logs events with auto-logged object relationships (async version).
306
+
307
+ Args:
308
+ event_type: The type of event to log
309
+ relationships: Dict mapping qualifiers to relationship specs:
310
+ - qualifier: Relationship qualifier (e.g., "order", "customer")
311
+ - spec: 2-tuple (source, obj_type) or 3-tuple (source, obj_type, attrs)
312
+ - source: Where to get object ("return", "ret", param name, "self")
313
+ - obj_type: OCEL object type
314
+ - attrs: Optional list of attribute names to log (omitted = auto-detect SporesAttr)
315
+ attributes: Dict mapping event attribute names to values or callables
316
+
317
+ Returns:
318
+ Decorator function
319
+ """
320
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
321
+ @functools.wraps(func)
322
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
323
+ # Execute the function
324
+ result = await func(*args, **kwargs)
325
+
326
+ # Build context for resolving objects and attributes
327
+ context = self._build_context(func, args, kwargs, result)
328
+
329
+ # Process relationships - log objects and build event relationships
330
+ event_relationships = {}
331
+ if relationships:
332
+ for qualifier, rel_spec in relationships.items():
333
+ # Handle both 2-tuple and 3-tuple formats
334
+ if len(rel_spec) == 2:
335
+ source, obj_type = rel_spec
336
+ attrs = None # Auto-detect SporesAttr
337
+ else:
338
+ source, obj_type, attrs = rel_spec
339
+
340
+ # Resolve object from source
341
+ obj = self._resolve_source(source, context, obj_type)
342
+ if obj is None:
343
+ continue
344
+
345
+ # Extract object ID
346
+ obj_id = self._get_object_id(obj)
347
+ if obj_id is None:
348
+ continue
349
+
350
+ # Extract and log object attributes
351
+ obj_attrs = self._extract_object_attrs(obj, attrs)
352
+ await self.log_object(obj_type, obj_id, **obj_attrs)
353
+
354
+ # Add to event relationships
355
+ event_relationships[qualifier] = Relationship(object_id=obj_id, qualifier=qualifier)
356
+
357
+ # Extract event attributes
358
+ event_attrs = {}
359
+ if attributes:
360
+ for attr_name, attr_value in attributes.items():
361
+ event_attrs[attr_name] = self._evaluate_expression(attr_value, context)
362
+
363
+ # Log the event with relationships
364
+ await self.event(event_type, relationships=event_relationships, **event_attrs)
365
+
366
+ return result
367
+
368
+ return wrapper # type: ignore
369
+ return decorator
370
+
371
+ def _build_context(self, func: Callable, args: tuple, kwargs: dict, result: Any) -> Dict[str, Any]:
372
+ """Build context dict for async version."""
373
+ context = {
374
+ 'return': result,
375
+ 'ret': result,
376
+ }
377
+
378
+ sig = inspect.signature(func)
379
+ bound = sig.bind(*args, **kwargs)
380
+ bound.apply_defaults()
381
+
382
+ for param_name, param_value in bound.arguments.items():
383
+ context[param_name] = param_value
384
+
385
+ return context
386
+
387
+ def _resolve_source(self, source: str, context: Dict[str, Any], obj_type: str | None = None) -> Any:
388
+ """Resolve source for async version.
389
+
390
+ If source is "bb" (blackboard) and obj_type is provided, extract the field
391
+ of that type from the blackboard instead of returning the entire blackboard.
392
+ """
393
+ if source == "return" or source == "ret":
394
+ return context.get("return")
395
+ elif source == "self":
396
+ return context.get("self")
397
+ else:
398
+ obj = context.get(source)
399
+
400
+ # If source is blackboard and we need a specific type, extract that field
401
+ if obj_type and source == "bb" and hasattr(obj, '__annotations__'):
402
+ return self._find_object_by_type(obj, obj_type)
403
+
404
+ return obj
405
+
406
+ def _find_object_by_type(self, blackboard: Any, obj_type: str) -> Any:
407
+ """Find a field in the blackboard that matches the requested object type.
408
+
409
+ Scans the blackboard's fields and returns the first field whose type
410
+ annotation matches obj_type.
411
+ """
412
+ # Get the class to check annotations
413
+ obj_class = blackboard if isinstance(blackboard, type) else type(blackboard)
414
+
415
+ if not hasattr(obj_class, '__annotations__'):
416
+ return blackboard
417
+
418
+ # Check each field's type annotation
419
+ for field_name, field_type in obj_class.__annotations__.items():
420
+ # Handle Annotated types
421
+ if get_origin(field_type) is Annotated:
422
+ args = get_args(field_type)
423
+ if args:
424
+ actual_type = args[0]
425
+ # Check if type name matches (handle both str and type)
426
+ type_name = actual_type if isinstance(actual_type, str) else actual_type.__name__
427
+ if type_name == obj_type:
428
+ field_value = getattr(blackboard, field_name, None)
429
+ # Return the actual value, not None
430
+ if field_value is not None:
431
+ return field_value
432
+ # Handle Union types (e.g., Sample | None)
433
+ elif get_origin(field_type) is Union:
434
+ args = get_args(field_type)
435
+ for arg in args:
436
+ # Skip None
437
+ if arg is type(None):
438
+ continue
439
+ # Check if this arg matches our target type
440
+ if get_origin(arg) is Annotated:
441
+ annotated_args = get_args(arg)
442
+ if annotated_args:
443
+ actual_type = annotated_args[0]
444
+ type_name = actual_type if isinstance(actual_type, str) else actual_type.__name__
445
+ if type_name == obj_type:
446
+ field_value = getattr(blackboard, field_name, None)
447
+ if field_value is not None:
448
+ return field_value
449
+ else:
450
+ type_name = arg if isinstance(arg, str) else arg.__name__
451
+ if type_name == obj_type:
452
+ field_value = getattr(blackboard, field_name, None)
453
+ if field_value is not None:
454
+ return field_value
455
+ else:
456
+ # Check if type name matches
457
+ type_name = field_type if isinstance(field_type, str) else field_type.__name__
458
+ if type_name == obj_type:
459
+ field_value = getattr(blackboard, field_name, None)
460
+ if field_value is not None:
461
+ return field_value
462
+
463
+ # Fallback: return blackboard if no matching field found
464
+ return blackboard
465
+
466
+ def _get_object_id(self, obj: Any) -> str | None:
467
+ """Extract object ID for async version."""
468
+ if obj is None:
469
+ return None
470
+
471
+ if hasattr(obj, 'id'):
472
+ return str(getattr(obj, 'id'))
473
+ else:
474
+ return str(obj)
475
+
476
+ def _extract_object_attrs(self, obj: Any, attrs_spec: list | dict) -> Dict[str, Any]:
477
+ """Extract attributes for async version."""
478
+ if attrs_spec is None:
479
+ return self._extract_spores_attrs(obj)
480
+
481
+ if isinstance(attrs_spec, list):
482
+ result = {}
483
+ for attr_name in attrs_spec:
484
+ if hasattr(obj, attr_name):
485
+ result[attr_name] = getattr(obj, attr_name)
486
+ return result
487
+
488
+ elif isinstance(attrs_spec, dict):
489
+ result = {}
490
+ for attr_name, expr in attrs_spec.items():
491
+ if isinstance(expr, str) and expr.startswith(("return.", "ret.", "self.")):
492
+ parts = expr.split(".", 1)
493
+ source = self._resolve_source(parts[0], {})
494
+ if source and len(parts) > 1:
495
+ result[attr_name] = getattr(source, parts[1])
496
+ elif isinstance(expr, str) and hasattr(obj, expr):
497
+ result[attr_name] = getattr(obj, expr)
498
+ elif callable(expr):
499
+ result[attr_name] = expr(obj)
500
+ else:
501
+ result[attr_name] = expr
502
+ return result
503
+
504
+ return {}
505
+
506
+ def _extract_spores_attrs(self, obj: Any) -> Dict[str, Any]:
507
+ """Extract SporesAttr-marked fields for async version."""
508
+ result = {}
509
+ obj_class = obj if isinstance(obj, type) else type(obj)
510
+
511
+ if hasattr(obj_class, '__annotations__'):
512
+ for field_name, field_type in obj_class.__annotations__.items():
513
+ if get_origin(field_type) is Annotated:
514
+ args = get_args(field_type)
515
+ for arg in args:
516
+ if arg is SporesAttr:
517
+ if hasattr(obj, field_name):
518
+ value = getattr(obj, field_name)
519
+ result[field_name] = value
520
+ break
521
+
522
+ return result
523
+
524
+ def _evaluate_expression(self, expr: Any, context: Dict[str, Any]) -> Any:
525
+ """Evaluate expression for async version."""
526
+ if callable(expr):
527
+ sig = inspect.signature(expr)
528
+ params = sig.parameters
529
+
530
+ if len(params) == 1 and list(params.values())[0].kind == inspect.Parameter.VAR_POSITIONAL:
531
+ return expr(**context)
532
+ elif len(params) == 0:
533
+ return expr()
534
+ else:
535
+ kwargs = {}
536
+ for param_name in params:
537
+ if param_name in context:
538
+ kwargs[param_name] = context[param_name]
539
+ return expr(**kwargs)
540
+ elif isinstance(expr, str):
541
+ # Check if it's a simple parameter reference
542
+ if expr in context:
543
+ return context[expr]
544
+ # Check if it's a dotted attribute access
545
+ elif "." in expr:
546
+ parts = expr.split(".", 1)
547
+ if parts[0] in context:
548
+ obj = context[parts[0]]
549
+ if hasattr(obj, parts[1]):
550
+ return getattr(obj, parts[1])
551
+ return expr
552
+
553
+
554
+ class SyncEventLogger(EventLogger):
555
+ """
556
+ Sync event logger for use in synchronous contexts.
557
+
558
+ Uses daemon threads for fire-and-forget logging - business logic never blocks.
559
+ Logs are written via sync transport's blocking send() (no event loop needed).
560
+ """
561
+
562
+ def __init__(self, name: str):
563
+ self.name = name
564
+
565
+ def event(self, event_type: str, relationships: Dict[str, Relationship] | None = None, **kwargs) -> None:
566
+ """
567
+ Log an event in background daemon thread (fire-and-forget).
568
+
569
+ Args:
570
+ event_type: The type of event
571
+ relationships: Optional dict of qualifier -> Relationship for OCEL object relationships
572
+ **kwargs: Event attributes
573
+ """
574
+ config = get_config()
575
+ if not config.enabled:
576
+ return
577
+
578
+ def log_in_thread():
579
+ timestamp = datetime.now()
580
+
581
+ attr_values = {}
582
+ for key, value in kwargs.items():
583
+ attr_values[key] = attribute_value_from_python(value)
584
+
585
+ event = Event(
586
+ id=generate_event_id(),
587
+ type=event_type,
588
+ time=timestamp,
589
+ attributes=attr_values,
590
+ relationships=relationships or {}
591
+ )
592
+
593
+ record = LogRecord(event=event)
594
+ # Blocking send - no event loop needed
595
+ _send_log_record_sync(record)
596
+
597
+ thread = threading.Thread(target=log_in_thread, daemon=True)
598
+ thread.start()
599
+
600
+ def log_object(self, obj_type: str, obj_id: str, **kwargs) -> None:
601
+ """Log an object in background daemon thread (fire-and-forget)."""
602
+ config = get_config()
603
+ if not config.enabled:
604
+ return
605
+
606
+ def log_in_thread():
607
+ timestamp = datetime.now()
608
+
609
+ attr_values = {}
610
+ for key, value in kwargs.items():
611
+ attr_values[key] = object_attribute_from_python(value, time=timestamp)
612
+
613
+ obj = Object(
614
+ id=obj_id,
615
+ type=obj_type,
616
+ attributes=attr_values
617
+ )
618
+
619
+ record = LogRecord(object=obj)
620
+ # Blocking send - no event loop needed
621
+ _send_log_record_sync(record)
622
+
623
+ thread = threading.Thread(target=log_in_thread, daemon=True)
624
+ thread.start()
625
+
626
+ def log_event(self, event_type: str, relationships: Dict[str, tuple] | None = None, attributes: Dict[str, Any] | None = None):
627
+ """
628
+ Decorator factory that logs events with auto-logged object relationships.
629
+
630
+ Args:
631
+ event_type: The type of event to log
632
+ relationships: Dict mapping qualifiers to relationship specs:
633
+ - qualifier: Relationship qualifier (e.g., "order", "customer")
634
+ - spec: 2-tuple (source, obj_type) or 3-tuple (source, obj_type, attrs)
635
+ - source: Where to get object ("return", "ret", param name, "self")
636
+ - obj_type: OCEL object type
637
+ - attrs: Optional list of attribute names to log (omitted = auto-detect SporesAttr)
638
+ attributes: Dict mapping event attribute names to values or callables
639
+
640
+ Returns:
641
+ Decorator function
642
+
643
+ Example:
644
+ ```python
645
+ @spore.log_event(
646
+ event_type="OrderCreated",
647
+ relationships={
648
+ "order": ("return", "Order"), # 2-tuple: auto-detect SporesAttr
649
+ "customer": ("customer", "Customer"), # 2-tuple: auto-detect SporesAttr
650
+ },
651
+ attributes={
652
+ "item_count": lambda items: len(items),
653
+ },
654
+ )
655
+ def create_order(customer: Customer, items: list) -> Order:
656
+ order = Order(...)
657
+ return order
658
+ ```
659
+ """
660
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
661
+ @functools.wraps(func)
662
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
663
+ # Execute the function
664
+ result = func(*args, **kwargs)
665
+
666
+ # Build context for resolving objects and attributes
667
+ context = self._build_context(func, args, kwargs, result)
668
+
669
+ # Process relationships - log objects and build event relationships
670
+ event_relationships = {}
671
+ if relationships:
672
+ for qualifier, rel_spec in relationships.items():
673
+ # Handle both 2-tuple and 3-tuple formats
674
+ if len(rel_spec) == 2:
675
+ source, obj_type = rel_spec
676
+ attrs = None # Auto-detect SporesAttr
677
+ else:
678
+ source, obj_type, attrs = rel_spec
679
+
680
+ # Resolve object from source
681
+ obj = self._resolve_source(source, context, obj_type)
682
+ if obj is None:
683
+ continue
684
+
685
+ # Extract object ID
686
+ obj_id = self._get_object_id(obj)
687
+ if obj_id is None:
688
+ continue
689
+
690
+ # Extract and log object attributes
691
+ obj_attrs = self._extract_object_attrs(obj, attrs)
692
+ self.log_object(obj_type, obj_id, **obj_attrs)
693
+
694
+ # Add to event relationships
695
+ event_relationships[qualifier] = Relationship(object_id=obj_id, qualifier=qualifier)
696
+
697
+ # Extract event attributes
698
+ event_attrs = {}
699
+ if attributes:
700
+ for attr_name, attr_value in attributes.items():
701
+ event_attrs[attr_name] = self._evaluate_expression(attr_value, context)
702
+
703
+ # Log the event with relationships
704
+ self.event(event_type, relationships=event_relationships, **event_attrs)
705
+
706
+ return result
707
+
708
+ return wrapper # type: ignore
709
+ return decorator
710
+
711
+ def _build_context(self, func: Callable, args: tuple, kwargs: dict, result: Any) -> Dict[str, Any]:
712
+ """
713
+ Build a context dict for resolving sources and evaluating expressions.
714
+
715
+ Returns dict with:
716
+ - 'return' and 'ret': The return value
717
+ - 'self': For methods, the self parameter
718
+ - All function parameters by name
719
+ """
720
+ context = {
721
+ 'return': result,
722
+ 'ret': result,
723
+ }
724
+
725
+ # Bind arguments to parameter names
726
+ sig = inspect.signature(func)
727
+ bound = sig.bind(*args, **kwargs)
728
+ bound.apply_defaults()
729
+
730
+ for param_name, param_value in bound.arguments.items():
731
+ context[param_name] = param_value
732
+
733
+ return context
734
+
735
+ def _resolve_source(self, source: str, context: Dict[str, Any], obj_type: str | None = None) -> Any:
736
+ """
737
+ Resolve an object from a source expression.
738
+
739
+ Sources:
740
+ - "return" or "ret": Return value
741
+ - "self": For methods
742
+ - Any other string: Parameter name from context
743
+ - If source is "bb" (blackboard) and obj_type is provided, extract the field of that type
744
+ """
745
+ if source == "return" or source == "ret":
746
+ return context.get("return")
747
+ elif source == "self":
748
+ return context.get("self")
749
+ else:
750
+ obj = context.get(source)
751
+
752
+ # If source is blackboard and we need a specific type, extract that field
753
+ if obj_type and source == "bb" and hasattr(obj, '__annotations__'):
754
+ return self._find_object_by_type(obj, obj_type)
755
+
756
+ return obj
757
+
758
+ def _find_object_by_type(self, blackboard: Any, obj_type: str) -> Any:
759
+ """Find a field in the blackboard that matches the requested object type.
760
+
761
+ Scans the blackboard's fields and returns the first field whose type
762
+ annotation matches obj_type.
763
+ """
764
+ # Get the class to check annotations
765
+ obj_class = blackboard if isinstance(blackboard, type) else type(blackboard)
766
+
767
+ if not hasattr(obj_class, '__annotations__'):
768
+ return blackboard
769
+
770
+ # Check each field's type annotation
771
+ for field_name, field_type in obj_class.__annotations__.items():
772
+ # Handle Annotated types
773
+ if get_origin(field_type) is Annotated:
774
+ args = get_args(field_type)
775
+ if args:
776
+ actual_type = args[0]
777
+ # Check if type name matches (handle both str and type)
778
+ type_name = actual_type if isinstance(actual_type, str) else actual_type.__name__
779
+ if type_name == obj_type:
780
+ field_value = getattr(blackboard, field_name, None)
781
+ # Return the actual value, not None
782
+ if field_value is not None:
783
+ return field_value
784
+ # Handle Union types (e.g., Sample | None)
785
+ elif get_origin(field_type) is Union:
786
+ args = get_args(field_type)
787
+ for arg in args:
788
+ # Skip None
789
+ if arg is type(None):
790
+ continue
791
+ # Check if this arg matches our target type
792
+ if get_origin(arg) is Annotated:
793
+ annotated_args = get_args(arg)
794
+ if annotated_args:
795
+ actual_type = annotated_args[0]
796
+ type_name = actual_type if isinstance(actual_type, str) else actual_type.__name__
797
+ if type_name == obj_type:
798
+ field_value = getattr(blackboard, field_name, None)
799
+ if field_value is not None:
800
+ return field_value
801
+ else:
802
+ type_name = arg if isinstance(arg, str) else arg.__name__
803
+ if type_name == obj_type:
804
+ field_value = getattr(blackboard, field_name, None)
805
+ if field_value is not None:
806
+ return field_value
807
+ else:
808
+ # Check if type name matches
809
+ type_name = field_type if isinstance(field_type, str) else field_type.__name__
810
+ if type_name == obj_type:
811
+ field_value = getattr(blackboard, field_name, None)
812
+ if field_value is not None:
813
+ return field_value
814
+
815
+ # Fallback: return blackboard if no matching field found
816
+ return blackboard
817
+
818
+ def _get_object_id(self, obj: Any) -> str | None:
819
+ """
820
+ Extract object ID from an object.
821
+
822
+ Tries:
823
+ 1. obj.id attribute
824
+ 2. str(obj)
825
+ """
826
+ if obj is None:
827
+ return None
828
+
829
+ if hasattr(obj, 'id'):
830
+ return str(getattr(obj, 'id'))
831
+ else:
832
+ return str(obj)
833
+
834
+ def _extract_object_attrs(self, obj: Any, attrs_spec: list | dict) -> Dict[str, Any]:
835
+ """
836
+ Extract attributes from an object for logging.
837
+
838
+ Args:
839
+ obj: The object to extract from
840
+ attrs_spec: Either a list of attribute names, or a dict of {name: expression}
841
+
842
+ Returns:
843
+ Dict of attribute name -> value
844
+ """
845
+ if attrs_spec is None:
846
+ # Check for SporesAttr annotations on the object's class
847
+ return self._extract_spores_attrs(obj)
848
+
849
+ if isinstance(attrs_spec, list):
850
+ # Simple list of attribute names
851
+ result = {}
852
+ for attr_name in attrs_spec:
853
+ if hasattr(obj, attr_name):
854
+ result[attr_name] = getattr(obj, attr_name)
855
+ return result
856
+
857
+ elif isinstance(attrs_spec, dict):
858
+ # Dict with custom names or callables
859
+ result = {}
860
+ for attr_name, expr in attrs_spec.items():
861
+ if isinstance(expr, str) and expr.startswith(("return.", "ret.", "self.")):
862
+ # Special handling for return/ret/self references
863
+ parts = expr.split(".", 1)
864
+ source = self._resolve_source(parts[0], {})
865
+ if source and len(parts) > 1:
866
+ result[attr_name] = getattr(source, parts[1])
867
+ elif isinstance(expr, str) and hasattr(obj, expr):
868
+ # Simple attribute access
869
+ result[attr_name] = getattr(obj, expr)
870
+ elif callable(expr):
871
+ # Callable expression
872
+ result[attr_name] = expr(obj)
873
+ else:
874
+ # Static value
875
+ result[attr_name] = expr
876
+ return result
877
+
878
+ return {}
879
+
880
+ def _extract_spores_attrs(self, obj: Any) -> Dict[str, Any]:
881
+ """
882
+ Extract attributes marked with SporesAttr from a Pydantic model.
883
+
884
+ Checks the object's class __annotations__ for Annotated[type, SporesAttr] fields.
885
+ """
886
+ result = {}
887
+
888
+ # Get the class (handle both instances and classes)
889
+ obj_class = obj if isinstance(obj, type) else type(obj)
890
+
891
+ if hasattr(obj_class, '__annotations__'):
892
+ for field_name, field_type in obj_class.__annotations__.items():
893
+ # Check if this is an Annotated type with SporesAttr
894
+ if get_origin(field_type) is Annotated:
895
+ args = get_args(field_type)
896
+ for arg in args:
897
+ if arg is SporesAttr:
898
+ # Found a SporesAttr marker - log this field
899
+ if hasattr(obj, field_name):
900
+ value = getattr(obj, field_name)
901
+ result[field_name] = value
902
+ break
903
+
904
+ return result
905
+
906
+ def _evaluate_expression(self, expr: Any, context: Dict[str, Any]) -> Any:
907
+ """
908
+ Evaluate an expression for event or object attributes.
909
+
910
+ Supports:
911
+ - Static values (strings, numbers, etc.)
912
+ - Callables (called with context)
913
+ - Strings that are parameter references or attribute accesses
914
+ """
915
+ if callable(expr):
916
+ # Callable - try to detect what params it wants
917
+ sig = inspect.signature(expr)
918
+ params = sig.parameters
919
+
920
+ if len(params) == 1 and list(params.values())[0].kind == inspect.Parameter.VAR_POSITIONAL:
921
+ # **kwargs style
922
+ return expr(**context)
923
+ elif len(params) == 0:
924
+ # No params
925
+ return expr()
926
+ else:
927
+ # Named params - pass relevant context
928
+ kwargs = {}
929
+ for param_name in params:
930
+ if param_name in context:
931
+ kwargs[param_name] = context[param_name]
932
+ return expr(**kwargs)
933
+ elif isinstance(expr, str):
934
+ # Check if it's a simple parameter reference
935
+ if expr in context:
936
+ return context[expr]
937
+ # Check if it's an attribute access like "order.id"
938
+ elif "." in expr:
939
+ parts = expr.split(".", 1)
940
+ if parts[0] in context:
941
+ obj = context[parts[0]]
942
+ if hasattr(obj, parts[1]):
943
+ return getattr(obj, parts[1])
944
+ return expr
945
+
946
+
947
+ def get_spore_sync(name: str) -> SyncEventLogger:
948
+ """
949
+ Get a synchronous spore logger.
950
+
951
+ Use this in synchronous code. The logger uses daemon threads for
952
+ fire-and-forget logging - business logic never blocks.
953
+
954
+ Args:
955
+ name: Spore name (typically __module__ or __name__)
956
+
957
+ Returns:
958
+ A SyncEventLogger instance
959
+
960
+ Example:
961
+ ```python
962
+ from mycorrhizal.spores import configure, get_spore_sync
963
+ from mycorrhizal.spores.transport import SyncFileTransport
964
+
965
+ configure(transport=SyncFileTransport("logs/ocel.jsonl"))
966
+ spore = get_spore_sync(__name__)
967
+
968
+ @spore.log_event(event_type="OrderCreated", order_id="order.id")
969
+ def create_order(order: Order) -> Order:
970
+ return order
971
+ ```
972
+ """
973
+ return SyncEventLogger(name)
974
+
975
+
976
+ def get_spore_async(name: str) -> AsyncEventLogger:
977
+ """
978
+ Get an asynchronous spore logger.
979
+
980
+ Use this in asynchronous code. The logger uses async I/O.
981
+
982
+ Args:
983
+ name: Spore name (typically __module__ or __name__)
984
+
985
+ Returns:
986
+ An AsyncEventLogger instance
987
+
988
+ Example:
989
+ ```python
990
+ from mycorrhizal.spores import configure, get_spore_async
991
+ from mycorrhizal.spores.transport import AsyncFileTransport
992
+
993
+ configure(transport=AsyncFileTransport("logs/ocel.jsonl"))
994
+ spore = get_spore_async(__name__)
995
+
996
+ @spore.log_event(event_type="OrderCreated", order_id="order.id")
997
+ async def create_order(order: Order) -> Order:
998
+ return order
999
+ ```
1000
+ """
1001
+ return AsyncEventLogger(name)
1002
+
1003
+
189
1004
  # ============================================================================
190
- # Decorators
1005
+ # Legacy API (deprecated)
191
1006
  # ============================================================================
192
1007
 
193
1008
  class SporeDecorator:
@@ -237,10 +1052,20 @@ class SporeDecorator:
237
1052
  # Call the original function
238
1053
  result = func(*args, **kwargs)
239
1054
 
240
- # Log the event asynchronously
241
- asyncio.create_task(self._log_event(
242
- func, args, kwargs, event_type, attributes, objects
243
- ))
1055
+ # Log in background thread since we're not in async context
1056
+ import threading
1057
+ def log_in_thread():
1058
+ new_loop = asyncio.new_event_loop()
1059
+ asyncio.set_event_loop(new_loop)
1060
+ try:
1061
+ new_loop.run_until_complete(self._log_event(
1062
+ func, args, kwargs, event_type, attributes, objects
1063
+ ))
1064
+ finally:
1065
+ new_loop.close()
1066
+
1067
+ thread = threading.Thread(target=log_in_thread, daemon=True)
1068
+ thread.start()
244
1069
 
245
1070
  return result
246
1071
 
@@ -297,61 +1122,73 @@ class SporeDecorator:
297
1122
  # Extract objects
298
1123
  event_objects = []
299
1124
 
300
- # 1. Extract from blackboard with ObjectRef metadata
301
- if bb is not None:
302
- bb_objects = extraction.extract_objects_from_blackboard(bb, objects)
303
- event_objects.extend(bb_objects)
304
-
305
- # 2. Manual object specification
306
- if isinstance(attributes, dict):
307
- # Check for object specifications (tuples with qualifier)
308
- manual_objects = extraction.extract_objects_from_spec(
309
- attributes, timestamp
310
- )
311
- event_objects.extend(manual_objects)
312
-
313
- # Build relationships
314
- relationships = {}
315
- for obj in event_objects:
316
- rel = Relationship(object_id=obj.id, qualifier="related")
317
- relationships[obj.id] = rel
318
-
319
- # Build event
1125
+ if objects is not None and bb is not None:
1126
+ # Extract objects from interface using proper Annotated type handling
1127
+ for obj_field in objects:
1128
+ if hasattr(bb, obj_field):
1129
+ obj = getattr(bb, obj_field)
1130
+
1131
+ # Get the field type from annotations
1132
+ field_type = type(bb).__annotations__.get(obj_field)
1133
+ if field_type is not None:
1134
+ # Check if it's an Annotated type
1135
+ if get_origin(field_type) is Annotated:
1136
+ args = get_args(field_type)
1137
+ if len(args) >= 2:
1138
+ # args[0] is the actual type, args[1:] are metadata
1139
+ for metadata in args[1:]:
1140
+ # Check if it's an ObjectRef
1141
+ if isinstance(metadata, ObjectRef):
1142
+ rel = Relationship(
1143
+ object_id=obj.id,
1144
+ qualifier=metadata.qualifier
1145
+ )
1146
+ event_objects.append((obj, rel))
1147
+ break
1148
+
1149
+ # Create event
320
1150
  event = Event(
321
1151
  id=generate_event_id(),
322
1152
  type=event_type,
323
1153
  time=timestamp,
324
- attributes=event_attrs,
325
- relationships=relationships
1154
+ attributes={
1155
+ k: attribute_value_from_python(v)
1156
+ for k, v in event_attrs.items()
1157
+ },
1158
+ relationships={
1159
+ r.qualifier: r
1160
+ for _, r in event_objects
1161
+ }
326
1162
  )
327
1163
 
328
1164
  # Send event
329
1165
  await _send_log_record(LogRecord(event=event))
330
1166
 
331
- # Send objects to cache
332
- cache = get_object_cache()
333
- for obj in event_objects:
334
- cache.contains_or_add(obj.id, obj)
1167
+ # Send object records (convert Pydantic models to OCEL Objects)
1168
+ for obj, _ in event_objects:
1169
+ ocel_obj = extraction.convert_to_ocel_object(obj)
1170
+ if ocel_obj:
1171
+ obj_record = LogRecord(object=ocel_obj)
1172
+ await _send_log_record(obj_record)
335
1173
 
336
1174
  except Exception as e:
337
1175
  logger.error(f"Failed to log event: {e}")
338
1176
 
339
1177
  def _find_blackboard(self, args: tuple, kwargs: dict) -> Optional[Any]:
340
- """Find blackboard in function arguments."""
341
- # Try common parameter names
342
- for param_name in ('bb', 'blackboard', 'ctx', 'context'):
343
- if param_name in kwargs:
344
- return kwargs[param_name]
345
-
346
- # Try positional arguments
347
- for i, arg in enumerate(args):
348
- # Skip self
349
- if i == 0 and inspect.ismethod(args[0] if args else None):
350
- continue
351
-
352
- # Check for common blackboard patterns
353
- type_name = type(arg).__name__.lower()
354
- if 'blackboard' in type_name or 'context' in type_name:
1178
+ """Find blackboard in arguments.
1179
+
1180
+ Looks for objects marked with _is_blackboard, or falls back to
1181
+ the first argument that looks like a Pydantic model (has __annotations__).
1182
+ """
1183
+ # First, look for explicitly marked blackboard
1184
+ for arg in args:
1185
+ if hasattr(arg, '_is_blackboard'):
1186
+ return arg
1187
+
1188
+ # Fallback: look for Pydantic BaseModel or dataclass with annotations
1189
+ for arg in args:
1190
+ if hasattr(arg, '__annotations__') and hasattr(arg, '__dict__'):
1191
+ # Looks like a data container (Pydantic model or dataclass)
355
1192
  return arg
356
1193
 
357
1194
  return None
@@ -377,43 +1214,38 @@ class SporeDecorator:
377
1214
  def decorator(cls: type) -> type:
378
1215
  # Store metadata on the class
379
1216
  cls._spores_object_type = object_type
1217
+ # Store in global registry
1218
+ if not hasattr(self, '_object_types'):
1219
+ self._object_types = {}
1220
+ self._object_types[object_type] = cls
380
1221
  return cls
381
1222
 
382
1223
  return decorator
383
1224
 
384
1225
 
385
- # Global spores decorator instance
1226
+ # Create global spore instance for backward compatibility
386
1227
  spore = SporeDecorator()
387
1228
 
388
1229
 
389
1230
  # ============================================================================
390
- # Module Exports
1231
+ # Public API
391
1232
  # ============================================================================
392
1233
 
393
1234
  __all__ = [
1235
+ # Configuration
394
1236
  'configure',
395
1237
  'get_config',
396
1238
  'get_object_cache',
1239
+
1240
+ # Spore getters (explicit)
1241
+ 'get_spore_sync',
1242
+ 'get_spore_async',
1243
+
1244
+ # Logger types
1245
+ 'EventLogger',
1246
+ 'AsyncEventLogger',
1247
+ 'SyncEventLogger',
1248
+
1249
+ # Legacy (deprecated)
397
1250
  '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
1251
  ]