mycorrhizal 0.2.0__py3-none-any.whl → 0.2.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.
@@ -8,11 +8,14 @@ LRU cache for tracking seen objects and logging them on eviction.
8
8
  from __future__ import annotations
9
9
 
10
10
  from collections import OrderedDict
11
- from typing import Dict, Callable, Optional, TypeVar, Generic
11
+ from typing import Dict, Callable, Optional, TypeVar, Generic, TYPE_CHECKING
12
12
  from dataclasses import dataclass
13
13
 
14
14
  from .models import Object
15
15
 
16
+ if TYPE_CHECKING:
17
+ from .core import CacheMetrics
18
+
16
19
 
17
20
  # Generic types for cache
18
21
  K = TypeVar('K') # Key type (object ID)
@@ -28,61 +31,90 @@ class CacheEntry:
28
31
  object: The OCEL Object
29
32
  sight_count: How many times we've seen this object
30
33
  first_sight_time: When we first saw the object
34
+ attributes_hash: Hash of object attributes for change detection
31
35
  """
32
36
  object: Object
33
37
  sight_count: int = 1
34
38
  first_sight_time: Optional[float] = None
39
+ attributes_hash: Optional[int] = None
40
+
41
+
42
+ def _compute_attributes_hash(obj: Object) -> Optional[int]:
43
+ """
44
+ Compute hash of object attributes for change detection.
45
+
46
+ Args:
47
+ obj: The OCEL Object
48
+
49
+ Returns:
50
+ Hash of attributes, or None if object has no attributes
51
+ """
52
+ if not obj.attributes:
53
+ return None
54
+ # Hash frozenset of (attr_name, attr_value) tuples
55
+ # ObjectAttributeValue.value holds the actual Python value
56
+ attrs = {k: v.value for k, v in obj.attributes.items()}
57
+ return hash(frozenset(attrs.items()))
35
58
 
36
59
 
37
60
  class ObjectLRUCache(Generic[K, V]):
38
61
  """
39
- LRU cache with eviction callback for object tracking.
62
+ LRU cache with unified callback for object logging.
40
63
 
41
- When an object is first seen, it should be logged via on_first_sight.
42
- When an object is evicted from the cache, it's logged via on_evict.
64
+ The cache fires the `needs_logged` callback when an object needs to be logged:
65
+ - On first sight (new object added to cache)
66
+ - On eviction (object removed from cache to make room)
67
+ - When attributes change (detected via hash comparison)
68
+ - Every N touches (configurable via `touch_resend_n`)
43
69
 
44
70
  This ensures OCEL consumers see object evolution:
45
71
  - First sight: Initial object state
72
+ - Attribute changes: Updated state
46
73
  - Eviction: Final state before being removed from cache
74
+ - Periodic resend: Long-lived objects are re-logged periodically
47
75
 
48
76
  Example:
49
77
  ```python
50
- def on_evict(object_id: str, obj: Object):
78
+ def needs_logged(object_id: str, obj: Object):
51
79
  # Send object to transport
52
80
  transport.send(LogRecord(object=obj))
53
81
 
54
- cache = ObjectLRUCache(maxsize=128, on_evict=on_evict)
82
+ cache = ObjectLRUCache(maxsize=128, needs_logged=needs_logged, touch_resend_n=100)
55
83
 
56
84
  # Check if object exists, add if not
57
85
  if not cache.contains_or_add(object_id, object):
58
- # Object not in cache, already logged by on_first_sight
86
+ # Object not in cache, already logged by needs_logged
59
87
  pass
60
88
  ```
61
89
 
62
90
  Args:
63
91
  maxsize: Maximum number of objects to cache
64
- on_evict: Callback when object is evicted (object_id, object) -> None
65
- on_first_sight: Optional callback when object is first seen (object_id, object) -> None
92
+ needs_logged: Callback when object needs logging (object_id, object) -> None
93
+ touch_resend_n: Resend object every N touches (0 to disable periodic resend)
66
94
  """
67
95
 
68
96
  def __init__(
69
97
  self,
70
98
  maxsize: int = 128,
71
- on_evict: Optional[Callable[[K, Object], None]] = None,
72
- on_first_sight: Optional[Callable[[K, Object], None]] = None
99
+ needs_logged: Optional[Callable[[K, Object], None]] = None,
100
+ touch_resend_n: int = 100,
101
+ metrics: Optional["CacheMetrics"] = None
73
102
  ):
74
103
  self.maxsize = maxsize
75
- self.on_evict = on_evict
76
- self.on_first_sight = on_first_sight
104
+ self.needs_logged = needs_logged
105
+ self.touch_resend_n = touch_resend_n
106
+ self.metrics = metrics
77
107
  self._cache: OrderedDict[K, CacheEntry] = OrderedDict()
78
108
 
79
109
  def _evict_if_needed(self) -> None:
80
110
  """Evict oldest entry if cache is full."""
81
- if len(self._cache) >= self.maxsize:
111
+ while len(self._cache) >= self.maxsize:
82
112
  # FIFO from OrderedDict (oldest first)
83
113
  object_id, entry = self._cache.popitem(last=False)
84
- if self.on_evict:
85
- self.on_evict(object_id, entry.object)
114
+ if self.metrics is not None:
115
+ self.metrics.evictions += 1
116
+ if self.needs_logged:
117
+ self.needs_logged(object_id, entry.object)
86
118
 
87
119
  def get(self, key: K) -> Optional[Object]:
88
120
  """
@@ -119,8 +151,13 @@ class ObjectLRUCache(Generic[K, V]):
119
151
  Check if key exists, add if not.
120
152
 
121
153
  This is the primary method for object tracking:
122
- - If key exists: mark as recently used, return True
123
- - If key doesn't exist: add object, potentially evict, call on_first_sight, return False
154
+ - If key exists: check for attribute changes, periodic resend, mark as recently used
155
+ - If key doesn't exist: add object, potentially evict, log first sight
156
+
157
+ The `needs_logged` callback is fired when:
158
+ - First sight: new object added to cache
159
+ - Attribute change: object attributes changed (hash comparison)
160
+ - Every N touches: periodic resend for long-lived objects
124
161
 
125
162
  Args:
126
163
  key: The object ID
@@ -130,27 +167,51 @@ class ObjectLRUCache(Generic[K, V]):
130
167
  True if object was already in cache, False if it was just added
131
168
  """
132
169
  if key in self._cache:
133
- # Already seen, mark as recently used
134
- self._cache.move_to_end(key)
170
+ # Object exists - check if we need to log
135
171
  entry = self._cache[key]
136
172
  entry.sight_count += 1
173
+
174
+ # Compute current attribute hash
175
+ current_hash = _compute_attributes_hash(obj)
176
+
177
+ # Check if attributes changed
178
+ attrs_changed = (entry.attributes_hash is not None and
179
+ current_hash != entry.attributes_hash)
180
+
181
+ # Update if attributes changed
182
+ if attrs_changed:
183
+ entry.object = obj
184
+ entry.attributes_hash = current_hash
185
+
186
+ # Fire callback if ANY condition met
187
+ should_log = any([
188
+ attrs_changed,
189
+ self.touch_resend_n > 0 and entry.sight_count % self.touch_resend_n == 0,
190
+ ])
191
+ if should_log and self.needs_logged:
192
+ self.needs_logged(key, entry.object)
193
+
194
+ self._cache.move_to_end(key)
137
195
  return True
138
196
  else:
139
- # First sight
197
+ # First sight - store hash, fire callback
140
198
  self._evict_if_needed()
141
-
142
- entry = CacheEntry(object=obj)
199
+ if self.metrics is not None:
200
+ self.metrics.first_sights += 1
201
+ attr_hash = _compute_attributes_hash(obj)
202
+ entry = CacheEntry(object=obj, attributes_hash=attr_hash)
143
203
  self._cache[key] = entry
144
-
145
- if self.on_first_sight:
146
- self.on_first_sight(key, obj)
147
-
204
+ if self.needs_logged:
205
+ self.needs_logged(key, obj)
148
206
  return False
149
207
 
150
208
  def add(self, key: K, obj: Object) -> None:
151
209
  """
152
210
  Add an object to the cache (or update if exists).
153
211
 
212
+ Note: This method fires needs_logged for new objects, but not for updates.
213
+ For attribute change detection and periodic resend, use contains_or_add().
214
+
154
215
  Args:
155
216
  key: The object ID
156
217
  obj: The OCEL Object
@@ -165,11 +226,12 @@ class ObjectLRUCache(Generic[K, V]):
165
226
  # New entry
166
227
  self._evict_if_needed()
167
228
 
168
- entry = CacheEntry(object=obj)
229
+ attr_hash = _compute_attributes_hash(obj)
230
+ entry = CacheEntry(object=obj, attributes_hash=attr_hash)
169
231
  self._cache[key] = entry
170
232
 
171
- if self.on_first_sight:
172
- self.on_first_sight(key, obj)
233
+ if self.needs_logged:
234
+ self.needs_logged(key, obj)
173
235
 
174
236
  def remove(self, key: K) -> Optional[Object]:
175
237
  """
@@ -13,11 +13,12 @@ import functools
13
13
  import logging
14
14
  import threading
15
15
  from datetime import datetime
16
+ from enum import Enum
16
17
  from typing import (
17
18
  Any, Callable, Optional, Union, Dict, List,
18
19
  ParamSpec, TypeVar, overload, get_origin, get_args, Annotated
19
20
  )
20
- from dataclasses import dataclass
21
+ from dataclasses import dataclass, field
21
22
  from abc import ABC, abstractmethod
22
23
 
23
24
  from .models import (
@@ -44,6 +45,28 @@ R = TypeVar('R')
44
45
  # Global Configuration
45
46
  # ============================================================================
46
47
 
48
+ class EvictionPolicy(str, Enum):
49
+ """
50
+ Cache eviction policy for object logging.
51
+
52
+ Attributes:
53
+ EVICT_AND_LOG: Evict from cache when full, log immediately via sync/async path
54
+ EVICT_AND_BUFFER: Evict from cache, buffer for later logging (future)
55
+ NO_EVICT: Keep in cache until explicit flush (future)
56
+ """
57
+ EVICT_AND_LOG = "evict_and_log"
58
+ EVICT_AND_BUFFER = "evict_and_buffer"
59
+ NO_EVICT = "no_evict"
60
+
61
+
62
+ @dataclass
63
+ class CacheMetrics:
64
+ """Track cache eviction statistics."""
65
+ evictions: int = 0
66
+ eviction_failures: int = 0
67
+ first_sights: int = 0
68
+
69
+
47
70
  @dataclass
48
71
  class SporesConfig:
49
72
  """
@@ -54,11 +77,15 @@ class SporesConfig:
54
77
  object_cache_size: Maximum objects in LRU cache
55
78
  encoder: Encoder instance to use
56
79
  transport: Transport instance to use (required for logging to work)
80
+ eviction_policy: Policy for handling cache eviction
81
+ touch_resend_n: Resend object every N touches (0 to disable periodic resend)
57
82
  """
58
83
  enabled: bool = True
59
84
  object_cache_size: int = 128
60
85
  encoder: Optional[Encoder] = None
61
86
  transport: Optional[Transport] = None
87
+ eviction_policy: EvictionPolicy = EvictionPolicy.EVICT_AND_LOG
88
+ touch_resend_n: int = 100
62
89
 
63
90
  def __post_init__(self):
64
91
  """Set default encoder if not provided."""
@@ -69,24 +96,34 @@ class SporesConfig:
69
96
  # Global state
70
97
  _config: Optional[SporesConfig] = None
71
98
  _object_cache: Optional[ObjectLRUCache[str, Object]] = None
99
+ _config_lock = threading.RLock() # Reentrant lock for global state initialization
100
+ _config_initialized = False # Track whether configure() has been called
101
+ _cache_metrics = CacheMetrics() # Track cache eviction statistics
72
102
 
73
103
 
74
104
  def configure(
75
105
  enabled: bool = True,
76
106
  object_cache_size: int = 128,
77
107
  encoder: Optional[Encoder] = None,
78
- transport: Optional[Transport] = None
108
+ transport: Optional[Transport] = None,
109
+ eviction_policy: Union[EvictionPolicy, str] = EvictionPolicy.EVICT_AND_LOG,
110
+ touch_resend_n: int = 100
79
111
  ) -> None:
80
112
  """
81
- Configure the Spores logging system.
113
+ Configure the Spores logging system (thread-safe).
82
114
 
83
115
  This should be called once at application startup.
116
+ If called multiple times, the last call wins.
117
+
118
+ Thread-safe: Can be called from multiple threads concurrently.
84
119
 
85
120
  Args:
86
121
  enabled: Whether spores logging is enabled (default: True)
87
122
  object_cache_size: Maximum objects in LRU cache (default: 128)
88
123
  encoder: Encoder instance (defaults to JSONEncoder)
89
124
  transport: Transport instance (required for logging to work)
125
+ eviction_policy: Policy for cache eviction (default: evict_and_log)
126
+ touch_resend_n: Resend object every N touches (default: 100, 0 to disable)
90
127
 
91
128
  Example:
92
129
  ```python
@@ -94,72 +131,183 @@ def configure(
94
131
 
95
132
  spore.configure(
96
133
  transport=FileTransport("logs/ocel.jsonl"),
97
- object_cache_size=256
134
+ object_cache_size=256,
135
+ touch_resend_n=100,
98
136
  )
99
137
  ```
100
138
  """
101
- global _config, _object_cache
102
-
103
- _config = SporesConfig(
104
- enabled=enabled,
105
- object_cache_size=object_cache_size,
106
- encoder=encoder,
107
- transport=transport
108
- )
109
-
110
- # Create object cache with eviction callback
111
- def on_evict(object_id: str, obj: Object):
112
- """Send object when evicted from cache."""
113
- try:
114
- loop = asyncio.get_event_loop()
115
- if loop.is_running():
116
- asyncio.create_task(_send_log_record(LogRecord(object=obj)))
117
- else:
118
- # No running loop, schedule the coroutine
119
- asyncio.ensure_future(_send_log_record(LogRecord(object=obj)))
120
- except RuntimeError:
121
- # No event loop at all, ignore for now
122
- pass
123
-
124
- def on_first_sight(object_id: str, obj: Object):
125
- """Send object when first seen."""
126
- try:
127
- loop = asyncio.get_event_loop()
128
- if loop.is_running():
129
- asyncio.create_task(_send_log_record(LogRecord(object=obj)))
130
- else:
131
- # No running loop, schedule the coroutine
132
- asyncio.ensure_future(_send_log_record(LogRecord(object=obj)))
133
- except RuntimeError:
134
- # No event loop at all, ignore for now
135
- pass
139
+ global _config, _object_cache, _config_initialized, _cache_metrics
140
+
141
+ # Convert string to EvictionPolicy if needed
142
+ if isinstance(eviction_policy, str):
143
+ eviction_policy = EvictionPolicy(eviction_policy)
144
+
145
+ with _config_lock:
146
+ # Warn if already configured (but allow reconfiguration)
147
+ if _config_initialized:
148
+ logger.warning(
149
+ "Spores already configured. Reconfiguring may cause issues. "
150
+ "Ensure configure() is called only once at startup."
151
+ )
152
+
153
+ # Create new config (atomic under lock)
154
+ _config = SporesConfig(
155
+ enabled=enabled,
156
+ object_cache_size=object_cache_size,
157
+ encoder=encoder,
158
+ transport=transport,
159
+ eviction_policy=eviction_policy,
160
+ touch_resend_n=touch_resend_n
161
+ )
136
162
 
137
- _object_cache = ObjectLRUCache(
138
- maxsize=object_cache_size,
139
- on_evict=on_evict,
140
- on_first_sight=on_first_sight
141
- )
163
+ # Reset metrics on reconfiguration
164
+ _cache_metrics = CacheMetrics()
142
165
 
143
- logger.info(f"Spores configured: enabled={enabled}, cache_size={object_cache_size}")
166
+ # Create object cache with unified callback
167
+ def needs_logged(object_id: str, obj: Object):
168
+ """Log object when it needs to be logged (first sight, eviction, change, or Nth touch)."""
169
+ global _cache_metrics
170
+
171
+ try:
172
+ loop = asyncio.get_event_loop()
173
+ if loop.is_running():
174
+ # Async context: schedule task
175
+ asyncio.create_task(_send_log_record(LogRecord(object=obj)))
176
+ else:
177
+ # No running loop, use sync path
178
+ _send_log_record_sync(LogRecord(object=obj))
179
+ except RuntimeError:
180
+ # No event loop at all, use sync path
181
+ try:
182
+ _send_log_record_sync(LogRecord(object=obj))
183
+ except Exception as e:
184
+ logger.error(f"Failed to log object {object_id}: {e}")
185
+
186
+ _object_cache = ObjectLRUCache(
187
+ maxsize=object_cache_size,
188
+ needs_logged=needs_logged,
189
+ touch_resend_n=touch_resend_n,
190
+ metrics=_cache_metrics
191
+ )
192
+
193
+ _config_initialized = True
194
+
195
+ logger.info(f"Spores configured: enabled={enabled}, cache_size={object_cache_size}, eviction_policy={eviction_policy.value}")
144
196
 
145
197
 
146
198
  def get_config() -> SporesConfig:
147
- """Get the current spores configuration."""
148
- global _config
149
- if _config is None:
150
- # Use default configuration
151
- _config = SporesConfig()
199
+ """
200
+ Get the current spores configuration (thread-safe).
201
+
202
+ If no configuration exists, creates a default one.
203
+ Thread-safe: Can be called from multiple threads concurrently.
204
+ """
205
+ global _config, _config_initialized
206
+
207
+ # Fast path: read without lock (GIL protects single read)
208
+ if _config is not None:
209
+ return _config
210
+
211
+ # Slow path: needs initialization
212
+ with _config_lock:
213
+ # Double-check: another thread may have initialized while we waited
214
+ if _config is None:
215
+ # Initialize with defaults
216
+ _config = SporesConfig()
217
+ _config_initialized = True
218
+
152
219
  return _config
153
220
 
154
221
 
155
222
  def get_object_cache() -> ObjectLRUCache[str, Object]:
156
- """Get the object cache."""
223
+ """
224
+ Get the object cache (thread-safe).
225
+
226
+ If no cache exists, initializes with default configuration.
227
+ Thread-safe: Can be called from multiple threads concurrently.
228
+ """
157
229
  global _object_cache
158
- if _object_cache is None:
159
- configure() # Initialize with defaults
230
+
231
+ # Fast path: read without lock (GIL protects single read)
232
+ if _object_cache is not None:
233
+ return _object_cache
234
+
235
+ # Slow path: needs initialization
236
+ with _config_lock:
237
+ # Double-check: another thread may have initialized while we waited
238
+ if _object_cache is None:
239
+ # Trigger full initialization
240
+ configure() # Will acquire lock again, but that's OK (reentrant in same thread)
241
+
160
242
  return _object_cache
161
243
 
162
244
 
245
+ def flush_object_cache() -> None:
246
+ """
247
+ Flush all objects from the cache to the log.
248
+
249
+ This forces all cached objects to be written, even if they haven't
250
+ been evicted yet. Use this before application shutdown to ensure
251
+ all objects are logged.
252
+
253
+ Thread-safe: Can be called from multiple threads concurrently.
254
+
255
+ Example:
256
+ ```python
257
+ from mycorrhizal.spores import configure, flush_object_cache
258
+ from mycorrhizal.spores.transport import FileTransport
259
+
260
+ configure(transport=FileTransport("logs/ocel.jsonl"))
261
+
262
+ # ... application logic ...
263
+
264
+ flush_object_cache() # Ensure all objects logged
265
+ ```
266
+ """
267
+ cache = get_object_cache()
268
+ config = get_config()
269
+
270
+ if not config.enabled:
271
+ return
272
+
273
+ # Get all objects currently in cache
274
+ all_objects = list(cache._cache.values())
275
+
276
+ # Log each object
277
+ flushed_count = 0
278
+ for entry in all_objects:
279
+ try:
280
+ # Use sync path to ensure it's written
281
+ _send_log_record_sync(LogRecord(object=entry.object))
282
+ flushed_count += 1
283
+ except Exception as e:
284
+ logger.error(f"Failed to flush object {entry.object.id}: {e}")
285
+
286
+ logger.info(f"Flushed {flushed_count} objects from cache")
287
+
288
+
289
+ def get_cache_metrics() -> CacheMetrics:
290
+ """
291
+ Get cache eviction metrics.
292
+
293
+ Returns statistics about cache evictions and failures.
294
+
295
+ Returns:
296
+ CacheMetrics with eviction statistics
297
+
298
+ Example:
299
+ ```python
300
+ from mycorrhizal.spores import get_cache_metrics
301
+
302
+ metrics = get_cache_metrics()
303
+ print(f"Evictions: {metrics.evictions}")
304
+ print(f"Failures: {metrics.eviction_failures}")
305
+ ```
306
+ """
307
+ global _cache_metrics
308
+ return _cache_metrics
309
+
310
+
163
311
  async def _send_log_record(record: LogRecord) -> None:
164
312
  """
165
313
  Send a log record via the configured transport (async).
@@ -1236,6 +1384,13 @@ __all__ = [
1236
1384
  'configure',
1237
1385
  'get_config',
1238
1386
  'get_object_cache',
1387
+ 'flush_object_cache',
1388
+ 'get_cache_metrics',
1389
+
1390
+ # Types
1391
+ 'SporesConfig',
1392
+ 'EvictionPolicy',
1393
+ 'CacheMetrics',
1239
1394
 
1240
1395
  # Spore getters (explicit)
1241
1396
  'get_spore_sync',
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import json
12
12
  from datetime import datetime
13
- from typing import Any, Dict
13
+ from typing import Any, Dict, TYPE_CHECKING
14
14
 
15
15
  from .base import Encoder
16
16
  from ..models import LogRecord, Event, Object, Relationship, EventAttributeValue, ObjectAttributeValue
@@ -20,7 +20,7 @@ class JSONEncoder(Encoder):
20
20
  """
21
21
  JSON encoder for OCEL LogRecords.
22
22
 
23
- Produces OCEL-compatible JSON.
23
+ Produces OCEL-compatible JSON with Unix float64 timestamps.
24
24
 
25
25
  Example output:
26
26
  {
@@ -28,9 +28,9 @@ class JSONEncoder(Encoder):
28
28
  "id": "evt-123",
29
29
  "type": "process_item",
30
30
  "activity": null,
31
- "time": "2025-01-11T12:34:56.789000000Z",
31
+ "time": 1705689296.123456,
32
32
  "attributes": [
33
- {"name": "priority", "value": "high", "type": "string", "time": null}
33
+ {"name": "priority", "value": "high", "type": "string"}
34
34
  ],
35
35
  "relationships": [
36
36
  {"objectId": "obj-456", "qualifier": "input"}
@@ -46,13 +46,21 @@ class JSONEncoder(Encoder):
46
46
  "id": "obj-456",
47
47
  "type": "WorkItem",
48
48
  "attributes": [
49
- {"name": "status", "value": "pending", "type": "string", "time": null}
49
+ {"name": "status", "value": "pending", "type": "string", "time": 1705689296.123456}
50
50
  ],
51
51
  "relationships": []
52
52
  }
53
53
  }
54
54
  """
55
55
 
56
+ def __init__(self):
57
+ """
58
+ Initialize JSONEncoder.
59
+
60
+ Timestamps are always encoded as Unix float64 timestamps.
61
+ """
62
+ pass
63
+
56
64
  def encode(self, record: LogRecord) -> bytes:
57
65
  """
58
66
  Encode a LogRecord to JSON bytes.
@@ -136,33 +144,14 @@ class JSONEncoder(Encoder):
136
144
  "qualifier": qualifier
137
145
  }
138
146
 
139
- def _format_datetime(self, dt: datetime) -> str:
147
+ def _format_datetime(self, dt: datetime) -> float:
140
148
  """
141
- Format datetime to RFC3339Nano.
149
+ Format datetime as Unix float64 timestamp.
142
150
 
143
151
  Args:
144
152
  dt: The datetime to format
145
153
 
146
154
  Returns:
147
- ISO format string with nanosecond precision
155
+ Unix float64 timestamp (seconds since epoch)
148
156
  """
149
- # Python's isoformat() produces RFC3339-compatible output
150
- # For nanosecond precision, we need to ensure it has 9 digits
151
- iso = dt.isoformat()
152
-
153
- # Add microseconds if not present (Python 3.11+ has timespec='nanoseconds')
154
- if '.' not in iso:
155
- iso += '.000000000'
156
-
157
- # Ensure we have 9 digits of fractional seconds
158
- if '.' in iso:
159
- main, frac = iso.split('.')
160
- # Pad or truncate to 9 digits
161
- frac = (frac + '0' * 9)[:9]
162
- iso = f"{main}.{frac}"
163
-
164
- # Ensure timezone is Z (UTC) or offset
165
- if iso.endswith('+00:00'):
166
- iso = iso[:-6] + 'Z'
167
-
168
- return iso
157
+ return dt.timestamp()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycorrhizal
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Utilities and DSLs for modelling and implementing safe, performant, structured systems
5
5
  Author-email: Jeff Ciesielski <jeffciesielski@gmail.com>
6
6
  Requires-Python: >=3.10