specter-runtime 0.1.1__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.
specter/__init__.py ADDED
@@ -0,0 +1,136 @@
1
+ # Copyright 2026 BleedingXiko
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ SPECTER — Service Primitives for Event Control, Teardown, and Execution Runtime
17
+ ================================================================================
18
+
19
+ Lifecycle-first backend framework for Python, Flask, and gevent.
20
+ Provides explicit ownership, deterministic cleanup, and clear service
21
+ boundaries for Flask/gevent/SQLite applications.
22
+
23
+ **Framework Surface:**
24
+
25
+ +--------------------+----------------------------------+
26
+ | Channel | Role |
27
+ +====================+==================================+
28
+ | ``Service`` | Background lifecycle owner |
29
+ +--------------------+----------------------------------+
30
+ | ``QueueService`` | Queue-backed worker lifecycle |
31
+ +--------------------+----------------------------------+
32
+ | ``Controller`` | Feature composition root |
33
+ +--------------------+----------------------------------+
34
+ | ``Handler`` | Socket event lifecycle owner |
35
+ +--------------------+----------------------------------+
36
+ | ``create_model`` | Structured state graph |
37
+ +--------------------+----------------------------------+
38
+ | ``create_store`` | Shared mutable state |
39
+ +--------------------+----------------------------------+
40
+ | ``create_cache`` | Shared state with TTL + cascade |
41
+ +--------------------+----------------------------------+
42
+ | ``ManagedProcess`` | Subprocess + stream ownership |
43
+ +--------------------+----------------------------------+
44
+ | ``Watcher`` | Observation loop primitive |
45
+ +--------------------+----------------------------------+
46
+ | ``Schema`` | Payload contract / validation |
47
+ +--------------------+----------------------------------+
48
+ | ``Outcome`` | Structured operation result |
49
+ +--------------------+----------------------------------+
50
+ | ``Operation`` | Structured backend action |
51
+ +--------------------+----------------------------------+
52
+ | ``Router`` | Class-based HTTP composition |
53
+ +--------------------+----------------------------------+
54
+ | ``registry`` | Composition-root service locator |
55
+ +--------------------+----------------------------------+
56
+ | ``bus`` | Internal pub/sub |
57
+ +--------------------+----------------------------------+
58
+ | ``json_endpoint`` | Flask route envelope helpers |
59
+ +--------------------+----------------------------------+
60
+
61
+ **Import from here:**
62
+
63
+ from specter import (
64
+ Service, QueueService, Controller, Handler, create_store,
65
+ create_cache, Watcher, Schema, Field,
66
+ registry, bus, HTTPError, json_endpoint, expect_json,
67
+ )
68
+
69
+ See ``specter.md`` for the full guide.
70
+ """
71
+
72
+ # Core primitives
73
+ from .core.lifecycle import Service
74
+ from .core.queue_service import QueueService
75
+ from .core.controller import Controller
76
+ from .core.process import ManagedProcess, start_process
77
+ from .core.watcher import Watcher, WatcherError
78
+ from .core.model import Model, create_model
79
+ from .core.schema import Schema, Field, SchemaError
80
+ from .core.outcome import Outcome
81
+ from .core.operation import Operation, OperationError
82
+ from .core.store import Store, create_store
83
+ from .core.handler import Handler
84
+ from .core.cache import Cache, create_cache
85
+ from .core.bus import EventBus, bus
86
+ from .core.registry import SPECTERRegistry, registry
87
+ from .core.manager import ServiceManager
88
+ from .core.socket_ingress import SocketIngress
89
+ from .http import HTTPError, json_endpoint, expect_json, require_fields
90
+ from .router import Router, route
91
+ from .boot import boot
92
+
93
+ __version__ = '0.1.1'
94
+
95
+ __all__ = [
96
+ # Lifecycle
97
+ 'Service',
98
+ 'QueueService',
99
+ 'Controller',
100
+ 'ManagedProcess',
101
+ 'start_process',
102
+ 'Watcher',
103
+ 'WatcherError',
104
+ 'Outcome',
105
+ 'Operation',
106
+ 'OperationError',
107
+ 'Router',
108
+ 'route',
109
+ 'Handler',
110
+ 'ServiceManager',
111
+ 'SocketIngress',
112
+ 'boot',
113
+ # State
114
+ 'Model',
115
+ 'create_model',
116
+ 'Store',
117
+ 'create_store',
118
+ 'Cache',
119
+ 'create_cache',
120
+ # Contracts
121
+ 'Schema',
122
+ 'Field',
123
+ 'SchemaError',
124
+ # Communication
125
+ 'EventBus',
126
+ 'bus',
127
+ # Registry
128
+ 'SPECTERRegistry',
129
+ 'registry',
130
+ # HTTP
131
+ 'HTTPError',
132
+ 'json_endpoint',
133
+ 'expect_json',
134
+ 'require_fields',
135
+ '__version__',
136
+ ]
specter/boot.py ADDED
@@ -0,0 +1,53 @@
1
+ # Copyright 2026 BleedingXiko
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """SPECTER boot helpers for app-level composition roots."""
16
+
17
+ from .core.manager import ServiceManager
18
+
19
+
20
+ def boot(
21
+ app,
22
+ socketio,
23
+ *,
24
+ services=None,
25
+ handlers=None,
26
+ controllers=None,
27
+ ):
28
+ """
29
+ Create and boot a ``ServiceManager`` with the supplied components.
30
+
31
+ Args:
32
+ app: Flask app instance.
33
+ socketio: Flask-SocketIO instance.
34
+ services: Optional iterable of ``Service`` instances.
35
+ handlers: Optional iterable of handler instances.
36
+ controllers: Optional iterable of ``Controller`` instances.
37
+
38
+ Returns:
39
+ A booted ``ServiceManager``.
40
+ """
41
+ manager = ServiceManager(app, socketio)
42
+
43
+ for service in services or ():
44
+ manager.register_service(service)
45
+
46
+ for handler in handlers or ():
47
+ manager.register_handler(handler)
48
+
49
+ for controller in controllers or ():
50
+ manager.register_controller(controller)
51
+
52
+ manager.boot()
53
+ return manager
@@ -0,0 +1,37 @@
1
+ # Copyright 2026 BleedingXiko
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """SPECTER Core — internal primitives."""
16
+
17
+ from .lifecycle import Service
18
+ from .queue_service import QueueService
19
+ from .process import ManagedProcess, start_process
20
+ from .model import Model, create_model
21
+ from .outcome import Outcome
22
+ from .operation import Operation, OperationError
23
+ from .store import Store, create_store
24
+
25
+ __all__ = [
26
+ 'Service',
27
+ 'QueueService',
28
+ 'ManagedProcess',
29
+ 'start_process',
30
+ 'Model',
31
+ 'create_model',
32
+ 'Outcome',
33
+ 'Operation',
34
+ 'OperationError',
35
+ 'Store',
36
+ 'create_store',
37
+ ]
specter/core/bus.py ADDED
@@ -0,0 +1,163 @@
1
+ # Copyright 2026 BleedingXiko
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ SPECTER EventBus — Internal pub/sub for cross-service communication.
17
+
18
+ Decoupled broadcast semantics so services can react to events without
19
+ importing each other directly.
20
+
21
+ NOTE: This bus is for **internal server-side** events only.
22
+ Socket events go through ``socketio.emit()``.
23
+
24
+ Concurrency model:
25
+ Subscribers run synchronously in the emitter's greenlet.
26
+ If a subscriber needs to do heavy work, it should spawn its own
27
+ greenlet via ``gevent.spawn()``.
28
+ """
29
+
30
+ import logging
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class EventBus:
36
+ """Global event bus for decoupled service communication."""
37
+
38
+ def __init__(self):
39
+ self._events = {} # event_name -> set of callbacks
40
+
41
+ # ------------------------------------------------------------------
42
+ # Subscribe
43
+ # ------------------------------------------------------------------
44
+
45
+ def on(self, event, callback):
46
+ """
47
+ Subscribe to an event.
48
+
49
+ Args:
50
+ event: Event name string.
51
+ callback: Function to call when event is emitted. Receives
52
+ a single ``data`` argument (may be ``None``).
53
+
54
+ Returns:
55
+ An unsubscribe function.
56
+ """
57
+ if not callable(callback):
58
+ raise TypeError(
59
+ f"[SPECTER:bus] on('{event}'): callback must be callable, "
60
+ f"got {type(callback).__name__}"
61
+ )
62
+ if event not in self._events:
63
+ self._events[event] = set()
64
+ self._events[event].add(callback)
65
+ return lambda: self.off(event, callback)
66
+
67
+ def off(self, event, callback):
68
+ """
69
+ Unsubscribe from an event.
70
+
71
+ Args:
72
+ event: Event name
73
+ callback: The exact callback reference passed to ``on()``
74
+ """
75
+ listeners = self._events.get(event)
76
+ if listeners:
77
+ listeners.discard(callback)
78
+ if not listeners:
79
+ del self._events[event]
80
+
81
+ def once(self, event, callback):
82
+ """
83
+ Subscribe to an event exactly once — auto-unsubscribes after
84
+ the first call.
85
+
86
+ Args:
87
+ event: Event name
88
+ callback: Function to call once
89
+
90
+ Returns:
91
+ An unsubscribe function (no-op after first call).
92
+ """
93
+ def wrapper(data=None):
94
+ self.off(event, wrapper)
95
+ callback(data)
96
+
97
+ return self.on(event, wrapper)
98
+
99
+ # ------------------------------------------------------------------
100
+ # Emit
101
+ # ------------------------------------------------------------------
102
+
103
+ def emit(self, event, data=None):
104
+ """
105
+ Emit an event to all subscribers.
106
+
107
+ Subscribers run synchronously in the caller's greenlet. If a
108
+ subscriber raises, the error is logged and remaining subscribers
109
+ still execute.
110
+
111
+ Args:
112
+ event: Event name
113
+ data: Optional payload passed to every listener
114
+ """
115
+ listeners = self._events.get(event)
116
+ if not listeners:
117
+ return
118
+ # Iterate over a snapshot so subscribers can unsub during iteration
119
+ for callback in list(listeners):
120
+ try:
121
+ callback(data)
122
+ except Exception as e:
123
+ logger.error(
124
+ f"[SPECTER:bus] Error in listener for '{event}': {e}",
125
+ exc_info=True,
126
+ )
127
+
128
+ # ------------------------------------------------------------------
129
+ # Housekeeping
130
+ # ------------------------------------------------------------------
131
+
132
+ def clear(self, event=None):
133
+ """
134
+ Clear listeners.
135
+
136
+ Args:
137
+ event: If provided, clear only listeners for this event.
138
+ If ``None``, clear **all** events.
139
+ """
140
+ if event:
141
+ self._events.pop(event, None)
142
+ else:
143
+ self._events.clear()
144
+
145
+ def has_listeners(self, event):
146
+ """Return ``True`` if the event has at least one subscriber."""
147
+ return bool(self._events.get(event))
148
+
149
+ def listener_count(self, event=None):
150
+ """
151
+ Return listener count.
152
+
153
+ Args:
154
+ event: If provided, count for that event only.
155
+ If ``None``, count all listeners across all events.
156
+ """
157
+ if event:
158
+ return len(self._events.get(event, set()))
159
+ return sum(len(s) for s in self._events.values())
160
+
161
+
162
+ # Singleton — the one bus to rule them all.
163
+ bus = EventBus()
specter/core/cache.py ADDED
@@ -0,0 +1,301 @@
1
+ # Copyright 2026 BleedingXiko
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ SPECTER Cache — Shared state with TTL and dependency-based invalidation.
17
+
18
+ Shared state with TTL expiry, gevent-safe locking, and automatic cache
19
+ cascade through the bus.
20
+
21
+ Dependency cascade:
22
+ When a cache with ``depends_on=['storage:mounts']`` is created, it
23
+ auto-subscribes to that bus event. When that event fires, the cache
24
+ invalidates itself and emits ``'{name}:invalidated'`` on the bus —
25
+ which in turn triggers any caches that depend on *it*.
26
+
27
+ Example chain:
28
+ storage:mounts → drives_cache invalidates
29
+ → emits 'drives:invalidated'
30
+ → categories_cache invalidates
31
+ → emits 'categories:invalidated'
32
+ → hidden_files_cache invalidates
33
+
34
+ Concurrency:
35
+ All read/write operations are protected by a ``BoundedSemaphore``
36
+ (gevent-aware). Never use ``threading.Lock``.
37
+ """
38
+
39
+ import time
40
+ import logging
41
+ from gevent.lock import BoundedSemaphore
42
+
43
+ from .bus import bus
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ class Cache:
49
+ """
50
+ Named cache with TTL and dependency-based cascade invalidation.
51
+
52
+ Do not instantiate directly — use :func:`create_cache`.
53
+ """
54
+
55
+ def __init__(self, name, ttl=0, depends_on=None):
56
+ """
57
+ Args:
58
+ name: Unique cache name (used in bus events and logging).
59
+ ttl: Time-to-live in seconds. 0 means no auto-expiry.
60
+ depends_on: List of bus event names that trigger invalidation.
61
+ """
62
+ self.name = name
63
+ self._ttl = max(0.0, float(ttl or 0))
64
+ self._entry_ttl = self._ttl
65
+ self._value = None
66
+ self._updated_at = 0
67
+ self._lock = BoundedSemaphore(1)
68
+ self._invalidate_callbacks = []
69
+ self._bus_unsubs = []
70
+
71
+ # Subscribe to dependency events
72
+ if depends_on:
73
+ for event in depends_on:
74
+ unsub = bus.on(event, self._on_dependency_invalidated)
75
+ self._bus_unsubs.append(unsub)
76
+
77
+ # ------------------------------------------------------------------
78
+ # Read
79
+ # ------------------------------------------------------------------
80
+
81
+ def get(self, default=None):
82
+ """
83
+ Return cached value, or ``default`` if expired/empty.
84
+
85
+ This is a non-blocking read protected by a gevent lock.
86
+ """
87
+ with self._lock:
88
+ if self._value is None:
89
+ return default
90
+ if (
91
+ self._entry_ttl > 0 and
92
+ (time.time() - self._updated_at) >= self._entry_ttl
93
+ ):
94
+ # Expired — clear in place
95
+ self._value = None
96
+ self._updated_at = 0
97
+ self._entry_ttl = self._ttl
98
+ return default
99
+ return self._value
100
+
101
+ def is_valid(self):
102
+ """Return ``True`` if the cache has a non-expired value."""
103
+ with self._lock:
104
+ if self._value is None:
105
+ return False
106
+ if (
107
+ self._entry_ttl > 0 and
108
+ (time.time() - self._updated_at) >= self._entry_ttl
109
+ ):
110
+ return False
111
+ return True
112
+
113
+ @property
114
+ def updated_at(self):
115
+ """Timestamp of the last ``set()`` call (epoch seconds)."""
116
+ return self._updated_at
117
+
118
+ @property
119
+ def ttl(self):
120
+ """Configured TTL in seconds. 0 means no auto-expiry."""
121
+ return self._ttl
122
+
123
+ # ------------------------------------------------------------------
124
+ # Write
125
+ # ------------------------------------------------------------------
126
+
127
+ def set(self, value, ttl=None):
128
+ """
129
+ Set the cached value.
130
+
131
+ Args:
132
+ value: The value to cache.
133
+ ttl: Optional TTL override for this specific write.
134
+ Does not change the cache's default TTL.
135
+ """
136
+ with self._lock:
137
+ self._value = value
138
+ self._updated_at = time.time()
139
+ self._entry_ttl = self._ttl if ttl is None else max(0.0, float(ttl))
140
+
141
+ def get_or_compute(self, factory, ttl=None):
142
+ """
143
+ Return cached value if valid, otherwise compute it using
144
+ ``factory()`` and cache the result.
145
+
146
+ This is atomic — only one greenlet runs the factory at a time.
147
+
148
+ Args:
149
+ factory: Callable that returns the value to cache.
150
+ ttl: Optional TTL override.
151
+
152
+ Returns:
153
+ The cached or freshly computed value.
154
+ """
155
+ # Fast path: check without lock contention
156
+ value = self.get()
157
+ if value is not None:
158
+ return value
159
+
160
+ # Slow path: compute under lock
161
+ with self._lock:
162
+ # Double-check after acquiring lock
163
+ if self._value is not None:
164
+ if (
165
+ self._entry_ttl == 0 or
166
+ (time.time() - self._updated_at) < self._entry_ttl
167
+ ):
168
+ return self._value
169
+
170
+ computed = factory()
171
+ self._value = computed
172
+ self._updated_at = time.time()
173
+ self._entry_ttl = self._ttl if ttl is None else max(0.0, float(ttl))
174
+ return computed
175
+
176
+ # ------------------------------------------------------------------
177
+ # Invalidation
178
+ # ------------------------------------------------------------------
179
+
180
+ def invalidate(self):
181
+ """
182
+ Clear the cache and emit ``'{name}:invalidated'`` on the bus.
183
+
184
+ Any caches that ``depend_on`` this event will cascade-invalidate.
185
+ Also fires registered ``on_invalidate`` callbacks.
186
+ """
187
+ with self._lock:
188
+ was_valid = self._value is not None
189
+ self._value = None
190
+ self._updated_at = 0
191
+ self._entry_ttl = self._ttl
192
+
193
+ if was_valid:
194
+ logger.info(f"[SPECTER:cache] '{self.name}' invalidated")
195
+
196
+ # Fire invalidation callbacks
197
+ for cb in list(self._invalidate_callbacks):
198
+ try:
199
+ cb()
200
+ except Exception as e:
201
+ logger.error(
202
+ f"[SPECTER:cache] Error in invalidation callback "
203
+ f"for '{self.name}': {e}",
204
+ exc_info=True,
205
+ )
206
+
207
+ # Cascade: emit so dependent caches can react
208
+ bus.emit(f'{self.name}:invalidated')
209
+
210
+ def on_invalidate(self, callback):
211
+ """
212
+ Subscribe to invalidation events for this cache.
213
+
214
+ Args:
215
+ callback: Callable (no arguments) invoked when the cache
216
+ is invalidated.
217
+
218
+ Returns:
219
+ An unsubscribe function.
220
+ """
221
+ self._invalidate_callbacks.append(callback)
222
+ return lambda: (
223
+ self._invalidate_callbacks.remove(callback)
224
+ if callback in self._invalidate_callbacks else None
225
+ )
226
+
227
+ # ------------------------------------------------------------------
228
+ # Lifecycle
229
+ # ------------------------------------------------------------------
230
+
231
+ def destroy(self):
232
+ """
233
+ Tear down the cache: unsubscribe from all bus events,
234
+ clear callbacks, and reset state. Called by the framework
235
+ when the owning service stops.
236
+ """
237
+ for unsub in self._bus_unsubs:
238
+ try:
239
+ unsub()
240
+ except Exception:
241
+ pass
242
+ self._bus_unsubs.clear()
243
+ self._invalidate_callbacks.clear()
244
+ with self._lock:
245
+ self._value = None
246
+ self._updated_at = 0
247
+ self._entry_ttl = self._ttl
248
+ logger.debug(f"[SPECTER:cache] '{self.name}' destroyed")
249
+
250
+ # ------------------------------------------------------------------
251
+ # Internal
252
+ # ------------------------------------------------------------------
253
+
254
+ def _on_dependency_invalidated(self, data=None):
255
+ """Bus callback: a dependency was invalidated, so we invalidate too."""
256
+ logger.debug(
257
+ f"[SPECTER:cache] '{self.name}' dependency triggered, invalidating"
258
+ )
259
+ self.invalidate()
260
+
261
+ def __repr__(self):
262
+ valid = self.is_valid()
263
+ return (
264
+ f"<Cache '{self.name}' valid={valid} "
265
+ f"ttl={self._ttl}s>"
266
+ )
267
+
268
+
269
+ def create_cache(name, ttl=0, depends_on=None):
270
+ """
271
+ Factory for creating a named cache.
272
+
273
+ Args:
274
+ name: Unique cache name.
275
+ ttl: Time-to-live in seconds. ``0`` = no auto-expiry.
276
+ depends_on: List of bus event names that trigger invalidation.
277
+ Convention: ``'{cache_name}:invalidated'``
278
+ or domain events like ``'storage:mounts'``.
279
+
280
+ Returns:
281
+ A :class:`Cache` instance.
282
+
283
+ Example::
284
+
285
+ category_cache = create_cache(
286
+ 'categories',
287
+ ttl=86400,
288
+ depends_on=['storage:mounts'],
289
+ )
290
+
291
+ # Set
292
+ category_cache.set(categories_list)
293
+
294
+ # Get (returns None if expired)
295
+ cached = category_cache.get()
296
+
297
+ # Auto-invalidates when 'storage:mounts' fires on the bus
298
+ """
299
+ if depends_on is None:
300
+ depends_on = []
301
+ return Cache(name=name, ttl=ttl, depends_on=depends_on)