From 38386b53a270a5e29634dd7d42d4be4434ca1177 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 12:02:40 +0400 Subject: [PATCH] =?UTF-8?q?test:=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=20?= =?UTF-8?q?=D0=BA=20../fixtures=20(ARM=5FKA=5FFIXTURES),=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D0=BE=2066=20=D0=9E=D0=A1=D0=92;=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?openpyxl/xlrd/pyxlsb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requirements: openpyxl==3.1.5, pyxlsb==1.0.10, xlrd==2.0.2, et_xmlfile==2.0.0 (транзитивная) - tests/fixtures_helpers.py, conftest: фикстура fixtures_dir, параметризация osv_file/synthetic_file, skip маркера fixtures - pyproject: mypy ignore_missing_imports для openpyxl/xlrd/pyxlsb, disallow_untyped_defs для src.parser.* Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/pyproject.toml | 10 ++++ api/requirements.txt | 4 ++ api/tests/conftest.py | 38 ++++++++++++ api/tests/fixtures_helpers.py | 45 ++++++++++++++ api/tests/unit/test_fixtures_helpers.py | 58 +++++++++++++++++++ api/tests/unit/test_smoke_fixtures.py | 10 ++++ .../plans/2026-10-05-slice-2a-osv-parser.md | 6 +- openspec/changes/slice-2a-osv-parser/tasks.md | 2 +- 8 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 api/tests/fixtures_helpers.py create mode 100644 api/tests/unit/test_fixtures_helpers.py create mode 100644 api/tests/unit/test_smoke_fixtures.py diff --git a/api/pyproject.toml b/api/pyproject.toml index 53b4910..334461b 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -28,3 +28,13 @@ ignore_missing_imports = true module = ["miniopy_async", "miniopy_async.*"] follow_imports = "skip" ignore_missing_imports = true + +# Библиотеки чтения Excel: без типов в поставке +[[tool.mypy.overrides]] +module = ["openpyxl", "openpyxl.*", "xlrd", "xlrd.*", "pyxlsb", "pyxlsb.*"] +ignore_missing_imports = true + +# Строгая типизация парсера (чистые функции над книгой Excel) +[[tool.mypy.overrides]] +module = ["src.parser", "src.parser.*"] +disallow_untyped_defs = true diff --git a/api/requirements.txt b/api/requirements.txt index baf800b..4050601 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -11,6 +11,7 @@ cffi==2.1.0 click==8.4.2 dnspython==2.8.0 email-validator==2.3.0 +et_xmlfile==2.0.0 fastapi==0.135.1 fastapi-cli==0.0.24 fastapi-cloud-cli==0.14.1 @@ -30,6 +31,7 @@ miniopy-async==1.23.4 multidict==6.7.1 nats-py==2.14.0 nkeys==0.2.1 +openpyxl==3.1.5 propcache==0.5.2 pycparser==3.0 pycryptodome==3.23.0 @@ -41,6 +43,7 @@ Pygments==2.20.0 PyNaCl==1.5.0 python-dotenv==1.2.2 python-multipart==0.0.32 +pyxlsb==1.0.10 PyYAML==6.0.3 rich==15.0.0 rich-toolkit==0.19.7 @@ -56,4 +59,5 @@ uvicorn==0.46.0 uvloop==0.22.1 watchfiles==1.2.0 websockets==16.1.1 +xlrd==2.0.2 yarl==1.24.5 diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 90c2304..efa66ce 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -5,6 +5,7 @@ from collections.abc import Callable from pathlib import Path import pytest +from fixtures_helpers import fixtures_skip_reason, osv_fixture_files, resolve_fixtures_dir, synthetic_files from src.core.config import Settings @@ -46,3 +47,40 @@ def minimal_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Callabl return Settings(_env_file=env_file) # type: ignore[call-arg] return build + + +@pytest.fixture(scope="session") +def fixtures_dir() -> Path: + """Каталог `../fixtures`; тест пропускается с причиной, если каталога нет.""" + reason = fixtures_skip_reason() + if reason is not None: + pytest.skip(reason) + return resolve_fixtures_dir() + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Параметры `osv_file` (реальные ОСВ) и `synthetic_file` (синтетика); без каталога — один параметр со skip.""" + sources = {"osv_file": osv_fixture_files, "synthetic_file": synthetic_files} + for argname, listing in sources.items(): + if argname not in metafunc.fixturenames: + continue + reason = fixtures_skip_reason() + if reason is not None: + metafunc.parametrize( + argname, [pytest.param(None, marks=pytest.mark.skip(reason=reason))], ids=["нет-фикстур"] + ) + continue + root = resolve_fixtures_dir() + files = listing(root) + metafunc.parametrize(argname, files, ids=[path.relative_to(root).as_posix() for path in files]) + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Тесты с маркером `fixtures` пропускаются, если каталог фикстур недоступен.""" + reason = fixtures_skip_reason() + if reason is None: + return + skip = pytest.mark.skip(reason=reason) + for item in items: + if item.get_closest_marker("fixtures"): + item.add_marker(skip) diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py new file mode 100644 index 0000000..c044f20 --- /dev/null +++ b/api/tests/fixtures_helpers.py @@ -0,0 +1,45 @@ +"""Доступ к реальным данным из `../fixtures` (отдельный репозиторий, внутренний доступ).""" + +import os +from pathlib import Path + +FIXTURES_ENV = "ARM_KA_FIXTURES" +OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"}) +_SYNTHETIC = "synthetic" + + +def resolve_fixtures_dir() -> Path: + """Каталог фикстур: `ARM_KA_FIXTURES` или соседний с репозиторием `fixtures/` (workspace).""" + override = os.environ.get(FIXTURES_ENV) + if override: + return Path(override) + return Path(__file__).resolve().parents[3] / "fixtures" + + +def fixtures_skip_reason() -> str | None: + """Причина пропуска тестов на фикстурах (None — каталог доступен). Текст называет переменную и путь (OQ-81).""" + root = resolve_fixtures_dir() + if (root / "osv").is_dir(): + return None + return f"каталог фикстур не найден: {root} (задайте {FIXTURES_ENV}=<путь к репозиторию fixtures>)" + + +def osv_fixture_files(root: Path) -> list[Path]: + """Все `*.xls/*.xlsx/*.xlsb` под `root/osv`, кроме каталога synthetic; отсортированы.""" + osv = root / "osv" + if not osv.is_dir(): + return [] + synthetic = osv / _SYNTHETIC + return sorted( + path + for path in osv.rglob("*") + if path.is_file() and path.suffix.lower() in OSV_EXTENSIONS and synthetic not in path.parents + ) + + +def synthetic_files(root: Path) -> list[Path]: + """Файлы `root/osv/synthetic/*` (скрытые, вроде `.gitkeep`, не считаются); отсортированы.""" + directory = root / "osv" / _SYNTHETIC + if not directory.is_dir(): + return [] + return sorted(path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".")) diff --git a/api/tests/unit/test_fixtures_helpers.py b/api/tests/unit/test_fixtures_helpers.py new file mode 100644 index 0000000..1614842 --- /dev/null +++ b/api/tests/unit/test_fixtures_helpers.py @@ -0,0 +1,58 @@ +"""Хелперы доступа к ../fixtures: каталог из ARM_KA_FIXTURES, список файлов ОСВ без синтетики, причина skip.""" + +from pathlib import Path + +import pytest +from fixtures_helpers import ( + FIXTURES_ENV, + fixtures_skip_reason, + osv_fixture_files, + resolve_fixtures_dir, + synthetic_files, +) + + +def test_env_overrides_fixtures_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(FIXTURES_ENV, str(tmp_path)) + assert resolve_fixtures_dir() == tmp_path + + +def test_default_fixtures_dir_is_sibling_of_repository(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(FIXTURES_ENV, raising=False) + assert resolve_fixtures_dir() == Path(__file__).resolve().parents[4] / "fixtures" + + +def test_no_osv_directory_gives_no_files(tmp_path: Path) -> None: + assert osv_fixture_files(tmp_path) == [] + assert synthetic_files(tmp_path) == [] + + +def test_synthetic_excluded_and_foreign_files_ignored(tmp_path: Path) -> None: + osv = tmp_path / "osv" + (osv / "rb").mkdir(parents=True) + (osv / "synthetic").mkdir() + (osv / "rb" / "b.xlsx").write_bytes(b"") + (osv / "a.xls").write_bytes(b"") + (osv / "c.xlsb").write_bytes(b"") + (osv / ".DS_Store").write_bytes(b"") + (osv / "notes.txt").write_bytes(b"") + (osv / "synthetic" / "s.xlsx").write_bytes(b"") + (osv / "synthetic" / ".gitkeep").write_bytes(b"") + + assert osv_fixture_files(tmp_path) == [osv / "a.xls", osv / "c.xlsb", osv / "rb" / "b.xlsx"] + assert synthetic_files(tmp_path) == [osv / "synthetic" / "s.xlsx"] + + +def test_skip_reason_mentions_env_and_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + missing = tmp_path / "нет-такого" + monkeypatch.setenv(FIXTURES_ENV, str(missing)) + reason = fixtures_skip_reason() + assert reason is not None + assert FIXTURES_ENV in reason + assert str(missing) in reason + + +def test_skip_reason_none_when_available(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / "osv").mkdir() + monkeypatch.setenv(FIXTURES_ENV, str(tmp_path)) + assert fixtures_skip_reason() is None diff --git a/api/tests/unit/test_smoke_fixtures.py b/api/tests/unit/test_smoke_fixtures.py new file mode 100644 index 0000000..c3f72c3 --- /dev/null +++ b/api/tests/unit/test_smoke_fixtures.py @@ -0,0 +1,10 @@ +"""Дымовой тест доступа к реальным ОСВ: параметризация `osv_file` из conftest видит все файлы.""" + +from pathlib import Path + +import pytest + + +@pytest.mark.fixtures +def test_smoke_fixtures(osv_file: Path) -> None: + assert osv_file.exists() diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md index 5d81c24..a217a87 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md +++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md @@ -84,9 +84,9 @@ def pytest_generate_tests(metafunc): параметризует аргумент def pytest_collection_modifyitems: тестам с маркером `fixtures` — skip при недоступном каталоге ``` -- [ ] **Step 1:** тест `tests/unit/test_fixtures_helpers.py`: при `ARM_KA_FIXTURES=` без `osv/` — `osv_fixture_files` пуст; с двумя файлами и `synthetic/` — синтетика исключена; `fixtures_skip_reason()` даёт текст с путём. -- [ ] **Step 2:** реализовать; `make venv`; `pytest -m fixtures --collect-only -q | tail -3` показывает 66 параметров (через временный тест-заглушку `test_smoke_fixtures(osv_file)` с `assert osv_file.exists()`, который остаётся как дымовой). -- [ ] **Step 3:** `make check` → PASS. Commit: `test: доступ к ../fixtures (ARM_KA_FIXTURES), параметризация по 66 ОСВ; зависимости openpyxl/xlrd/pyxlsb`. +- [x] **Step 1:** тест `tests/unit/test_fixtures_helpers.py`: при `ARM_KA_FIXTURES=` без `osv/` — `osv_fixture_files` пуст; с двумя файлами и `synthetic/` — синтетика исключена; `fixtures_skip_reason()` даёт текст с путём. +- [x] **Step 2:** реализовать; `make venv`; `pytest -m fixtures --collect-only -q | tail -3` показывает 66 параметров (через временный тест-заглушку `test_smoke_fixtures(osv_file)` с `assert osv_file.exists()`, который остаётся как дымовой). +- [x] **Step 3:** `make check` → PASS. Commit: `test: доступ к ../fixtures (ARM_KA_FIXTURES), параметризация по 66 ОСВ; зависимости openpyxl/xlrd/pyxlsb`. --- diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md index 2496002..73d225f 100644 --- a/openspec/changes/slice-2a-osv-parser/tasks.md +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -6,7 +6,7 @@ ## 1. Основание -- [ ] 1.1 Зависимости и фикстуры: `openpyxl==3.1.5`, `xlrd==2.0.2`, `pyxlsb==1.0.10` в `api/requirements.txt`; `make venv`; +- [x] 1.1 Зависимости и фикстуры: `openpyxl==3.1.5`, `xlrd==2.0.2`, `pyxlsb==1.0.10` в `api/requirements.txt`; `make venv`; `tests/fixtures_helpers.py` (`ARM_KA_FIXTURES`, как в arm-core) и `conftest.py` с фикстурой `osv_files` (все файлы `../fixtures/osv/**` кроме `synthetic`, параметризация по относительному пути); маркер `fixtures` в деле; `test_layout` не ломается. Проверка: `pytest -m fixtures --collect-only` показывает 66 параметров.