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.
@@ -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,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
- # Decorators
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 the event asynchronously
241
- asyncio.create_task(self._log_event(
242
- func, args, kwargs, event_type, attributes, objects
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
- # 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
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=event_attrs,
325
- relationships=relationships
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 objects to cache
332
- cache = get_object_cache()
333
- for obj in event_objects:
334
- cache.contains_or_add(obj.id, obj)
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 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:
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
- # Global spores decorator instance
1105
+ # Create global spore instance for backward compatibility
386
1106
  spore = SporeDecorator()
387
1107
 
388
1108
 
389
1109
  # ============================================================================
390
- # Module Exports
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
  ]