From 3116742df10622c72f549ccbc0586fa4d05952e9 Mon Sep 17 00:00:00 2001 From: Artem Markitanov <86671904+ArtVsMark@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:08:26 +0300 Subject: [PATCH 1/2] fix(version): exclude badge-bot commits from PATCH counter; docs(claude): require CHANGELOG.md entry per PR scripts/version.py's PATCH counter (README Version badge) used git describe --tags --long's raw commit count. Every push to main adds two commits: the merge/squash commit itself and a separate "chore(ci): update badges [skip ci]" bot commit (.github/workflows/ci.yml), which fires almost every time since the version badge changes on every commit. PATCH was growing roughly 2x faster than the number of real merged changes (34 raw commits since v1.6.0 vs. 21 excluding the bot). project_version() now gets the tag via `git describe --tags --abbrev=0` and counts commits separately via `git rev-list --count ..HEAD --invert-grep --grep="chore(ci): update badges" --fixed-strings`, so the bot's own commits no longer inflate the count. Chose excluding bot commits over the inverse (counting only bot commits as a merge-count proxy) because it degrades safely: if the badge workflow ever changes or stops firing, PATCH keeps counting real commits instead of silently freezing. Does not affect setuptools-scm's own package version (X.Y.0.postN) -- that has independent, non-filterable commit-counting logic; scripts/version.py only drives the README badge. Also adds a CLAUDE.md rule (raised directly by the maintainer) that was missing: CHANGELOG.md needs an [Unreleased] entry in every merged PR, no exceptions for internal refactors -- the five-PR cli.py decomposition epic (#117-#122) that just landed skipped it entirely, which is exactly the gap this rule closes. docs/history.md and CHECKPOINT.md stay release-cadence (once per git tag), not per-PR, matching how they were already being used. Backfilled the CHANGELOG.md [Unreleased] entry for that epic and this fix, applying the new rule immediately rather than leaving it for the next release. Issue #231. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 27 ++++++++++++++++++++++ CLAUDE.md | 29 +++++++++++++++++++++++- scripts/version.py | 44 +++++++++++++++++++++++++++++------- tests/test_version_script.py | 43 +++++++++++++++++++++++++++++++---- 4 files changed, 130 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e9a40c..137c7f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,33 @@ раздел. Не путать с этим блоком. --> +### 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. + +### Fixed +- `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). + ## [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/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] From c610e3d64b3498088f1e320c34452cb2ab9ec62b Mon Sep 17 00:00:00 2001 From: Artem Markitanov <86671904+ArtVsMark@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:18:18 +0300 Subject: [PATCH 2/2] docs: backfill CHANGELOG.md/history.md gaps since v1.6.0 Applying the CHANGELOG-per-PR rule just added to CLAUDE.md immediately surfaced how far behind it already was: nine non-bot commits landed between the v1.6.0 tag and this session's work without ever getting a CHANGELOG.md entry (Runner Protocol abstraction #136-140, lazy CONFIG + i18n foundation #141-145, Stepik client retry/backoff #108-111, TestResult/Verdict #112-116, two bugfixes #191/#214, and three docs/CI PRs #173/#213/#215-218/#199/#116/#140/#156/#157/#152/#150/#204). Expanded [Unreleased] to cover all of them under Added/Fixed/Docs, alongside the cli.py decomposition entry from the prior commit. docs/history.md never got an archival entry for the v1.6.0 release itself (last entry was around issue #72, well before the epics that shipped in 1.6.0) -- added one, plus the missing v1.6.0 row in the metrics table (784 tests / 95%, matching CLAUDE.md's own metrics and docs/versions.md's canonical table). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 81 +++++++++++++++++++++++++++++++++++++++++++++---- docs/history.md | 41 +++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 137c7f3..4fe9a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,63 @@ раздел. Не путать с этим блоком. --> +### 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 — @@ -29,12 +86,24 @@ `monkeypatch.setattr(cli, "...", ...)`-based tests passing unmodified through the move. -### Fixed -- `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). +### 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 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).