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.
- specstar/__init__.py +68 -0
- specstar/backend.py +646 -0
- specstar/cli/__init__.py +0 -0
- specstar/cli/__main__.py +12 -0
- specstar/cli/build.py +408 -0
- specstar/cli/config.py +65 -0
- specstar/crud/__init__.py +0 -0
- specstar/crud/async_job_builder.py +302 -0
- specstar/crud/async_jobs.py +505 -0
- specstar/crud/core.py +2913 -0
- specstar/crud/custom_actions.py +44 -0
- specstar/crud/openapi_builder.py +915 -0
- specstar/crud/qb_parser.py +366 -0
- specstar/crud/ref_manager.py +107 -0
- specstar/crud/route_templates/__init__.py +0 -0
- specstar/crud/route_templates/backup.py +254 -0
- specstar/crud/route_templates/basic.py +131 -0
- specstar/crud/route_templates/blob.py +302 -0
- specstar/crud/route_templates/create.py +77 -0
- specstar/crud/route_templates/delete.py +325 -0
- specstar/crud/route_templates/dependency_provider.py +94 -0
- specstar/crud/route_templates/exception_handlers.py +107 -0
- specstar/crud/route_templates/get.py +764 -0
- specstar/crud/route_templates/graphql.py +746 -0
- specstar/crud/route_templates/job_logs.py +59 -0
- specstar/crud/route_templates/migrate.py +479 -0
- specstar/crud/route_templates/patch.py +153 -0
- specstar/crud/route_templates/query_inputs.py +398 -0
- specstar/crud/route_templates/rerun.py +82 -0
- specstar/crud/route_templates/responses.py +138 -0
- specstar/crud/route_templates/search.py +514 -0
- specstar/crud/route_templates/switch.py +82 -0
- specstar/crud/route_templates/update.py +108 -0
- specstar/errors.py +35 -0
- specstar/events.py +1266 -0
- specstar/events.pyi +812 -0
- specstar/message_queue/__init__.py +30 -0
- specstar/message_queue/basic.py +462 -0
- specstar/message_queue/celery_queue.py +492 -0
- specstar/message_queue/context.py +160 -0
- specstar/message_queue/heartbeat.py +84 -0
- specstar/message_queue/log_flush.py +84 -0
- specstar/message_queue/rabbitmq.py +642 -0
- specstar/message_queue/simple.py +414 -0
- specstar/permission/__init__.py +42 -0
- specstar/permission/acl.py +224 -0
- specstar/permission/action.py +74 -0
- specstar/permission/checker.py +49 -0
- specstar/permission/composite.py +71 -0
- specstar/permission/data_based.py +69 -0
- specstar/permission/meta_based.py +53 -0
- specstar/permission/rbac.py +238 -0
- specstar/permission/simple.py +27 -0
- specstar/permission/store_backed.py +110 -0
- specstar/query.py +1421 -0
- specstar/query_types.py +226 -0
- specstar/resource_manager/__init__.py +51 -0
- specstar/resource_manager/basic.py +1471 -0
- specstar/resource_manager/binary_processor.py +392 -0
- specstar/resource_manager/blob_store/__init__.py +0 -0
- specstar/resource_manager/blob_store/s3.py +596 -0
- specstar/resource_manager/blob_store/simple.py +745 -0
- specstar/resource_manager/constraint_handler.py +381 -0
- specstar/resource_manager/constraint_lifecycle.py +75 -0
- specstar/resource_manager/core.py +2741 -0
- specstar/resource_manager/dump_format.py +140 -0
- specstar/resource_manager/meta_store/__init__.py +0 -0
- specstar/resource_manager/meta_store/df.py +151 -0
- specstar/resource_manager/meta_store/fast_slow.py +91 -0
- specstar/resource_manager/meta_store/postgres.py +733 -0
- specstar/resource_manager/meta_store/redis.py +97 -0
- specstar/resource_manager/meta_store/simple.py +115 -0
- specstar/resource_manager/meta_store/sqlalchemy.py +1139 -0
- specstar/resource_manager/meta_store/sqlite3.py +963 -0
- specstar/resource_manager/partial.py +370 -0
- specstar/resource_manager/permission.py +0 -0
- specstar/resource_manager/pydantic_converter.py +674 -0
- specstar/resource_manager/resource_store/__init__.py +27 -0
- specstar/resource_manager/resource_store/cache.py +247 -0
- specstar/resource_manager/resource_store/cached_s3.py +127 -0
- specstar/resource_manager/resource_store/etag_cached_s3.py +282 -0
- specstar/resource_manager/resource_store/mq_cached_s3.py +263 -0
- specstar/resource_manager/resource_store/postgres.py +390 -0
- specstar/resource_manager/resource_store/s3.py +412 -0
- specstar/resource_manager/resource_store/simple.py +281 -0
- specstar/resource_manager/storage_factory.py +505 -0
- specstar/resource_manager/unique_handler.py +136 -0
- specstar/schema.py +602 -0
- specstar/types.py +2134 -0
- specstar/util/__init__.py +0 -0
- specstar/util/datetime_utils.py +36 -0
- specstar/util/naming.py +88 -0
- specstar/util/type_utils.py +763 -0
- specstar-0.10.0.dist-info/METADATA +424 -0
- specstar-0.10.0.dist-info/RECORD +97 -0
- specstar-0.10.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|