diff --git a/CHANGELOG.md b/CHANGELOG.md index f20c167..be92e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.29.2] — 2026-09-02 + +**可编辑安装失效时给出友好报错**([#58](https://github.com/handsomejustin/easy_tdx/discussions/58))——`pip install -e .` 会在 site-packages 写入 `_editable_impl_easy_tdx.pth`(内容为仓库 `src/` 绝对路径)。仓库目录被移动/重命名/重新 clone 或该文件丢失后,`easy-tdx` 只会抛出一句无法定位的 `No module named 'easy_tdx.cli'`:`easy_tdx` 本体因 site-packages 里的 `web/dist` 命名空间碎片仍可导入,报错极具误导性(实测复现,失效态 `easy_tdx.__path__` 只剩 site-packages 碎片路径)。 + +### 新增 + +- **入口守卫模块 `easy_tdx._editable_guard`**——控制台脚本入口从 `easy_tdx.cli:cli` 改为经 `main()` 转发(正常态行为完全不变);`easy_tdx.cli` 不可导入时打印中文修复指引(失效原因说明 + `python -c "import easy_tdx; print(easy_tdx.__path__)"` 排查命令 + 免重建 venv 的修复命令 `pip uninstall easy-tdx -y && pip install -e . --no-deps` + issue 链接),退出码 1。守卫文件经 `force-include` 同时复制进 site-packages 的碎片目录——失效态下 `src/` 代码全部不可达,唯有该副本可导入,这正是守卫能"在坏掉时还活着"的关键(`__init__.py` 途径不可行:失效态解析到的是无 `__init__.py` 的命名空间碎片)。 +- 单测 `tests/unit/test_editable_guard.py`(2 例):meta_path 阻断器模拟 `ModuleNotFoundError` → 断言提示内容与退出码;正常态断言转发调用 click 组。已本地实测三种安装形态:可编辑健康态 `--help` 正常、模拟失效态输出指引且退出码 1、wheel 安装态正常(hatchling 对包内文件 + force-include 同路径映射自动去重,guard 在 wheel 中恰好一份)。 + ## [1.29.1] — 2026-09-02 **中金所成交持仓排名采集(ccpm,独立数据源)**——散户能免费看到的**最接近"主力动向"的公开数据**:每个交易日收盘后约 16:15,中金所官网公布各期货品种「成交量 / 持买单量(多单)/ 持卖单量(空单)」各前 20 名期货公司会员排名。新增 `easy_tdx.ccpm` 模块并三端接入(CLI / Web API / WebUI),零第三方依赖(标准库 urllib)。 diff --git a/pyproject.toml b/pyproject.toml index d10ee11..115117a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,16 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.29.1" +version = "1.29.2" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" dependencies = ["pandas>=2.0,<3", "tzdata>=2024.1", "click>=8.0,<9"] [project.scripts] -easy-tdx = "easy_tdx.cli:cli" # cli/__init__.py exposes the click group +# 经由守卫转发:可编辑安装失效(仓库被移动/.pth 丢失)时打印修复指引(#58), +# 机制见 _editable_guard.py 模块 docstring。 +easy-tdx = "easy_tdx._editable_guard:main" [project.optional-dependencies] dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "pytest-cov", "mypy>=1.9", "ruff>=0.4", "scipy>=1.10,<1.16", "httpx>=0.27", "duckdb>=1.0"] @@ -34,7 +36,9 @@ artifacts = [ packages = ["src/easy_tdx"] # 前端 dist 映射到包内 easy_tdx/web/dist/。CI(ci.yml)、publish(publish.yml) # 和 release(release.yml)都会在 pip install / build 前先 npm run build。 -force-include = { "web-ui/dist" = "easy_tdx/web/dist" } +# _editable_guard.py 一并映射进 site-packages 的碎片目录:可编辑安装失效时 +# src/ 代码全部不可达,唯有这份副本可导入,入口守卫才能打印修复指引(#58)。 +force-include = { "web-ui/dist" = "easy_tdx/web/dist", "src/easy_tdx/_editable_guard.py" = "easy_tdx/_editable_guard.py" } [tool.mypy] strict = true diff --git a/src/easy_tdx/_editable_guard.py b/src/easy_tdx/_editable_guard.py new file mode 100644 index 0000000..82f7a43 --- /dev/null +++ b/src/easy_tdx/_editable_guard.py @@ -0,0 +1,54 @@ +"""easy-tdx 控制台入口守卫——可编辑安装失效时给出可操作的修复指引。 + +背景(#58):``pip install -e .``(hatchling 可编辑安装)会在 site-packages +留下两样东西: + +1. ``_editable_impl_easy_tdx.pth`` —— 内容是仓库 ``src/`` 目录的**绝对路径**, + Python 靠它把 clone 的源码挂进 ``sys.path``; +2. 一个真实的 ``easy_tdx/`` 目录(pyproject 的 ``force-include`` 把 + ``web-ui/dist`` 前端产物映射进来),**没有 ``__init__.py``**,是 PEP 420 + 命名空间包的一个"碎片"。 + +当仓库目录被移动/重命名/重新 clone、或 .pth 丢失时,``src/`` 从 +``sys.path`` 消失,但碎片目录还在:``import easy_tdx`` 依旧成功(解析为 +只剩静态资源的命名空间包),``import easy_tdx.cli`` 才失败——pip 生成的 +控制台脚本会把 ``ModuleNotFoundError: No module named 'easy_tdx.cli'`` +原样抛出,用户完全无法定位。 + +本模块的存活机制:它同时通过 ``force-include`` 被复制进 site-packages 的 +碎片目录。失效态下 ``src/`` 里的代码一行都不可达,唯独这份副本仍可导入, +``easy-tdx`` 入口指向这里的 :func:`main`,就能在坏掉时打印修复指引。 +""" + +from __future__ import annotations + +import sys + +_HINT = """\ +easy-tdx: 无法导入 easy_tdx.cli —— 可编辑安装(pip install -e .)的注册信息很可能已失效。 + +可编辑安装在 site-packages 生成 _editable_impl_easy_tdx.pth,内容是仓库 src/ 目录的 +绝对路径。仓库目录被移动/重命名/重新 clone,或该文件丢失后,easy_tdx 只剩 +site-packages 里的静态资源碎片,源码全部不可达(所以 easy_tdx 本体仍能导入, +偏偏 cli 导不进来)。 + +排查: + python -c "import easy_tdx; print(easy_tdx.__path__)" + # 正常应包含你仓库的 src/easy_tdx 绝对路径;只剩 site-packages 路径即失效 + +修复(无需删除重建虚拟环境): + pip uninstall easy-tdx -y && pip install -e . --no-deps + +若以上排查不符合你的情况,请带报错提 issue: + https://github.com/handsomejustin/easy_tdx/issues +""" + + +def main() -> None: + """``easy-tdx`` 控制台入口:转发到 click 组;导入失败时打印修复指引。""" + try: + from .cli import cli + except ModuleNotFoundError: + print(_HINT, file=sys.stderr) + raise SystemExit(1) from None + cli() diff --git a/tests/unit/test_editable_guard.py b/tests/unit/test_editable_guard.py new file mode 100644 index 0000000..1974df8 --- /dev/null +++ b/tests/unit/test_editable_guard.py @@ -0,0 +1,55 @@ +"""_editable_guard 入口守卫测试:失效态提示 + 正常态转发(#58)。 + +失效态无法在测试里真实构造(需要破坏 site-packages 的 .pth),用 +meta_path 阻断器让 ``easy_tdx.cli`` 的导入抛 ``ModuleNotFoundError``。 +""" + +from __future__ import annotations + +import sys +import types + +import pytest + +from easy_tdx import _editable_guard + + +class _CliImportBlocker: + """meta_path finder:``easy_tdx.cli`` 一律抛 ModuleNotFoundError,其余放行。""" + + def find_spec(self, fullname, path=None, target=None): # noqa: ARG002 + if fullname == "easy_tdx.cli": + raise ModuleNotFoundError(f"No module named {fullname!r}", name=fullname) + return None + + +def test_broken_editable_prints_hint_and_exits(capsys, monkeypatch): + """easy_tdx.cli 不可导入时:打印修复指引并以退出码 1 结束。""" + # easy_tdx.cli 可能已被 conftest/其他用例真实导入,先清缓存并阻断再次加载, + # 否则 import 系统直接命中 sys.modules 短路,阻断器根本不会被咨询。 + monkeypatch.delitem(sys.modules, "easy_tdx.cli", raising=False) + monkeypatch.setattr(sys, "meta_path", [_CliImportBlocker(), *sys.meta_path]) + + with pytest.raises(SystemExit) as exc_info: + _editable_guard.main() + + assert exc_info.value.code == 1 + err = capsys.readouterr().err + assert "easy_tdx.cli" in err + # 指引必须点名失效根源与两条修复命令,否则用户无从下手 + assert "_editable_impl_easy_tdx.pth" in err + assert "pip uninstall easy-tdx -y && pip install -e . --no-deps" in err + assert "easy_tdx.__path__" in err + + +def test_healthy_path_forwards_to_click_group(monkeypatch): + """正常态:main() 直接转发调用 easy_tdx.cli.cli。""" + called: list[bool] = [] + + stub = types.ModuleType("easy_tdx.cli") + stub.cli = lambda: called.append(True) # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "easy_tdx.cli", stub) + + _editable_guard.main() + + assert called == [True]