diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e9a40c..4fe9a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,102 @@ раздел. Не путать с этим блоком. --> +### Added +- Runner Protocol abstraction (epic #136, issues #137-#139): new + `core/runner.py` implements `docs/server-mode.md`'s already-designed + Runner layer (#140) as real code. `Runner` (runtime-checkable Protocol), + `RunSpec`/`RunOutcome` (raw subprocess result, no verdict), and + `LocalRunner` (the existing `subprocess.Popen` + best-effort `RLIMIT_AS` + + psutil RSS-polling logic, moved verbatim out of `run_single_test`). + `grader_core.run_single_test()` now builds a `RunSpec` and delegates to + `_RUNNER.run(spec)`; verdict/diff computation stays in `grader_core.py`. + No behavior change — sets up a future `SandboxRunner` (#157) behind the + same interface. +- Lazy `CONFIG` + JSON-locale i18n foundation (epic #141, issues + #142-#145): `stepik_grader.config` no longer reads `pyproject.toml` as + an import-time side effect — a module `__getattr__` (PEP 562) + + cached `get_config()` defer the read to first access to `.CONFIG`, with + every existing `from stepik_grader.config import CONFIG` call site + unaffected. `load_config()` filters overrides via + `dataclasses.fields(GraderConfig)` instead of the private + `__dataclass_fields__` dunder. New `core/i18n.py` + + `core/locales/{ru,en}.json`: an additive JSON-locale loader sitting in + front of `cli.py`'s static `_MESSAGES` dict — `_t()` checks the JSON + locale first, falling back to `_MESSAGES`; empty locale files today + keep behavior byte-identical. +- Stepik client retry/backoff (epic #108, issues #109-#111): + `make_session()` mounts an `HTTPAdapter` with a `urllib3.Retry` on + http/https, so 429 (rate limit) and transient 5xx (500/502/503/504) are + retried with exponential backoff (respecting `Retry-After`) for every + request through the session, not just the call sites that already used + `_get_with_retry()`. 4xx other than 429 still isn't retried. +- `TestResult` dataclass + `Verdict` Literal (epic #112, issues + #113-#115): new leaf module `core/result.py` matching + `docs/result-contract.md`'s case-result fields; `from_dict()`/ + `to_dict()` round-trip the same dict shape `run_single_test()` has + always returned, so the public dict contract (CLI JSON, `run_tests()`/ + `run_benchmark()`, `/api/grade`) is unchanged. + `core/reporter.print_case_verbose` now reads typed attributes instead + of ad-hoc `dict.get()` calls with inline defaults; output is + byte-identical. + +### Fixed +- Glossary exception-name detector (`_last_exception_name`) reduced false + positives: plain text lines that happened to look like a capitalized + identifier (e.g. an `exc.add_note()` note) were being reported as + exception names. `_looks_like_exception_name()` now requires the + `Error`/`Exception`/`Warning` naming convention or membership in the + small set of builtins that don't follow it (issue #191). +- Web UI client-side `esc()` escaped `&`/`<`/`>` for text context but not + quotes, so a value landing inside an HTML attribute (`errorCard()`'s + `href="..."`) could still break out of it. Not exploitable today + (`g.url` is server-controlled), but hardened ahead of more action/error + cards being added the same way (issue #214). +- `scripts/version.py`'s logical `X.Y.Z` version (README `Version` badge) + no longer double-counts CI's own `chore(ci): update badges [skip ci]` + bot commits toward PATCH — it excludes them via `git rev-list + --invert-grep` instead of `git describe --tags --long`'s raw commit + count (issue #231). + +### Refactored +- `cli.py` decomposed into a package (`cli/`), epic #117 (issues #118-#122). + `stepik_grader.cli` (`__init__.py`) stays the compatibility facade — + `main()`, mode-handler/interactive-menu wrapper functions, and mutable + i18n state (`_LANG`/`_MESSAGES`/`_LOCALE_MESSAGES`/`_t`, deliberately kept + in place since `main()` mutates `_LANG` at runtime and moving it would + turn the facade re-export into a stale snapshot). Four new leaf modules + hold the actual logic, none importing `stepik_grader.cli`: + `cli/options.py` (argparse parsing, #119); `cli/commands.py` + + `cli/context.py` (mode handlers behind an explicit `CliContext` + dependency-injection object, #120); `cli/rendering.py` (csv/markdown + table output, #121 Phase 1); `cli/interactive.py` (menu/prompts, + extending `CliContext`, #121 Phase 2). `tests/test_entrypoint.py` adds + subprocess-level regression coverage for the `stepik-grader` console + script and `python -m stepik_grader[.grader]` (#122). Across all five + PRs, essentially no existing test files needed modification — the + `CliContext` design was built specifically to keep + `monkeypatch.setattr(cli, "...", ...)`-based tests passing unmodified + through the move. + +### Docs +- Sandbox limits clarified in `executor.py`'s module/`main()` docstrings — + explicitly no OS-sandbox, no FS/network isolation, trusted solutions + only (issue #213); Windows limitations of the future + `SandboxRunner`/`LocalRunner` documented in `docs/server-mode.md`, + completing #140's acceptance criteria. Stale follow-up references + cleaned up in `docs/README.md`/`docs/claude-handoff.md`; README + `--watch` marked as requiring the `[watch]` extra (issue #215). +- README line-budget (220 lines) and local Markdown link/anchor + guardrails: new `scripts/check_docs_guardrails.py`, wired into CI as a + `docs-guardrails` job, documented in CONTRIBUTING.md (issue #173). +- Architecture/design docs formalized: `glossary/stdlib_inventory.py` + + `coverage.py` registered in the DAG (#199); `docs/result-contract.md` + for CLI/Web/API case-result fields and verdicts (#116); server-mode + design — Runner layer, remote execution API, sandbox requirements + (#140/#156/#157) plus ADR-0001 (#152); diagnostic/logging design with + secret redaction (#150); Contributor Covenant `CODE_OF_CONDUCT.md` + linked from CONTRIBUTING (#204). + ## [1.6.0] - 2026-07-08 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index f11bcff..7b8844e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,6 +166,30 @@ chore(deps): bump psutil upper bound UX-полировка вывода `--version` (dev vs release маркер) — задача #163, см. [`docs/claude-handoff.md`](docs/claude-handoff.md). +`scripts/version.py`'s "логическая" `X.Y.Z` (README `Version`-бейдж) считает +PATCH через `git rev-list --invert-grep`, исключая автокоммиты CI +`chore(ci): update badges [skip ci]` — иначе счётчик рос бы вдвое быстрее +реальных изменений (issue #231). `setuptools-scm`-версия пакета (`X.Y.0.postN`) +это не затрагивает — у неё независимая логика без фильтрации по commit message. + +--- + +## 📝 Обновление CHANGELOG.md / docs/history.md — когда + +- **`CHANGELOG.md`** (английский) — запись под `## [Unreleased]` в **каждом** + смерженном PR, без исключений для "внутренних"/рефакторинговых PR + (используйте `### Refactored`/`### Changed`/`### Internal` — прецеденты уже + есть в файле). При релизе `[Unreleased]` переименовывается в + `[X.Y.0] - ДАТА`, наверх добавляется новый пустой `[Unreleased]`. +- **`docs/history.md`** (русский) — архивная запись на **каждый релиз** + (новый git-тег `vX.Y.0`), не на каждый PR: сводка вошедшего в релиз, в + стиле уже существующих записей (`**#NNN (дата):** ...`). +- **`CHECKPOINT.md`** — обновляется вместе с `docs/history.md`, на каждый + релиз (это исторический snapshot, не отслеживает промежуточные PR). + +Не откладывать `CHANGELOG.md` "до конца фичи/спринта" — если PR смержен, +запись нужна сразу этим же PR, а не пост-фактум пачкой. + --- ## 📚 Источники истины (не дублировать) @@ -235,7 +259,10 @@ server mode ([docs/server-mode.md](docs/server-mode.md) + ADR-0001): Runner-сл [ ] Новые функции: type hints + docstring; новые модули: __all__ [ ] from __future__ import annotations в начале нового файла [ ] Коммит в формате Conventional Commits -[ ] CHECKPOINT.md/CHANGELOG.md обновлены (если завершена фича/спринт) +[ ] CHANGELOG.md: добавлена запись под ## [Unreleased] — в КАЖДОМ PR, без + исключений для рефакторингов (см. § Обновление CHANGELOG.md/docs/history.md) +[ ] docs/history.md/CHECKPOINT.md — НЕ на каждый PR, обновляются вместе на + релиз (см. ту же секцию) [ ] Версия не правится вручную — CI (check_version_consistency.py) сам следит за дрейфом (issue #165); достаточно, чтобы CHECKPOINT/CHANGELOG совпадали с последним git-тегом diff --git a/docs/history.md b/docs/history.md index 86417a6..e82e334 100644 --- a/docs/history.md +++ b/docs/history.md @@ -234,6 +234,46 @@ --- +## Релиз v1.6.0 (2026-07-08) + +- **Эпик #98 — packaging hygiene (PR-1):** явный MIT `LICENSE` в корне + + PEP 639 SPDX-метаданные лицензии в `pyproject.toml` (issue #100); PEP 561 + `py.typed`-маркер для тайпчекеров downstream-потребителей (issue #101). + `setuptools>=77` для поддержки SPDX. +- **Эпик #102 — документация (PR-2):** README сведён к лаконичной витрине, + тяжёлые технические разделы вынесены в `docs/` — `architecture.md` (DAG + + слои, #105), `project-structure.md` (дерево файлов, #104), `versions.md` + (сравнение релизов, #106). CONTRIBUTING получил правило «README — + витрина, docs/ — база знаний» (#107). +- **Эпик #123 — локальный глоссарий:** + - **#126:** foundation `stepik_grader.glossary` — `GlossaryCard`/ + `GlossaryMissingEntry`, `JsonGlossaryProvider`, `MissingConceptDetector` + (консервативный AST-детектор пропущенных концепций, не исполняет код). + - **#194/#200:** зафиксирован инвариант источников истины — внутренняя + база грейдера полнота контента, официальный Python/stdlib — полнота + покрытия, внешний Glossary-Python — только витрина экспорта, никогда + не эталон. + - **#195–#198:** source-driven покрытие. `GlossaryMissingEntry` получил + `origin`/`module`/`qualname`; leaf-модуль `stdlib_inventory.py` — + офлайн-инвентарь builtins/exceptions/stdlib без сети и исполнения кода; + `coverage.py` сопоставляет инвентарь с локальной базой карточек, CLI + `python -m stepik_grader.glossary.coverage`. +- **Эпик #161/#163:** `--version` различает dev-сборки и релизы — вне тега + добавляется суффикс `(dev build, not a release)` к строке + `setuptools-scm`, на теге вывод не меняется. +- **Живые README-бейджи:** `scripts/generate_coverage_badge.py`/ + `generate_version_badge.py` пишут shields.io endpoint-JSON из реального + `pytest --cov` и логической версии (`scripts/version.py`); CI + (ubuntu-latest/3.12, push в main) коммитит их после каждого прогона — + заменили статический coverage-бейдж, который тихо разошёлся с + реальностью. +- **#201/#202:** политика ответственного раскрытия уязвимостей + (`SECURITY.md`), GitHub PR/issue-шаблоны. + +Полное содержание релиза — в [`../CHANGELOG.md`](../CHANGELOG.md#160---2026-07-08). + +--- + ## Динамическая версия (issue #162, PR #183) - **#68:** задокументирована собственная схема версионирования (тег = @@ -266,6 +306,7 @@ | v1.3.0 | 599 | 95% | Онбординг + PyPI | | v1.4.0 | 622 | 95% | Web UI (`--serve`) + IDE-интеграция | | v1.5.0 | 660 | 95% | Кэш, pytest-плагин, инкрементальный watch | +| v1.6.0 | 784 | 95% | Локальный глоссарий + source-driven покрытие, packaging hygiene, README-витрина | > Подробное сравнение релизов и отличия от оригинала — в > [versions.md](versions.md). diff --git a/scripts/version.py b/scripts/version.py index d0af291..c32638e 100644 --- a/scripts/version.py +++ b/scripts/version.py @@ -4,11 +4,16 @@ Схема проекта (см. CONTRIBUTING.md §Версионирование, issue #68) — НЕ SemVer: * MAJOR.MINOR берутся из последнего git-тега вида ``vX.Y.0``; - * PATCH = число коммитов после этого тега (``git describe --tags --long``). + * PATCH = число коммитов после этого тега, БЕЗ автогенерированных + badge-коммитов CI (``chore(ci): update badges [skip ci]``, + ``.github/workflows/ci.yml``) — иначе PATCH растёт почти вдвое быстрее + реальных изменений: этот коммит бот создаёт отдельно почти на каждый push + в main вдобавок к самому merge-коммиту (issue #231). До первого тега ``git describe`` завершается ошибкой — тогда MAJOR.MINOR -читаются из ``[project].version`` в pyproject.toml, а PATCH = полное число -коммитов в истории (монотонный счётчик, разумный fallback уже сейчас). +читаются из ``[project].version`` в pyproject.toml, а PATCH = число коммитов +в истории по той же логике исключения (монотонный счётчик, разумный fallback +уже сейчас). Запуск:: @@ -25,6 +30,10 @@ _PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" +# issue #231: подстрока commit-сообщения badge-бота (см. модульный докстринг) — +# --fixed-strings ищет её буквально, без интерпретации как regex. +_BOT_COMMIT_GREP = "chore(ci): update badges" + def _git(*args: str) -> str | None: """Вернуть stdout git-команды без хвостового перевода строки. @@ -55,18 +64,37 @@ def _major_minor_from_pyproject() -> tuple[str, str]: return major, minor +def _commits_since(rev_range: str) -> str: + """Число «настоящих» коммитов в rev_range, без badge-бота (issue #231). + + --invert-grep --grep=<подстрока> --fixed-strings исключает коммиты, + сообщение которых содержит _BOT_COMMIT_GREP буквально (не regex). + """ + return ( + _git( + "rev-list", + "--count", + rev_range, + "--invert-grep", + "--grep", + _BOT_COMMIT_GREP, + "--fixed-strings", + ) + or "0" + ) + + def project_version() -> str: """Вернуть версию вида '1.2.17' по схеме проекта (см. модульный докстринг).""" - described = _git("describe", "--tags", "--long") - if described is not None: - # vX.Y.0-N-gHASH → MAJOR.MINOR из тега, PATCH = N (коммитов после тега). - tag, commits, _ = described.rsplit("-", 2) + tag = _git("describe", "--tags", "--abbrev=0") + if tag is not None: major, minor, _patch = tag.lstrip("v").split(".") + commits = _commits_since(f"{tag}..HEAD") return f"{major}.{minor}.{commits}" # Fallback до первого тега: MAJOR.MINOR из pyproject, PATCH = все коммиты. major, minor = _major_minor_from_pyproject() - commits = _git("rev-list", "--count", "HEAD") or "0" + commits = _commits_since("HEAD") return f"{major}.{minor}.{commits}" diff --git a/tests/test_version_script.py b/tests/test_version_script.py index 973f836..f94ac84 100644 --- a/tests/test_version_script.py +++ b/tests/test_version_script.py @@ -1,8 +1,9 @@ """Tests for scripts/version.py — версионирование по схеме проекта (issue #68). Схема (CONTRIBUTING.md §Версионирование) — НЕ SemVer: MAJOR.MINOR из тега -``vX.Y.0``, PATCH = число коммитов после тега; до первого тега — fallback на -MAJOR.MINOR из pyproject + полное число коммитов. +``vX.Y.0``, PATCH = число коммитов после тега, БЕЗ badge-бота +(``chore(ci): update badges``, issue #231); до первого тега — fallback на +MAJOR.MINOR из pyproject + то же число коммитов без бота. """ from __future__ import annotations @@ -45,9 +46,17 @@ def test_version_script_cli_prints_version() -> None: def test_tagged_path_parses_commits_as_patch(monkeypatch) -> None: - """При наличии тега PATCH = число коммитов после него (git describe).""" + """При наличии тега PATCH = число коммитов после него (git rev-list).""" module = _load_module() - monkeypatch.setattr(module, "_git", lambda *a: "v1.2.0-17-g3fa9c21") + + def fake_git(*args: str) -> str | None: + if args[:1] == ("describe",): + return "v1.2.0" + if args[:2] == ("rev-list", "--count"): + return "17" + return None + + monkeypatch.setattr(module, "_git", fake_git) assert module.project_version() == "1.2.17" @@ -67,3 +76,29 @@ def fake_git(*args: str) -> str | None: version = module.project_version() assert version.endswith(".42"), version assert _XYZ.match(version), version + + +def test_patch_count_excludes_badge_bot_commits(monkeypatch) -> None: + """PATCH-счётчик исключает chore(ci): update badges коммиты (issue #231): + rev-list вызывается с --invert-grep/--grep/--fixed-strings на их подстроку, + а не просто считает всё в диапазоне.""" + module = _load_module() + calls: list[tuple[str, ...]] = [] + + def fake_git(*args: str) -> str | None: + calls.append(args) + if args[:1] == ("describe",): + return "v2.0.0" + if args[:2] == ("rev-list", "--count"): + return "5" + return None + + monkeypatch.setattr(module, "_git", fake_git) + assert module.project_version() == "2.0.5" + + rev_list_call = next(c for c in calls if c[:2] == ("rev-list", "--count")) + assert rev_list_call[2] == "v2.0.0..HEAD" + assert "--invert-grep" in rev_list_call + assert "--fixed-strings" in rev_list_call + grep_index = rev_list_call.index("--grep") + assert "update badges" in rev_list_call[grep_index + 1]