store-api-py 0.2.0__tar.gz → 0.3.1__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.
- {store_api_py-0.2.0 → store_api_py-0.3.1}/PKG-INFO +1 -1
- {store_api_py-0.2.0 → store_api_py-0.3.1}/pyproject.toml +1 -1
- {store_api_py-0.2.0 → store_api_py-0.3.1}/src/store_api_py/app.py +89 -9
- {store_api_py-0.2.0 → store_api_py-0.3.1}/src/store_api_py/errors.py +24 -1
- {store_api_py-0.2.0 → store_api_py-0.3.1}/src/store_api_py.egg-info/PKG-INFO +1 -1
- {store_api_py-0.2.0 → store_api_py-0.3.1}/tests/test_smoke.py +102 -5
- {store_api_py-0.2.0 → store_api_py-0.3.1}/README.md +0 -0
- {store_api_py-0.2.0 → store_api_py-0.3.1}/setup.cfg +0 -0
- {store_api_py-0.2.0 → store_api_py-0.3.1}/src/store_api_py/__init__.py +0 -0
- {store_api_py-0.2.0 → store_api_py-0.3.1}/src/store_api_py/params.py +0 -0
- {store_api_py-0.2.0 → store_api_py-0.3.1}/src/store_api_py.egg-info/SOURCES.txt +0 -0
- {store_api_py-0.2.0 → store_api_py-0.3.1}/src/store_api_py.egg-info/dependency_links.txt +0 -0
- {store_api_py-0.2.0 → store_api_py-0.3.1}/src/store_api_py.egg-info/requires.txt +0 -0
- {store_api_py-0.2.0 → store_api_py-0.3.1}/src/store_api_py.egg-info/top_level.txt +0 -0
|
@@ -16,10 +16,14 @@ from fastapi.responses import JSONResponse, Response
|
|
|
16
16
|
from .errors import (
|
|
17
17
|
StoreApiError,
|
|
18
18
|
context_error,
|
|
19
|
+
empty_body,
|
|
19
20
|
error_payload,
|
|
21
|
+
file_not_configured,
|
|
20
22
|
invalid_body,
|
|
21
23
|
map_error,
|
|
22
24
|
not_found,
|
|
25
|
+
too_large,
|
|
26
|
+
upload_not_configured,
|
|
23
27
|
)
|
|
24
28
|
from .params import parse_query_params
|
|
25
29
|
|
|
@@ -51,6 +55,8 @@ def create_app(
|
|
|
51
55
|
permission_error: type[BaseException] | None = None,
|
|
52
56
|
file_field: str = "file",
|
|
53
57
|
file_resolver: Callable[[Request, dict, str], Any] | None = None,
|
|
58
|
+
upload_resolver: Callable[[Request, dict, str], Any] | None = None,
|
|
59
|
+
upload_limit: int = 32 * 1024 * 1024,
|
|
54
60
|
) -> FastAPI:
|
|
55
61
|
"""为 store(py-store 的 store 实例,需已 init + register)生成 RESTful FastAPI 应用。
|
|
56
62
|
|
|
@@ -59,6 +65,9 @@ def create_app(
|
|
|
59
65
|
- context_provider: 每请求上下文钩子(spec/04-context.md);非权限类抛错 ⇒ 401 CONTEXT_ERROR
|
|
60
66
|
- resources: 显式资源名;缺省取 store.list() 并过滤归档表
|
|
61
67
|
- permission_error: store 权限错误类;缺省取 store.PermissionError
|
|
68
|
+
- file_resolver: 下载字节接缝 (request, rec, rid) => {body, contentType, fileName};缺省时下载 501
|
|
69
|
+
- upload_resolver: 上传字节接缝 (request, rec, rid) => {ref};缺省时上传 501
|
|
70
|
+
- upload_limit: 上传字节体上限(超限 413),默认 32MB
|
|
62
71
|
"""
|
|
63
72
|
app = FastAPI(title="store-api")
|
|
64
73
|
if permission_error is None:
|
|
@@ -99,7 +108,10 @@ def create_app(
|
|
|
99
108
|
# 显式投影(spec/01+02):GQL 省略投影段 = 只返回 _id(三端 core 契约)。
|
|
100
109
|
# 列表路由仅在 q 缺失时用它;q 存在时投影完全由 q 决定,适配层不追加。
|
|
101
110
|
proj = _schema_projection(store, name)
|
|
102
|
-
_register_resource(
|
|
111
|
+
_register_resource(
|
|
112
|
+
app, store, name, prefix, id_field, permission_error, proj,
|
|
113
|
+
file_field, file_resolver, upload_resolver, upload_limit,
|
|
114
|
+
)
|
|
103
115
|
|
|
104
116
|
return app
|
|
105
117
|
|
|
@@ -147,6 +159,42 @@ def _guard(permission_error: type[BaseException] | None) -> Callable:
|
|
|
147
159
|
return deco
|
|
148
160
|
|
|
149
161
|
|
|
162
|
+
def _pick_field(rec: dict | None, field_path: str) -> Any:
|
|
163
|
+
"""点路径取字段(决策 C):'images.full' → rec['images']['full'];任一段缺失 → None。"""
|
|
164
|
+
if rec is None:
|
|
165
|
+
return None
|
|
166
|
+
cur: Any = rec
|
|
167
|
+
for seg in field_path.split("."):
|
|
168
|
+
if not isinstance(cur, dict):
|
|
169
|
+
return None
|
|
170
|
+
cur = cur.get(seg)
|
|
171
|
+
if cur is None:
|
|
172
|
+
return None
|
|
173
|
+
return cur
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def _read_upload_body(request: Request, upload_limit: int) -> bytes:
|
|
177
|
+
"""上传字节体(决策 B'):空体 → 400 EMPTY_BODY;超限 → 413 UPLOAD_TOO_LARGE。
|
|
178
|
+
|
|
179
|
+
FastAPI/starlette 无 Fastify 式 parser/bodyLimit,故 upload_limit 是 py 端唯一防线。
|
|
180
|
+
"""
|
|
181
|
+
body = await request.body()
|
|
182
|
+
if not body:
|
|
183
|
+
raise empty_body(f"上传请求体为空: {request.method} {request.url.path}")
|
|
184
|
+
if len(body) > upload_limit:
|
|
185
|
+
raise too_large(f"上传字节体 {len(body)} 字节超过上限 {upload_limit}")
|
|
186
|
+
return body
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _set_by_path(field_path: str, value: Any) -> dict:
|
|
190
|
+
"""上传回写的 set 体:字面键,交由 store 寻址。
|
|
191
|
+
|
|
192
|
+
store 对非 `$` 前缀键自动包 `$set`(py-store/src/py_store/crud/write.py),
|
|
193
|
+
点路径键 `$set {'a.b': v}` 即嵌套写入 —— 与 node 端同策略(parity)。
|
|
194
|
+
"""
|
|
195
|
+
return {field_path: value}
|
|
196
|
+
|
|
197
|
+
|
|
150
198
|
def _register_resource(
|
|
151
199
|
app: FastAPI,
|
|
152
200
|
store: Any,
|
|
@@ -157,11 +205,18 @@ def _register_resource(
|
|
|
157
205
|
proj: str,
|
|
158
206
|
file_field: str,
|
|
159
207
|
file_resolver: Callable[[Request, dict, str], Any] | None,
|
|
208
|
+
upload_resolver: Callable[[Request, dict, str], Any] | None,
|
|
209
|
+
upload_limit: int,
|
|
160
210
|
) -> None:
|
|
161
211
|
# 工厂函数隔离闭包:handler 签名只含 Request 与路径参数,
|
|
162
212
|
# 否则 FastAPI 会把捕获变量解析成查询参数(缺参即 422)
|
|
163
213
|
base = f"{prefix}/{name}"
|
|
164
214
|
|
|
215
|
+
def _field_path(request: Request) -> str:
|
|
216
|
+
# 请求级字段路径:?field= 优先,缺省回落插件级 file_field(spec/02-params.md)
|
|
217
|
+
f = request.query_params.get("field")
|
|
218
|
+
return f if f else file_field
|
|
219
|
+
|
|
165
220
|
@app.get(base)
|
|
166
221
|
@_guard(permission_error)
|
|
167
222
|
async def list_resource(request: Request):
|
|
@@ -181,24 +236,49 @@ def _register_resource(
|
|
|
181
236
|
@app.get(base + "/{rid}/file")
|
|
182
237
|
@_guard(permission_error)
|
|
183
238
|
async def get_file(rid: str, request: Request):
|
|
239
|
+
# spec/03 判定顺序第 1 层:未注入 file_resolver → 501(先于 query_one 判定)
|
|
240
|
+
if file_resolver is None:
|
|
241
|
+
raise file_not_configured(f"GET {base}/{{rid}}/file 未注入 file_resolver")
|
|
242
|
+
fp = _field_path(request)
|
|
184
243
|
rec = await store.query_one(f"{name}($condition: @c0){proj}", {"c0": {id_field: rid}})
|
|
185
244
|
if rec is None:
|
|
186
245
|
raise not_found(f"记录不存在: {id_field}={rid}")
|
|
187
|
-
raw = rec
|
|
246
|
+
raw = _pick_field(rec, fp)
|
|
188
247
|
if raw is None:
|
|
189
|
-
raise not_found(f"文件不存在: {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
out = await out
|
|
194
|
-
else:
|
|
195
|
-
out = {"body": str(raw), "contentType": "text/plain; charset=utf-8", "fileName": f"{name}-{rid}"}
|
|
248
|
+
raise not_found(f"文件不存在: {fp}={rid}")
|
|
249
|
+
out = file_resolver(request, rec, rid)
|
|
250
|
+
if inspect.isawaitable(out):
|
|
251
|
+
out = await out
|
|
196
252
|
return Response(
|
|
197
253
|
content=out["body"],
|
|
198
254
|
media_type=out.get("contentType", "application/octet-stream"),
|
|
199
255
|
headers={"content-disposition": f'attachment; filename="{out.get("fileName", "file")}"'},
|
|
200
256
|
)
|
|
201
257
|
|
|
258
|
+
@app.post(base + "/{rid}/file")
|
|
259
|
+
@_guard(permission_error)
|
|
260
|
+
async def upload_file(rid: str, request: Request):
|
|
261
|
+
# spec/03 判定顺序第 1 层:未注入 upload_resolver → 501
|
|
262
|
+
if upload_resolver is None:
|
|
263
|
+
raise upload_not_configured(f"POST {base}/{{rid}}/file 未注入 upload_resolver")
|
|
264
|
+
await _read_upload_body(request, upload_limit) # 空体 400 / 超限 413
|
|
265
|
+
fp = _field_path(request)
|
|
266
|
+
rec = await store.query_one(f"{name}($condition: @c0){proj}", {"c0": {id_field: rid}})
|
|
267
|
+
if rec is None:
|
|
268
|
+
raise not_found(f"记录不存在: {id_field}={rid}")
|
|
269
|
+
# 接缝:resolver 负责字节落库并返回引用(决策 B:与 file_resolver 对称)
|
|
270
|
+
out = upload_resolver(request, rec, rid)
|
|
271
|
+
if inspect.isawaitable(out):
|
|
272
|
+
out = await out
|
|
273
|
+
ref = out.get("ref") if isinstance(out, dict) else None
|
|
274
|
+
if ref is None:
|
|
275
|
+
raise RuntimeError( # → map_error 末层 500 原样透传(禁静默)
|
|
276
|
+
f"upload_resolver 未返回 ref: {name}({id_field}={rid})"
|
|
277
|
+
)
|
|
278
|
+
# 回写:走 update 语义 → RBAC 写权限自动生效(决策 A)
|
|
279
|
+
data = await store.update(name, {id_field: rid}, _set_by_path(fp, ref))
|
|
280
|
+
return {"data": data}
|
|
281
|
+
|
|
202
282
|
@app.post(base, status_code=201)
|
|
203
283
|
@_guard(permission_error)
|
|
204
284
|
async def create_resource(request: Request):
|
|
@@ -38,13 +38,31 @@ def not_found(message: str) -> StoreApiError:
|
|
|
38
38
|
return StoreApiError(404, "NOT_FOUND", message)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
# ── 资源字节上传/下载守卫工厂(spec/03-errors.md) ──
|
|
42
|
+
def empty_body(message: str | None = None) -> StoreApiError:
|
|
43
|
+
return StoreApiError(400, "EMPTY_BODY", message or "上传请求体为空")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def too_large(message: str | None = None) -> StoreApiError:
|
|
47
|
+
return StoreApiError(413, "UPLOAD_TOO_LARGE", message or "上传字节体超过上限")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def upload_not_configured(message: str | None = None) -> StoreApiError:
|
|
51
|
+
return StoreApiError(501, "UPLOAD_NOT_CONFIGURED", message or "未注入 upload_resolver")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def file_not_configured(message: str | None = None) -> StoreApiError:
|
|
55
|
+
return StoreApiError(501, "FILE_NOT_CONFIGURED", message or "未注入 file_resolver")
|
|
56
|
+
|
|
57
|
+
|
|
41
58
|
def context_error(message: str | None) -> StoreApiError:
|
|
42
59
|
return StoreApiError(401, "CONTEXT_ERROR", message)
|
|
43
60
|
|
|
44
61
|
|
|
45
62
|
def map_error(err: BaseException, permission_error: type[BaseException] | None) -> StoreApiError:
|
|
46
63
|
"""判定顺序(spec/03-errors.md):
|
|
47
|
-
适配层守卫(400) → PermissionError(403)
|
|
64
|
+
未注入 resolver / 适配层守卫(400/413/501,由 StoreApiError 自带 status) → PermissionError(403)
|
|
65
|
+
→ GQL 解析失败(400) → 其余 500 透传。
|
|
48
66
|
"""
|
|
49
67
|
if isinstance(err, StoreApiError):
|
|
50
68
|
return err
|
|
@@ -59,4 +77,9 @@ def map_error(err: BaseException, permission_error: type[BaseException] | None)
|
|
|
59
77
|
text = str(err)
|
|
60
78
|
if text.startswith("ERR_GQL_PARSE:"):
|
|
61
79
|
return StoreApiError(400, "GQL_PARSE", text[len("ERR_GQL_PARSE:"):])
|
|
80
|
+
# spec/03 判定顺序第 4 层(core 稳定前缀类):资源无任何可读副本
|
|
81
|
+
# (py-store resource open 零副本行 → ERR_RESOURCE_NOT_FOUND:)→ 404 NOT_FOUND,
|
|
82
|
+
# message 剥前缀取原文(与 GQL_PARSE 同构;禁按文案匹配)。
|
|
83
|
+
if text.startswith("ERR_RESOURCE_NOT_FOUND:"):
|
|
84
|
+
return StoreApiError(404, "NOT_FOUND", text[len("ERR_RESOURCE_NOT_FOUND:"):] or None)
|
|
62
85
|
return StoreApiError(500, store_code(err), text or None)
|
|
@@ -20,6 +20,19 @@ class MockPermissionError(Exception):
|
|
|
20
20
|
pass
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def _apply_set(row: dict, path: str, value):
|
|
24
|
+
"""模拟 store $set 的点路径写入语义:'images.full' → row['images']['full'](Mongo $set {'a.b': v} 即嵌套写)。"""
|
|
25
|
+
if path.startswith("$"):
|
|
26
|
+
return # 原生操作符($inc/$unset 等)本 mock 不模拟
|
|
27
|
+
segs = path.split(".")
|
|
28
|
+
cur = row
|
|
29
|
+
for s in segs[:-1]:
|
|
30
|
+
if not isinstance(cur.get(s), dict):
|
|
31
|
+
cur[s] = {}
|
|
32
|
+
cur = cur[s]
|
|
33
|
+
cur[segs[-1]] = value
|
|
34
|
+
|
|
35
|
+
|
|
23
36
|
class MockStore:
|
|
24
37
|
"""mock store:只实现适配器调用面(query/query_one/insert/update/remove/list/set_context)。"""
|
|
25
38
|
|
|
@@ -59,7 +72,10 @@ class MockStore:
|
|
|
59
72
|
async def update(self, name, cond, data):
|
|
60
73
|
row = self.rows.get(cond.get("_id"))
|
|
61
74
|
if row:
|
|
62
|
-
|
|
75
|
+
# 模拟 store 的 $set 语义:非 `$` 前缀键自动包 $set(py-store/src/py_store/crud/write.py),
|
|
76
|
+
# 键内点路径由后端寻址(Mongo $set {'a.b': v} 即嵌套写入)。
|
|
77
|
+
for k, v in data.items():
|
|
78
|
+
_apply_set(row, k, v)
|
|
63
79
|
return row
|
|
64
80
|
|
|
65
81
|
async def remove(self, name, cond):
|
|
@@ -226,14 +242,22 @@ def test_x_cache_header():
|
|
|
226
242
|
|
|
227
243
|
|
|
228
244
|
def test_file_download_route():
|
|
229
|
-
"""
|
|
245
|
+
"""文件下载路由:注入 resolver → 200 + 头;未注入 → 501;缺失 → 404(spec/01+03+05)"""
|
|
230
246
|
store = MockStore()
|
|
231
|
-
client = TestClient(build_app(store))
|
|
232
247
|
|
|
233
|
-
|
|
234
|
-
|
|
248
|
+
# 未注入 file_resolver → 501 FILE_NOT_CONFIGURED(决策 D,原 text/plain 兜底已废止)
|
|
249
|
+
bare = TestClient(build_app(store))
|
|
250
|
+
rid = bare.post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
|
|
235
251
|
store.rows[rid]["file"] = "hello"
|
|
252
|
+
not_configured = bare.get(f"/api/user/{rid}/file")
|
|
253
|
+
assert not_configured.status_code == 501
|
|
254
|
+
assert not_configured.json()["error"]["code"] == "FILE_NOT_CONFIGURED"
|
|
255
|
+
|
|
256
|
+
# 注入 resolver → 200
|
|
257
|
+
async def _fr(request, rec, i):
|
|
258
|
+
return {"body": b"hello", "contentType": "text/plain; charset=utf-8", "fileName": f"user-{i}"}
|
|
236
259
|
|
|
260
|
+
client = TestClient(build_app(store, file_resolver=_fr))
|
|
237
261
|
file = client.get(f"/api/user/{rid}/file")
|
|
238
262
|
assert file.status_code == 200
|
|
239
263
|
assert file.headers["content-type"] == "text/plain; charset=utf-8"
|
|
@@ -243,3 +267,76 @@ def test_file_download_route():
|
|
|
243
267
|
missing = client.get("/api/user/nope/file")
|
|
244
268
|
assert missing.status_code == 404
|
|
245
269
|
assert missing.json()["error"]["code"] == "NOT_FOUND"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_file_download_resource_not_found_maps_404():
|
|
273
|
+
"""下载路由:资源无任何可读副本(core 前缀 ERR_RESOURCE_NOT_FOUND:)→ 404 NOT_FOUND(spec/03)。"""
|
|
274
|
+
store = MockStore()
|
|
275
|
+
rid = TestClient(build_app(store)).post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
|
|
276
|
+
store.rows[rid]["file"] = "sha1x" # 字段非空才会走到 resolver
|
|
277
|
+
|
|
278
|
+
async def _fr(request, rec, i):
|
|
279
|
+
raise FileNotFoundError("ERR_RESOURCE_NOT_FOUND:资源不存在或无可读副本: sha1x")
|
|
280
|
+
|
|
281
|
+
res = TestClient(build_app(store, file_resolver=_fr)).get(f"/api/user/{rid}/file")
|
|
282
|
+
assert res.status_code == 404
|
|
283
|
+
assert res.json()["error"]["code"] == "NOT_FOUND"
|
|
284
|
+
assert res.json()["error"]["message"] == "资源不存在或无可读副本: sha1x" # 前缀已剥离
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_file_download_provider_failure_stays_500():
|
|
288
|
+
"""下载路由:provider 读取失败(非前缀错误)→ 500 透传,不得伪装成 404。"""
|
|
289
|
+
store = MockStore()
|
|
290
|
+
rid = TestClient(build_app(store)).post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
|
|
291
|
+
store.rows[rid]["file"] = "sha1x"
|
|
292
|
+
|
|
293
|
+
async def _fr(request, rec, i):
|
|
294
|
+
raise RuntimeError("io down")
|
|
295
|
+
|
|
296
|
+
res = TestClient(build_app(store, file_resolver=_fr)).get(f"/api/user/{rid}/file")
|
|
297
|
+
assert res.status_code == 500
|
|
298
|
+
assert res.json()["error"]["message"] == "io down"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_upload_route_and_field():
|
|
302
|
+
"""上传路由:未注入 → 501;注入 → 200 回写;空体 → 400;超限 → 413;field 点路径(spec/01+02+03)"""
|
|
303
|
+
store = MockStore()
|
|
304
|
+
bare = TestClient(build_app(store))
|
|
305
|
+
rid = bare.post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
|
|
306
|
+
|
|
307
|
+
nc = bare.post(f"/api/user/{rid}/file", content=b"bytes",
|
|
308
|
+
headers={"content-type": "application/octet-stream"})
|
|
309
|
+
assert nc.status_code == 501
|
|
310
|
+
assert nc.json()["error"]["code"] == "UPLOAD_NOT_CONFIGURED"
|
|
311
|
+
|
|
312
|
+
async def _ur(request, rec, i):
|
|
313
|
+
return {"ref": "sha1abc"}
|
|
314
|
+
|
|
315
|
+
client = TestClient(build_app(store, upload_resolver=_ur))
|
|
316
|
+
ok = client.post(f"/api/user/{rid}/file", content=b"bytes",
|
|
317
|
+
headers={"content-type": "application/octet-stream"})
|
|
318
|
+
assert ok.status_code == 200
|
|
319
|
+
assert ok.json()["data"]["file"] == "sha1abc"
|
|
320
|
+
|
|
321
|
+
empty = client.post(f"/api/user/{rid}/file", content=b"",
|
|
322
|
+
headers={"content-type": "application/octet-stream"})
|
|
323
|
+
assert empty.status_code == 400
|
|
324
|
+
assert empty.json()["error"]["code"] == "EMPTY_BODY"
|
|
325
|
+
|
|
326
|
+
# 超限 → 413 UPLOAD_TOO_LARGE(A4 parity;用小 upload_limit 触发,避免构造 32MB 大包)
|
|
327
|
+
small = TestClient(build_app(store, upload_resolver=_ur, upload_limit=4))
|
|
328
|
+
too_large = small.post(f"/api/user/{rid}/file", content=b"bytes",
|
|
329
|
+
headers={"content-type": "application/octet-stream"})
|
|
330
|
+
assert too_large.status_code == 413
|
|
331
|
+
assert too_large.json()["error"]["code"] == "UPLOAD_TOO_LARGE"
|
|
332
|
+
|
|
333
|
+
store.rows[rid]["images"] = {"full": "old-ref"}
|
|
334
|
+
ok2 = client.post(f"/api/user/{rid}/file?field=images.full", content=b"bytes",
|
|
335
|
+
headers={"content-type": "application/octet-stream"})
|
|
336
|
+
assert ok2.status_code == 200
|
|
337
|
+
assert ok2.json()["data"]["images"]["full"] == "sha1abc"
|
|
338
|
+
|
|
339
|
+
missing = client.post("/api/user/nope/file", content=b"bytes",
|
|
340
|
+
headers={"content-type": "application/octet-stream"})
|
|
341
|
+
assert missing.status_code == 404
|
|
342
|
+
assert missing.json()["error"]["code"] == "NOT_FOUND"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|