store-resource-bootstrap-py 0.1.0__tar.gz

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.
@@ -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,22 @@
1
+ [project]
2
+ name = "store-resource-bootstrap-py"
3
+ version = "0.1.0"
4
+ description = "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
+ requires-python = ">=3.10"
6
+ license = { text = "MIT" }
7
+ keywords = ["py-store", "resource", "bootstrap", "provider", "skin"]
8
+ dependencies = []
9
+
10
+ [project.optional-dependencies]
11
+ dev = ["pytest>=8"]
12
+ providers = ["boto3>=1.34"]
13
+
14
+ [build-system]
15
+ requires = ["setuptools>=68"]
16
+ build-backend = "setuptools.build_meta"
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ """可选 provider 参考模块命名空间(oss / minio)。"""
2
+ from . import minio as minio
3
+ from . import oss as oss
4
+
5
+ __all__ = ["oss", "minio"]
@@ -0,0 +1,9 @@
1
+ """MinIO provider 预设:自建网关须 path-style。"""
2
+ from . import s3_compat
3
+
4
+ PRESET = {"kind": "minio", "forcePathStyle": True}
5
+
6
+
7
+ def create(options=None, sdk=None):
8
+ merged = {**PRESET, **(options or {}), "kind": "minio"}
9
+ return s3_compat.create(merged, sdk)
@@ -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,16 @@
1
+ pyproject.toml
2
+ src/store_resource_bootstrap/__init__.py
3
+ src/store_resource_bootstrap/schemas.py
4
+ src/store_resource_bootstrap/providers/__init__.py
5
+ src/store_resource_bootstrap/providers/minio.py
6
+ src/store_resource_bootstrap/providers/oss.py
7
+ src/store_resource_bootstrap/providers/s3_compat.py
8
+ src/store_resource_bootstrap_py.egg-info/PKG-INFO
9
+ src/store_resource_bootstrap_py.egg-info/SOURCES.txt
10
+ src/store_resource_bootstrap_py.egg-info/dependency_links.txt
11
+ src/store_resource_bootstrap_py.egg-info/requires.txt
12
+ src/store_resource_bootstrap_py.egg-info/top_level.txt
13
+ tests/test_adapter.py
14
+ tests/test_capability.py
15
+ tests/test_conformance.py
16
+ tests/test_providers.py
@@ -0,0 +1,6 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+
5
+ [providers]
6
+ boto3>=1.34
@@ -0,0 +1,108 @@
1
+ """adapter 用例(D1–D9,与 02 node/test/adapter.test.js 逐场景对拍)。"""
2
+ import asyncio
3
+
4
+ import pytest
5
+
6
+ from store_resource_bootstrap import adapter
7
+ from mock_store import MockStore
8
+
9
+ PROJECTION = "Resource($condition: @c0) { _id, fileName, mime }"
10
+
11
+
12
+ def run(coro):
13
+ return asyncio.run(coro)
14
+
15
+
16
+ def _store():
17
+ return MockStore()
18
+
19
+
20
+ def test_D1_download_normal():
21
+ store = _store()
22
+ resolver = adapter.download(store)
23
+ out = run(resolver({"headers": {}}, {"file": "ref1"}, "id1"))
24
+ assert out == {"body": b"hello", "contentType": "text/plain", "fileName": "a.txt"}
25
+ assert ("resource_open", "ref1", {}) in store.calls
26
+ assert ("query_one", PROJECTION, {"c0": {"_id": "ref1"}}) in store.calls
27
+
28
+
29
+ def test_D2_custom_field():
30
+ store = _store()
31
+ resolver = adapter.download(store, {"field": "coverId"})
32
+ run(resolver({"headers": {}}, {"coverId": "ref2"}, "id"))
33
+ assert ("resource_open", "ref2", {}) in store.calls
34
+
35
+
36
+ def test_D3_order_passthrough_as_list():
37
+ store = _store()
38
+ resolver = adapter.download(store, {"order": ["local"]})
39
+ run(resolver({"headers": {}}, {"file": "ref1"}, "id"))
40
+ assert ("resource_open", "ref1", {"order": ["local"]}) in store.calls
41
+
42
+
43
+ def test_D4_missing_ref_field():
44
+ store = _store()
45
+ resolver = adapter.download(store)
46
+ with pytest.raises(ValueError, match="file"):
47
+ run(resolver({"headers": {}}, {}, "id"))
48
+ assert not any(c[0] == "resource_open" for c in store.calls)
49
+
50
+
51
+ def test_D5_empty_ref():
52
+ store = _store()
53
+ resolver = adapter.download(store)
54
+ with pytest.raises(ValueError):
55
+ run(resolver({"headers": {}}, {"file": ""}, "id"))
56
+
57
+
58
+ def test_D6_mime_fallback():
59
+ store = _store()
60
+
61
+ async def query_one_mime_none(gql, params):
62
+ store.calls.append(("query_one", gql, params))
63
+ return {"_id": params["c0"]["_id"], "fileName": "a.txt", "mime": None}
64
+
65
+ store.query_one = query_one_mime_none
66
+ resolver = adapter.download(store)
67
+ out = run(resolver({"headers": {}}, {"file": "ref1"}, "id"))
68
+ assert out["contentType"] == "application/octet-stream"
69
+
70
+
71
+ def test_D7_filename_fallback():
72
+ store = _store()
73
+
74
+ async def query_one_no_name(gql, params):
75
+ store.calls.append(("query_one", gql, params))
76
+ return {"_id": params["c0"]["_id"], "mime": "text/plain"}
77
+
78
+ store.query_one = query_one_no_name
79
+ resolver = adapter.download(store)
80
+ out = run(resolver({"headers": {}}, {"file": "ref9"}, "id"))
81
+ assert out["fileName"] == "ref9"
82
+
83
+
84
+ def test_D8_construction_errors():
85
+ with pytest.raises(ValueError):
86
+ adapter.download(MockStore(), {"field": ""})
87
+ no_query = _store()
88
+ no_query.query_one = None
89
+ with pytest.raises(ValueError):
90
+ adapter.download(no_query)
91
+ no_open = _store()
92
+ no_open.resource_open = None
93
+ with pytest.raises(ValueError):
94
+ adapter.download(no_open)
95
+
96
+
97
+ def test_D9_upload_passthrough():
98
+ store = _store()
99
+ upload_ = adapter.upload(store)
100
+ payload = {"sha1": "s1", "size": 3}
101
+ out = run(upload_(payload))
102
+ assert ("resource_put", payload) in store.calls
103
+ assert out == {"resourceId": "sha1", "sha1": "sha1", "locations": []}
104
+
105
+ broken = _store()
106
+ broken.resource_put = None
107
+ with pytest.raises(ValueError):
108
+ adapter.upload(broken)
@@ -0,0 +1,188 @@
1
+ """capability 用例(C1–C13,与 02 node/test/capability.test.js 逐场景对拍)。"""
2
+ import asyncio
3
+
4
+ import pytest
5
+
6
+ from store_resource_bootstrap import capability
7
+ from mock_store import MockStore
8
+
9
+ NAMES = ["Resource", "ResourceLocation", "ResourceBinding"]
10
+ DEFAULT_SCHEMA_MAP = {"resource": "Resource", "location": "ResourceLocation", "binding": "ResourceBinding"}
11
+
12
+
13
+ def run(coro):
14
+ return asyncio.run(coro)
15
+
16
+
17
+ def _store():
18
+ return MockStore()
19
+
20
+
21
+ def _ok_opts(**over):
22
+ opts = {"providers": [{"kind": "local"}]}
23
+ opts.update(over)
24
+ return opts
25
+
26
+
27
+ # ---- C1 / C2 / C3:schemas ----
28
+
29
+
30
+ def test_C1_schemas_count_and_names():
31
+ defs = capability.schemas()
32
+ assert len(defs) == 3
33
+ assert [d["name"] for d in defs] == NAMES
34
+
35
+
36
+ def test_C2_schemas_key_fields():
37
+ r, rl, rb = capability.schemas()
38
+ assert r["idPrefix"] == ""
39
+ assert r["collection"] == "resources"
40
+ assert r["indexes"] == [{"keys": {"sha1": 1}, "options": {"unique": True}}]
41
+ assert rl["idPrefix"] == "rl"
42
+ assert rl["indexes"] == [{"keys": {"resourceId": 1, "backend": 1}, "options": {"unique": True}}]
43
+ assert rb["idPrefix"] == "rb"
44
+ assert rb["indexes"] == [
45
+ {"keys": {"resourceId": 1, "businessTable": 1, "businessId": 1}, "options": {"unique": True}}
46
+ ]
47
+
48
+
49
+ def test_C3_schemas_deep_copy():
50
+ first = capability.schemas()
51
+ first[0]["fields"]["_id"]["type"] = "int"
52
+ assert capability.schemas()[0]["fields"]["_id"]["type"] == "string"
53
+
54
+
55
+ # ---- C4–C8:create 构造期校验 ----
56
+
57
+
58
+ def test_C4_store_missing_facade():
59
+ with pytest.raises(ValueError, match="register"):
60
+ capability.create({}, _ok_opts())
61
+
62
+
63
+ def test_C5_providers_missing_or_empty():
64
+ store = _store()
65
+ with pytest.raises(ValueError, match="providers"):
66
+ capability.create(store)
67
+ with pytest.raises(ValueError, match="providers"):
68
+ capability.create(store, {"providers": []})
69
+
70
+
71
+ def test_C6_provider_item_invalid():
72
+ store = _store()
73
+ with pytest.raises(ValueError):
74
+ capability.create(store, {"providers": [{"kind": ""}]})
75
+ with pytest.raises(ValueError):
76
+ capability.create(store, {"providers": [{"kind": "local", "priority": 1.5}]})
77
+ with pytest.raises(ValueError):
78
+ capability.create(store, {"providers": [{"kind": "local", "options": 1}]})
79
+
80
+
81
+ def test_C7_unknown_opt_key():
82
+ with pytest.raises(ValueError, match="foo"):
83
+ capability.create(_store(), _ok_opts(foo=1))
84
+
85
+
86
+ def test_C8_url_and_sign_invalid():
87
+ store = _store()
88
+ with pytest.raises(ValueError):
89
+ capability.create(store, _ok_opts(url=1))
90
+ with pytest.raises(ValueError):
91
+ capability.create(store, _ok_opts(sign="x"))
92
+
93
+
94
+ # ---- C9–C13:start / reload ----
95
+
96
+
97
+ def test_C9_start_first_call():
98
+ store = _store()
99
+ handle = capability.create(store, _ok_opts())
100
+ out = run(handle.start())
101
+ assert [c[0] for c in store.calls] == ["register", "register", "register", "configure_resource"]
102
+ assert [c[1] for c in store.calls[:3]] == NAMES
103
+ assert out == {"registered": NAMES, "skipped": [], "providerRegistered": []}
104
+
105
+
106
+ def test_C10_configure_resource_payload():
107
+ store = _store()
108
+ handle = capability.create(store, _ok_opts())
109
+ run(handle.start())
110
+ cfg = store.calls[-1][1]
111
+ assert cfg["providers"] == [{"kind": "local"}]
112
+ assert cfg["url"] == {}
113
+ assert cfg["sign"] is None
114
+ assert cfg["schema"] == DEFAULT_SCHEMA_MAP
115
+
116
+
117
+ def test_C11_start_idempotent():
118
+ store = _store()
119
+ handle = capability.create(store, _ok_opts())
120
+ run(handle.start())
121
+ out2 = run(handle.start())
122
+ assert out2 == {"registered": [], "skipped": NAMES, "providerRegistered": []}
123
+ assert len([c for c in store.calls if c[0] == "register"]) == 3
124
+ assert len([c for c in store.calls if c[0] == "configure_resource"]) == 2
125
+
126
+
127
+ def test_C12_reload_equals_start():
128
+ store = _store()
129
+ handle = capability.create(store, _ok_opts())
130
+ run(handle.start())
131
+ run(handle.reload())
132
+ assert len([c for c in store.calls if c[0] == "register"]) == 3
133
+ assert len([c for c in store.calls if c[0] == "configure_resource"]) == 2
134
+
135
+
136
+ def test_C13_provider_kind_not_prechecked():
137
+ store = _store()
138
+ handle = capability.create(store, {"providers": [{"kind": "bogus"}]})
139
+ assert handle is not None
140
+
141
+
142
+ # ---- C14–C16:providerPlugins 条件化门面 ----
143
+
144
+
145
+ class _NoFacade:
146
+ """仅具 register / has / configure_resource,用于验证条件化门面。"""
147
+
148
+ def __init__(self):
149
+ self.calls = []
150
+
151
+ def has(self, name):
152
+ return False
153
+
154
+ def register(self, defn):
155
+ self.calls.append(("register", defn["name"]))
156
+
157
+ def configure_resource(self, cfg):
158
+ self.calls.append(("configure_resource", cfg))
159
+
160
+
161
+ def test_C14_provider_plugins_precede_register():
162
+ store = _store()
163
+ fn = lambda o=None: {"kind": "oss", "o": o}
164
+ handle = capability.create(store, {
165
+ "providers": [{"kind": "local"}],
166
+ "providerPlugins": [{"kind": "oss", "create": fn}],
167
+ })
168
+ out = run(handle.start())
169
+ assert [c[0] for c in store.calls] == [
170
+ "register_provider", "register", "register", "register", "configure_resource"
171
+ ]
172
+ assert store.calls[0][1] == "oss"
173
+ assert store.calls[0][2].create is fn
174
+ assert out["providerRegistered"] == ["oss"]
175
+
176
+
177
+ def test_C15_provider_plugins_missing_facade():
178
+ with pytest.raises(ValueError, match="register_provider"):
179
+ capability.create(_NoFacade(), {
180
+ "providers": [{"kind": "local"}],
181
+ "providerPlugins": [{"kind": "oss", "create": lambda o=None: {}}],
182
+ })
183
+
184
+
185
+ def test_C16_no_facade_required_when_absent_or_empty():
186
+ store = _NoFacade()
187
+ capability.create(store, {"providers": [{"kind": "local"}]}) # 未传
188
+ capability.create(store, {"providers": [{"kind": "local"}], "providerPlugins": []}) # 空数组
@@ -0,0 +1,163 @@
1
+ """conformance 用例(加载与 node 同一份 conformance/cases.json;期望值不复制)。"""
2
+ import asyncio
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from store_resource_bootstrap import capability, adapter
9
+ from mock_store import MockStore
10
+
11
+ CASES = json.loads((Path(__file__).parents[2] / "conformance" / "cases.json").read_text(encoding="utf-8"))
12
+
13
+ # 双端门面名映射(node camelCase → py snake_case)
14
+ _FACADE_MAP = dict(zip(CASES["facades"]["node"], CASES["facades"]["py"]))
15
+
16
+
17
+ def run(coro):
18
+ return asyncio.run(coro)
19
+
20
+
21
+ def tr(call):
22
+ """[facade, *args] → (py_facade, *args)"""
23
+ return tuple([_FACADE_MAP[call[0]], *call[1:]])
24
+
25
+
26
+ def test_conformance_schemas_canonical_equal():
27
+ got = capability.schemas()
28
+ assert got == CASES["schemas"]["canonical"]
29
+ assert [d["name"] for d in got] == CASES["schemas"]["names"]
30
+ for d in got:
31
+ assert d["collection"] == CASES["schemas"]["collections"][d["name"]]
32
+ assert d["idPrefix"] == CASES["schemas"]["id_prefixes"][d["name"]]
33
+
34
+
35
+ def test_conformance_schemas_deepcopy():
36
+ a = capability.schemas()
37
+ a[0]["fields"]["_id"]["type"] = "number"
38
+ assert capability.schemas()[0]["fields"]["_id"]["type"] == "string"
39
+
40
+
41
+ @pytest.mark.parametrize("key", ["fresh", "idempotent", "defaults"])
42
+ def test_conformance_capability_sequences(key):
43
+ entry = CASES["capability"][key]
44
+ store = MockStore(entry["seed"])
45
+ handle = capability.create(store, entry["opts"])
46
+ out = run(handle.start())
47
+ assert store.calls == [tr(c) for c in entry["expected_calls"]]
48
+ assert out == entry["expected_return"]
49
+
50
+
51
+ def test_conformance_capability_provider_plugins():
52
+ entry = CASES["capability"]["provider_plugins"]
53
+ store = MockStore(entry["seed"])
54
+ specs = entry["opts"]["providerPlugins"]
55
+ creates = [(lambda options=None, i=i: {"kind": "stub", "i": i}) for i in range(len(specs))]
56
+ opts = dict(entry["opts"])
57
+ opts["providerPlugins"] = [{"kind": s["kind"], "create": creates[i]} for i, s in enumerate(specs)]
58
+
59
+ out = run(capability.create(store, opts).start())
60
+
61
+ reg_calls = [c for c in store.calls if c[0] == "register_provider"]
62
+ assert out["providerRegistered"] == entry["expected_registered_kinds"]
63
+ assert len(reg_calls) == len(entry["expected_registered_kinds"])
64
+ for i, c in enumerate(reg_calls):
65
+ assert c[1] == entry["expected_registered_kinds"][i]
66
+ assert c[2].create is creates[i]
67
+
68
+ names = [c[0] for c in store.calls]
69
+ assert names.index("register_provider") < names.index("configure_resource")
70
+ assert store.calls[-len(entry["expected_tail_calls"]):] == [tr(c) for c in entry["expected_tail_calls"]]
71
+ for k, v in entry["expected_return_rest"].items():
72
+ assert out[k] == v
73
+
74
+
75
+ def test_conformance_capability_invalid():
76
+ for c in CASES["capability"]["invalid"]:
77
+ store = MockStore()
78
+ with pytest.raises(ValueError) as ei:
79
+ capability.create(store, c["opts"])
80
+ assert c["error_contains"] in str(ei.value), f"{c['name']} 消息应含 {c['error_contains']}"
81
+ assert len(store.calls) == c["expected_call_count"], f"{c['name']} 应零门面调用"
82
+
83
+
84
+ def test_conformance_download_default():
85
+ c = CASES["adapter"]["download"]
86
+ store = MockStore()
87
+ resolve = adapter.download(store, c["opts"])
88
+ out = run(resolve(None, c["record"], "id1"))
89
+ assert store.calls[0] == tr(c["expected_open_call"])
90
+ assert store.calls[1] == tr(c["expected_query_call"])
91
+ assert out["contentType"] == c["expected_result"]["contentType"]
92
+ assert out["fileName"] == c["expected_result"]["fileName"]
93
+ assert out["body"].decode() == c["expected_result"]["body_text"]
94
+
95
+
96
+ def test_conformance_download_field_and_order():
97
+ f = CASES["adapter"]["download_field"]
98
+ s1 = MockStore()
99
+ run(adapter.download(s1, f["opts"])(None, f["record"], "id1"))
100
+ assert s1.calls[0] == tr(f["expected_open_call"])
101
+
102
+ o = CASES["adapter"]["download_order"]
103
+ s2 = MockStore()
104
+ run(adapter.download(s2, o["opts"])(None, o["record"], "id1"))
105
+ assert s2.calls[0] == tr(o["expected_open_call"])
106
+
107
+
108
+ def test_conformance_download_missing_ref():
109
+ for c in CASES["adapter"]["missing_ref"]:
110
+ store = MockStore()
111
+ resolve = adapter.download(store, {})
112
+ with pytest.raises(ValueError) as ei:
113
+ run(resolve(None, c["record"], "id1"))
114
+ assert c["error_contains"] in str(ei.value), f"{c['name']} 消息应含 {c['error_contains']}"
115
+ assert len(store.calls) == 0, f"{c['name']} 应零资源调用"
116
+
117
+
118
+ def test_conformance_download_meta_fallbacks():
119
+ m = CASES["adapter"]["mime_fallback"]
120
+ s1 = MockStore()
121
+
122
+ async def meta_m(gql, params):
123
+ return dict(m["meta"])
124
+
125
+ s1.query_one = meta_m
126
+ o1 = run(adapter.download(s1, {})(None, {"file": "ref1"}, "id1"))
127
+ assert o1["contentType"] == m["expected"]["contentType"]
128
+ assert o1["fileName"] == m["expected"]["fileName"]
129
+
130
+ f = CASES["adapter"]["filename_fallback"]
131
+ s2 = MockStore()
132
+
133
+ async def meta_f(gql, params):
134
+ return dict(f["meta"])
135
+
136
+ s2.query_one = meta_f
137
+ o2 = run(adapter.download(s2, {})(None, {"file": "ref1"}, "id1"))
138
+ assert o2["contentType"] == f["expected"]["contentType"]
139
+ assert o2["fileName"] == f["expected"]["fileName"]
140
+
141
+
142
+ def test_conformance_upload_passthrough():
143
+ entry = CASES["adapter"]["upload"]
144
+ store = MockStore()
145
+ ret = run(adapter.upload(store)(entry["input"]))
146
+ assert store.calls[0] == ("resource_put", entry["input"])
147
+ assert ret == {"resourceId": "sha1", "sha1": "sha1", "locations": []}
148
+
149
+
150
+ def test_conformance_machine_checks():
151
+ src = Path(__file__).parents[1] / "src" / "store_resource_bootstrap"
152
+ blocked = (
153
+ CASES["machine_checks"]["forbidden_literals"]
154
+ + CASES["machine_checks"]["forbidden_host_refs"]
155
+ + CASES["machine_checks"]["forbidden_calls"]
156
+ )
157
+ hits = 0
158
+ for p in src.glob("*.py"):
159
+ text = p.read_text(encoding="utf-8")
160
+ for token in blocked:
161
+ if token in text:
162
+ hits += 1
163
+ assert hits == 0, f"src 命中禁用字样 {hits} 处"
@@ -0,0 +1,45 @@
1
+ """可选 provider 参考模块单测。"""
2
+ import sys
3
+ from types import SimpleNamespace
4
+
5
+ from store_resource_bootstrap import providers
6
+
7
+
8
+ class _FakeConfig:
9
+ def __init__(self, **kw):
10
+ self.kw = kw
11
+
12
+
13
+ def _capture_sdk(captured):
14
+ def fake_client(service, **kw):
15
+ captured["client_kw"] = kw
16
+ return SimpleNamespace()
17
+ return {"client": fake_client, "config": _FakeConfig}
18
+
19
+
20
+ def test_namespace_exports():
21
+ assert callable(providers.oss.create)
22
+ assert callable(providers.minio.create)
23
+
24
+
25
+ def test_presets():
26
+ assert providers.oss.PRESET == {"kind": "oss", "forcePathStyle": False, "signatureVersion": "s3"}
27
+ assert providers.minio.PRESET == {"kind": "minio", "forcePathStyle": True}
28
+
29
+
30
+ def test_oss_virtual_host_and_v2_signature():
31
+ captured = {}
32
+ providers.oss.create({"bucket": "b", "endpoint": "https://oss.example.com"}, sdk=_capture_sdk(captured))
33
+ assert captured["client_kw"]["config"].kw == {
34
+ "s3": {"addressing_style": "virtual"}, "signature_version": "s3",
35
+ }
36
+
37
+
38
+ def test_minio_path_style():
39
+ captured = {}
40
+ providers.minio.create({"bucket": "b", "endpoint": "http://minio:9000"}, sdk=_capture_sdk(captured))
41
+ assert captured["client_kw"]["config"].kw == {"s3": {"addressing_style": "path"}}
42
+
43
+
44
+ def test_sdk_not_eagerly_imported():
45
+ assert "boto3" not in sys.modules