Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, а не пост-фактум пачкой.

---

## 📚 Источники истины (не дублировать)
Expand Down Expand Up @@ -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-тегом
Expand Down
41 changes: 41 additions & 0 deletions docs/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:** задокументирована собственная схема версионирования (тег =
Expand Down Expand Up @@ -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).
44 changes: 36 additions & 8 deletions scripts/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
уже сейчас).

Запуск::

Expand All @@ -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-команды без хвостового перевода строки.
Expand Down Expand Up @@ -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}"


Expand Down
43 changes: 39 additions & 4 deletions tests/test_version_script.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"


Expand All @@ -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]
Loading