specstar 0.10.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 (97) hide show
  1. specstar/__init__.py +68 -0
  2. specstar/backend.py +646 -0
  3. specstar/cli/__init__.py +0 -0
  4. specstar/cli/__main__.py +12 -0
  5. specstar/cli/build.py +408 -0
  6. specstar/cli/config.py +65 -0
  7. specstar/crud/__init__.py +0 -0
  8. specstar/crud/async_job_builder.py +302 -0
  9. specstar/crud/async_jobs.py +505 -0
  10. specstar/crud/core.py +2913 -0
  11. specstar/crud/custom_actions.py +44 -0
  12. specstar/crud/openapi_builder.py +915 -0
  13. specstar/crud/qb_parser.py +366 -0
  14. specstar/crud/ref_manager.py +107 -0
  15. specstar/crud/route_templates/__init__.py +0 -0
  16. specstar/crud/route_templates/backup.py +254 -0
  17. specstar/crud/route_templates/basic.py +131 -0
  18. specstar/crud/route_templates/blob.py +302 -0
  19. specstar/crud/route_templates/create.py +77 -0
  20. specstar/crud/route_templates/delete.py +325 -0
  21. specstar/crud/route_templates/dependency_provider.py +94 -0
  22. specstar/crud/route_templates/exception_handlers.py +107 -0
  23. specstar/crud/route_templates/get.py +764 -0
  24. specstar/crud/route_templates/graphql.py +746 -0
  25. specstar/crud/route_templates/job_logs.py +59 -0
  26. specstar/crud/route_templates/migrate.py +479 -0
  27. specstar/crud/route_templates/patch.py +153 -0
  28. specstar/crud/route_templates/query_inputs.py +398 -0
  29. specstar/crud/route_templates/rerun.py +82 -0
  30. specstar/crud/route_templates/responses.py +138 -0
  31. specstar/crud/route_templates/search.py +514 -0
  32. specstar/crud/route_templates/switch.py +82 -0
  33. specstar/crud/route_templates/update.py +108 -0
  34. specstar/errors.py +35 -0
  35. specstar/events.py +1266 -0
  36. specstar/events.pyi +812 -0
  37. specstar/message_queue/__init__.py +30 -0
  38. specstar/message_queue/basic.py +462 -0
  39. specstar/message_queue/celery_queue.py +492 -0
  40. specstar/message_queue/context.py +160 -0
  41. specstar/message_queue/heartbeat.py +84 -0
  42. specstar/message_queue/log_flush.py +84 -0
  43. specstar/message_queue/rabbitmq.py +642 -0
  44. specstar/message_queue/simple.py +414 -0
  45. specstar/permission/__init__.py +42 -0
  46. specstar/permission/acl.py +224 -0
  47. specstar/permission/action.py +74 -0
  48. specstar/permission/checker.py +49 -0
  49. specstar/permission/composite.py +71 -0
  50. specstar/permission/data_based.py +69 -0
  51. specstar/permission/meta_based.py +53 -0
  52. specstar/permission/rbac.py +238 -0
  53. specstar/permission/simple.py +27 -0
  54. specstar/permission/store_backed.py +110 -0
  55. specstar/query.py +1421 -0
  56. specstar/query_types.py +226 -0
  57. specstar/resource_manager/__init__.py +51 -0
  58. specstar/resource_manager/basic.py +1471 -0
  59. specstar/resource_manager/binary_processor.py +392 -0
  60. specstar/resource_manager/blob_store/__init__.py +0 -0
  61. specstar/resource_manager/blob_store/s3.py +596 -0
  62. specstar/resource_manager/blob_store/simple.py +745 -0
  63. specstar/resource_manager/constraint_handler.py +381 -0
  64. specstar/resource_manager/constraint_lifecycle.py +75 -0
  65. specstar/resource_manager/core.py +2741 -0
  66. specstar/resource_manager/dump_format.py +140 -0
  67. specstar/resource_manager/meta_store/__init__.py +0 -0
  68. specstar/resource_manager/meta_store/df.py +151 -0
  69. specstar/resource_manager/meta_store/fast_slow.py +91 -0
  70. specstar/resource_manager/meta_store/postgres.py +733 -0
  71. specstar/resource_manager/meta_store/redis.py +97 -0
  72. specstar/resource_manager/meta_store/simple.py +115 -0
  73. specstar/resource_manager/meta_store/sqlalchemy.py +1139 -0
  74. specstar/resource_manager/meta_store/sqlite3.py +963 -0
  75. specstar/resource_manager/partial.py +370 -0
  76. specstar/resource_manager/permission.py +0 -0
  77. specstar/resource_manager/pydantic_converter.py +674 -0
  78. specstar/resource_manager/resource_store/__init__.py +27 -0
  79. specstar/resource_manager/resource_store/cache.py +247 -0
  80. specstar/resource_manager/resource_store/cached_s3.py +127 -0
  81. specstar/resource_manager/resource_store/etag_cached_s3.py +282 -0
  82. specstar/resource_manager/resource_store/mq_cached_s3.py +263 -0
  83. specstar/resource_manager/resource_store/postgres.py +390 -0
  84. specstar/resource_manager/resource_store/s3.py +412 -0
  85. specstar/resource_manager/resource_store/simple.py +281 -0
  86. specstar/resource_manager/storage_factory.py +505 -0
  87. specstar/resource_manager/unique_handler.py +136 -0
  88. specstar/schema.py +602 -0
  89. specstar/types.py +2134 -0
  90. specstar/util/__init__.py +0 -0
  91. specstar/util/datetime_utils.py +36 -0
  92. specstar/util/naming.py +88 -0
  93. specstar/util/type_utils.py +763 -0
  94. specstar-0.10.0.dist-info/METADATA +424 -0
  95. specstar-0.10.0.dist-info/RECORD +97 -0
  96. specstar-0.10.0.dist-info/WHEEL +4 -0
  97. specstar-0.10.0.dist-info/licenses/LICENSE +21 -0
specstar/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ from specstar.backend import (
2
+ BackendBinding,
3
+ BackendConfig,
4
+ BackendDefaults,
5
+ ConnectionProfile,
6
+ register_backend_provider,
7
+ )
8
+ from specstar.crud.core import LoadStats, SpecStar
9
+ from specstar.query import QB
10
+ from specstar.resource_manager.pydantic_converter import (
11
+ pydantic_to_struct,
12
+ struct_to_pydantic,
13
+ )
14
+ from specstar.schema import Schema
15
+ from specstar.types import (
16
+ BackgroundTaskAccepted,
17
+ BlobUploadSession,
18
+ DisplayName,
19
+ IConstraintChecker,
20
+ IValidator,
21
+ Job,
22
+ JobRedirectInfo,
23
+ OnDelete,
24
+ OnDuplicate,
25
+ Ref,
26
+ RefRevision,
27
+ RefType,
28
+ SearchedResource,
29
+ TaskStatus,
30
+ Unique,
31
+ )
32
+
33
+ # Global instance for simplified usage pattern
34
+ # Users can import and use this directly: from specstar import spec
35
+ # Configure it at application startup via spec.configure(backend=...) or
36
+ # the legacy split parameters during the transition window.
37
+ spec = SpecStar()
38
+
39
+ __all__ = [
40
+ "BackendBinding",
41
+ "BackendConfig",
42
+ "BackendDefaults",
43
+ "BackgroundTaskAccepted",
44
+ "BlobUploadSession",
45
+ "ConnectionProfile",
46
+ "DisplayName",
47
+ "IConstraintChecker",
48
+ "IValidator",
49
+ "Job",
50
+ "JobRedirectInfo",
51
+ "LoadStats",
52
+ "OnDelete",
53
+ "OnDuplicate",
54
+ "QB",
55
+ "Ref",
56
+ "RefRevision",
57
+ "RefType",
58
+ "Schema",
59
+ "SearchedResource",
60
+ "SpecStar",
61
+ "TaskStatus",
62
+ "Unique",
63
+ "spec",
64
+ "register_backend_provider",
65
+ "pydantic_to_struct",
66
+ "struct_to_pydantic",
67
+ ]
68
+ __version__ = "0.10.0"
specstar/backend.py ADDED
@@ -0,0 +1,646 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from abc import ABC
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Literal, Mapping
9
+
10
+ import msgspec
11
+ from msgspec import Struct, field
12
+
13
+ from specstar.message_queue.rabbitmq import RabbitMQMessageQueueFactory
14
+ from specstar.message_queue.simple import SimpleMessageQueueFactory
15
+ from specstar.resource_manager.basic import (
16
+ Encoding,
17
+ IBlobStore,
18
+ IMetaStore,
19
+ IResourceStore,
20
+ )
21
+ from specstar.resource_manager.blob_store.s3 import S3BlobStore
22
+ from specstar.resource_manager.blob_store.simple import DiskBlobStore, MemoryBlobStore
23
+ from specstar.resource_manager.core import SimpleStorage
24
+ from specstar.resource_manager.meta_store.postgres import PostgresMetaStore
25
+ from specstar.resource_manager.meta_store.simple import DiskMetaStore, MemoryMetaStore
26
+ from specstar.resource_manager.meta_store.sqlite3 import S3SqliteMetaStore
27
+ from specstar.resource_manager.resource_store.postgres import PostgresResourceStore
28
+ from specstar.resource_manager.resource_store.s3 import S3ResourceStore
29
+ from specstar.resource_manager.resource_store.simple import (
30
+ DiskResourceStore,
31
+ MemoryResourceStore,
32
+ )
33
+ from specstar.resource_manager.storage_factory import (
34
+ DiskStorageFactory,
35
+ IStorageFactory,
36
+ MemoryStorageFactory,
37
+ )
38
+ from specstar.types import IMessageQueueFactory
39
+ from specstar.util.naming import NameConverter
40
+
41
+ BackendRole = Literal["meta", "resource", "blob", "mq"]
42
+
43
+
44
+ class BackendDefaults(Struct, kw_only=True, omit_defaults=True):
45
+ """Shared defaults applied across unified backend configuration.
46
+
47
+ These values provide the common baseline for backend providers created
48
+ through ``BackendConfig``. Individual connection profiles or role bindings
49
+ may still supply provider-specific options to override the shared defaults
50
+ when needed.
51
+ """
52
+
53
+ encoding: Encoding = Encoding.json
54
+ table_prefix: str = ""
55
+ blob_prefix: str = "blobs/"
56
+ upload_method: Literal["proxy", "single_put"] = "proxy"
57
+ presigned_url_expiry: int = 3600
58
+
59
+
60
+ class ConnectionProfile(Struct, kw_only=True, omit_defaults=True):
61
+ """Reusable named backend connection.
62
+
63
+ A connection profile defines a backend ``type`` plus its provider-specific
64
+ options once, then lets multiple backend roles reuse that definition by
65
+ referring to it from ``BackendBinding(use=...)``.
66
+ """
67
+
68
+ type: str
69
+ options: dict[str, Any] = field(default_factory=dict)
70
+ enabled: bool = True
71
+ tags: tuple[str, ...] = ()
72
+
73
+
74
+ class BackendBinding(Struct, kw_only=True, omit_defaults=True):
75
+ """Map one backend role to either a named connection or an inline provider.
76
+
77
+ Bindings are used for the four SpecStar backend concerns: metadata,
78
+ structured resource payloads, blob storage, and the message queue.
79
+ """
80
+
81
+ use: str | None = None
82
+ type: str | None = None
83
+ options: dict[str, Any] = field(default_factory=dict)
84
+ required: bool = True
85
+
86
+
87
+ class BackendConfig(Struct, kw_only=True, omit_defaults=True):
88
+ """Schema-first unified backend configuration for SpecStar.
89
+
90
+ This higher-level API lets you configure metadata, resource, blob, and
91
+ message-queue backends together through one typed object, a plain mapping,
92
+ or a JSON file.
93
+ """
94
+
95
+ version: int = 1
96
+ defaults: BackendDefaults = field(default_factory=BackendDefaults)
97
+ connections: dict[str, ConnectionProfile] = field(default_factory=dict)
98
+ meta: BackendBinding = field(default_factory=lambda: BackendBinding(type="memory"))
99
+ resource: BackendBinding = field(
100
+ default_factory=lambda: BackendBinding(type="memory")
101
+ )
102
+ blob: BackendBinding = field(default_factory=lambda: BackendBinding(type="memory"))
103
+ mq: BackendBinding | None = None
104
+
105
+ @classmethod
106
+ def from_json_file(cls, path: str | Path) -> "BackendConfig":
107
+ raw = json.loads(Path(path).read_text())
108
+ return msgspec.convert(_expand_env(raw), type=cls)
109
+
110
+ @classmethod
111
+ def from_value(
112
+ cls, value: "BackendConfig | Mapping[str, Any] | str | Path"
113
+ ) -> "BackendConfig":
114
+ if isinstance(value, cls):
115
+ return value
116
+ if isinstance(value, (str, Path)):
117
+ return cls.from_json_file(value)
118
+ if isinstance(value, Mapping):
119
+ return msgspec.convert(_expand_env(dict(value)), type=cls)
120
+ raise TypeError(f"Unsupported backend config value: {type(value)!r}")
121
+
122
+
123
+ class BackendProvider(ABC):
124
+ """Pluggable provider for unified backend configuration."""
125
+
126
+ type: str = ""
127
+ capabilities: frozenset[BackendRole] = frozenset()
128
+
129
+ def validate(
130
+ self,
131
+ *,
132
+ role: BackendRole,
133
+ options: dict[str, Any],
134
+ defaults: BackendDefaults,
135
+ ) -> None:
136
+ """Validate provider-specific options before runtime build."""
137
+
138
+ def build_meta(
139
+ self,
140
+ *,
141
+ model_name: str,
142
+ options: dict[str, Any],
143
+ defaults: BackendDefaults,
144
+ ) -> IMetaStore:
145
+ raise NotImplementedError(f"Provider {self.type!r} does not support meta")
146
+
147
+ def build_resource(
148
+ self,
149
+ *,
150
+ model_name: str,
151
+ options: dict[str, Any],
152
+ defaults: BackendDefaults,
153
+ ) -> IResourceStore:
154
+ raise NotImplementedError(f"Provider {self.type!r} does not support resource")
155
+
156
+ def build_blob(
157
+ self,
158
+ *,
159
+ options: dict[str, Any],
160
+ defaults: BackendDefaults,
161
+ ) -> IBlobStore:
162
+ raise NotImplementedError(f"Provider {self.type!r} does not support blob")
163
+
164
+ def build_mq_factory(
165
+ self,
166
+ *,
167
+ options: dict[str, Any],
168
+ defaults: BackendDefaults,
169
+ ) -> IMessageQueueFactory:
170
+ raise NotImplementedError(f"Provider {self.type!r} does not support mq")
171
+
172
+
173
+ class BackendRegistry:
174
+ """Registry for built-in and custom backend providers."""
175
+
176
+ def __init__(self):
177
+ self._providers: dict[str, BackendProvider] = {}
178
+
179
+ def register(self, provider: BackendProvider) -> None:
180
+ if not provider.type:
181
+ raise ValueError("backend provider type cannot be empty")
182
+ self._providers[provider.type] = provider
183
+
184
+ def get(self, type_name: str) -> BackendProvider:
185
+ try:
186
+ return self._providers[type_name]
187
+ except KeyError as exc:
188
+ raise ValueError(f"unknown backend type: {type_name}") from exc
189
+
190
+
191
+ @dataclass(slots=True)
192
+ class BackendBundle:
193
+ config: BackendConfig | None
194
+ storage_factory: IStorageFactory
195
+ blob_store: IBlobStore
196
+ message_queue_factory: IMessageQueueFactory | None
197
+
198
+
199
+ class BackendStorageFactory(IStorageFactory):
200
+ """Storage factory built from explicit meta/resource backend bindings."""
201
+
202
+ def __init__(self, config: BackendConfig, registry: BackendRegistry):
203
+ self.config = config
204
+ self.registry = registry
205
+
206
+ def build(self, model_name: str):
207
+ meta_store = _resolve_store_role(
208
+ config=self.config,
209
+ registry=self.registry,
210
+ role="meta",
211
+ model_name=model_name,
212
+ )
213
+ resource_store = _resolve_store_role(
214
+ config=self.config,
215
+ registry=self.registry,
216
+ role="resource",
217
+ model_name=model_name,
218
+ )
219
+ return SimpleStorage(meta_store, resource_store)
220
+
221
+
222
+ class MemoryBackendProvider(BackendProvider):
223
+ type = "memory"
224
+ capabilities = frozenset({"meta", "resource", "blob"})
225
+
226
+ def build_meta(
227
+ self, *, model_name: str, options: dict[str, Any], defaults: BackendDefaults
228
+ ) -> IMetaStore:
229
+ return MemoryMetaStore(encoding=defaults.encoding)
230
+
231
+ def build_resource(
232
+ self, *, model_name: str, options: dict[str, Any], defaults: BackendDefaults
233
+ ) -> IResourceStore:
234
+ return MemoryResourceStore(encoding=defaults.encoding)
235
+
236
+ def build_blob(
237
+ self, *, options: dict[str, Any], defaults: BackendDefaults
238
+ ) -> IBlobStore:
239
+ return MemoryBlobStore()
240
+
241
+
242
+ class DiskBackendProvider(BackendProvider):
243
+ type = "disk"
244
+ capabilities = frozenset({"meta", "resource", "blob"})
245
+
246
+ def validate(
247
+ self,
248
+ *,
249
+ role: BackendRole,
250
+ options: dict[str, Any],
251
+ defaults: BackendDefaults,
252
+ ) -> None:
253
+ self._rootdir(options)
254
+
255
+ def _rootdir(self, options: dict[str, Any]) -> Path:
256
+ rootdir = options.get("rootdir")
257
+ if not rootdir:
258
+ raise ValueError("disk backend requires options.rootdir")
259
+ return Path(rootdir)
260
+
261
+ def build_meta(
262
+ self, *, model_name: str, options: dict[str, Any], defaults: BackendDefaults
263
+ ) -> IMetaStore:
264
+ rootdir = self._rootdir(options)
265
+ return DiskMetaStore(
266
+ rootdir=rootdir / model_name / "meta", encoding=defaults.encoding
267
+ )
268
+
269
+ def build_resource(
270
+ self, *, model_name: str, options: dict[str, Any], defaults: BackendDefaults
271
+ ) -> IResourceStore:
272
+ rootdir = self._rootdir(options)
273
+ return DiskResourceStore(
274
+ rootdir=rootdir / model_name / "data", encoding=defaults.encoding
275
+ )
276
+
277
+ def build_blob(
278
+ self, *, options: dict[str, Any], defaults: BackendDefaults
279
+ ) -> IBlobStore:
280
+ rootdir = self._rootdir(options)
281
+ return DiskBlobStore(rootdir / "_blobs")
282
+
283
+
284
+ class PostgresBackendProvider(BackendProvider):
285
+ type = "postgres"
286
+ capabilities = frozenset({"meta", "resource"})
287
+
288
+ def validate(
289
+ self,
290
+ *,
291
+ role: BackendRole,
292
+ options: dict[str, Any],
293
+ defaults: BackendDefaults,
294
+ ) -> None:
295
+ self._dsn(options)
296
+
297
+ def _dsn(self, options: dict[str, Any]) -> str:
298
+ dsn = options.get("dsn") or options.get("connection_string")
299
+ if not dsn:
300
+ raise ValueError("postgres backend requires options.dsn")
301
+ return str(dsn)
302
+
303
+ def build_meta(
304
+ self, *, model_name: str, options: dict[str, Any], defaults: BackendDefaults
305
+ ) -> IMetaStore:
306
+ safe_name = NameConverter(model_name).to("snake")
307
+ table_prefix = options.get("table_prefix", defaults.table_prefix)
308
+ table_name = (
309
+ f"{table_prefix}{safe_name}_meta" if table_prefix else f"{safe_name}_meta"
310
+ )
311
+ return PostgresMetaStore(
312
+ pg_dsn=self._dsn(options),
313
+ encoding=options.get("encoding", defaults.encoding),
314
+ table_name=table_name,
315
+ )
316
+
317
+ def build_resource(
318
+ self, *, model_name: str, options: dict[str, Any], defaults: BackendDefaults
319
+ ) -> IResourceStore:
320
+ safe_name = NameConverter(model_name).to("snake")
321
+ table_prefix = options.get("table_prefix", defaults.table_prefix)
322
+ resource_prefix = (
323
+ f"{table_prefix}{safe_name}_" if table_prefix else f"{safe_name}_"
324
+ )
325
+ return PostgresResourceStore(
326
+ pg_dsn=self._dsn(options),
327
+ encoding=options.get("encoding", defaults.encoding),
328
+ table_prefix=resource_prefix,
329
+ )
330
+
331
+
332
+ class S3BackendProvider(BackendProvider):
333
+ type = "s3"
334
+ capabilities = frozenset({"meta", "resource", "blob"})
335
+
336
+ def validate(
337
+ self,
338
+ *,
339
+ role: BackendRole,
340
+ options: dict[str, Any],
341
+ defaults: BackendDefaults,
342
+ ) -> None:
343
+ self._bucket(options)
344
+
345
+ def _bucket(self, options: dict[str, Any]) -> str:
346
+ bucket = options.get("bucket")
347
+ if not bucket:
348
+ raise ValueError("s3 backend requires options.bucket")
349
+ return str(bucket)
350
+
351
+ def _client_kwargs(self, options: dict[str, Any]) -> dict[str, Any]:
352
+ return dict(
353
+ options.get("client_kwargs") or options.get("s3_client_kwargs") or {}
354
+ )
355
+
356
+ def build_meta(
357
+ self, *, model_name: str, options: dict[str, Any], defaults: BackendDefaults
358
+ ) -> IMetaStore:
359
+ prefix = options.get("prefix", "")
360
+ model_prefix = f"{prefix}{model_name}/"
361
+ return S3SqliteMetaStore(
362
+ bucket=self._bucket(options),
363
+ key=f"{model_prefix}meta.db",
364
+ access_key_id=options.get(
365
+ "access_key_id", options.get("s3_access_key_id", "minioadmin")
366
+ ),
367
+ secret_access_key=options.get(
368
+ "secret_access_key", options.get("s3_secret_access_key", "minioadmin")
369
+ ),
370
+ region_name=options.get(
371
+ "region_name", options.get("s3_region", "us-east-1")
372
+ ),
373
+ endpoint_url=options.get("endpoint_url", options.get("s3_endpoint_url")),
374
+ encoding=options.get("encoding", defaults.encoding),
375
+ auto_sync=options.get("auto_sync", True),
376
+ sync_interval=options.get("sync_interval", 0),
377
+ enable_locking=options.get("enable_locking", True),
378
+ auto_reload_on_conflict=options.get("auto_reload_on_conflict", False),
379
+ check_etag_on_read=options.get("check_etag_on_read", True),
380
+ )
381
+
382
+ def build_resource(
383
+ self, *, model_name: str, options: dict[str, Any], defaults: BackendDefaults
384
+ ) -> IResourceStore:
385
+ prefix = options.get("prefix", "")
386
+ model_prefix = f"{prefix}{model_name}/"
387
+ return S3ResourceStore(
388
+ bucket=self._bucket(options),
389
+ access_key_id=options.get(
390
+ "access_key_id", options.get("s3_access_key_id", "minioadmin")
391
+ ),
392
+ secret_access_key=options.get(
393
+ "secret_access_key", options.get("s3_secret_access_key", "minioadmin")
394
+ ),
395
+ region_name=options.get(
396
+ "region_name", options.get("s3_region", "us-east-1")
397
+ ),
398
+ endpoint_url=options.get("endpoint_url", options.get("s3_endpoint_url")),
399
+ prefix=model_prefix,
400
+ encoding=options.get("encoding", defaults.encoding),
401
+ client_kwargs=self._client_kwargs(options),
402
+ )
403
+
404
+ def build_blob(
405
+ self, *, options: dict[str, Any], defaults: BackendDefaults
406
+ ) -> IBlobStore:
407
+ prefix = options.get("blob_prefix", defaults.blob_prefix)
408
+ return S3BlobStore(
409
+ bucket=options.get("blob_bucket", self._bucket(options)),
410
+ access_key_id=options.get(
411
+ "access_key_id", options.get("s3_access_key_id", "minioadmin")
412
+ ),
413
+ secret_access_key=options.get(
414
+ "secret_access_key", options.get("s3_secret_access_key", "minioadmin")
415
+ ),
416
+ region_name=options.get(
417
+ "region_name", options.get("s3_region", "us-east-1")
418
+ ),
419
+ endpoint_url=options.get("endpoint_url", options.get("s3_endpoint_url")),
420
+ prefix=prefix,
421
+ upload_method=options.get("upload_method", defaults.upload_method),
422
+ presigned_url_expiry=options.get(
423
+ "presigned_url_expiry", defaults.presigned_url_expiry
424
+ ),
425
+ client_kwargs=self._client_kwargs(options),
426
+ )
427
+
428
+
429
+ class SimpleMQBackendProvider(BackendProvider):
430
+ type = "simple"
431
+ capabilities = frozenset({"mq"})
432
+
433
+ def build_mq_factory(
434
+ self, *, options: dict[str, Any], defaults: BackendDefaults
435
+ ) -> IMessageQueueFactory:
436
+ return SimpleMessageQueueFactory(max_retries=options.get("max_retries", 3))
437
+
438
+
439
+ class RabbitMQBackendProvider(BackendProvider):
440
+ type = "rabbitmq"
441
+ capabilities = frozenset({"mq"})
442
+
443
+ def build_mq_factory(
444
+ self, *, options: dict[str, Any], defaults: BackendDefaults
445
+ ) -> IMessageQueueFactory:
446
+ return RabbitMQMessageQueueFactory(
447
+ amqp_url=options.get("amqp_url", "amqp://guest:guest@localhost:5672/"),
448
+ queue_prefix=options.get("queue_prefix", "specstar:"),
449
+ max_retries=options.get("max_retries", 3),
450
+ retry_delay_seconds=options.get("retry_delay_seconds", 10),
451
+ amqp_heartbeat_seconds=options.get("amqp_heartbeat_seconds", 600),
452
+ ) # ty:ignore[invalid-return-type]
453
+
454
+
455
+ _registry = BackendRegistry()
456
+ for _provider in (
457
+ MemoryBackendProvider(),
458
+ DiskBackendProvider(),
459
+ PostgresBackendProvider(),
460
+ S3BackendProvider(),
461
+ SimpleMQBackendProvider(),
462
+ RabbitMQBackendProvider(),
463
+ ):
464
+ _registry.register(_provider)
465
+
466
+
467
+ def get_backend_registry() -> BackendRegistry:
468
+ return _registry
469
+
470
+
471
+ def register_backend_provider(provider: BackendProvider) -> None:
472
+ """Register a custom backend provider for use in unified backend config."""
473
+
474
+ _registry.register(provider)
475
+
476
+
477
+ def build_backend_bundle(
478
+ backend: BackendConfig | Mapping[str, Any] | str | Path | None = None,
479
+ *,
480
+ storage_factory: IStorageFactory | None = None,
481
+ message_queue_factory: IMessageQueueFactory | None = None,
482
+ ) -> BackendBundle:
483
+ """Resolve unified backend config or legacy values into runtime objects."""
484
+
485
+ if backend is not None:
486
+ config = BackendConfig.from_value(backend)
487
+ registry = get_backend_registry()
488
+ _validate_backend_config(config, registry)
489
+ resolved_storage_factory = BackendStorageFactory(
490
+ config=config, registry=registry
491
+ )
492
+ resolved_blob_store = _resolve_blob_store(config, registry)
493
+ resolved_mq_factory = _resolve_mq_factory(config, registry)
494
+ return BackendBundle(
495
+ config=config,
496
+ storage_factory=resolved_storage_factory,
497
+ blob_store=resolved_blob_store,
498
+ message_queue_factory=resolved_mq_factory,
499
+ )
500
+
501
+ resolved_storage_factory = storage_factory or MemoryStorageFactory()
502
+ if isinstance(resolved_storage_factory, DiskStorageFactory):
503
+ resolved_blob_store = DiskBlobStore(resolved_storage_factory.rootdir / "_blobs")
504
+ else:
505
+ resolved_blob_store = (
506
+ resolved_storage_factory.build_blob_store() or MemoryBlobStore()
507
+ )
508
+
509
+ return BackendBundle(
510
+ config=None,
511
+ storage_factory=resolved_storage_factory,
512
+ blob_store=resolved_blob_store,
513
+ message_queue_factory=message_queue_factory,
514
+ )
515
+
516
+
517
+ def _validate_backend_config(config: BackendConfig, registry: BackendRegistry) -> None:
518
+ for role in ("meta", "resource", "blob"):
519
+ binding = getattr(config, role)
520
+ provider, options = _resolve_provider_and_options(
521
+ config=config,
522
+ registry=registry,
523
+ role=role,
524
+ binding=binding,
525
+ )
526
+ provider.validate(role=role, options=options, defaults=config.defaults)
527
+
528
+ if config.mq is not None:
529
+ provider, options = _resolve_provider_and_options(
530
+ config=config,
531
+ registry=registry,
532
+ role="mq",
533
+ binding=config.mq,
534
+ )
535
+ provider.validate(role="mq", options=options, defaults=config.defaults)
536
+
537
+
538
+ def _resolve_blob_store(config: BackendConfig, registry: BackendRegistry) -> IBlobStore:
539
+ binding = config.blob
540
+ provider, options = _resolve_provider_and_options(
541
+ config=config,
542
+ registry=registry,
543
+ role="blob",
544
+ binding=binding,
545
+ )
546
+ provider.validate(role="blob", options=options, defaults=config.defaults)
547
+ return provider.build_blob(options=options, defaults=config.defaults)
548
+
549
+
550
+ def _resolve_mq_factory(
551
+ config: BackendConfig,
552
+ registry: BackendRegistry,
553
+ ) -> IMessageQueueFactory | None:
554
+ if config.mq is None:
555
+ return None
556
+ provider, options = _resolve_provider_and_options(
557
+ config=config,
558
+ registry=registry,
559
+ role="mq",
560
+ binding=config.mq,
561
+ )
562
+ provider.validate(role="mq", options=options, defaults=config.defaults)
563
+ return provider.build_mq_factory(options=options, defaults=config.defaults)
564
+
565
+
566
+ def _resolve_store_role(
567
+ *,
568
+ config: BackendConfig,
569
+ registry: BackendRegistry,
570
+ role: Literal["meta", "resource"],
571
+ model_name: str,
572
+ ):
573
+ binding = getattr(config, role)
574
+ provider, options = _resolve_provider_and_options(
575
+ config=config,
576
+ registry=registry,
577
+ role=role,
578
+ binding=binding,
579
+ )
580
+ provider.validate(role=role, options=options, defaults=config.defaults)
581
+ if role == "meta":
582
+ return provider.build_meta(
583
+ model_name=model_name,
584
+ options=options,
585
+ defaults=config.defaults,
586
+ )
587
+ return provider.build_resource(
588
+ model_name=model_name,
589
+ options=options,
590
+ defaults=config.defaults,
591
+ )
592
+
593
+
594
+ def _resolve_provider_and_options(
595
+ *,
596
+ config: BackendConfig,
597
+ registry: BackendRegistry,
598
+ role: BackendRole,
599
+ binding: BackendBinding,
600
+ ) -> tuple[BackendProvider, dict[str, Any]]:
601
+ if binding.use:
602
+ try:
603
+ connection = config.connections[binding.use]
604
+ except KeyError as exc:
605
+ raise ValueError(f"unknown connection: {binding.use}") from exc
606
+ if not connection.enabled:
607
+ if binding.required:
608
+ raise ValueError(f"connection is disabled: {binding.use}")
609
+ raise ValueError(f"connection is disabled: {binding.use}")
610
+ type_name = connection.type
611
+ options = {**connection.options, **binding.options}
612
+ elif binding.type:
613
+ type_name = binding.type
614
+ options = dict(binding.options)
615
+ else:
616
+ raise ValueError(f"backend binding for role {role} must define use or type")
617
+
618
+ provider = registry.get(type_name)
619
+ if role not in provider.capabilities:
620
+ raise ValueError(f"backend type {type_name!r} does not support role {role!r}")
621
+ return provider, options
622
+
623
+
624
+ def _expand_env(value: Any) -> Any:
625
+ if isinstance(value, str):
626
+ return os.path.expandvars(value)
627
+ if isinstance(value, list):
628
+ return [_expand_env(item) for item in value]
629
+ if isinstance(value, dict):
630
+ return {key: _expand_env(item) for key, item in value.items()}
631
+ return value
632
+
633
+
634
+ __all__ = [
635
+ "BackendBinding",
636
+ "BackendBundle",
637
+ "BackendConfig",
638
+ "BackendDefaults",
639
+ "BackendProvider",
640
+ "BackendRegistry",
641
+ "BackendStorageFactory",
642
+ "ConnectionProfile",
643
+ "build_backend_bundle",
644
+ "get_backend_registry",
645
+ "register_backend_provider",
646
+ ]