feat(client): initialize procurement tool skeleton
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# 本机开发环境与解释器缓存
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# 运行时生成的日志、截图和其他证据产物不得进入版本库。
|
||||
logs/
|
||||
artifacts/
|
||||
runtime/
|
||||
*.log
|
||||
|
||||
# 本机凭据或环境覆盖仅可保存在未跟踪文件中。
|
||||
.env
|
||||
.env.*
|
||||
secrets/
|
||||
|
||||
# 打包工具生成的本机产物
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
@@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "cmbuyer-client"
|
||||
version = "0.1.0"
|
||||
description = "cmbuyer 采购工具桌面端"
|
||||
requires-python = ">=3.11"
|
||||
dynamic = ["dependencies"]
|
||||
|
||||
[project.scripts]
|
||||
cmbuyer-client = "cmbuyer_client.app:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
dependencies = { file = ["requirements.txt"] }
|
||||
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# 桌面界面(Qt 官方 Python 绑定)。
|
||||
PySide6
|
||||
# 后续真机取证会使用;本阶段不导入或连接设备。
|
||||
uiautomator2
|
||||
# 后续截图完整性检查会使用。
|
||||
Pillow
|
||||
@@ -0,0 +1,80 @@
|
||||
"""验证 wheel 元数据从 requirements.txt 声明了全部运行时依赖。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
|
||||
def normalize_project_name(name: str) -> str:
|
||||
"""使用足以比较 requirements 与 Core Metadata 的项目名规范化规则。"""
|
||||
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
def dependencies_from_requirements(requirements_file: Path) -> set[str]:
|
||||
"""从唯一依赖来源读取项目名;当前 requirements 不允许间接或可编辑依赖。"""
|
||||
|
||||
dependencies: set[str] = set()
|
||||
for line in requirements_file.read_text(encoding="utf-8").splitlines():
|
||||
requirement = line.partition("#")[0].strip()
|
||||
if not requirement:
|
||||
continue
|
||||
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
|
||||
if match is None:
|
||||
raise ValueError(f"requirements.txt 包含不支持的依赖声明:{requirement}")
|
||||
dependencies.add(normalize_project_name(match.group()))
|
||||
return dependencies
|
||||
|
||||
|
||||
def dependencies_from_wheel(wheel_file: Path) -> set[str]:
|
||||
"""读取 wheel 的 Core Metadata 中声明的 Requires-Dist 项目名。"""
|
||||
|
||||
with zipfile.ZipFile(wheel_file) as wheel:
|
||||
metadata_members = [name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")]
|
||||
if len(metadata_members) != 1:
|
||||
raise ValueError("wheel 中必须恰有一个 .dist-info/METADATA 文件")
|
||||
metadata = BytesParser(policy=policy.default).parsebytes(wheel.read(metadata_members[0]))
|
||||
|
||||
dependencies = set()
|
||||
for requirement in metadata.get_all("Requires-Dist", []):
|
||||
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
|
||||
if match is None:
|
||||
raise ValueError(f"wheel METADATA 包含无效的 Requires-Dist:{requirement}")
|
||||
dependencies.add(normalize_project_name(match.group()))
|
||||
return dependencies
|
||||
|
||||
|
||||
def verify_wheel_metadata(wheel_file: Path, requirements_file: Path) -> set[str]:
|
||||
"""返回没有被 wheel 元数据声明的 requirements 项目名。"""
|
||||
|
||||
return dependencies_from_requirements(requirements_file) - dependencies_from_wheel(wheel_file)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="检查 wheel 是否包含 requirements.txt 的依赖元数据")
|
||||
parser.add_argument("wheel", type=Path, help="待检查的 wheel 文件")
|
||||
parser.add_argument(
|
||||
"--requirements",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parents[1] / "requirements.txt",
|
||||
help="唯一依赖来源 requirements.txt 的路径",
|
||||
)
|
||||
arguments = parser.parse_args(argv)
|
||||
|
||||
missing = verify_wheel_metadata(arguments.wheel, arguments.requirements)
|
||||
if missing:
|
||||
print(f"wheel METADATA 缺少依赖:{', '.join(sorted(missing))}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("wheel METADATA 已声明 requirements.txt 中的全部依赖。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,6 @@
|
||||
"""采购工具桌面端包。
|
||||
|
||||
本包当前只提供应用骨架和安全的本地运行基础设施;不包含真机操作或采购流程。
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
"""支持通过 ``python -m cmbuyer_client`` 启动应用。"""
|
||||
|
||||
from .app import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""采购工具的最小桌面应用入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .logging_policy import configure_application_logger
|
||||
from .runtime import RuntimePaths
|
||||
|
||||
|
||||
def select_application_argv(argv: Sequence[str] | None) -> list[str]:
|
||||
"""保留调用方明确给出的空参数列表,避免改变测试或打包入口的语义。"""
|
||||
|
||||
return list(sys.argv if argv is None else argv)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""启动只表达当前工程状态的桌面外壳。
|
||||
|
||||
真机控制和采购执行必须在完成取证并实现后才可接入,因此此入口不导入
|
||||
uiautomator2,也不提供任何会影响采购或支付状态的命令。
|
||||
"""
|
||||
|
||||
try:
|
||||
paths = RuntimePaths.default()
|
||||
logger = configure_application_logger(paths)
|
||||
except OSError as error:
|
||||
print(f"无法创建采购工具运行目录:{error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
|
||||
except ImportError:
|
||||
logger.error("缺少 PySide6,无法启动桌面界面。")
|
||||
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
application = QApplication.instance() or QApplication(select_application_argv(argv))
|
||||
application.setApplicationName("采购工具")
|
||||
|
||||
window = QMainWindow()
|
||||
window.setWindowTitle("采购工具")
|
||||
window.setAccessibleName("采购工具")
|
||||
window.setMinimumSize(420, 240)
|
||||
window.resize(560, 320)
|
||||
|
||||
message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。")
|
||||
message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
message.setWordWrap(True)
|
||||
message.setAccessibleName("当前状态")
|
||||
window.setCentralWidget(message)
|
||||
|
||||
logger.info("应用已启动;采购执行功能尚未启用。")
|
||||
window.show()
|
||||
return application.exec()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""采购工具日志的最小脱敏策略。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from .runtime import RuntimePaths
|
||||
|
||||
|
||||
LOGGER_NAME = "cmbuyer_client"
|
||||
REDACTED = "[已隐藏]"
|
||||
|
||||
_SENSITIVE_KEY_PATTERN = (
|
||||
r"token|authorization|password|secret|api[_-]?key|"
|
||||
r"address|phone|mobile|payment|pay|card|bank[_-]?account|"
|
||||
r"令牌|授权|密码|密钥|地址|手机号|电话|支付|银行卡"
|
||||
)
|
||||
_KEY_VALUE_PATTERN = re.compile(
|
||||
rf"(?P<key>{_SENSITIVE_KEY_PATTERN})\s*(?P<separator>[:=])\s*"
|
||||
r"(?P<value>\"[^\"]*\"|'[^']*'|[^\s,;]+)",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
|
||||
|
||||
|
||||
def redact_text(message: str) -> str:
|
||||
"""移除日志文本中的凭据、地址、手机号和支付字段值。"""
|
||||
|
||||
def replace_key_value(match: re.Match[str]) -> str:
|
||||
return f"{match.group('key')}{match.group('separator')}{REDACTED}"
|
||||
|
||||
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, message)
|
||||
return _PHONE_PATTERN.sub(REDACTED, redacted)
|
||||
|
||||
|
||||
class SensitiveDataFilter(logging.Filter):
|
||||
"""在任何 handler 格式化记录前,清除敏感字段。
|
||||
|
||||
该过滤器在日志写入前替换 ``msg`` 与 ``args``,确保文件 handler 不会得到原始值。
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.msg = redact_text(record.getMessage())
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
|
||||
|
||||
paths.ensure_exists()
|
||||
logger = logging.getLogger(LOGGER_NAME)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
for handler in tuple(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
|
||||
handler.addFilter(SensitiveDataFilter())
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
@@ -0,0 +1,42 @@
|
||||
"""采购工具的本地运行目录策略。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimePaths:
|
||||
"""仅包含应用生成物的本地目录。
|
||||
|
||||
日志和证据产物不落在源码树中,避免把含有现场信息的运行数据误提交到 Git。
|
||||
"""
|
||||
|
||||
root: Path
|
||||
logs: Path
|
||||
artifacts: Path
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: Path) -> "RuntimePaths":
|
||||
resolved_root = root.expanduser()
|
||||
return cls(
|
||||
root=resolved_root,
|
||||
logs=resolved_root / "logs",
|
||||
artifacts=resolved_root / "artifacts",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "RuntimePaths":
|
||||
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
return cls.from_root(Path(local_app_data) / "cmbuyer")
|
||||
|
||||
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
|
||||
|
||||
def ensure_exists(self) -> None:
|
||||
"""创建运行目录;调用方负责向用户呈现无法创建目录的错误。"""
|
||||
|
||||
self.logs.mkdir(parents=True, exist_ok=True)
|
||||
self.artifacts.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""采购工具的离线单元测试。"""
|
||||
@@ -0,0 +1,24 @@
|
||||
"""验证桌面入口的纯参数处理,不启动 PySide6。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.app import select_application_argv
|
||||
|
||||
|
||||
class ApplicationArgumentsTests(unittest.TestCase):
|
||||
def test_empty_argument_list_is_not_replaced_with_process_arguments(self) -> None:
|
||||
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
|
||||
self.assertEqual([], select_application_argv([]))
|
||||
|
||||
def test_none_uses_process_arguments(self) -> None:
|
||||
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
|
||||
self.assertEqual(["process-name", "--process-option"], select_application_argv(None))
|
||||
@@ -0,0 +1,49 @@
|
||||
"""验证日志脱敏边界,不需要 PySide6 或真机。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.logging_policy import configure_application_logger, redact_text
|
||||
from cmbuyer_client.runtime import RuntimePaths
|
||||
|
||||
|
||||
class LoggingPolicyTests(unittest.TestCase):
|
||||
def test_redact_text_hides_required_sensitive_values(self) -> None:
|
||||
message = (
|
||||
"token=secret-value authorization: Bearer-value "
|
||||
"address='浙江省杭州市' phone=13800138000 payment=card-value"
|
||||
)
|
||||
|
||||
redacted = redact_text(message)
|
||||
|
||||
for raw_value in ("secret-value", "Bearer-value", "浙江省杭州市", "13800138000", "card-value"):
|
||||
self.assertNotIn(raw_value, redacted)
|
||||
self.assertIn("token=[已隐藏]", redacted)
|
||||
|
||||
def test_file_handler_writes_only_redacted_text(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = RuntimePaths.from_root(Path(directory))
|
||||
logger = configure_application_logger(paths)
|
||||
try:
|
||||
logger.info("token=not-for-log phone=13900139000 payment=not-for-log")
|
||||
|
||||
for handler in logger.handlers:
|
||||
handler.flush()
|
||||
content = (paths.logs / "client.log").read_text(encoding="utf-8")
|
||||
finally:
|
||||
# Windows 不允许在 FileHandler 持有文件时删除临时目录。
|
||||
for handler in tuple(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
self.assertNotIn("not-for-log", content)
|
||||
self.assertNotIn("13900139000", content)
|
||||
self.assertIn("[已隐藏]", content)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""验证本地运行目录策略,不需要连接设备。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.runtime import RuntimePaths
|
||||
|
||||
|
||||
class RuntimePathsTests(unittest.TestCase):
|
||||
def test_ensure_exists_creates_only_runtime_directories(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = RuntimePaths.from_root(Path(directory) / "runtime")
|
||||
|
||||
paths.ensure_exists()
|
||||
|
||||
self.assertTrue(paths.logs.is_dir())
|
||||
self.assertTrue(paths.artifacts.is_dir())
|
||||
@@ -0,0 +1,29 @@
|
||||
"""验证 wheel 元数据检查脚本,不构建 wheel 或安装运行时依赖。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "scripts"))
|
||||
|
||||
from verify_wheel_metadata import dependencies_from_requirements, verify_wheel_metadata
|
||||
|
||||
|
||||
class WheelMetadataTests(unittest.TestCase):
|
||||
def test_metadata_checker_accepts_dependencies_from_the_single_requirements_file(self) -> None:
|
||||
expected_dependencies = dependencies_from_requirements(CLIENT_ROOT / "requirements.txt")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
wheel_file = Path(directory) / "cmbuyer_client-0.1.0-py3-none-any.whl"
|
||||
metadata = "Metadata-Version: 2.3\n" + "".join(
|
||||
f"Requires-Dist: {dependency}\n" for dependency in sorted(expected_dependencies)
|
||||
)
|
||||
with zipfile.ZipFile(wheel_file, "w") as wheel:
|
||||
wheel.writestr("cmbuyer_client-0.1.0.dist-info/METADATA", metadata)
|
||||
|
||||
self.assertEqual(set(), verify_wheel_metadata(wheel_file, CLIENT_ROOT / "requirements.txt"))
|
||||
Reference in New Issue
Block a user