shopping-cli 3.0.1__tar.gz → 3.0.2__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.
- {shopping_cli-3.0.1/shopping_cli.egg-info → shopping_cli-3.0.2}/PKG-INFO +4 -2
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/pyproject.toml +12 -2
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/__init__.py +1 -1
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/buyer_cli.py +6 -1
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/merchant_daemon.py +58 -45
- shopping_cli-3.0.2/shopping_cli/agents/merchant_state_helpers.py +58 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/tools.py +2 -0
- shopping_cli-3.0.2/shopping_cli/api/app.py +121 -0
- shopping_cli-3.0.2/shopping_cli/api/error_handlers.py +117 -0
- shopping_cli-3.0.2/shopping_cli/api/error_response.py +22 -0
- shopping_cli-3.0.2/shopping_cli/api/fastapi_routes.py +602 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/agents.py +1 -1
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/buyer.py +34 -5
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/catalog.py +56 -19
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/idempotency.py +25 -0
- shopping_cli-3.0.2/shopping_cli/api/request_dispatch.py +62 -0
- shopping_cli-3.0.2/shopping_cli/api/request_limits.py +87 -0
- shopping_cli-3.0.2/shopping_cli/api/route_matching.py +19 -0
- shopping_cli-3.0.2/shopping_cli/api/route_table.py +610 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli.py +2 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_buyer_commands.py +9 -1
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_catalog_commands.py +2 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/commerce/adapters.py +17 -3
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/commerce/data_source.py +7 -4
- shopping_cli-3.0.2/shopping_cli/contracts/kiwi-contracts.lock.json +7 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/catalog.py +59 -137
- shopping_cli-3.0.2/shopping_cli/core/catalog_scoring.py +47 -0
- shopping_cli-3.0.2/shopping_cli/core/catalog_text.py +56 -0
- shopping_cli-3.0.2/shopping_cli/core/catalog_views.py +51 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/channels.py +0 -1
- shopping_cli-3.0.2/shopping_cli/core/negotiation.py +76 -0
- shopping_cli-3.0.1/shopping_cli/core/negotiation.py → shopping_cli-3.0.2/shopping_cli/core/negotiation_contracts.py +10 -48
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/data_sources/erp_source.py +57 -11
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/db/migrations.py +12 -1
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/db/models.py +4 -0
- shopping_cli-3.0.2/shopping_cli/http_client.py +326 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/listings/projection.py +3 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/negotiation.py +39 -234
- shopping_cli-3.0.2/shopping_cli/services/negotiation_gates.py +106 -0
- shopping_cli-3.0.2/shopping_cli/services/negotiation_message.py +57 -0
- shopping_cli-3.0.2/shopping_cli/services/negotiation_policy_helpers.py +48 -0
- shopping_cli-3.0.2/shopping_cli/services/negotiation_policy_result.py +57 -0
- shopping_cli-3.0.2/shopping_cli/services/negotiation_snapshot.py +45 -0
- shopping_cli-3.0.2/shopping_cli/services/negotiation_snapshot_projection.py +110 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2/shopping_cli.egg-info}/PKG-INFO +4 -2
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli.egg-info/SOURCES.txt +19 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli.egg-info/requires.txt +3 -1
- shopping_cli-3.0.1/shopping_cli/api/app.py +0 -1353
- shopping_cli-3.0.1/shopping_cli/http_client.py +0 -149
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/LICENSE +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/MANIFEST.in +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/README.md +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/setup.cfg +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/__init__.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/agent_cli.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/agents/merchant_agent.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/__init__.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/auth.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/fallback_asgi.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/__init__.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/audit.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/common.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/conversations.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/erp.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/human_review.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/listings_projection.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/handlers/negotiation.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/limits.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/route_registry.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/routes_agents.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/routes_conversations.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/routes_marketplace.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/routes_merchants.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/api/server.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_agent_commands.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_common.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_conversation_commands.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_erp_commands.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/cli_listing_commands.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/commerce/__init__.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/config.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/__init__.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/conversations.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/delivery.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/errors.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/harness.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/limits.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/policies.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/risk.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/core/tokens.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/data_sources/__init__.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/db/__init__.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/db/provenance.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/db/session.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/listings/__init__.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/__init__.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/agents.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/audit.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/buyer_bootstrap.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/conversations.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/human_review.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli/services/tokens.py +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli.egg-info/dependency_links.txt +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli.egg-info/entry_points.txt +0 -0
- {shopping_cli-3.0.1 → shopping_cli-3.0.2}/shopping_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shopping-cli
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.2
|
|
4
4
|
Summary: Standalone local-commerce AI consultation marketplace runtime
|
|
5
5
|
License: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
@@ -209,13 +209,15 @@ Description-Content-Type: text/markdown
|
|
|
209
209
|
License-File: LICENSE
|
|
210
210
|
Requires-Dist: psutil>=5.9
|
|
211
211
|
Provides-Extra: api
|
|
212
|
-
Requires-Dist: fastapi>=0.
|
|
212
|
+
Requires-Dist: fastapi>=0.141.1; extra == "api"
|
|
213
213
|
Requires-Dist: pydantic>=2; extra == "api"
|
|
214
214
|
Requires-Dist: uvicorn>=0.27; extra == "api"
|
|
215
215
|
Provides-Extra: dev
|
|
216
216
|
Requires-Dist: build>=1.2; extra == "dev"
|
|
217
217
|
Requires-Dist: coverage[toml]>=7.6; extra == "dev"
|
|
218
|
+
Requires-Dist: httpx>=0.28; extra == "dev"
|
|
218
219
|
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
220
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
219
221
|
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
220
222
|
Dynamic: license-file
|
|
221
223
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "shopping-cli"
|
|
7
|
-
version = "3.0.
|
|
7
|
+
version = "3.0.2"
|
|
8
8
|
description = "Standalone local-commerce AI consultation marketplace runtime"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -20,17 +20,24 @@ shopping-cli-agent = "shopping_cli.agents.agent_cli:main"
|
|
|
20
20
|
|
|
21
21
|
[project.optional-dependencies]
|
|
22
22
|
api = [
|
|
23
|
-
"fastapi>=0.
|
|
23
|
+
"fastapi>=0.141.1",
|
|
24
24
|
"pydantic>=2",
|
|
25
25
|
"uvicorn>=0.27",
|
|
26
26
|
]
|
|
27
27
|
dev = [
|
|
28
28
|
"build>=1.2",
|
|
29
29
|
"coverage[toml]>=7.6",
|
|
30
|
+
"httpx>=0.28",
|
|
30
31
|
"mypy>=1.11",
|
|
32
|
+
"pytest>=8.0",
|
|
31
33
|
"ruff>=0.8",
|
|
32
34
|
]
|
|
33
35
|
|
|
36
|
+
[tool.uv]
|
|
37
|
+
# Keep the optional API stack above the currently published Starlette security
|
|
38
|
+
# floor while retaining an upper bound for deliberate compatibility upgrades.
|
|
39
|
+
constraint-dependencies = ["fastapi>=0.141.1,<0.142", "starlette>=1.3.1,<1.7", "uvicorn<0.36"]
|
|
40
|
+
|
|
34
41
|
[tool.ruff]
|
|
35
42
|
target-version = "py311"
|
|
36
43
|
line-length = 120
|
|
@@ -58,3 +65,6 @@ omit = ["shopping_cli/__main__.py"]
|
|
|
58
65
|
[tool.setuptools.packages.find]
|
|
59
66
|
where = ["."]
|
|
60
67
|
include = ["shopping_cli*"]
|
|
68
|
+
|
|
69
|
+
[tool.setuptools.package-data]
|
|
70
|
+
shopping_cli = ["contracts/*.json"]
|
|
@@ -131,7 +131,12 @@ def summarize(conn: sqlite3.Connection, conversation_id: str) -> dict[str, Any]:
|
|
|
131
131
|
missing_facts.append("merchant contact")
|
|
132
132
|
if not option["delivery"].get("service_area"):
|
|
133
133
|
missing_facts.append("delivery rule")
|
|
134
|
-
|
|
134
|
+
# 审查 P2-1:公开投影只携带 availability_hint(精确库存不下发);
|
|
135
|
+
# 历史快照可能仍是旧形状(stock),两者都按"缺货"语义处理。
|
|
136
|
+
stock = option.get("stock")
|
|
137
|
+
if stock is None:
|
|
138
|
+
stock = 0 if option.get("availability_hint") == "out_of_stock" else 1
|
|
139
|
+
if stock <= 0:
|
|
135
140
|
warnings.append("Product is out of stock.")
|
|
136
141
|
warnings.extend(status_warnings(conversation))
|
|
137
142
|
for flag in conversation["flags"]:
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
"""Local lifecycle management for resident merchant-agent processes.
|
|
1
|
+
"""Local lifecycle management for resident merchant-agent processes.
|
|
2
|
+
|
|
3
|
+
Pure path-naming / id-sanitization / counter-coercion / error-classification
|
|
4
|
+
helpers moved move-only to :mod:`shopping_cli.agents.merchant_state_helpers`;
|
|
5
|
+
the public names are re-exported here (``as`` aliases) so
|
|
6
|
+
``merchant_daemon.safe_merchant_id`` / ``agent_paths`` / ``safe_replied_count``
|
|
7
|
+
/ ``permanent_agent_error`` / ``state_dir_from`` / ``DEFAULT_STATE_DIR`` keep
|
|
8
|
+
their exact surface, path naming, return types and call order. This module
|
|
9
|
+
still owns process start/stop, PID-file locking, signals, log rotation/tailing,
|
|
10
|
+
state file I/O and the process loop.
|
|
11
|
+
"""
|
|
2
12
|
|
|
3
13
|
from __future__ import annotations
|
|
4
14
|
|
|
5
15
|
import json
|
|
6
|
-
import hashlib
|
|
7
16
|
import os
|
|
8
17
|
import secrets
|
|
9
18
|
import signal
|
|
@@ -30,10 +39,17 @@ except ImportError: # pragma: no cover - optional runtime dependency
|
|
|
30
39
|
psutil = None
|
|
31
40
|
|
|
32
41
|
from shopping_cli.agents import merchant_agent
|
|
42
|
+
from shopping_cli.agents.merchant_state_helpers import (
|
|
43
|
+
DEFAULT_STATE_DIR as DEFAULT_STATE_DIR,
|
|
44
|
+
agent_paths as agent_paths,
|
|
45
|
+
permanent_agent_error as permanent_agent_error,
|
|
46
|
+
safe_merchant_id as safe_merchant_id,
|
|
47
|
+
safe_replied_count as safe_replied_count,
|
|
48
|
+
state_dir_from as state_dir_from,
|
|
49
|
+
)
|
|
33
50
|
from shopping_cli.db.session import db_session, decode_json, now_iso
|
|
34
51
|
from shopping_cli.core.limits import safe_non_negative_int, safe_positive_float, safe_non_negative_float_with_max as safe_non_negative_float
|
|
35
52
|
|
|
36
|
-
DEFAULT_STATE_DIR = Path.home() / ".local" / "state" / "shopping-cli"
|
|
37
53
|
MAX_AGENT_INTERVAL_SECONDS = 3600.0
|
|
38
54
|
MAX_AGENT_STOP_TIMEOUT_SECONDS = 300.0
|
|
39
55
|
MAX_AGENT_LOG_TAIL = 1000
|
|
@@ -42,30 +58,6 @@ MAX_AGENT_LOG_BACKUPS = 3
|
|
|
42
58
|
MAX_AGENT_ERROR_BACKOFF_SECONDS = 60.0
|
|
43
59
|
|
|
44
60
|
|
|
45
|
-
def state_dir_from(value: str | Path | None = None) -> Path:
|
|
46
|
-
return Path(value or os.environ.get("SHOPPING_CLI_STATE_DIR") or DEFAULT_STATE_DIR).expanduser()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def safe_merchant_id(merchant_id: str) -> str:
|
|
50
|
-
raw = str(merchant_id or "")
|
|
51
|
-
slug = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in raw).strip("._-")
|
|
52
|
-
slug = (slug or "merchant")[:64]
|
|
53
|
-
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
54
|
-
return f"{slug}-{digest}"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def agent_paths(merchant_id: str, state_dir: str | Path | None = None) -> dict[str, Path]:
|
|
58
|
-
root = state_dir_from(state_dir)
|
|
59
|
-
safe_id = safe_merchant_id(merchant_id)
|
|
60
|
-
return {
|
|
61
|
-
"state_dir": root,
|
|
62
|
-
"pid_file": root / "agents" / f"{safe_id}.pid",
|
|
63
|
-
"state_file": root / "agents" / f"{safe_id}.state.json",
|
|
64
|
-
"stop_file": root / "agents" / f"{safe_id}.stop",
|
|
65
|
-
"log_file": root / "logs" / f"{safe_id}.log",
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
61
|
def ensure_agent_dirs(paths: dict[str, Path]) -> None:
|
|
70
62
|
paths["pid_file"].parent.mkdir(parents=True, exist_ok=True)
|
|
71
63
|
paths["state_file"].parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -86,9 +78,25 @@ def rotate_agent_log(log_file: Path) -> bool:
|
|
|
86
78
|
if source.exists():
|
|
87
79
|
source.replace(log_file.with_suffix(log_file.suffix + f".{index + 1}"))
|
|
88
80
|
log_file.replace(log_file.with_suffix(log_file.suffix + ".1"))
|
|
81
|
+
# 审查 P2-07:rename 保留源文件权限——旧日志若世界可读,备份必须 0600
|
|
82
|
+
for index in range(1, MAX_AGENT_LOG_BACKUPS + 1):
|
|
83
|
+
backup = log_file.with_suffix(log_file.suffix + f".{index}")
|
|
84
|
+
if backup.exists():
|
|
85
|
+
os.chmod(backup, 0o600)
|
|
89
86
|
return True
|
|
90
87
|
|
|
91
88
|
|
|
89
|
+
def _open_log_append(log_path: Path):
|
|
90
|
+
"""以 0600 创建/复用 daemon 日志文件。
|
|
91
|
+
|
|
92
|
+
审查 P2-07:日志含会话内容,且 os.open 的 mode 只在新建时生效——对
|
|
93
|
+
已存在文件也要显式 chmod 0600。
|
|
94
|
+
"""
|
|
95
|
+
fd = os.open(str(log_path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
96
|
+
os.chmod(log_path, 0o600)
|
|
97
|
+
return os.fdopen(fd, "ab", buffering=0)
|
|
98
|
+
|
|
99
|
+
|
|
92
100
|
def _redirect_stdout_to(log_path: Path) -> None:
|
|
93
101
|
"""Repoint stdout at a freshly rotated log file.
|
|
94
102
|
|
|
@@ -99,7 +107,10 @@ def _redirect_stdout_to(log_path: Path) -> None:
|
|
|
99
107
|
target_fd = sys.stdout.fileno()
|
|
100
108
|
except (OSError, ValueError):
|
|
101
109
|
return
|
|
102
|
-
|
|
110
|
+
# 审查 P3/P2-07:日志含会话内容——0600,避免多用户机器上世界可读;
|
|
111
|
+
# mode 只对新文件生效,已存在文件同样 chmod。
|
|
112
|
+
fd = os.open(str(log_path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
113
|
+
os.chmod(log_path, 0o600)
|
|
103
114
|
try:
|
|
104
115
|
os.dup2(fd, target_fd)
|
|
105
116
|
finally:
|
|
@@ -137,24 +148,23 @@ def read_json(path: Path, default: Any) -> Any:
|
|
|
137
148
|
return decoded
|
|
138
149
|
|
|
139
150
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def safe_replied_count(value: Any) -> int:
|
|
144
|
-
if not isinstance(value, list):
|
|
145
|
-
return 0
|
|
146
|
-
return len(value)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def permanent_agent_error(error: str) -> bool:
|
|
150
|
-
lowered = str(error or "").lower()
|
|
151
|
-
return any(marker in lowered for marker in ("invalid authorization", "revoked authorization", "expired authorization", "token required", "unknown merchant"))
|
|
152
|
-
|
|
153
|
-
|
|
154
151
|
def write_json_atomic(path: Path, value: Any) -> None:
|
|
155
152
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
153
|
tmp = path.with_name(f"{path.name}.tmp")
|
|
157
154
|
tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
|
|
155
|
+
# 审查 P3:pid/state 文件含 launch_token —— 0600(此前继承 umask 0644
|
|
156
|
+
# 世界可读)。原子替换前 chmod,rename 后权限即为目标值。
|
|
157
|
+
os.chmod(tmp, 0o600)
|
|
158
|
+
tmp.replace(path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _write_private_text(path: Path, text: str) -> None:
|
|
162
|
+
"""原子写凭据类文本文件并强制 0600(审查 P2-07:stop 文件含
|
|
163
|
+
launch_token,open() 默认 mode 继承 umask 可能世界可读)。"""
|
|
164
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
tmp = path.with_name(f"{path.name}.tmp")
|
|
166
|
+
tmp.write_text(text, encoding="utf-8")
|
|
167
|
+
os.chmod(tmp, 0o600)
|
|
158
168
|
tmp.replace(path)
|
|
159
169
|
|
|
160
170
|
|
|
@@ -472,7 +482,9 @@ def start_agent(
|
|
|
472
482
|
elif merchant_token:
|
|
473
483
|
env["SHOPPING_MERCHANT_TOKEN"] = merchant_token
|
|
474
484
|
env.pop("SHOPPING_AGENT_TOKEN", None)
|
|
475
|
-
|
|
485
|
+
# 审查 P2-07:新建/已存在的日志文件都须 0600(open() 默认 mode 继承
|
|
486
|
+
# umask,多用户机器上可能 0644 世界可读)
|
|
487
|
+
with _open_log_append(paths["log_file"]) as log:
|
|
476
488
|
process = subprocess.Popen(
|
|
477
489
|
command,
|
|
478
490
|
stdin=subprocess.DEVNULL,
|
|
@@ -568,8 +580,9 @@ def stop_agent(
|
|
|
568
580
|
)
|
|
569
581
|
|
|
570
582
|
if was_running:
|
|
571
|
-
|
|
572
|
-
|
|
583
|
+
# 审查 P2-07:stop 文件含 launch_token——0600(open() 默认 mode 会
|
|
584
|
+
# 继承 umask 0644 世界可读)
|
|
585
|
+
_write_private_text(paths["stop_file"], launch_token)
|
|
573
586
|
if psutil is not None:
|
|
574
587
|
try:
|
|
575
588
|
os.kill(pid, signal.SIGTERM)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Pure merchant-daemon state helpers: path naming, id sanitization, counters.
|
|
2
|
+
|
|
3
|
+
Move-only extraction from :mod:`shopping_cli.agents.merchant_daemon`: this leaf
|
|
4
|
+
module owns the merchant-id sanitization (path isolation via slug + digest),
|
|
5
|
+
the state/log/pid/stop file path naming, the replied-count coercion and the
|
|
6
|
+
permanent-error classification. It never starts or stops processes, takes
|
|
7
|
+
locks, installs signal handlers, touches the database or network, writes files,
|
|
8
|
+
or drives state transitions — everything here is a pure function of its
|
|
9
|
+
inputs plus the ``SHOPPING_CLI_STATE_DIR`` environment override.
|
|
10
|
+
|
|
11
|
+
The parent module re-exports these names unchanged (``as`` aliases) so the
|
|
12
|
+
public ``merchant_daemon`` surface, path naming, return types and call order
|
|
13
|
+
are preserved.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
DEFAULT_STATE_DIR = Path.home() / ".local" / "state" / "shopping-cli"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def state_dir_from(value: str | Path | None = None) -> Path:
|
|
27
|
+
return Path(value or os.environ.get("SHOPPING_CLI_STATE_DIR") or DEFAULT_STATE_DIR).expanduser()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def safe_merchant_id(merchant_id: str) -> str:
|
|
31
|
+
raw = str(merchant_id or "")
|
|
32
|
+
slug = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in raw).strip("._-")
|
|
33
|
+
slug = (slug or "merchant")[:64]
|
|
34
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
35
|
+
return f"{slug}-{digest}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def agent_paths(merchant_id: str, state_dir: str | Path | None = None) -> dict[str, Path]:
|
|
39
|
+
root = state_dir_from(state_dir)
|
|
40
|
+
safe_id = safe_merchant_id(merchant_id)
|
|
41
|
+
return {
|
|
42
|
+
"state_dir": root,
|
|
43
|
+
"pid_file": root / "agents" / f"{safe_id}.pid",
|
|
44
|
+
"state_file": root / "agents" / f"{safe_id}.state.json",
|
|
45
|
+
"stop_file": root / "agents" / f"{safe_id}.stop",
|
|
46
|
+
"log_file": root / "logs" / f"{safe_id}.log",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def safe_replied_count(value: Any) -> int:
|
|
51
|
+
if not isinstance(value, list):
|
|
52
|
+
return 0
|
|
53
|
+
return len(value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def permanent_agent_error(error: str) -> bool:
|
|
57
|
+
lowered = str(error or "").lower()
|
|
58
|
+
return any(marker in lowered for marker in ("invalid authorization", "revoked authorization", "expired authorization", "token required", "unknown merchant"))
|
|
@@ -260,6 +260,7 @@ class HTTPMerchantAgentTools:
|
|
|
260
260
|
merchant_token: str,
|
|
261
261
|
timeout: float = 10.0,
|
|
262
262
|
opener: Any | None = None,
|
|
263
|
+
transport: Any | None = None,
|
|
263
264
|
host: str = "",
|
|
264
265
|
session_id: str = "",
|
|
265
266
|
allow_insecure_http: bool = False,
|
|
@@ -275,6 +276,7 @@ class HTTPMerchantAgentTools:
|
|
|
275
276
|
self.merchant_token,
|
|
276
277
|
timeout=timeout,
|
|
277
278
|
opener=opener,
|
|
279
|
+
transport=transport,
|
|
278
280
|
allow_insecure_http=allow_insecure_http,
|
|
279
281
|
)
|
|
280
282
|
self.base_url = self.http.base_url
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Marketplace API app factory.
|
|
2
|
+
|
|
3
|
+
FastAPI is used when installed. The lightweight fallback keeps route metadata
|
|
4
|
+
available for local tests in environments where optional API dependencies have
|
|
5
|
+
not been installed yet.
|
|
6
|
+
|
|
7
|
+
装配拆分(move-only,行为不变):可执行路由表与 handler wrapper 在
|
|
8
|
+
``api.route_table``(_ROUTE_TABLE / resolve_route),FastAPI 路由安装与
|
|
9
|
+
header 默认值在 ``api.fastapi_routes``(register_fastapi_routes)。本模块
|
|
10
|
+
保留公共门面——create_app / handle_request / _ROUTE_TABLE / RouteEntry /
|
|
11
|
+
resolve_route / FastAPI / AUTHORIZATION_HEADER / IDEMPOTENCY_KEY_HEADER——
|
|
12
|
+
route_registry、fallback ASGI 与既有导入方不变。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from shopping_cli import VERSION
|
|
22
|
+
from shopping_cli.api.fallback_asgi import MarketplaceASGIApp
|
|
23
|
+
from shopping_cli.api.fastapi_routes import (
|
|
24
|
+
AUTHORIZATION_HEADER, # noqa: F401 - compat re-export for external importers
|
|
25
|
+
FastAPI,
|
|
26
|
+
Header, # noqa: F401 - compat re-export for external importers
|
|
27
|
+
IDEMPOTENCY_KEY_HEADER, # noqa: F401 - compat re-export for external importers
|
|
28
|
+
JSONResponse, # noqa: F401 - compat re-export for external importers
|
|
29
|
+
Request, # noqa: F401 - compat re-export for external importers
|
|
30
|
+
RequestValidationError, # noqa: F401 - compat re-export for external importers
|
|
31
|
+
Response, # noqa: F401 - compat re-export for external importers
|
|
32
|
+
StarletteHTTPException, # noqa: F401 - compat re-export for external importers
|
|
33
|
+
register_fastapi_routes,
|
|
34
|
+
)
|
|
35
|
+
from shopping_cli.api.limits import validate_payload
|
|
36
|
+
from shopping_cli.api.request_dispatch import (
|
|
37
|
+
RouteEntry, # noqa: F401 - compat re-export for external importers
|
|
38
|
+
dispatch_request,
|
|
39
|
+
)
|
|
40
|
+
from shopping_cli.api.request_limits import (
|
|
41
|
+
ASGIApp as _ASGIApp, # noqa: F401 - compat re-export for external importers
|
|
42
|
+
ASGIReceive as _ASGIReceive, # noqa: F401 - compat re-export for external importers
|
|
43
|
+
ASGISend as _ASGISend, # noqa: F401 - compat re-export for external importers
|
|
44
|
+
RequestBodyLimitMiddleware as _RequestBodyLimitMiddleware, # noqa: F401 - compat re-export for external importers
|
|
45
|
+
)
|
|
46
|
+
from shopping_cli.api.route_table import _ROUTE_TABLE, resolve_route
|
|
47
|
+
from shopping_cli.core.errors import (
|
|
48
|
+
AuthError,
|
|
49
|
+
ConflictError,
|
|
50
|
+
IdempotencyConflict,
|
|
51
|
+
MethodNotAllowedError,
|
|
52
|
+
NotFoundError,
|
|
53
|
+
PermissionDenied,
|
|
54
|
+
RateLimitError,
|
|
55
|
+
PayloadTooLargeError,
|
|
56
|
+
ShoppingCliError,
|
|
57
|
+
ValidationError,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger("shopping-cli")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def handle_request(
|
|
64
|
+
db_path: str | Path,
|
|
65
|
+
method: str,
|
|
66
|
+
path: str,
|
|
67
|
+
payload: dict[str, Any] | None = None,
|
|
68
|
+
query: dict[str, Any] | None = None,
|
|
69
|
+
) -> tuple[int, dict[str, Any]]:
|
|
70
|
+
payload = payload or {}
|
|
71
|
+
query = query or {}
|
|
72
|
+
try:
|
|
73
|
+
validate_payload(payload)
|
|
74
|
+
return dispatch_request(db_path, method, path, payload, query, _ROUTE_TABLE)
|
|
75
|
+
except AuthError as exc:
|
|
76
|
+
return 403, {"ok": False, "error": str(exc)}
|
|
77
|
+
except PermissionDenied as exc:
|
|
78
|
+
return 403, {"ok": False, "error": str(exc)}
|
|
79
|
+
except IdempotencyConflict as exc:
|
|
80
|
+
return 409, {"ok": False, "error": str(exc)}
|
|
81
|
+
except ConflictError as exc:
|
|
82
|
+
return 409, {"ok": False, "error": str(exc)}
|
|
83
|
+
except NotFoundError as exc:
|
|
84
|
+
return 404, {"ok": False, "error": str(exc)}
|
|
85
|
+
except RateLimitError as exc:
|
|
86
|
+
return 429, {"ok": False, "error": str(exc)}
|
|
87
|
+
except PayloadTooLargeError as exc:
|
|
88
|
+
return 413, {"ok": False, "error": str(exc)}
|
|
89
|
+
except MethodNotAllowedError as exc:
|
|
90
|
+
return 405, {"ok": False, "error": str(exc)}
|
|
91
|
+
except ValidationError as exc:
|
|
92
|
+
return 400, {"ok": False, "error": str(exc)}
|
|
93
|
+
except ShoppingCliError as exc:
|
|
94
|
+
return 400, {"ok": False, "error": str(exc)}
|
|
95
|
+
except Exception:
|
|
96
|
+
logger.exception("unhandled error handling %s %s", method, path)
|
|
97
|
+
return 500, {"ok": False, "error": "internal server error"}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def create_app(db_path: str | Path = "shopping-cli.sqlite") -> Any:
|
|
101
|
+
from shopping_cli.api.route_registry import route_info
|
|
102
|
+
|
|
103
|
+
routes = route_info()
|
|
104
|
+
if FastAPI is None:
|
|
105
|
+
return MarketplaceASGIApp(
|
|
106
|
+
db_path,
|
|
107
|
+
route_provider=lambda: routes,
|
|
108
|
+
route_resolver=lambda method, path: resolve_route(method, path, routes=routes),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
app = FastAPI(
|
|
112
|
+
title="shopping-cli Marketplace API",
|
|
113
|
+
version=VERSION,
|
|
114
|
+
docs_url=None,
|
|
115
|
+
redoc_url=None,
|
|
116
|
+
openapi_url=None,
|
|
117
|
+
)
|
|
118
|
+
app.state.db_path = str(db_path)
|
|
119
|
+
app.state.fastapi_available = True
|
|
120
|
+
register_fastapi_routes(app, db_path)
|
|
121
|
+
return app
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""FastAPI exception-handler registration for the marketplace API.
|
|
2
|
+
|
|
3
|
+
Centralizes the business exception → HTTP mapping (status code, JSON envelope,
|
|
4
|
+
error string) that :func:`shopping_cli.api.app.create_app` used to register
|
|
5
|
+
inline, so the transport layer only wires the registration in. This module has
|
|
6
|
+
no hard dependency on FastAPI: the response factory and the optional
|
|
7
|
+
Starlette/FastAPI exception types are injected by the caller, matching how the
|
|
8
|
+
dual API stack isolates optional imports (see ``app.py``'s try/except import
|
|
9
|
+
block).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from shopping_cli.api.error_response import build_error_response
|
|
18
|
+
from shopping_cli.core.errors import (
|
|
19
|
+
AuthError,
|
|
20
|
+
ConflictError,
|
|
21
|
+
IdempotencyConflict,
|
|
22
|
+
MethodNotAllowedError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
PayloadTooLargeError,
|
|
25
|
+
PermissionDenied,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
ShoppingCliError,
|
|
28
|
+
ValidationError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("shopping-cli")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _json_error_response(json_response: Any, status_code: int, error: str) -> Any:
|
|
35
|
+
return build_error_response(status_code, error, json_response)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register_error_handlers(
|
|
39
|
+
app: Any,
|
|
40
|
+
*,
|
|
41
|
+
json_response: Any = None,
|
|
42
|
+
request_validation_error: Any = None,
|
|
43
|
+
starlette_http_exception: Any = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Register business and transport error handlers on *app*.
|
|
46
|
+
|
|
47
|
+
*app* must expose ``exception_handler`` (a FastAPI app or a test double).
|
|
48
|
+
``json_response`` is the FastAPI ``JSONResponse`` class, or ``None`` when
|
|
49
|
+
FastAPI is not installed (handlers then return the fallback
|
|
50
|
+
``SimpleNamespace`` envelope). ``request_validation_error`` and
|
|
51
|
+
``starlette_http_exception`` are the optional FastAPI/Starlette exception
|
|
52
|
+
types; passing ``None`` skips those handlers, mirroring ``app.py``'s
|
|
53
|
+
conditional registration.
|
|
54
|
+
|
|
55
|
+
Registration order is load-bearing: specific business exceptions come
|
|
56
|
+
before their base classes and before the catch-all ``Exception`` handler,
|
|
57
|
+
so a first-match handler walk (as done by the FakeFastAPI test harness)
|
|
58
|
+
resolves a subclass to its own status rather than a base class's.
|
|
59
|
+
"""
|
|
60
|
+
@app.exception_handler(AuthError)
|
|
61
|
+
def auth_error_handler(_request: Any, exc: AuthError) -> Any:
|
|
62
|
+
return _json_error_response(json_response, 403, str(exc))
|
|
63
|
+
|
|
64
|
+
@app.exception_handler(PermissionDenied)
|
|
65
|
+
def permission_denied_handler(_request: Any, exc: PermissionDenied) -> Any:
|
|
66
|
+
return _json_error_response(json_response, 403, str(exc))
|
|
67
|
+
|
|
68
|
+
@app.exception_handler(IdempotencyConflict)
|
|
69
|
+
def idempotency_conflict_handler(_request: Any, exc: IdempotencyConflict) -> Any:
|
|
70
|
+
return _json_error_response(json_response, 409, str(exc))
|
|
71
|
+
|
|
72
|
+
@app.exception_handler(ConflictError)
|
|
73
|
+
def conflict_error_handler(_request: Any, exc: ConflictError) -> Any:
|
|
74
|
+
return _json_error_response(json_response, 409, str(exc))
|
|
75
|
+
|
|
76
|
+
@app.exception_handler(NotFoundError)
|
|
77
|
+
def not_found_error_handler(_request: Any, exc: NotFoundError) -> Any:
|
|
78
|
+
return _json_error_response(json_response, 404, str(exc))
|
|
79
|
+
|
|
80
|
+
@app.exception_handler(RateLimitError)
|
|
81
|
+
def rate_limit_error_handler(_request: Any, exc: RateLimitError) -> Any:
|
|
82
|
+
return _json_error_response(json_response, 429, str(exc))
|
|
83
|
+
|
|
84
|
+
@app.exception_handler(PayloadTooLargeError)
|
|
85
|
+
def payload_too_large_handler(_request: Any, exc: PayloadTooLargeError) -> Any:
|
|
86
|
+
return _json_error_response(json_response, 413, str(exc))
|
|
87
|
+
|
|
88
|
+
@app.exception_handler(MethodNotAllowedError)
|
|
89
|
+
def method_not_allowed_handler(_request: Any, exc: MethodNotAllowedError) -> Any:
|
|
90
|
+
return _json_error_response(json_response, 405, str(exc))
|
|
91
|
+
|
|
92
|
+
@app.exception_handler(ValidationError)
|
|
93
|
+
def validation_error_handler(_request: Any, exc: ValidationError) -> Any:
|
|
94
|
+
return _json_error_response(json_response, 400, str(exc))
|
|
95
|
+
|
|
96
|
+
@app.exception_handler(ShoppingCliError)
|
|
97
|
+
def shopping_cli_error_handler(_request: Any, exc: ShoppingCliError) -> Any:
|
|
98
|
+
return _json_error_response(json_response, 400, str(exc))
|
|
99
|
+
|
|
100
|
+
if request_validation_error is not None: # pragma: no cover - exercised with fastapi installed
|
|
101
|
+
@app.exception_handler(request_validation_error)
|
|
102
|
+
def request_validation_error_handler(request: Any, exc: Exception) -> Any:
|
|
103
|
+
# 不回显 str(exc):包含 schema 内部结构并回显调用方输入。
|
|
104
|
+
logger.warning("request validation failed on %s: %s", getattr(request, "url", "?"), exc)
|
|
105
|
+
return _json_error_response(json_response, 400, "invalid request body")
|
|
106
|
+
|
|
107
|
+
if starlette_http_exception is not None: # pragma: no cover - exercised with fastapi installed
|
|
108
|
+
@app.exception_handler(starlette_http_exception)
|
|
109
|
+
def http_exception_handler(_request: Any, exc: Any) -> Any:
|
|
110
|
+
status = int(exc.status_code)
|
|
111
|
+
message = "not found" if status == 404 else "method not allowed" if status == 405 else str(exc.detail)
|
|
112
|
+
return _json_error_response(json_response, status, message)
|
|
113
|
+
|
|
114
|
+
@app.exception_handler(Exception)
|
|
115
|
+
def unexpected_error_handler(request: Any, exc: Exception) -> Any:
|
|
116
|
+
logger.exception("unhandled error on %s %s", getattr(request, "method", "?"), getattr(request, "url", "?"))
|
|
117
|
+
return _json_error_response(json_response, 500, "internal server error")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Transport-only error response construction for the dual API stack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_error_response(
|
|
11
|
+
status_code: int,
|
|
12
|
+
error: str,
|
|
13
|
+
response_factory: Callable[..., Any] | None = None,
|
|
14
|
+
) -> Any:
|
|
15
|
+
"""Build the existing JSON error envelope for FastAPI or fallback mode."""
|
|
16
|
+
payload = {"ok": False, "error": error}
|
|
17
|
+
if response_factory is not None:
|
|
18
|
+
return response_factory(status_code=status_code, content=payload)
|
|
19
|
+
return SimpleNamespace(
|
|
20
|
+
status_code=status_code,
|
|
21
|
+
body=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
22
|
+
)
|