store-resource-bootstrap-py 0.1.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.
- store_resource_bootstrap/__init__.py +188 -0
- store_resource_bootstrap/providers/__init__.py +5 -0
- store_resource_bootstrap/providers/minio.py +9 -0
- store_resource_bootstrap/providers/oss.py +9 -0
- store_resource_bootstrap/providers/s3_compat.py +86 -0
- store_resource_bootstrap/schemas.py +61 -0
- store_resource_bootstrap_py-0.1.0.dist-info/METADATA +11 -0
- store_resource_bootstrap_py-0.1.0.dist-info/RECORD +10 -0
- store_resource_bootstrap_py-0.1.0.dist-info/WHEEL +5 -0
- store_resource_bootstrap_py-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""store-resource-bootstrap-py — 资源引导插件包(可选,宿主旁路)。
|
|
2
|
+
|
|
3
|
+
唯一事实源:spec/00-protocol.md。零判决、零语义发明、零宿主依赖。
|
|
4
|
+
两命名空间:capability(能力引导:schemas / create)与 adapter(接缝适配:download / upload)。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import inspect
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
from .schemas import RESOURCE_SCHEMAS
|
|
13
|
+
from . import providers as providers
|
|
14
|
+
|
|
15
|
+
CAPABILITY_FACADES = ("register", "has", "configure_resource")
|
|
16
|
+
ALLOWED_OPTS = ("providers", "url", "sign", "schema", "providerPlugins")
|
|
17
|
+
DEFAULT_SCHEMA_NAMES = {
|
|
18
|
+
"resource": "Resource",
|
|
19
|
+
"location": "ResourceLocation",
|
|
20
|
+
"binding": "ResourceBinding",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _call(fn, *args, **kwargs):
|
|
25
|
+
"""门面调用:sync 直接调;返回 awaitable(宿主支持 async 门面时)则 await。"""
|
|
26
|
+
out = fn(*args, **kwargs)
|
|
27
|
+
if inspect.isawaitable(out):
|
|
28
|
+
out = await out
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _assert_store(store, facades, where):
|
|
33
|
+
if store is None:
|
|
34
|
+
raise ValueError(f"{where}: store 必填")
|
|
35
|
+
for name in facades:
|
|
36
|
+
if not callable(getattr(store, name, None)):
|
|
37
|
+
raise ValueError(f"{where}: store 缺少门面方法: {name}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _assert_known_keys(opts, where):
|
|
41
|
+
for key in opts:
|
|
42
|
+
if key not in ALLOWED_OPTS:
|
|
43
|
+
raise ValueError(f"{where}: 未知配置键: {key}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---- capability:能力引导 ----
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def schemas():
|
|
50
|
+
# 深拷贝:调用方改写不得影响内部定义
|
|
51
|
+
return copy.deepcopy(RESOURCE_SCHEMAS)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Handle:
|
|
55
|
+
def __init__(self, store, opts):
|
|
56
|
+
self._store = store
|
|
57
|
+
self._opts = opts
|
|
58
|
+
|
|
59
|
+
async def start(self):
|
|
60
|
+
registered = []
|
|
61
|
+
skipped = []
|
|
62
|
+
provider_registered = []
|
|
63
|
+
for spec in self._opts["providerPlugins"]:
|
|
64
|
+
# 宿主 py 端 register_provider 校验 hasattr(mod, "create"),须传具属性对象
|
|
65
|
+
self._store.register_provider(spec["kind"], SimpleNamespace(create=spec["create"]))
|
|
66
|
+
provider_registered.append(spec["kind"])
|
|
67
|
+
for defn in schemas():
|
|
68
|
+
if self._store.has(defn["name"]): # has 为同步门面
|
|
69
|
+
skipped.append(defn["name"])
|
|
70
|
+
else:
|
|
71
|
+
self._store.register(defn) # register 为同步门面,幂等
|
|
72
|
+
registered.append(defn["name"])
|
|
73
|
+
await _call(self._store.configure_resource, {
|
|
74
|
+
"providers": self._opts["providers"],
|
|
75
|
+
"url": self._opts["url"],
|
|
76
|
+
"sign": self._opts["sign"],
|
|
77
|
+
"schema": self._opts["schema"],
|
|
78
|
+
}) # configure_resource 为同步实现,_call 兼容
|
|
79
|
+
return {"registered": registered, "skipped": skipped, "providerRegistered": provider_registered}
|
|
80
|
+
|
|
81
|
+
async def reload(self):
|
|
82
|
+
# 宿主 configure 整体替换 _pool,故 reload ≡ start(无需先清除)
|
|
83
|
+
return await self.start()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create(store, opts=None):
|
|
87
|
+
_assert_store(store, CAPABILITY_FACADES, "capability.create")
|
|
88
|
+
opts = {} if opts is None else opts
|
|
89
|
+
if not isinstance(opts, dict):
|
|
90
|
+
raise ValueError("capability.create: opts 须为 dict")
|
|
91
|
+
_assert_known_keys(opts, "capability.create")
|
|
92
|
+
|
|
93
|
+
providers = opts.get("providers")
|
|
94
|
+
if not isinstance(providers, list) or len(providers) == 0:
|
|
95
|
+
raise ValueError("capability.create: providers 必填且须为非空数组")
|
|
96
|
+
for i, spec in enumerate(providers):
|
|
97
|
+
if not isinstance(spec, dict):
|
|
98
|
+
raise ValueError(f"capability.create: providers[{i}] 须为对象")
|
|
99
|
+
kind = spec.get("kind")
|
|
100
|
+
if not isinstance(kind, str) or kind == "":
|
|
101
|
+
raise ValueError(f"capability.create: providers[{i}].kind 须为非空字符串")
|
|
102
|
+
options = spec.get("options")
|
|
103
|
+
if options is not None and not isinstance(options, dict):
|
|
104
|
+
raise ValueError(f"capability.create: providers[{i}].options 须为对象")
|
|
105
|
+
priority = spec.get("priority")
|
|
106
|
+
if priority is not None and not isinstance(priority, int):
|
|
107
|
+
raise ValueError(f"capability.create: providers[{i}].priority 须为整数")
|
|
108
|
+
|
|
109
|
+
url = opts.get("url", {})
|
|
110
|
+
if not isinstance(url, dict):
|
|
111
|
+
raise ValueError("capability.create: url 须为对象")
|
|
112
|
+
sign = opts.get("sign", None)
|
|
113
|
+
if sign is not None and not callable(sign):
|
|
114
|
+
raise ValueError("capability.create: sign 须为函数或 None")
|
|
115
|
+
schema = opts.get("schema", dict(DEFAULT_SCHEMA_NAMES))
|
|
116
|
+
if not isinstance(schema, dict):
|
|
117
|
+
raise ValueError("capability.create: schema 须为对象")
|
|
118
|
+
provider_plugins = opts.get("providerPlugins", [])
|
|
119
|
+
if not isinstance(provider_plugins, list):
|
|
120
|
+
raise ValueError("capability.create: providerPlugins 须为数组")
|
|
121
|
+
for i, spec in enumerate(provider_plugins):
|
|
122
|
+
if not isinstance(spec, dict):
|
|
123
|
+
raise ValueError(f"capability.create: providerPlugins[{i}] 须为对象")
|
|
124
|
+
kind = spec.get("kind")
|
|
125
|
+
if not isinstance(kind, str) or kind == "":
|
|
126
|
+
raise ValueError(f"capability.create: providerPlugins[{i}].kind 须为非空字符串")
|
|
127
|
+
if not callable(spec.get("create")):
|
|
128
|
+
raise ValueError(f"capability.create: providerPlugins[{i}].create 须为函数")
|
|
129
|
+
# 条件化门面校验:仅当有 provider 需要注册时才要求宿主门面(向后兼容旧 store)
|
|
130
|
+
if len(provider_plugins) > 0:
|
|
131
|
+
_assert_store(store, ("register_provider",), "capability.create")
|
|
132
|
+
|
|
133
|
+
return _Handle(store, {
|
|
134
|
+
"providers": providers, "url": url, "sign": sign, "schema": schema,
|
|
135
|
+
"providerPlugins": provider_plugins,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---- adapter:接缝适配 ----
|
|
140
|
+
|
|
141
|
+
_RESOURCE_PROJECTION = " { _id, fileName, mime }" # 投影语法同 store-api/node/src/plugin.js L109
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def download(store, opts=None):
|
|
145
|
+
_assert_store(store, ("resource_open", "query_one"), "adapter.download")
|
|
146
|
+
opts = {} if opts is None else opts
|
|
147
|
+
if not isinstance(opts, dict):
|
|
148
|
+
raise ValueError("adapter.download: opts 须为 dict")
|
|
149
|
+
field = opts.get("field", "file")
|
|
150
|
+
if not isinstance(field, str) or field == "":
|
|
151
|
+
raise ValueError("adapter.download: field 须为非空字符串")
|
|
152
|
+
order = opts.get("order")
|
|
153
|
+
|
|
154
|
+
# 返回 store-api 既有 file_resolver 契约:(request, rec, rid) -> {body, contentType, fileName}
|
|
155
|
+
async def file_resolver(request, rec, rid):
|
|
156
|
+
ref = None if rec is None else rec.get(field)
|
|
157
|
+
if ref is None or ref == "":
|
|
158
|
+
raise ValueError(f"记录缺少资源引用字段: {field}")
|
|
159
|
+
if order is None:
|
|
160
|
+
opened = await _call(store.resource_open, ref)
|
|
161
|
+
else:
|
|
162
|
+
opened = await _call(store.resource_open, ref, order=order)
|
|
163
|
+
meta = await _call(
|
|
164
|
+
store.query_one,
|
|
165
|
+
f"Resource($condition: @c0){_RESOURCE_PROJECTION}",
|
|
166
|
+
{"c0": {"_id": ref}},
|
|
167
|
+
)
|
|
168
|
+
return {
|
|
169
|
+
"body": opened["bytes"],
|
|
170
|
+
"contentType": (meta or {}).get("mime") or "application/octet-stream",
|
|
171
|
+
"fileName": (meta or {}).get("fileName") or str(ref),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return file_resolver
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def upload(store):
|
|
178
|
+
_assert_store(store, ("resource_put",), "adapter.upload")
|
|
179
|
+
|
|
180
|
+
# 薄透传:不改写入参、不改写出参
|
|
181
|
+
async def upload_(input):
|
|
182
|
+
return await _call(store.resource_put, **input)
|
|
183
|
+
|
|
184
|
+
return upload_
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
capability = SimpleNamespace(schemas=schemas, create=create)
|
|
188
|
+
adapter = SimpleNamespace(download=download, upload=upload)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""OSS provider 预设:仅 virtual-hosted;boto3 须用 V2 签名。"""
|
|
2
|
+
from . import s3_compat
|
|
3
|
+
|
|
4
|
+
PRESET = {"kind": "oss", "forcePathStyle": False, "signatureVersion": "s3"}
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create(options=None, sdk=None):
|
|
8
|
+
merged = {**PRESET, **(options or {}), "kind": "oss"}
|
|
9
|
+
return s3_compat.create(merged, sdk)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""S3 兼容 provider 参考实现(OSS / MinIO / AWS 共用);SDK 懒加载。"""
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def create(options=None, sdk=None):
|
|
6
|
+
options = options or {}
|
|
7
|
+
if sdk is None:
|
|
8
|
+
import boto3 # 懒加载:未安装 SDK 时仅本模块 create() 报错
|
|
9
|
+
from botocore.config import Config
|
|
10
|
+
sdk = {"client": boto3.client, "config": Config}
|
|
11
|
+
|
|
12
|
+
bucket = options.get("bucket")
|
|
13
|
+
if not bucket:
|
|
14
|
+
raise ValueError("s3-compat provider 需要 options.bucket")
|
|
15
|
+
|
|
16
|
+
client = options.get("client")
|
|
17
|
+
if client is None:
|
|
18
|
+
endpoint = options.get("endpoint")
|
|
19
|
+
force_path = options.get("forcePathStyle")
|
|
20
|
+
if force_path is None:
|
|
21
|
+
force_path = bool(endpoint)
|
|
22
|
+
addressing = "path" if force_path else "virtual"
|
|
23
|
+
creds = options.get("credentials") or {}
|
|
24
|
+
client = sdk["client"](
|
|
25
|
+
"s3",
|
|
26
|
+
region_name=options.get("region") or "us-east-1",
|
|
27
|
+
endpoint_url=endpoint,
|
|
28
|
+
aws_access_key_id=creds.get("accessKeyId"),
|
|
29
|
+
aws_secret_access_key=creds.get("secretAccessKey"),
|
|
30
|
+
config=_config(sdk["config"], addressing, options.get("signatureVersion")),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
prefix = options.get("prefix")
|
|
34
|
+
|
|
35
|
+
def key_of(key):
|
|
36
|
+
if prefix:
|
|
37
|
+
return f"{str(prefix).rstrip('/')}/{key}"
|
|
38
|
+
return key
|
|
39
|
+
|
|
40
|
+
def _put(key, data, opts):
|
|
41
|
+
client.put_object(
|
|
42
|
+
Bucket=bucket,
|
|
43
|
+
Key=key_of(key),
|
|
44
|
+
Body=data,
|
|
45
|
+
ContentType=(opts or {}).get("mime") or "application/octet-stream",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def _get(key):
|
|
49
|
+
r = client.get_object(Bucket=bucket, Key=key_of(key))
|
|
50
|
+
return r["Body"].read()
|
|
51
|
+
|
|
52
|
+
def _remove(key):
|
|
53
|
+
client.delete_object(Bucket=bucket, Key=key_of(key))
|
|
54
|
+
|
|
55
|
+
def _exists(key):
|
|
56
|
+
try:
|
|
57
|
+
client.head_object(Bucket=bucket, Key=key_of(key))
|
|
58
|
+
return True
|
|
59
|
+
except Exception as e:
|
|
60
|
+
response = getattr(e, "response", {}) or {}
|
|
61
|
+
status = (response.get("ResponseMetadata") or {}).get("HTTPStatusCode")
|
|
62
|
+
err_code = (response.get("Error") or {}).get("Code")
|
|
63
|
+
if status == 404 or err_code in ("404", "NoSuchKey", "NotFound"):
|
|
64
|
+
return False
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
async def put(key, data, opts=None):
|
|
68
|
+
await asyncio.to_thread(_put, key, data, opts)
|
|
69
|
+
|
|
70
|
+
async def get(key, opts=None):
|
|
71
|
+
return await asyncio.to_thread(_get, key)
|
|
72
|
+
|
|
73
|
+
async def remove(key, opts=None):
|
|
74
|
+
await asyncio.to_thread(_remove, key)
|
|
75
|
+
|
|
76
|
+
async def exists(key, opts=None):
|
|
77
|
+
return await asyncio.to_thread(_exists, key)
|
|
78
|
+
|
|
79
|
+
return {"kind": options.get("kind", "s3"), "put": put, "get": get, "remove": remove, "exists": exists}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _config(config_cls, addressing, signature_version=None):
|
|
83
|
+
kwargs = {"s3": {"addressing_style": addressing}}
|
|
84
|
+
if signature_version:
|
|
85
|
+
kwargs["signature_version"] = signature_version
|
|
86
|
+
return config_cls(**kwargs)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""三 schema 定义(唯一事实源:spec/00-protocol.md 附录 A)。
|
|
2
|
+
|
|
3
|
+
逐字复制 01 执行文档 §4.8 的 JSON 数组;conformance A9 校验本常量与源契约深比较全等。
|
|
4
|
+
注意:Resource.idPrefix 必须为空串 ""(falsy)——宿主仅当真值才生成 _id,
|
|
5
|
+
否则会为内容寻址资源生成随机 _id、破坏 resourceId = sha1 约定。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
RESOURCE_SCHEMAS: list[dict] = [
|
|
10
|
+
{
|
|
11
|
+
"name": "Resource", "collection": "resources", "idPrefix": "", "timestamps": True,
|
|
12
|
+
"read": [], "write": [],
|
|
13
|
+
"fields": {
|
|
14
|
+
"_id": {"type": "string"},
|
|
15
|
+
"sha1": {"type": "string"},
|
|
16
|
+
"fileName": {"type": "string"},
|
|
17
|
+
"mime": {"type": "string"},
|
|
18
|
+
"size": {"type": "int"},
|
|
19
|
+
"kind": {"type": "string"},
|
|
20
|
+
},
|
|
21
|
+
"relations": {},
|
|
22
|
+
"indexes": [
|
|
23
|
+
{"keys": {"sha1": 1}, "options": {"unique": True}},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "ResourceLocation", "collection": "resource_locations", "idPrefix": "rl", "timestamps": True,
|
|
28
|
+
"read": [], "write": [],
|
|
29
|
+
"fields": {
|
|
30
|
+
"_id": {"type": "string"},
|
|
31
|
+
"resourceId": {"type": "string"},
|
|
32
|
+
"backend": {"type": "string"},
|
|
33
|
+
"key": {"type": "string"},
|
|
34
|
+
"status": {"type": "string"},
|
|
35
|
+
"priority": {"type": "int"},
|
|
36
|
+
},
|
|
37
|
+
"relations": {
|
|
38
|
+
"resource": {"model": "Resource", "type": "one", "localField": "resourceId", "foreignField": "_id", "read": []},
|
|
39
|
+
},
|
|
40
|
+
"indexes": [
|
|
41
|
+
{"keys": {"resourceId": 1, "backend": 1}, "options": {"unique": True}},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "ResourceBinding", "collection": "resource_bindings", "idPrefix": "rb", "timestamps": True,
|
|
46
|
+
"read": [], "write": [],
|
|
47
|
+
"fields": {
|
|
48
|
+
"_id": {"type": "string"},
|
|
49
|
+
"resourceId": {"type": "string"},
|
|
50
|
+
"businessTable": {"type": "string"},
|
|
51
|
+
"businessId": {"type": "string"},
|
|
52
|
+
"userId": {"type": "string"},
|
|
53
|
+
},
|
|
54
|
+
"relations": {
|
|
55
|
+
"resource": {"model": "Resource", "type": "one", "localField": "resourceId", "foreignField": "_id", "read": []},
|
|
56
|
+
},
|
|
57
|
+
"indexes": [
|
|
58
|
+
{"keys": {"resourceId": 1, "businessTable": 1, "businessId": 1}, "options": {"unique": True}},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-resource-bootstrap-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Optional resource bootstrap plugin for common-store: registers the three resource schemas, wires the host resource capability (providers / url / sign) and produces the store-api fileResolver seam, for py-store hosts
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,resource,bootstrap,provider,skin
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
10
|
+
Provides-Extra: providers
|
|
11
|
+
Requires-Dist: boto3>=1.34; extra == "providers"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
store_resource_bootstrap/__init__.py,sha256=Xhc1u5WToXHXEU21rISNY6AmUBYY64NEio63UzrmQe0,7514
|
|
2
|
+
store_resource_bootstrap/schemas.py,sha256=QuhfdfDkgUrYxHfgx_pvXOhSz45BHKR4SpOa2p_4hcE,2460
|
|
3
|
+
store_resource_bootstrap/providers/__init__.py,sha256=9hUpwZMTULfG2CDduHN50kArL3ASbQ7fEpBi6SpjUj8,149
|
|
4
|
+
store_resource_bootstrap/providers/minio.py,sha256=1kKvAQUuexUp39lKXRi4cpBki--aGdsw368O4mgLd_U,275
|
|
5
|
+
store_resource_bootstrap/providers/oss.py,sha256=Ywfoyd05gaq-F-03AmWFNMhp3WrIj-g2t2laf7t-xGo,313
|
|
6
|
+
store_resource_bootstrap/providers/s3_compat.py,sha256=l2VX2zdsvdWCYZtERKsIv-2N01nKAwnR0Ki32kiYvy4,2948
|
|
7
|
+
store_resource_bootstrap_py-0.1.0.dist-info/METADATA,sha256=VHLYClf4px0qUeTR2deTrd2G5QR_4iNh0TpF8h0GXN4,521
|
|
8
|
+
store_resource_bootstrap_py-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
store_resource_bootstrap_py-0.1.0.dist-info/top_level.txt,sha256=LcA0l-l6nvweadQKwUJPU4juk8an7HmOzLmZ-ildW9s,25
|
|
10
|
+
store_resource_bootstrap_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
store_resource_bootstrap
|