现象:编辑器 / pyright 满屏 reportMissingImports,但 .venv 与 conda 环境里 import numpy, pandas 都正常,pytest 也全过。 根因两层: 1. pyright 没配 venvPath / pythonPath 时会去 PATH 上找 python。本机 PATH 上 唯一的 python 是 agent-tools 的 shim(Codex 运行时,Python 3.14),没有科学栈。 pyright --verbose 显示它选了 3.14.6,Search paths 里一个 Anaconda 路径都没有。 注意持久化的 User/Machine PATH 并没有这一项,是 Codex 进程临时注入的——所以 "我的终端没问题"和"工具有问题"可以同时成立。 2. .venv 是 --system-site-packages 建的,三方包在 D:\anaconda\Lib\site-packages, 而 pyright / Pylance 不认 pyvenv.cfg 的 include-system-site-packages, 只去 .venv\Lib\site-packages 找(那里只有 pip)。 pyright 不读 pyvenv.cfg,但**认 .pth**。 改动: - pyproject.toml [tool.pyright]:venvPath = "." / venv = ".venv" / pythonVersion = "3.13",并注释了为什么必须写 - scripts/setup-venv.ps1:新增第 [2/4] 步,自动往 .venv 写 _mcm_base_site_packages.pth 指向该机 base 的 site-packages,换机重建即可 - tests/test_data.py:3 处 isna().all() 断言外面套 bool()(它的类型是 Series | bool,直接当条件 pyright 报"条件操作数无效"); 2 处故意传错类型的用例加 type: ignore[arg-type] 结果:pyright 17 errors -> 0 errors / 0 warnings;pytest 两套环境均 40 passed, 44 skipped;ruff All checks passed;setup-venv.ps1 通过 AST 解析 (纯 ASCII + LF)。 文档:docs/04-environment.md 新增坑 7(含 pyright --verbose 诊断口诀与 VS Code / PyCharm 的操作步骤)、STATUS 决策 29、CHANGELOG。
175 lines
6.0 KiB
Python
175 lines
6.0 KiB
Python
"""data 包测试。
|
||
|
||
L0 阶段的约定:**只测已实现的行为**。未实现的算法用逐条 skip 标出,
|
||
不要用模块级 pytestmark —— 那会把同文件里已实现的函数一起跳过,
|
||
让 load_table 这种已经写好的函数变成零覆盖。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
import pytest
|
||
|
||
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L0/L1"
|
||
|
||
|
||
# ---------------------------------------------------------------- io: load_table
|
||
|
||
|
||
def test_load_table_detects_gbk(tmp_path):
|
||
"""国内赛题数据常见 GBK,编码探测必须能兜住。"""
|
||
from mcm_toolkit.data import load_table
|
||
|
||
p = tmp_path / "gbk.csv"
|
||
p.write_bytes("城市,人口\n北京,2189\n".encode("gbk"))
|
||
df = load_table(p)
|
||
assert list(df.columns) == ["城市", "人口"]
|
||
|
||
|
||
def test_load_table_keeps_utf8_readable(tmp_path):
|
||
"""UTF-8 文件必须按 UTF-8 读出,不能先试 GBK 变成乱码。"""
|
||
from mcm_toolkit.data import load_table
|
||
|
||
p = tmp_path / "utf8.csv"
|
||
p.write_text("省份,GDP增速\n广东,5.8\n", encoding="utf-8")
|
||
df = load_table(p)
|
||
assert list(df.columns) == ["省份", "GDP增速"]
|
||
assert df.loc[0, "省份"] == "广东"
|
||
|
||
|
||
def test_load_table_maps_na_like_tokens_to_nan(tmp_path):
|
||
"""赛题里的缺失值长得五花八门,默认 na_values 要全部吃成 NaN。"""
|
||
from mcm_toolkit.data import load_table
|
||
|
||
p = tmp_path / "na.csv"
|
||
p.write_text("a,b,c\n1,NA,\n2,-,#N/A\n", encoding="utf-8")
|
||
df = load_table(p)
|
||
# 外面套 bool():df["b"] 的类型是 Series | DataFrame,直接当条件用 pyright 会报
|
||
# "条件操作数无效";套上 bool() 既满足类型检查,又能在它真返回 Series 时当场炸掉。
|
||
assert bool(df["b"].isna().all())
|
||
assert bool(df["c"].isna().all())
|
||
assert bool(df["a"].notna().all())
|
||
|
||
|
||
def test_load_table_respects_explicit_encoding(tmp_path):
|
||
"""显式给了 encoding 就照办,不再做探测。"""
|
||
from mcm_toolkit.data import load_table
|
||
|
||
p = tmp_path / "gbk.csv"
|
||
p.write_bytes("城市,人口\n上海,2487\n".encode("gbk"))
|
||
df = load_table(p, encoding="gbk")
|
||
assert list(df.columns) == ["城市", "人口"]
|
||
|
||
|
||
def test_load_table_rejects_unknown_suffix(tmp_path):
|
||
"""不支持的扩展名要抛 ValueError,而且要带上后缀名。"""
|
||
from mcm_toolkit.data import load_table
|
||
|
||
p = tmp_path / "data.txt"
|
||
p.write_text("a,b\n1,2\n", encoding="utf-8")
|
||
with pytest.raises(ValueError, match=r"\.txt"):
|
||
load_table(p)
|
||
|
||
|
||
def test_load_table_missing_file_raises(tmp_path):
|
||
"""文件不存在时要把路径原样带出来,方便定位。"""
|
||
from mcm_toolkit.data import load_table
|
||
|
||
missing = tmp_path / "nope.csv"
|
||
with pytest.raises(FileNotFoundError, match=r"nope\.csv"):
|
||
load_table(missing)
|
||
|
||
|
||
# ---------------------------------------------------------------- io: save_table
|
||
|
||
|
||
def test_save_table_round_trip(tmp_path):
|
||
"""存进去再读出来,内容要一致,并且返回实际写入的 Path。"""
|
||
from mcm_toolkit.data import load_table, save_table
|
||
|
||
df = pd.DataFrame({"城市": ["北京", "上海"], "人口": [2189.0, 2487.0]})
|
||
out = save_table(df, tmp_path / "out.csv")
|
||
assert isinstance(out, Path)
|
||
assert out.exists()
|
||
back = load_table(out)
|
||
assert list(back.columns) == ["城市", "人口"]
|
||
assert back["人口"].tolist() == [2189.0, 2487.0]
|
||
|
||
|
||
def test_save_table_rejects_non_dataframe(tmp_path):
|
||
"""传错类型要当场报错,不要写出一个奇怪的文件。"""
|
||
from mcm_toolkit.data import save_table
|
||
|
||
with pytest.raises(TypeError):
|
||
save_table([1, 2, 3], tmp_path / "bad.csv") # type: ignore[arg-type] # 就是要传错
|
||
|
||
|
||
def test_save_table_validates_before_touching_disk(tmp_path):
|
||
"""校验必须早于副作用:参数不合法时,一个目录、一个文件都不能留下。"""
|
||
from mcm_toolkit.data import save_table
|
||
|
||
with pytest.raises(ValueError, match=r"\.txt"):
|
||
save_table(pd.DataFrame({"a": [1]}), tmp_path / "d1" / "x.txt")
|
||
with pytest.raises(TypeError):
|
||
save_table([1, 2, 3], tmp_path / "d2" / "x.csv") # type: ignore[arg-type] # 就是要传错
|
||
|
||
assert sorted(p.name for p in tmp_path.iterdir()) == []
|
||
|
||
|
||
# ------------------------------------------------- 以下为未实现算法(L0/L1 待写)
|
||
|
||
|
||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
||
def test_detect_outliers_finds_planted_points():
|
||
"""人工埋 3 个离群点,IQR 法应正好命中 3 个。"""
|
||
from mcm_toolkit.data import detect_outliers
|
||
|
||
x = np.concatenate([np.arange(100.0), [1e6, -1e6, 1e7]])
|
||
mask = np.asarray(detect_outliers(x, method="iqr"))
|
||
assert mask.sum() == 3
|
||
assert mask[-3:].all()
|
||
|
||
|
||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
||
def test_detect_outliers_returns_mask_not_filtered_data():
|
||
"""约定:只标不删——返回与原数据同形的布尔掩码。"""
|
||
from mcm_toolkit.data import detect_outliers
|
||
|
||
x = np.arange(50.0)
|
||
mask = np.asarray(detect_outliers(x, method="zscore"))
|
||
assert mask.shape == x.shape
|
||
assert mask.dtype == bool
|
||
|
||
|
||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
||
def test_normalize_minmax_range(rng):
|
||
from mcm_toolkit.data import normalize
|
||
|
||
x = rng.normal(size=(50, 3))
|
||
out = np.asarray(normalize(x, method="minmax"))
|
||
assert np.allclose(out.min(axis=0), 0.0)
|
||
assert np.allclose(out.max(axis=0), 1.0)
|
||
|
||
|
||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
||
def test_standardize_has_zero_mean_unit_std(rng):
|
||
from mcm_toolkit.data import standardize
|
||
|
||
x = rng.normal(5, 3, size=1000)
|
||
out = np.asarray(standardize(x))
|
||
assert abs(out.mean()) < 1e-9
|
||
assert abs(out.std() - 1.0) < 1e-9
|
||
|
||
|
||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
||
def test_train_test_split_sizes_and_no_overlap(rng):
|
||
from mcm_toolkit.data import train_test_split
|
||
|
||
X = rng.normal(size=(100, 2))
|
||
Xtr, Xte = train_test_split(X, test_size=0.2, random_state=0)
|
||
assert len(Xtr) == 80 and len(Xte) == 20
|
||
assert len({tuple(r) for r in Xtr} & {tuple(r) for r in Xte}) == 0
|