mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
leetcode_coach: shared package, SQL shape gate
Standalone utils subproject (pyproject, pre-commit, CI, install.sh, README with the library API). It no longer imports leetcode_guard: guard_bridge resolves the three read-only calls on demand. Q5: SQL problems skip req: and ask for an OUTPUT SHAPE (columns, rows, grouping) before code; algorithm problems keep req:. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
This commit is contained in:
parent
11cb1d81d9
commit
716231a7f3
73
.github/workflows/leetcode-coach-precommit.yml
vendored
Normal file
73
.github/workflows/leetcode-coach-precommit.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
name: leetcode-coach pre-commit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# The workflow's own file counts as leetcode_coach: editing only this file would
|
||||
# otherwise trigger nothing, so a fix to the job itself could never prove
|
||||
# itself.
|
||||
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml']
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
# Only pre-commit itself. Every linting hook runs in its own isolated
|
||||
# env -- ruff, mypy, pylint, bandit and codespell each come from their
|
||||
# own repo+rev, and the local hooks need nothing beyond bash and
|
||||
# python3 -- so installing the full requirements.txt spent 9s a run on
|
||||
# packages no pre-commit-stage hook imports. Verified by running all
|
||||
# three configs green in a venv holding pre-commit and nothing else.
|
||||
# The pin is read out of requirements.txt rather than repeated here, so
|
||||
# it cannot drift from what the dependency-freshness gate governs.
|
||||
- name: Install pre-commit
|
||||
run: |
|
||||
pip install "$(grep '^pre-commit==' leetcode_coach/requirements.txt)"
|
||||
|
||||
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
|
||||
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
|
||||
# package jobs shared one key, raced to overwrite it with hook sets built
|
||||
# from four different configs, and editing a per-package config did not
|
||||
# invalidate it. The cache was therefore silently wrong rather than
|
||||
# missing, and the hooks reinstalled from scratch on every run (38-49s).
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: >-
|
||||
pre-commit-leetcode-coach-${{ env.pythonLocation }}-${{
|
||||
hashFiles('leetcode_coach/.pre-commit-config.yaml') }}
|
||||
# Prefix fallback, or this fix would evaporate on the very
|
||||
# event that motivated it: bumping a hook rev changes the
|
||||
# exact key, and a total miss means reinstalling every hook
|
||||
# again. pre-commit's store is keyed internally by hook
|
||||
# repo+rev, so a superseded entry is ignored disk rather
|
||||
# than a wrong hook, and cache still re-saves under the new
|
||||
# exact key after a prefix-only hit.
|
||||
restore-keys: pre-commit-leetcode-coach-${{ env.pythonLocation }}-
|
||||
# mypy re-analyses the stdlib and every third-party stub from scratch
|
||||
# without this, which measured 10s of the hook step in CI against 0.25s
|
||||
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
|
||||
# the config, not the sources: mypy's own cache metadata records the
|
||||
# interpreter, the flag set and a hash per source file, so it re-checks
|
||||
# what changed and cannot pass stale code. Deliberately NOT keyed on
|
||||
# github.sha -- that re-saves 19MB x3 every commit and would evict the
|
||||
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
|
||||
# the expensive part, the dependency analysis, which is what costs 10s.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .mypy_cache
|
||||
key: >-
|
||||
mypy-leetcode-coach-${{ env.pythonLocation }}-${{
|
||||
hashFiles('leetcode_coach/.pre-commit-config.yaml') }}
|
||||
restore-keys: mypy-leetcode-coach-${{ env.pythonLocation }}-
|
||||
- name: Run the hooks
|
||||
run: >-
|
||||
pre-commit run --config leetcode_coach/.pre-commit-config.yaml
|
||||
--all-files --show-diff-on-failure
|
||||
34
.github/workflows/leetcode-coach-tests.yml
vendored
Normal file
34
.github/workflows/leetcode-coach-tests.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
name: leetcode-coach tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# The workflow's own file counts as leetcode_coach: editing only this file would
|
||||
# otherwise trigger nothing, so a fix to the job itself could never prove
|
||||
# itself.
|
||||
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml']
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: leetcode_coach
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
cache: pip
|
||||
cache-dependency-path: leetcode_coach/requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt && pip install --no-deps -e .
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: python -m pytest -v
|
||||
157
leetcode_coach/.pre-commit-config.yaml
Normal file
157
leetcode_coach/.pre-commit-config.yaml
Normal file
@ -0,0 +1,157 @@
|
||||
# Pre-commit Configuration for leetcode_coach
|
||||
# Install: pre-commit install && pre-commit install --hook-type pre-push
|
||||
# Run: pre-commit run --all-files
|
||||
# Update: pre-commit autoupdate
|
||||
|
||||
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
|
||||
# one level up. pre-commit always runs hooks from the git root, so every path
|
||||
# below is written relative to THAT, not to this directory, and the top-level
|
||||
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
|
||||
# config exactly the same way:
|
||||
# pre-commit run --config leetcode_coach/.pre-commit-config.yaml --all-files
|
||||
files: ^leetcode_coach/
|
||||
|
||||
default_language_version:
|
||||
python: python3
|
||||
default_stages: [pre-commit]
|
||||
fail_fast: false
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=2000]
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
- id: debug-statements
|
||||
- id: name-tests-test
|
||||
args: [--pytest-test-first]
|
||||
# Shared, non-test helpers (fixtures, fakes) imported by the real test
|
||||
# modules: deliberately not collected, hence the leading underscore.
|
||||
exclude: ^leetcode_coach/leetcode_coach/tests/_[a-z][a-z_]*\.py$
|
||||
- id: check-ast
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
- id: requirements-txt-fixer
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: file-length-cap
|
||||
name: file length <= 250 lines
|
||||
entry: bash scripts/check_file_length.sh
|
||||
language: system
|
||||
pass_filenames: true
|
||||
- id: subproject-scope
|
||||
name: subproject scope regex still matches
|
||||
entry: scripts/check_subproject_scope.sh leetcode_coach
|
||||
language: script
|
||||
# always_run so the top-level `files` filter cannot skip the one hook
|
||||
# whose job is to notice that filter has stopped matching anything.
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
- id: no-noqa
|
||||
name: Block noqa comments
|
||||
entry: '(?i)#\s*(noqa|type:\s*ignore)'
|
||||
language: pygrep
|
||||
types: [python]
|
||||
- id: no-ruff-noqa
|
||||
name: Block ruff noqa file-level comments
|
||||
entry: '(?i)#\s*ruff:\s*noqa'
|
||||
language: pygrep
|
||||
types: [python]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.17.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
|
||||
types_or: [python, pyi]
|
||||
- id: ruff-format
|
||||
types_or: [python, pyi]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.13.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
# Isolated env: the requests stubs must be declared here too.
|
||||
additional_dependencies:
|
||||
- types-requests
|
||||
args:
|
||||
- --ignore-missing-imports
|
||||
- --no-error-summary
|
||||
- --disable-error-code=no-untyped-def
|
||||
- --disable-error-code=no-untyped-call
|
||||
- --disable-error-code=var-annotated
|
||||
- --disable-error-code=no-any-unimported
|
||||
- --disable-error-code=type-arg
|
||||
- --disable-error-code=no-any-return
|
||||
- --disable-error-code=misc
|
||||
- --disable-error-code=unused-ignore
|
||||
- --disable-error-code=unreachable
|
||||
- --disable-error-code=assignment
|
||||
- --disable-error-code=no-redef
|
||||
- --disable-error-code=attr-defined
|
||||
- --disable-error-code=arg-type
|
||||
- --disable-error-code=union-attr
|
||||
- --disable-error-code=call-overload
|
||||
- --disable-error-code=return-value
|
||||
- --disable-error-code=redundant-cast
|
||||
- --disable-error-code=empty-body
|
||||
- --disable-error-code=list-item
|
||||
|
||||
- repo: https://github.com/pylint-dev/pylint
|
||||
rev: v4.0.8
|
||||
hooks:
|
||||
- id: pylint
|
||||
args:
|
||||
- --rcfile=leetcode_coach/pyproject.toml
|
||||
- --fail-under=8.0
|
||||
- --jobs=4
|
||||
- leetcode_coach/leetcode_coach
|
||||
# Whole package, never the staged subset: a score over an arbitrary
|
||||
# handful of files is an arbitrary number. Kept in step with
|
||||
# requirements.txt.
|
||||
pass_filenames: false
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
- requests
|
||||
- genanki
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.9.4
|
||||
hooks:
|
||||
- id: bandit
|
||||
args:
|
||||
- -c
|
||||
- leetcode_coach/pyproject.toml
|
||||
- --severity-level=high
|
||||
- --confidence-level=medium
|
||||
- --skip=B113
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
exclude: ^(leetcode_coach/leetcode_coach/tests/|.*test.*\.py$)
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest-coverage
|
||||
name: pytest with coverage enforcement
|
||||
entry: scripts/run_subproject_tests.sh leetcode_coach
|
||||
language: script
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
stages: [pre-push]
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.3.0
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --skip=*.json,*.lock,.git,__pycache__,.venv
|
||||
132
leetcode_coach/README.md
Normal file
132
leetcode_coach/README.md
Normal file
@ -0,0 +1,132 @@
|
||||
# leetcode_coach
|
||||
|
||||
The `/leetcode` coach as a library: the Socratic method gates, the session log,
|
||||
the grader and card builder, and the relay that serves them. Moved here from
|
||||
`leetcode-guard` (with its git history) so the tutor server and leetcode-guard
|
||||
share ONE coach. Python 3.14, `import leetcode_coach`.
|
||||
|
||||
Specs stay in `~/src/leetcode-guard/DOCS-coach-{spec,ui-spec,relay}.md`.
|
||||
|
||||
## Install
|
||||
|
||||
| Who | How |
|
||||
|---|---|
|
||||
| leetcode-guard | pinned: `leetcode-coach @ git+https://github.com/kuhyx/utils@leetcode-coach-v0.1.0#subdirectory=leetcode_coach` (its `install.sh` pip-installs it) |
|
||||
| tutor server / development | `./install.sh` (editable, system python) |
|
||||
|
||||
Tests: `scripts/run_subproject_tests.sh leetcode_coach` (100% branch coverage).
|
||||
Gate: `pre-commit run --config leetcode_coach/.pre-commit-config.yaml --all-files`.
|
||||
|
||||
## Layers
|
||||
|
||||
Stdlib only unless noted. Import only what you need.
|
||||
|
||||
| Layer | Modules |
|
||||
|---|---|
|
||||
| Gates | `phase`, `shape`, `gate_notes`, `req_check`, `leak`, `toolbox`, `stop_gate`, `gate_detector` |
|
||||
| Log + progress | `session_log`, `progress`, `report` |
|
||||
| Content | `fetch_problem`, `toolbox.json`, `answer_key`, `clusters.json` |
|
||||
| Cards | `toolbox_cards`, `card_rules`, `make_cards` (needs genanki), `anki_connect` |
|
||||
| Models | `claude_cli` (haiku/sonnet via `claude -p`), `local_backend` (Ollama) |
|
||||
| Relay | `relay.*`: state machine, grading, run-code, HTTP + RTDB (needs requests, crdt-sync) |
|
||||
| Clients | `leetcode_repl` (`leetcode` command), `cluster_cli`, `local_session`, `how_to` |
|
||||
|
||||
## Library API for the tutor server
|
||||
|
||||
Everything keys off the session log: a JSONL file per problem and day, state is
|
||||
derived from it (never held in memory), so any caller and a restart agree.
|
||||
|
||||
```python
|
||||
from leetcode_coach import fetch_problem, phase, session_log, toolbox, leak
|
||||
```
|
||||
|
||||
**Statement.** `fetch_problem.fetch(slug) -> dict | None` (None / `isPaidOnly`
|
||||
means paste it), `fetch_problem.render(q, lang) -> str` (markdown with
|
||||
`Tags:` and `Languages available:` lines), `slug_from(url_or_slug)`.
|
||||
|
||||
**Phase gate (req-before-code).**
|
||||
`gate = phase.PhaseGate(log, toolbox.load_safe(), source=lambda: statement, ask=None)`
|
||||
|
||||
- `gate.screen(block, *, is_code=None) -> (sent | None, note)`: `None` means
|
||||
refuse and show `note`; otherwise forward `sent` to the coach and show `note`
|
||||
if non-empty. Logs `req`, `req_check`, `code`, `reveal`.
|
||||
- `gate.refusal_note()`, `gate.code_seen()`, `gate.events()`, `gate.sql()`,
|
||||
`gate.leaks(reply, user_texts) -> [names]` (bounce and regenerate),
|
||||
`gate.after_reply(text) -> text without the rung marker` (logs `rung`).
|
||||
- `phase.req_state(events) -> ReqState(accepted, forced, tries, .open)`,
|
||||
`phase.step2_attempts(events)`, `phase.regex_verdict(block) -> bool | None`.
|
||||
- 3 incomplete lists force the gate open (`req_incomplete: true` on `code`).
|
||||
|
||||
**Q5, SQL problems.** If the statement has the `Database` tag or offers a SQL
|
||||
dialect (`shape.is_sql`), `req:` is skipped: the gate asks for an OUTPUT SHAPE,
|
||||
a message starting `shape:` that says the **columns**, the **rows** (what one
|
||||
row stands for) and the **grouping**. Judged mechanically
|
||||
(`shape.check -> req_check.Check(verdict, missing, "shape")`, no model, never
|
||||
fails open), logged as the same `req` / `req_check` events with
|
||||
`via: "shape"`, forced after 3 tries like `req:`. A `req:` message on a SQL
|
||||
problem counts as the shape. An unfetched statement (just a slug) is not SQL:
|
||||
the stricter `req:` gate applies. Algorithm problems are unchanged.
|
||||
The relay state's `session.kind` is `"sql"` or `"algorithm"`.
|
||||
|
||||
**Leaks and code.** `leak.leaked_names(reply, allowed, box)` (toolbox names
|
||||
too early), `stop_gate.verdict(raw, reply)`, `gate_detector.offending_snippets`.
|
||||
|
||||
**Log.** `session_log.log_path(problem)`, `append(path, event, **fields)`,
|
||||
`read(path)`, `current(events)`, `has_code(path)`. Events: `session_start`,
|
||||
`req`, `req_check`, `code`, `rung`, `reveal`, `grade`, `chat`. The field set is
|
||||
a FIXED contract read by `leetcode_guard._coach_log`: add a field only together
|
||||
with that reader.
|
||||
|
||||
**Progress.** `progress.history(slug)`, `due_retests()` (cold re-tests at +3
|
||||
and +10 days), `cluster_progress()`, `next_new(...)`, `cluster_of(slug)`.
|
||||
|
||||
**Toolbox + cards.** `toolbox.load_safe() -> Toolbox` (`.entries`,
|
||||
`.properties`), `toolbox_cards.cards_for(names) -> [Card]` (property list ->
|
||||
operation), `toolbox_cards.append_entry(...)` (new entry, after kuhy confirms),
|
||||
`make_cards.deliver(...)` (Anki deck "LeetCode", `.apkg` fallback).
|
||||
|
||||
**Relay (the five-step coach).** `relay.coach.Coach(Deps(...))`:
|
||||
`handle(Request) -> dict` for the seven kinds in `relay.protocol.KINDS`
|
||||
(`session_start`, `session_end`, `chat`, `grade_step`, `hint`, `reveal`,
|
||||
`run_code`), `state()`, `chat_after(id)`, `tick()` (Accepted probe).
|
||||
`relay.app.Relay(Deps())` adds loopback HTTP (`start()`, `port`) and the RTDB
|
||||
phone link (`connect_phone(make_rtdb)`). Every dependency is a `Deps` field:
|
||||
`ask`, `local_chat`, `fetch`, `probe`, `run_code`, `answers`, `box`,
|
||||
`state_dir`, `cards_dry_run`, `clock_ms`. The wire is `DOCS-coach-relay.md`.
|
||||
|
||||
## What it needs from leetcode-guard
|
||||
|
||||
Three read-only calls, resolved on demand in `guard_bridge` (a consumer
|
||||
without leetcode-guard imports everything else; these raise
|
||||
`GuardUnavailableError`, never a silent "nothing solved"):
|
||||
|
||||
| Call | Used by |
|
||||
|---|---|
|
||||
| `solved_slugs(slugs)` | `cluster_cli` (skip already-Accepted problems) |
|
||||
| `recent_accepted()` | relay step 4 probe (`AcceptedProbe`, at most 1/min) |
|
||||
| `phone_id_token(app, timeout_seconds=)` | `relay.__main__` RTDB sign-in |
|
||||
|
||||
No coach module imports `leetcode_guard` statically and no route can reach the
|
||||
gate's ledger, credits or unlocks (`tests/test_guard_boundary.py` reads the
|
||||
bridge's strings against an allow-list). Credit stays leetcode-guard's job.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `LEETCODE_COACH_SESSIONS_DIR` | session logs (default `~/.local/share/leetcode-coach/sessions`) |
|
||||
| `LEETCODE_COACH_LOG` | log the REPL exported for the Stop hook |
|
||||
| `LEETCODE_COACH_CLUSTER` | cluster tag for new logs |
|
||||
| `LEETCODE_CLAUDE_BIN` | the `claude` binary (tests: a fake) |
|
||||
| `LEETCODE_COACH_RELAY_DIR` / `_PORT` / `_RTDB_URL` | relay state dir, port (default 8777), RTDB override |
|
||||
| `LEETCODE_COACH_CARDS_DRY_RUN` | `1`: build cards, push nothing |
|
||||
|
||||
## Commands (the live units use these paths)
|
||||
|
||||
- `python3 -m leetcode_coach.relay`: the relay (leetcode-coach-relay.service)
|
||||
- `python3 -P -m leetcode_coach.leetcode_repl <url>` (the `leetcode` command)
|
||||
- `python3 -m leetcode_coach.stop_gate`: the Stop hook (shim in `hook_shims/`)
|
||||
- `python3 -m leetcode_coach.toolbox_cards --help`, `make_cards`, `report`,
|
||||
`cluster_cli`, `fetch_problem`: the skill's scripts
|
||||
- The skill body is `leetcode_coach/SKILL.md`; the skill dir
|
||||
`~/.claude/skills/leetcode` points at `leetcode_coach/leetcode_coach/`.
|
||||
14
leetcode_coach/install.sh
Executable file
14
leetcode_coach/install.sh
Executable file
@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Editable user install so /usr/bin/python3 (the tutor server, the CLI, the
|
||||
# Stop hook) can `import leetcode_coach` straight from this checkout.
|
||||
# leetcode-guard installs the TAGGED copy instead (its pyproject pin); run
|
||||
# this only to develop the coach against live callers. Idempotent.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly REPO_DIR
|
||||
readonly SYSTEM_PYTHON="/usr/bin/python3"
|
||||
|
||||
"$SYSTEM_PYTHON" -m pip install --user --no-deps --break-system-packages -e "$REPO_DIR"
|
||||
cd /
|
||||
"$SYSTEM_PYTHON" -P -c 'import leetcode_coach; print(leetcode_coach.__file__)'
|
||||
@ -66,7 +66,9 @@ kuhy is practising a fixed method; coach it in this order:
|
||||
(XOR, hash map, sort are names, not properties). The client checks the
|
||||
list and shows kuhy the missing properties; coach toward them with H1
|
||||
questions, never by stating them. Code sent before an accepted list is
|
||||
held back by the client and never reaches you.
|
||||
held back by the client and never reaches you. **SQL problems** (tag
|
||||
Database) skip `req:`: kuhy sends `shape:` naming the columns, the rows
|
||||
and the grouping of the result; the client checks that mechanically.
|
||||
3. Only then does kuhy search for an operation with those properties.
|
||||
|
||||
**Never name a toolbox operation or structure before kuhy's first code** —
|
||||
|
||||
@ -27,9 +27,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from leetcode_coach import how_to, progress
|
||||
from leetcode_guard._live_solved import check_solved
|
||||
from leetcode_guard._settings import build_client
|
||||
from leetcode_coach import guard_bridge, how_to, progress
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -53,11 +51,11 @@ Start = Callable[[list[str]], int]
|
||||
def accepted_on_leetcode(slug: str) -> bool:
|
||||
"""Whether LeetCode confirms an Accepted submission; False if it cannot say."""
|
||||
try:
|
||||
evidence = check_solved(build_client().post, [slug])
|
||||
solved = guard_bridge.solved_slugs([slug])
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_logger.exception("live solved-check for %s raised; serving it", slug)
|
||||
return False
|
||||
return slug in evidence.solved
|
||||
return slug in solved
|
||||
|
||||
|
||||
def _due() -> int:
|
||||
|
||||
76
leetcode_coach/leetcode_coach/gate_notes.py
Normal file
76
leetcode_coach/leetcode_coach/gate_notes.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""The one-line notes kuhy reads from the phase gate: req list or output shape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from leetcode_coach import leak, shape
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from leetcode_coach.req_check import Check
|
||||
from leetcode_coach.toolbox import Toolbox
|
||||
|
||||
|
||||
def property_line(prop: str, box: Toolbox) -> str:
|
||||
"""`prop: meaning`, or just `prop` when the meaning names an entry.
|
||||
|
||||
Some meanings illustrate with the operation itself (self-inverse:
|
||||
"a ^ x ^ x = a"); shown to kuhy that is the answer, not a property.
|
||||
"""
|
||||
meaning = box.properties[prop]
|
||||
return prop if leak.leaked_names(meaning, [], box) else f"{prop}: {meaning}"
|
||||
|
||||
|
||||
def _forced(what: str, result: Check) -> str:
|
||||
missing = ", ".join(result.missing)
|
||||
return f"[{what} still incomplete ({missing}) -- code accepted anyway]"
|
||||
|
||||
|
||||
def req_note(
|
||||
result: Check, tries: int, *, forced: bool, limit: int, box: Toolbox
|
||||
) -> str:
|
||||
if result.via == shape.VIA:
|
||||
return shape_note(result, tries, forced=forced, limit=limit)
|
||||
if result.verdict == "pass":
|
||||
return "[req list accepted -- code is open]"
|
||||
if result.verdict == "unverified":
|
||||
return "[req check unavailable -- continuing unverified, code is open]"
|
||||
if forced:
|
||||
return _forced("req list", result)
|
||||
props = "\n".join(f" - {property_line(p, box)}" for p in result.missing)
|
||||
return (
|
||||
f"[req list incomplete -- it does not yet say these properties:\n{props}\n"
|
||||
f" revise with req: ({tries} of {limit})]"
|
||||
)
|
||||
|
||||
|
||||
def shape_note(result: Check, tries: int, *, forced: bool, limit: int) -> str:
|
||||
if result.verdict == "pass":
|
||||
return "[output shape accepted -- code is open]"
|
||||
if forced:
|
||||
return _forced("output shape", result)
|
||||
lines = "\n".join(f" - {h}" for h in shape.hints(result.missing))
|
||||
return (
|
||||
f"[output shape incomplete -- it does not yet say:\n{lines}\n"
|
||||
f" revise with shape: ({tries} of {limit})]"
|
||||
)
|
||||
|
||||
|
||||
def refusal(tries: int, limit: int, *, sql: bool) -> str:
|
||||
if not tries:
|
||||
if sql:
|
||||
return (
|
||||
"[code held back: state the output shape first -- one message "
|
||||
"starting with shape: that says the columns, the rows and the "
|
||||
"grouping]"
|
||||
)
|
||||
return (
|
||||
"[code held back: write the requirement list first -- one message "
|
||||
"starting with req: that lists the properties the operation must "
|
||||
"have, without naming an operation or structure]"
|
||||
)
|
||||
gate = "output shape" if sql else "req list"
|
||||
return (
|
||||
f"[code held back: the {gate} is still incomplete -- revise it "
|
||||
f"with {'shape' if sql else 'req'}: ({tries} of {limit} so far)]"
|
||||
)
|
||||
62
leetcode_coach/leetcode_coach/guard_bridge.py
Normal file
62
leetcode_coach/leetcode_coach/guard_bridge.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""The three read-only calls the coach borrows from leetcode-guard, on demand.
|
||||
|
||||
Without leetcode-guard installed (tutor server, CI) every other module still
|
||||
imports; these raise `GuardUnavailableError`, never a silent "nothing solved".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import TYPE_CHECKING, Final, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Sequence
|
||||
from types import ModuleType
|
||||
|
||||
class Submission(Protocol):
|
||||
title_slug: str
|
||||
timestamp: int
|
||||
|
||||
class SolveProbe(Protocol):
|
||||
@property
|
||||
def usable(self) -> bool: ...
|
||||
|
||||
reason: str
|
||||
submissions: Sequence[Submission]
|
||||
|
||||
|
||||
SETTINGS: Final = "leetcode_guard._settings"
|
||||
LIVE_SOLVED: Final = "leetcode_guard._live_solved"
|
||||
SUBMISSIONS: Final = "leetcode_guard._submissions"
|
||||
SNAPSHOT_AUTH: Final = "leetcode_guard._snapshot_auth"
|
||||
ALLOWED: Final = frozenset({SETTINGS, LIVE_SOLVED, SUBMISSIONS, SNAPSHOT_AUTH})
|
||||
|
||||
|
||||
class GuardUnavailableError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _load(name: str) -> ModuleType:
|
||||
try:
|
||||
return importlib.import_module(name)
|
||||
except ImportError as exc:
|
||||
msg = f"{name} needs leetcode-guard installed ({exc})"
|
||||
raise GuardUnavailableError(msg) from exc
|
||||
|
||||
|
||||
def solved_slugs(slugs: Sequence[str]) -> frozenset[str]:
|
||||
client = _load(SETTINGS).build_client()
|
||||
evidence = _load(LIVE_SOLVED).check_solved(client.post, list(slugs))
|
||||
return frozenset(evidence.solved)
|
||||
|
||||
|
||||
def recent_accepted() -> SolveProbe:
|
||||
client = _load(SETTINGS).build_client()
|
||||
probe: SolveProbe = _load(SUBMISSIONS).fetch_recent_ac(client.post, client.username)
|
||||
return probe
|
||||
|
||||
|
||||
def phone_id_token(app_name: str, *, timeout_seconds: float) -> Callable[[], str]:
|
||||
client = _load(SNAPSHOT_AUTH).connect(app_name, timeout_seconds=timeout_seconds)
|
||||
token: Callable[[], str] = client.auth.id_token
|
||||
return token
|
||||
@ -25,11 +25,13 @@ import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from leetcode_coach import (
|
||||
gate_notes,
|
||||
leak,
|
||||
local_prompt,
|
||||
local_session,
|
||||
req_check,
|
||||
session_log,
|
||||
shape,
|
||||
toolbox,
|
||||
)
|
||||
|
||||
@ -52,7 +54,7 @@ OPERATOR_RE = re.compile(
|
||||
|
||||
def regex_verdict(block: str) -> bool | None:
|
||||
"""True for clear code, False for clear prose, None when it cannot tell."""
|
||||
if REQ_RE.match(block):
|
||||
if REQ_RE.match(block) or shape.SHAPE_RE.match(block):
|
||||
return False
|
||||
if STRONG_RE.search(block):
|
||||
return True
|
||||
@ -113,31 +115,6 @@ def step2_attempts(events: list[dict[str, Any]]) -> int:
|
||||
return req_state(events).tries + fails
|
||||
|
||||
|
||||
def property_line(prop: str, box: toolbox.Toolbox) -> str:
|
||||
"""`prop: meaning`, or just `prop` when the meaning names an entry.
|
||||
|
||||
Some meanings illustrate with the operation itself (self-inverse:
|
||||
"a ^ x ^ x = a"); shown to kuhy that is the answer, not a property.
|
||||
"""
|
||||
meaning = box.properties[prop]
|
||||
return prop if leak.leaked_names(meaning, [], box) else f"{prop}: {meaning}"
|
||||
|
||||
|
||||
def _req_note(result: req_check.Check, state: ReqState, box: toolbox.Toolbox) -> str:
|
||||
if result.verdict == "pass":
|
||||
return "[req list accepted -- code is open]"
|
||||
if result.verdict == "unverified":
|
||||
return "[req check unavailable -- continuing unverified, code is open]"
|
||||
if state.forced:
|
||||
missing = ", ".join(result.missing)
|
||||
return f"[req list still incomplete ({missing}) -- code accepted anyway]"
|
||||
props = "\n".join(f" - {property_line(p, box)}" for p in result.missing)
|
||||
return (
|
||||
f"[req list incomplete -- it does not yet say these properties:\n{props}\n"
|
||||
f" revise with req: ({state.tries} of {MAX_LISTS})]"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhaseGate:
|
||||
"""One problem's gate: its log, toolbox and (lazily fetched) statement."""
|
||||
@ -185,7 +162,7 @@ class PhaseGate:
|
||||
if text.lower() == "reveal":
|
||||
session_log.append(self.log, "reveal")
|
||||
return block, ""
|
||||
if REQ_RE.match(text):
|
||||
if REQ_RE.match(text) or (shape.SHAPE_RE.match(text) and self.sql()):
|
||||
return self._req(block)
|
||||
verdict = regex_verdict(text) if is_code is None else is_code
|
||||
if verdict is None:
|
||||
@ -201,6 +178,9 @@ class PhaseGate:
|
||||
|
||||
def _req(self, block: str) -> tuple[str, str]:
|
||||
session_log.append(self.log, "req", text=block.strip())
|
||||
if self.sql():
|
||||
result = shape.check(block)
|
||||
else:
|
||||
result = req_check.check(self.statement(), block, self.toolbox, self.ask)
|
||||
session_log.append(
|
||||
self.log,
|
||||
@ -213,24 +193,25 @@ class PhaseGate:
|
||||
tag = result.verdict
|
||||
if result.missing:
|
||||
tag += "; missing properties: " + ", ".join(result.missing)
|
||||
return f"{block}\n\n[req check: {tag}]", _req_note(result, state, self.toolbox)
|
||||
note = gate_notes.req_note(
|
||||
result,
|
||||
state.tries,
|
||||
forced=state.forced,
|
||||
limit=MAX_LISTS,
|
||||
box=self.toolbox,
|
||||
)
|
||||
return f"{block}\n\n[req check: {tag}]", note
|
||||
|
||||
def refusal_note(self) -> str:
|
||||
"""The line a refused code block gets now (the relay's ``note``)."""
|
||||
return self._refusal(req_state(self.events()))
|
||||
|
||||
@staticmethod
|
||||
def _refusal(state: ReqState) -> str:
|
||||
if not state.tries:
|
||||
return (
|
||||
"[code held back: write the requirement list first -- one message "
|
||||
"starting with req: that lists the properties the operation must "
|
||||
"have, without naming an operation or structure]"
|
||||
)
|
||||
return (
|
||||
"[code held back: the req list is still incomplete -- revise it "
|
||||
f"with req: ({state.tries} of {MAX_LISTS} so far)]"
|
||||
)
|
||||
def sql(self) -> bool:
|
||||
"""Whether this is a SQL problem (an unfetched statement counts as not)."""
|
||||
return shape.is_sql(self.statement())
|
||||
|
||||
def _refusal(self, state: ReqState) -> str:
|
||||
return gate_notes.refusal(state.tries, MAX_LISTS, sql=self.sql())
|
||||
|
||||
def after_reply(self, text: str) -> str:
|
||||
"""Log the reply's rung marker and return the reply without it."""
|
||||
|
||||
0
leetcode_coach/leetcode_coach/py.typed
Normal file
0
leetcode_coach/leetcode_coach/py.typed
Normal file
@ -6,6 +6,6 @@ both feed one paced serial lane (``lane``) into the session backend
|
||||
(``coach``), which grades the five steps and runs the coaching chat.
|
||||
|
||||
Nothing here can reach the gate's ledger, credits or unlocks: the only
|
||||
``leetcode_guard`` modules in the import closure are the read-only
|
||||
submission probe and its client (``test_relay_boundary.py``).
|
||||
``leetcode_guard`` modules reachable are the read-only ones `guard_bridge`
|
||||
names, imported on demand (``test_guard_boundary.py``).
|
||||
"""
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
Logs to stderr (the journal) at INFO; SIGTERM stops taking requests and
|
||||
lets the running one finish (the unit's ``KillMode=mixed`` spares the claude
|
||||
child meanwhile). The phone path signs in as crdt_sync app
|
||||
``leetcode_coach_relay`` via `_snapshot_auth.connect`, which heals from a
|
||||
``leetcode_coach_relay`` via `guard_bridge.phone_id_token`, which heals from a
|
||||
sibling app's session; ``$LEETCODE_COACH_RTDB_URL`` skips sign-in (tests).
|
||||
"""
|
||||
|
||||
@ -16,11 +16,11 @@ import threading
|
||||
|
||||
from crdt_sync import CONFIG_FILE, FirebaseConfig
|
||||
|
||||
from leetcode_coach import guard_bridge
|
||||
from leetcode_coach.relay import config
|
||||
from leetcode_coach.relay.app import Relay
|
||||
from leetcode_coach.relay.coach import Deps
|
||||
from leetcode_coach.relay.rtdb import Rtdb
|
||||
from leetcode_guard import _snapshot_auth
|
||||
|
||||
FAKE_ID = "relay-test"
|
||||
"""What the relay hands the fake RTDB, which takes any non-empty id token."""
|
||||
@ -31,9 +31,9 @@ def make_rtdb() -> Rtdb:
|
||||
url = config.rtdb_url()
|
||||
if url:
|
||||
return Rtdb(url, lambda: FAKE_ID)
|
||||
client = _snapshot_auth.connect(config.APP_NAME, timeout_seconds=15)
|
||||
id_token = guard_bridge.phone_id_token(config.APP_NAME, timeout_seconds=15)
|
||||
database = FirebaseConfig.load(CONFIG_FILE).database_url
|
||||
return Rtdb(database, client.auth.id_token)
|
||||
return Rtdb(database, id_token)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from leetcode_coach import leak, phase
|
||||
from leetcode_coach import gate_notes, leak, phase, shape
|
||||
from leetcode_coach.relay import cards, graders, steps, toolbox_grade, turns
|
||||
from leetcode_coach.relay.errors import CoachError
|
||||
from leetcode_coach.relay.session import grade_line
|
||||
@ -36,22 +36,34 @@ NAMES_A_TOOL: Final = (
|
||||
|
||||
|
||||
def _requirements(session: Session, answer: str, box: Toolbox) -> tuple[str, str, str]:
|
||||
"""Step 2 through the phase gate; a list that is code or names a tool fails."""
|
||||
"""Step 2 through the phase gate; a list that is code or names a tool fails.
|
||||
|
||||
A SQL problem's step 2 is the output shape (judged mechanically, `shape`).
|
||||
"""
|
||||
text = answer.strip()
|
||||
words = phase.REQ_RE.sub("", text, count=1).strip()
|
||||
sql = session.gate.sql()
|
||||
prefix = shape.SHAPE_RE if sql and shape.SHAPE_RE.match(text) else phase.REQ_RE
|
||||
words = prefix.sub("", text, count=1).strip()
|
||||
if phase.regex_verdict(words) is True:
|
||||
return "fail", READS_AS_CODE, "toolbox"
|
||||
if leak.leaked_names(words, [], box):
|
||||
if not sql and leak.leaked_names(words, [], box):
|
||||
return "fail", NAMES_A_TOOL, "toolbox"
|
||||
session.gate.screen(text if phase.REQ_RE.match(text) else f"req: {text}")
|
||||
session.gate.screen(text if prefix.match(text) else f"req: {text}")
|
||||
check = [e for e in session.events() if e.get("event") == "req_check"][-1]
|
||||
via = str(check.get("via") or "unverified")
|
||||
if check.get("verdict") == "pass":
|
||||
return "pass", "", via
|
||||
if check.get("verdict") == "unverified":
|
||||
return "pass", graders.UNVERIFIED, "unverified"
|
||||
missing = [str(m) for m in check.get("missing", []) if m in box.properties]
|
||||
lines = [f"- {phase.property_line(m, box)}" for m in missing]
|
||||
missing = [str(m) for m in check.get("missing", [])]
|
||||
if sql:
|
||||
lines = [f"- {h}" for h in shape.hints(missing)]
|
||||
else:
|
||||
lines = [
|
||||
f"- {gate_notes.property_line(m, box)}"
|
||||
for m in missing
|
||||
if m in box.properties
|
||||
]
|
||||
return "partial", "\n".join(["It does not yet say:", *lines]), via
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""Step 4: has LeetCode seen an Accepted for this problem since it started?
|
||||
|
||||
The harvester's own read-only probe (``fetch_recent_ac`` with the client
|
||||
``_settings.build_client`` builds), at most once a minute: LeetCode answers
|
||||
The harvester's own read-only probe (leetcode-guard's ``fetch_recent_ac``,
|
||||
reached through `guard_bridge`), at most once a minute: LeetCode answers
|
||||
a rate limit exactly like a dead session, so polling harder manufactures
|
||||
the failure. Three answers, never two: ``accepted``, ``not_yet``, and
|
||||
``unknown`` when the probe is unusable -- "could not check" is never "not
|
||||
@ -15,12 +15,13 @@ import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from leetcode_guard._settings import build_client
|
||||
from leetcode_guard._submissions import SolveProbe, fetch_recent_ac
|
||||
from leetcode_coach import guard_bridge
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from leetcode_coach.guard_bridge import SolveProbe
|
||||
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
|
||||
EVERY_S: Final = 60.0
|
||||
@ -31,8 +32,7 @@ UNKNOWN: Final = "unknown"
|
||||
|
||||
def live_probe() -> SolveProbe:
|
||||
"""One read of kuhy's recent accepted submissions."""
|
||||
client = build_client()
|
||||
return fetch_recent_ac(client.post, client.username)
|
||||
return guard_bridge.recent_accepted()
|
||||
|
||||
|
||||
class AcceptedProbe:
|
||||
|
||||
@ -95,6 +95,7 @@ class Session: # pylint: disable=too-many-instance-attributes
|
||||
"started": self.started,
|
||||
"ended": self.ended,
|
||||
"end_reason": self.end_reason,
|
||||
"kind": "sql" if self.gate.sql() else "algorithm",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -14,14 +14,14 @@ part that names no entry ("merge") means only the combined entry counts.
|
||||
* fail: nothing right was picked.
|
||||
|
||||
Feedback is the properties the uncovered key entries need that no pick has,
|
||||
as property names (`phase.property_line`), never an entry name.
|
||||
as property names (`gate_notes.property_line`), never an entry name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from leetcode_coach import phase
|
||||
from leetcode_coach import gate_notes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from leetcode_coach.answer_key import Answer
|
||||
@ -94,7 +94,9 @@ def _feedback(
|
||||
for prop in () if entry is None else entry.properties:
|
||||
if prop not in have and prop not in need:
|
||||
need.append(prop)
|
||||
lines = [f"- {phase.property_line(p, box)}" for p in need if p in box.properties]
|
||||
lines = [
|
||||
f"- {gate_notes.property_line(p, box)}" for p in need if p in box.properties
|
||||
]
|
||||
if lines:
|
||||
head = "The operation you need also has these properties, which no pick has:"
|
||||
text = "\n".join([head, *lines])
|
||||
|
||||
61
leetcode_coach/leetcode_coach/shape.py
Normal file
61
leetcode_coach/leetcode_coach/shape.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""SQL problems state the OUTPUT SHAPE (columns, rows, grouping) before any code.
|
||||
|
||||
The SQL counterpart of the ``req:`` gate: a ``shape:`` message is judged
|
||||
mechanically (no model, nothing to fail open), and the verdict rides the same
|
||||
``req`` / ``req_check`` log events (``via: "shape"``), so readers need no change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
from leetcode_coach import req_check
|
||||
|
||||
VIA: Final = "shape"
|
||||
SHAPE_RE: Final = re.compile(r"^\s*shape\s*:", re.IGNORECASE)
|
||||
SQL_LANGS: Final = frozenset(
|
||||
{"mysql", "mssql", "oraclesql", "postgresql", "pythondata"}
|
||||
)
|
||||
LANGUAGES_RE: Final = re.compile(r"^Languages available:(.*)$", re.MULTILINE)
|
||||
TAGS_RE: Final = re.compile(r"^Tags:(.*)$", re.MULTILINE)
|
||||
FACETS: Final = {
|
||||
"columns": (
|
||||
re.compile(r"\b(?:columns?|fields?|headers?|named)\b", re.IGNORECASE),
|
||||
"which columns come back, named how",
|
||||
),
|
||||
"rows": (
|
||||
re.compile(r"\b(?:rows?|records?|per)\b", re.IGNORECASE),
|
||||
"what one row stands for (one row per ...)",
|
||||
),
|
||||
"grouping": (
|
||||
re.compile(
|
||||
r"\b(?:group\w*|aggregat\w*|distinct|unique|per|sum|count|ungrouped)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"how rows are grouped or aggregated, or that nothing is",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _items(line: re.Match[str] | None) -> set[str]:
|
||||
return set() if line is None else {p.strip() for p in line.group(1).split(",")}
|
||||
|
||||
|
||||
def is_sql(statement: str) -> bool:
|
||||
"""A rendered statement offers a SQL dialect or carries the Database tag."""
|
||||
langs = _items(LANGUAGES_RE.search(statement))
|
||||
return bool(langs & SQL_LANGS) or "Database" in _items(TAGS_RE.search(statement))
|
||||
|
||||
|
||||
def missing_facets(text: str) -> tuple[str, ...]:
|
||||
return tuple(name for name, (found, _) in FACETS.items() if not found.search(text))
|
||||
|
||||
|
||||
def check(text: str) -> req_check.Check:
|
||||
missing = missing_facets(text)
|
||||
return req_check.Check("incomplete" if missing else "pass", missing, VIA)
|
||||
|
||||
|
||||
def hints(missing: tuple[str, ...] | list[str]) -> list[str]:
|
||||
return [f"{name}: {FACETS[name][1]}" for name in missing if name in FACETS]
|
||||
@ -17,11 +17,11 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from leetcode_coach import answer_key, session_log
|
||||
from leetcode_coach.tests import _relay_support as rs
|
||||
from leetcode_guard import _coach_log
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
ACCEPTED_STEP = 4
|
||||
OPENER = "Let's start. What limit binds here?\n<!-- rung:H0 -->"
|
||||
HINT = "What happens to a value combined with itself?\n<!-- rung:H1 -->"
|
||||
CODE = (
|
||||
@ -165,18 +165,18 @@ def run_flow(rig: rs.Rig, ends: Ends) -> None:
|
||||
events = session_log.read(log)
|
||||
assert [e["event"] for e in events].count("grade") == 10
|
||||
assert all(e.get("sid") == sid for e in events if e["event"] in {"grade", "chat"})
|
||||
problem = _coach_log.read_session(log)
|
||||
assert problem is not None
|
||||
assert problem.max_rung == "H1"
|
||||
assert problem.accepted is True
|
||||
assert problem.grades is not None
|
||||
assert {k: v["grade"] for k, v in problem.grades.items()} == {
|
||||
rungs = [e["rung"] for e in events if e["event"] == "rung"]
|
||||
assert max(rungs) == "H1"
|
||||
last = {e["step"]: e for e in events if e["event"] == "grade"}
|
||||
assert {e["grade"] for n, e in last.items() if n == ACCEPTED_STEP} == {"pass"}
|
||||
names = {1: "constraint", 2: "requirements", 3: "toolbox", 5: "postmortem"}
|
||||
assert {names[n]: last[n]["grade"] for n in names} == {
|
||||
"constraint": "fail",
|
||||
"requirements": "pass",
|
||||
"toolbox": "pass",
|
||||
"postmortem": "pass",
|
||||
}
|
||||
assert problem.grades["constraint"]["forced"] is True
|
||||
assert last[1]["forced"] is True
|
||||
coach_calls = [c for c in rs.calls(rig.extra["claude"]) if c["kind"] == "coach"]
|
||||
assert all(c["log"] == str(log) for c in coach_calls)
|
||||
key = answer_key.load_answers()["missing-number"]
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
|
||||
The relay runs in-process with its real threads (coverage sees it), on port
|
||||
0, with the 3 s pace and the minute between probes injected away. The
|
||||
LeetCode guard's data dir is redirected into the test's tmp dir and seeded
|
||||
(`guard_digest`), so "byte-identical afterwards" is checked without racing
|
||||
the live harvester on the developer's real files.
|
||||
LeetCode guard's default data dir sits under a redirected HOME and is seeded
|
||||
(`guard_digest`), so "byte-identical afterwards" is checked on files the live
|
||||
harvester cannot touch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -18,15 +18,15 @@ from pathlib import Path
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from leetcode_coach.relay.app import Relay
|
||||
from leetcode_coach.relay.coach import Deps
|
||||
from leetcode_coach.relay.probe import AcceptedProbe
|
||||
from leetcode_coach.tests._relay_statements import MISSING_NUMBER
|
||||
from leetcode_guard import _constants
|
||||
from leetcode_guard._submissions import AcSubmission, ProbeStatus, SolveProbe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
from collections.abc import Sequence
|
||||
|
||||
FAKE_CLAUDE = Path(__file__).with_name("_fake_claude.py")
|
||||
DEV = "3f2a7c1e-9b4d-4e6f-8a1b-2c3d4e5f6a7b"
|
||||
@ -57,28 +57,41 @@ def calls(root: Path) -> list[dict[str, Any]]:
|
||||
return [json.loads(line) for line in lines]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FakeSubmission:
|
||||
"""One accepted submission, as the probe reports it."""
|
||||
|
||||
title_slug: str
|
||||
timestamp: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FakeSolve:
|
||||
"""Guard's ``SolveProbe``, structurally."""
|
||||
|
||||
usable: bool
|
||||
submissions: Sequence[FakeSubmission]
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeProbe:
|
||||
"""``fetch_recent_ac`` as a switch: unusable, nothing, or an Accepted."""
|
||||
"""``recent_accepted`` as a switch: unusable, nothing, or an Accepted."""
|
||||
|
||||
status: str = "nothing"
|
||||
timestamp_s: int = 0
|
||||
calls: int = 0
|
||||
|
||||
def __call__(self) -> SolveProbe:
|
||||
def __call__(self) -> FakeSolve:
|
||||
self.calls += 1
|
||||
if self.status == "unusable":
|
||||
return SolveProbe(ProbeStatus.UNVERIFIABLE, (), "session expired")
|
||||
return FakeSolve(False, (), "session expired")
|
||||
rows = (
|
||||
(
|
||||
AcSubmission(
|
||||
"1", "Missing Number", "missing-number", self.timestamp_s, "js"
|
||||
),
|
||||
)
|
||||
(FakeSubmission("missing-number", self.timestamp_s),)
|
||||
if self.status == "accepted"
|
||||
else ()
|
||||
)
|
||||
return SolveProbe(ProbeStatus.OK, rows, "ok")
|
||||
return FakeSolve(True, rows, "ok")
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -170,10 +183,32 @@ def wait_for(check: Any, timeout: float = 5.0) -> Any:
|
||||
raise AssertionError(msg)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""A throwaway HOME, so the guard's default data dir is the test's own."""
|
||||
home = tmp_path / "home"
|
||||
guard_dir_of(home).mkdir(parents=True)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
|
||||
|
||||
def guard_dir_of(home: Path) -> Path:
|
||||
"""Where leetcode-guard keeps its data under `home`."""
|
||||
return home / ".local" / "share" / "leetcode-guard"
|
||||
|
||||
|
||||
def guard_dir() -> Path:
|
||||
"""The (redirected) guard data dir; refuses to name the real one."""
|
||||
root = guard_dir_of(Path.home())
|
||||
if "pytest-of-" not in str(root):
|
||||
msg = f"{root} is not a pytest tmp dir: refusing to touch guard data"
|
||||
raise RuntimeError(msg)
|
||||
return root
|
||||
|
||||
|
||||
def guard_digest() -> str:
|
||||
"""A hash of every file under the (redirected) guard data dir."""
|
||||
digest = hashlib.sha256()
|
||||
root = Path(_constants.DATA_DIR)
|
||||
root = guard_dir()
|
||||
for path in sorted(root.rglob("*")):
|
||||
digest.update(str(path.relative_to(root)).encode())
|
||||
if path.is_file():
|
||||
@ -183,7 +218,7 @@ def guard_digest() -> str:
|
||||
|
||||
def seed_guard() -> None:
|
||||
"""Put a ledger and caches where the guard would read them."""
|
||||
root = Path(_constants.DATA_DIR)
|
||||
root = guard_dir()
|
||||
(root / "ledger.json").write_text(
|
||||
'{"entries": [{"k": "credit:1"}]}', encoding="utf-8"
|
||||
)
|
||||
|
||||
@ -13,6 +13,7 @@ import pytest
|
||||
|
||||
from leetcode_coach import (
|
||||
claude_cli,
|
||||
gate_notes,
|
||||
local_backend,
|
||||
phase,
|
||||
req_check,
|
||||
@ -130,7 +131,7 @@ def test_screen_judges_only_the_current_session(tmp_path: Path) -> None:
|
||||
def test_property_line_is_public() -> None:
|
||||
box = toolbox.load_safe()
|
||||
prop = next(iter(box.properties))
|
||||
assert phase.property_line(prop, box).startswith(prop)
|
||||
assert gate_notes.property_line(prop, box).startswith(prop)
|
||||
|
||||
|
||||
def test_ask_merges_the_extra_env(
|
||||
|
||||
@ -11,9 +11,14 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from leetcode_coach import cluster_cli, leetcode_repl, local_session, progress
|
||||
from leetcode_coach import (
|
||||
cluster_cli,
|
||||
guard_bridge,
|
||||
leetcode_repl,
|
||||
local_session,
|
||||
progress,
|
||||
)
|
||||
from leetcode_coach.tests.test_progress import log, solve
|
||||
from leetcode_guard import _leetcode, _live_solved
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -171,43 +176,36 @@ def test_retest_of_an_unsolved_problem_is_refused(
|
||||
|
||||
|
||||
def test_live_check_fails_open(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def boom() -> object:
|
||||
def boom(_slugs: list[str]) -> frozenset[str]:
|
||||
raise OSError
|
||||
|
||||
monkeypatch.setattr(cluster_cli, "build_client", boom)
|
||||
monkeypatch.setattr(guard_bridge, "solved_slugs", boom)
|
||||
assert cluster_cli.accepted_on_leetcode("missing-number") is False
|
||||
|
||||
|
||||
def test_live_check_without_leetcode_guard_serves_the_problem(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def absent(_slugs: list[str]) -> frozenset[str]:
|
||||
raise guard_bridge.GuardUnavailableError
|
||||
|
||||
monkeypatch.setattr(guard_bridge, "solved_slugs", absent)
|
||||
assert cluster_cli.accepted_on_leetcode("missing-number") is False
|
||||
|
||||
|
||||
def test_live_check_reports_an_accepted_problem(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
seen: list[object] = []
|
||||
seen: list[list[str]] = []
|
||||
|
||||
class Client:
|
||||
post = "post"
|
||||
def solved(slugs: list[str]) -> frozenset[str]:
|
||||
seen.append(slugs)
|
||||
return frozenset({"missing-number"}) & frozenset(slugs)
|
||||
|
||||
def check(post: object, slugs: list[str]) -> _live_solved.LiveSolved:
|
||||
seen.extend([post, *slugs])
|
||||
return _live_solved.LiveSolved(solved=frozenset({"missing-number"}))
|
||||
|
||||
monkeypatch.setattr(cluster_cli, "build_client", Client)
|
||||
monkeypatch.setattr(cluster_cli, "check_solved", check)
|
||||
monkeypatch.setattr(guard_bridge, "solved_slugs", solved)
|
||||
assert cluster_cli.accepted_on_leetcode("missing-number") is True
|
||||
assert cluster_cli.accepted_on_leetcode("two-sum") is False
|
||||
assert seen[:2] == ["post", "missing-number"]
|
||||
|
||||
|
||||
def test_a_silent_live_check_serves_the_problem(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def silent(_q: str, _v: dict[str, object]) -> _leetcode.GraphQLResult:
|
||||
return _leetcode.GraphQLResult(data=None, transport_error="offline")
|
||||
|
||||
class Client:
|
||||
post = staticmethod(silent)
|
||||
|
||||
monkeypatch.setattr(cluster_cli, "build_client", Client)
|
||||
assert cluster_cli.accepted_on_leetcode("missing-number") is False
|
||||
assert seen[0] == ["missing-number"]
|
||||
|
||||
|
||||
def test_both_retests_done_means_nothing_more(
|
||||
|
||||
144
leetcode_coach/leetcode_coach/tests/test_guard_bridge.py
Normal file
144
leetcode_coach/leetcode_coach/tests/test_guard_bridge.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""The on-demand bridge into leetcode-guard, and the boundary around it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import builtins
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
import leetcode_coach
|
||||
from leetcode_coach import guard_bridge
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
PACKAGE = Path(leetcode_coach.__file__).resolve().parent
|
||||
DENIED = re.compile(
|
||||
r"_ledger.*|_balance|_debt|_harvest.*|_gate.*|_sync.*|_lock.*|_breakglass"
|
||||
r"|_escape.*|_snapshot_push"
|
||||
)
|
||||
|
||||
|
||||
def _guard(monkeypatch: pytest.MonkeyPatch, **modules: Any) -> None:
|
||||
"""Install fake ``leetcode_guard._<name>`` modules."""
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "leetcode_guard", types.ModuleType("leetcode_guard")
|
||||
)
|
||||
for name, attrs in modules.items():
|
||||
module = types.ModuleType(f"leetcode_guard.{name}")
|
||||
module.__dict__.update(attrs)
|
||||
monkeypatch.setitem(sys.modules, f"leetcode_guard.{name}", module)
|
||||
|
||||
|
||||
class Client:
|
||||
username = "kuhy"
|
||||
|
||||
@staticmethod
|
||||
def post(*_a: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_solved_slugs_asks_guards_checker_with_guards_client(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
seen: list[Any] = []
|
||||
|
||||
def check_solved(post: object, slugs: list[str]) -> Any:
|
||||
seen.append((post, slugs))
|
||||
return types.SimpleNamespace(solved={"a"})
|
||||
|
||||
_guard(
|
||||
monkeypatch,
|
||||
_settings={"build_client": Client},
|
||||
_live_solved={"check_solved": check_solved},
|
||||
)
|
||||
assert guard_bridge.solved_slugs(("a", "b")) == frozenset({"a"})
|
||||
assert seen == [(Client.post, ["a", "b"])]
|
||||
|
||||
|
||||
def test_recent_accepted_returns_guards_probe(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
probe = object()
|
||||
|
||||
def fetch(post: object, username: str) -> object:
|
||||
assert (post, username) == (Client.post, "kuhy")
|
||||
return probe
|
||||
|
||||
_guard(
|
||||
monkeypatch,
|
||||
_settings={"build_client": Client},
|
||||
_submissions={"fetch_recent_ac": fetch},
|
||||
)
|
||||
assert guard_bridge.recent_accepted() is probe
|
||||
|
||||
|
||||
def test_phone_id_token_connects_as_the_named_app(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def connect(app: str, *, timeout_seconds: float) -> Any:
|
||||
assert (app, timeout_seconds) == ("relay", 3)
|
||||
return types.SimpleNamespace(auth=types.SimpleNamespace(id_token=lambda: "tok"))
|
||||
|
||||
_guard(monkeypatch, _snapshot_auth={"connect": connect})
|
||||
assert guard_bridge.phone_id_token("relay", timeout_seconds=3)() == "tok"
|
||||
|
||||
|
||||
def test_a_missing_guard_is_a_named_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
real_import: Callable[..., Any] = builtins.__import__
|
||||
for name in list(sys.modules):
|
||||
if name.startswith("leetcode_guard"):
|
||||
monkeypatch.delitem(sys.modules, name)
|
||||
|
||||
def refuse(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if name.startswith("leetcode_guard"):
|
||||
raise ModuleNotFoundError(name)
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", refuse)
|
||||
with pytest.raises(
|
||||
guard_bridge.GuardUnavailableError, match="needs leetcode-guard"
|
||||
):
|
||||
guard_bridge.solved_slugs(["a"])
|
||||
|
||||
|
||||
def _static_guard_imports(path: Path) -> list[str]:
|
||||
found: list[str] = []
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
||||
if isinstance(node, ast.Import):
|
||||
found += [a.name for a in node.names if a.name.startswith("leetcode_guard")]
|
||||
elif isinstance(node, ast.ImportFrom) and (node.module or "").startswith(
|
||||
"leetcode_guard"
|
||||
):
|
||||
found.append(node.module or "")
|
||||
return found
|
||||
|
||||
|
||||
def test_no_coach_module_imports_the_guard_statically() -> None:
|
||||
sources = [p for p in PACKAGE.rglob("*.py") if "tests" not in p.parts]
|
||||
offenders = {
|
||||
p.name: _static_guard_imports(p) for p in sources if _static_guard_imports(p)
|
||||
}
|
||||
assert not offenders
|
||||
|
||||
|
||||
def test_the_walk_would_catch_a_static_import(tmp_path: Path) -> None:
|
||||
probe = tmp_path / "x.py"
|
||||
probe.write_text(
|
||||
"from leetcode_guard import _ledger\nimport leetcode_guard._gate\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _static_guard_imports(probe) == ["leetcode_guard", "leetcode_guard._gate"]
|
||||
|
||||
|
||||
def test_the_bridge_names_only_read_only_guard_modules() -> None:
|
||||
text = (PACKAGE / "guard_bridge.py").read_text(encoding="utf-8")
|
||||
named = set(re.findall(r"leetcode_guard\.\w+", text))
|
||||
assert named == guard_bridge.ALLOWED
|
||||
assert not [m for m in named if DENIED.fullmatch(m.split(".")[1])]
|
||||
assert DENIED.fullmatch("_ledger_io")
|
||||
assert DENIED.fullmatch("_harvest_run")
|
||||
@ -1,90 +0,0 @@
|
||||
"""Tests for scripts/link_leetcode_coach.sh, the install step for the coach.
|
||||
|
||||
Run against a throwaway HOME: the script must never be pointed at the real
|
||||
~/.claude from a test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = REPO / "scripts" / "link_leetcode_coach.sh"
|
||||
COACH = REPO / "leetcode_coach"
|
||||
|
||||
|
||||
def _run(home: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[str(SCRIPT)],
|
||||
env={"HOME": str(home), "PATH": "/usr/bin:/bin"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _old_skill(home: Path) -> Path:
|
||||
old = home / ".claude" / "skills" / "leetcode"
|
||||
old.mkdir(parents=True)
|
||||
(old / "SKILL.md").write_text("---\nname: leetcode\n---\n", encoding="utf-8")
|
||||
(old / "leetcode_repl.py").write_text("", encoding="utf-8")
|
||||
return old
|
||||
|
||||
|
||||
def test_fresh_home_gets_both_links(tmp_path: Path) -> None:
|
||||
proc = _run(tmp_path)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
skill = tmp_path / ".claude" / "skills" / "leetcode"
|
||||
assert skill.is_symlink()
|
||||
assert skill.resolve() == COACH
|
||||
assert (tmp_path / ".local" / "bin" / "leetcode").resolve() == COACH / "leetcode"
|
||||
assert _run(tmp_path).returncode == 0 # idempotent
|
||||
|
||||
|
||||
def test_old_skill_dir_is_backed_up_outside_skills(tmp_path: Path) -> None:
|
||||
_old_skill(tmp_path)
|
||||
bin_link = tmp_path / ".local" / "bin" / "leetcode"
|
||||
bin_link.parent.mkdir(parents=True)
|
||||
bin_link.symlink_to(tmp_path / ".claude" / "skills" / "leetcode" / "leetcode")
|
||||
proc = _run(tmp_path)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
backups = list((tmp_path / ".claude" / "backups").glob("skill-leetcode-*"))
|
||||
assert len(backups) == 1
|
||||
assert (backups[0] / "leetcode_repl.py").is_file()
|
||||
assert sorted(p.name for p in (tmp_path / ".claude" / "skills").iterdir()) == [
|
||||
"leetcode"
|
||||
]
|
||||
assert (tmp_path / ".claude" / "skills" / "leetcode").resolve() == COACH
|
||||
assert bin_link.resolve() == COACH / "leetcode"
|
||||
|
||||
|
||||
def test_existing_symlink_is_repointed(tmp_path: Path) -> None:
|
||||
skills = tmp_path / ".claude" / "skills"
|
||||
skills.mkdir(parents=True)
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
(skills / "leetcode").symlink_to(elsewhere)
|
||||
assert _run(tmp_path).returncode == 0
|
||||
assert (skills / "leetcode").resolve() == COACH
|
||||
assert list(elsewhere.iterdir()) == []
|
||||
|
||||
|
||||
def test_unknown_directory_is_refused(tmp_path: Path) -> None:
|
||||
other = tmp_path / ".claude" / "skills" / "leetcode"
|
||||
other.mkdir(parents=True)
|
||||
(other / "notes.txt").write_text("mine", encoding="utf-8")
|
||||
proc = _run(tmp_path)
|
||||
assert proc.returncode == 1
|
||||
assert "not replacing it" in proc.stderr
|
||||
assert (other / "notes.txt").is_file()
|
||||
assert not other.is_symlink()
|
||||
|
||||
|
||||
def test_regular_file_at_the_command_is_refused(tmp_path: Path) -> None:
|
||||
bin_file = tmp_path / ".local" / "bin" / "leetcode"
|
||||
bin_file.parent.mkdir(parents=True)
|
||||
bin_file.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
proc = _run(tmp_path)
|
||||
assert proc.returncode == 1
|
||||
assert not bin_file.is_symlink()
|
||||
@ -35,6 +35,7 @@ CODE = "function missingNumber(nums) {\n return 0;\n}"
|
||||
("is it XOR?", False),
|
||||
("I think (not sure) it is about pairs", False),
|
||||
("req: a = b", False),
|
||||
("shape: one row per id (a = b)", False),
|
||||
("is it x ^ y?", None),
|
||||
("a = b", None),
|
||||
("what does f(3) give", None),
|
||||
|
||||
@ -1,92 +1,17 @@
|
||||
"""No relay route reaches the ledger (DOCS-coach-ui-spec.md decision 2, contract 4).
|
||||
"""The relay spawns nothing but claude and node (DOCS-coach-ui-spec.md decision 2).
|
||||
|
||||
Walks the import closure of every ``leetcode_coach.relay`` module by AST --
|
||||
through ``leetcode_coach`` and ``leetcode_guard`` alike, ``TYPE_CHECKING``
|
||||
imports included -- and holds the guard side to an allowlist: the read-only
|
||||
submission probe, its client, and the Firebase sign-in. A credit still comes
|
||||
only from a LeetCode Accepted the harvester sees.
|
||||
The ledger boundary (no coach module imports leetcode_guard, the bridge names
|
||||
only read-only guard modules) is ``test_guard_boundary.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import leetcode_coach
|
||||
|
||||
ROOT = Path(leetcode_coach.__file__).resolve().parent.parent
|
||||
ALLOWED = {
|
||||
"_submissions",
|
||||
"_queries",
|
||||
"_settings",
|
||||
"_auth",
|
||||
"_constants",
|
||||
"_leetcode",
|
||||
"_throttle",
|
||||
"_snapshot_auth",
|
||||
}
|
||||
DENIED = re.compile(
|
||||
r"_ledger.*|_balance|_debt|_harvest.*|_gate.*|_sync.*|_lock.*|_breakglass"
|
||||
r"|_escape.*|_snapshot_push"
|
||||
)
|
||||
PACKAGES = ("leetcode_coach", "leetcode_guard")
|
||||
|
||||
|
||||
def _file(module: str) -> Path | None:
|
||||
base = ROOT.joinpath(*module.split("."))
|
||||
for path in (base.with_suffix(".py"), base / "__init__.py"):
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _imports(path: Path) -> set[str]:
|
||||
found: set[str] = set()
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
||||
if isinstance(node, ast.Import):
|
||||
found |= {alias.name for alias in node.names}
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
found.add(node.module)
|
||||
found |= {f"{node.module}.{alias.name}" for alias in node.names}
|
||||
return {m for m in found if m.split(".")[0] in PACKAGES and _file(m)}
|
||||
|
||||
|
||||
def closure() -> set[str]:
|
||||
"""Every first-party module the relay package can load."""
|
||||
start = {
|
||||
f"leetcode_coach.relay.{p.stem}"
|
||||
for p in (ROOT / "leetcode_coach" / "relay").glob("*.py")
|
||||
}
|
||||
seen: set[str] = set()
|
||||
todo = sorted(start)
|
||||
while todo:
|
||||
module = todo.pop()
|
||||
if module in seen:
|
||||
continue
|
||||
seen.add(module)
|
||||
path = _file(module)
|
||||
assert path is not None, module
|
||||
todo += sorted(_imports(path) - seen)
|
||||
return seen
|
||||
|
||||
|
||||
def test_the_relay_reaches_only_the_read_only_guard_modules() -> None:
|
||||
guard = {m.split(".")[1] for m in closure() if m.startswith("leetcode_guard.")}
|
||||
assert guard <= ALLOWED, guard - ALLOWED
|
||||
assert not [m for m in guard if DENIED.fullmatch(m)]
|
||||
assert "_submissions" in guard # the walk does see the guard side
|
||||
|
||||
|
||||
def test_the_walk_would_catch_a_ledger_import(tmp_path: Path) -> None:
|
||||
probe = tmp_path / "x.py"
|
||||
probe.write_text(
|
||||
"from leetcode_guard import _ledger\nimport leetcode_guard._gate\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _imports(probe) >= {"leetcode_guard._ledger", "leetcode_guard._gate"}
|
||||
assert DENIED.fullmatch("_ledger_io")
|
||||
assert DENIED.fullmatch("_harvest_run")
|
||||
|
||||
|
||||
def test_no_relay_module_spawns_anything_but_claude_and_node() -> None:
|
||||
|
||||
@ -13,13 +13,15 @@ import pytest
|
||||
|
||||
from leetcode_coach.tests import _relay_flow as flow
|
||||
from leetcode_coach.tests import _relay_support as rs
|
||||
from leetcode_guard.tests._paths_fixture import _isolate_paths
|
||||
from leetcode_coach.tests._relay_support import _isolate_paths
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["_isolate_paths"] # the guard's paths, redirected for every test here
|
||||
__all__ = [
|
||||
"_isolate_paths"
|
||||
] # the guard's data dir, under a redirected HOME, for every test here
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -9,15 +9,16 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import crdt_sync
|
||||
|
||||
from leetcode_coach import guard_bridge
|
||||
from leetcode_coach.relay import __main__ as entry
|
||||
from leetcode_coach.relay import app as app_mod
|
||||
from leetcode_coach.relay import config
|
||||
from leetcode_coach.relay.rtdb import Rtdb
|
||||
from leetcode_coach.tests import _relay_support as rs
|
||||
from leetcode_coach.tests._fake_rtdb import FakeRtdb
|
||||
from leetcode_guard import _snapshot_auth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@ -27,23 +28,15 @@ def test_make_rtdb_signs_in_as_its_own_app(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
monkeypatch.setenv(config.RTDB_ENV, "")
|
||||
seen: list[str] = []
|
||||
|
||||
class Auth:
|
||||
@staticmethod
|
||||
def id_token() -> str:
|
||||
return "live"
|
||||
|
||||
class Client:
|
||||
auth = Auth()
|
||||
|
||||
def connect(app: str, *, timeout_seconds: float) -> Client:
|
||||
def phone_id_token(app: str, *, timeout_seconds: float) -> Callable[[], str]:
|
||||
seen.append(app)
|
||||
assert timeout_seconds == 15
|
||||
return Client()
|
||||
return lambda: "live"
|
||||
|
||||
class Config:
|
||||
database_url = "https://db.example"
|
||||
|
||||
monkeypatch.setattr(_snapshot_auth, "connect", connect)
|
||||
monkeypatch.setattr(guard_bridge, "phone_id_token", phone_id_token)
|
||||
monkeypatch.setattr(crdt_sync.FirebaseConfig, "load", lambda _path: Config())
|
||||
rtdb = entry.make_rtdb()
|
||||
assert seen == ["leetcode_coach_relay"]
|
||||
|
||||
@ -17,13 +17,15 @@ from leetcode_coach.relay.rtdb import Rtdb
|
||||
from leetcode_coach.tests import _relay_flow as flow
|
||||
from leetcode_coach.tests import _relay_support as rs
|
||||
from leetcode_coach.tests._fake_rtdb import FakeRtdb
|
||||
from leetcode_guard.tests._paths_fixture import _isolate_paths
|
||||
from leetcode_coach.tests._relay_support import _isolate_paths
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["_isolate_paths"] # the guard's paths, redirected for every test here
|
||||
__all__ = [
|
||||
"_isolate_paths"
|
||||
] # the guard's data dir, under a redirected HOME, for every test here
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -6,8 +6,9 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from leetcode_coach import answer_key, leak, toolbox
|
||||
from leetcode_coach import answer_key, guard_bridge, leak, toolbox
|
||||
from leetcode_coach.relay import chat, config, probe, toolbox_grade, turns
|
||||
from leetcode_coach.tests import _relay_support as rs
|
||||
from leetcode_coach.tests._relay_direct import FakeAsk, make_coach, req, started
|
||||
from leetcode_coach.tests._relay_support import Clock, FakeProbe
|
||||
|
||||
@ -90,18 +91,12 @@ def test_the_probe_is_three_valued_and_spaced(caplog: pytest.LogCaptureFixture)
|
||||
assert "probe failed" in caplog.text
|
||||
|
||||
|
||||
def test_the_live_probe_uses_the_harvesters_client(
|
||||
def test_the_live_probe_reads_through_the_guard_bridge(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class Client:
|
||||
post = staticmethod(lambda *_a: None)
|
||||
username = "kuhy"
|
||||
|
||||
seen: list[str] = []
|
||||
monkeypatch.setattr(probe, "build_client", Client)
|
||||
monkeypatch.setattr(probe, "fetch_recent_ac", lambda _post, user: seen.append(user))
|
||||
probe.live_probe()
|
||||
assert seen == ["kuhy"]
|
||||
answer = rs.FakeSolve(True, (), "ok")
|
||||
monkeypatch.setattr(guard_bridge, "recent_accepted", lambda: answer)
|
||||
assert probe.live_probe() is answer
|
||||
|
||||
|
||||
def test_ticks_that_must_do_nothing(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Tests for the coach's JSONL session log (contract read by leetcode_guard)."""
|
||||
"""Tests for the coach's JSONL session log (contract read by leetcode_guard._coach_log)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -10,7 +10,6 @@ from typing import TYPE_CHECKING
|
||||
import pytest
|
||||
|
||||
from leetcode_coach import progress, session_log
|
||||
from leetcode_guard import _coach_log
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -82,21 +81,3 @@ def test_read_tolerates_a_missing_file_and_torn_lines(
|
||||
assert "not JSON" in caplog.text
|
||||
assert session_log.has_code(path)
|
||||
assert not session_log.has_code(tmp_path / "other.jsonl")
|
||||
|
||||
|
||||
def test_the_status_reader_understands_what_is_written(tmp_path: Path) -> None:
|
||||
path = tmp_path / "2026-10-23-missing-number.jsonl"
|
||||
session_log.append(path, "req", text="req: x", cluster="bit-cancellation")
|
||||
session_log.append(path, "req_check", verdict="incomplete", missing=["p"])
|
||||
session_log.append(path, "code", req_incomplete=True)
|
||||
session_log.append(path, "rung", rung="H2")
|
||||
problem = _coach_log.read_session(path)
|
||||
assert problem == _coach_log.CoachProblem(
|
||||
slug="missing-number",
|
||||
cluster="bit-cancellation",
|
||||
req_before_code=True,
|
||||
max_rung="H2",
|
||||
revealed=False,
|
||||
req_check="incomplete",
|
||||
day="2026-10-23",
|
||||
)
|
||||
|
||||
176
leetcode_coach/leetcode_coach/tests/test_shape.py
Normal file
176
leetcode_coach/leetcode_coach/tests/test_shape.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""Q5: SQL problems ask for the OUTPUT SHAPE, algorithm problems keep ``req:``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, NoReturn
|
||||
|
||||
import pytest
|
||||
|
||||
from leetcode_coach import phase, req_check, session_log, shape, toolbox
|
||||
from leetcode_coach.relay import grading
|
||||
from leetcode_coach.tests._relay_direct import FakeAsk, grade, make_coach, started
|
||||
from leetcode_coach.tests._relay_statements import MISSING_NUMBER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
SQL_STATEMENT = """# 1179. Reformat Department Table (Easy)
|
||||
Tags: Database
|
||||
Languages available: mysql, mssql, oraclesql, postgresql, pythondata
|
||||
|
||||
Reformat the table so there is a department id column and a revenue column
|
||||
for each month.
|
||||
"""
|
||||
SHAPE = "shape: columns id plus Jan_Revenue..Dec_Revenue, one row per department id"
|
||||
QUERY = "SELECT id, SUM(CASE WHEN month = 'Jan' THEN revenue END) FROM Department GROUP BY id;"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("statement", "expected"),
|
||||
[
|
||||
(SQL_STATEMENT, True),
|
||||
("# 176. Second Highest Salary (Medium)\nTags: Database\n", True),
|
||||
("# 1. X (Easy)\nTags: Array\nLanguages available: mysql, python3\n", True),
|
||||
(MISSING_NUMBER, False),
|
||||
("missing-number", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_is_sql(statement: str, expected: bool) -> None:
|
||||
assert shape.is_sql(statement) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "missing"),
|
||||
[
|
||||
(SHAPE, ()),
|
||||
("shape: two columns, name and total; a row per customer, grouped by name", ()),
|
||||
("shape: the columns are id and name", ("rows", "grouping")),
|
||||
("shape: one row per id", ("columns",)),
|
||||
("shape: nothing yet", ("columns", "rows", "grouping")),
|
||||
],
|
||||
)
|
||||
def test_missing_facets(text: str, missing: tuple[str, ...]) -> None:
|
||||
assert shape.missing_facets(text) == missing
|
||||
|
||||
|
||||
def test_check_is_a_req_check_result_without_a_model() -> None:
|
||||
assert shape.check(SHAPE) == req_check.Check("pass", (), "shape")
|
||||
assert shape.check("shape: columns id") == req_check.Check(
|
||||
"incomplete", ("rows", "grouping"), "shape"
|
||||
)
|
||||
|
||||
|
||||
def _gate(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, text: str
|
||||
) -> phase.PhaseGate:
|
||||
def never(*_a: object) -> NoReturn:
|
||||
pytest.fail("a SQL shape must never reach the requirement checker")
|
||||
|
||||
monkeypatch.setattr(req_check, "check", never)
|
||||
return phase.PhaseGate(tmp_path / "log.jsonl", toolbox.load_safe(), lambda: text)
|
||||
|
||||
|
||||
def _events(gate: phase.PhaseGate) -> list[str]:
|
||||
return [e["event"] for e in session_log.read(gate.log)]
|
||||
|
||||
|
||||
def test_sql_code_is_held_back_until_a_shape_passes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
gate = _gate(tmp_path, monkeypatch, SQL_STATEMENT)
|
||||
sent, note = gate.screen(QUERY)
|
||||
assert sent is None
|
||||
assert "shape:" in note
|
||||
assert "req:" not in note
|
||||
assert _events(gate) == []
|
||||
sent, note = gate.screen(SHAPE)
|
||||
assert sent == f"{SHAPE}\n\n[req check: pass]"
|
||||
assert "output shape accepted" in note
|
||||
assert gate.screen(QUERY) == (QUERY, "")
|
||||
assert _events(gate) == ["req", "req_check", "code"]
|
||||
assert session_log.read(gate.log)[1]["via"] == "shape"
|
||||
|
||||
|
||||
def test_a_req_list_on_a_sql_problem_counts_as_the_shape(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
gate = _gate(tmp_path, monkeypatch, SQL_STATEMENT)
|
||||
_, note = gate.screen("req: " + SHAPE.removeprefix("shape: "))
|
||||
assert "output shape accepted" in note
|
||||
assert gate.screen(QUERY) == (QUERY, "")
|
||||
|
||||
|
||||
def test_an_incomplete_shape_names_the_missing_facets_then_forces(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
gate = _gate(tmp_path, monkeypatch, SQL_STATEMENT)
|
||||
_, note = gate.screen("shape: the columns are id and name")
|
||||
assert "rows" in note
|
||||
assert "grouping" in note
|
||||
assert "1 of 3" in note
|
||||
refused, why = gate.screen(QUERY)
|
||||
assert refused is None
|
||||
assert "shape" in why
|
||||
assert "1 of 3" in why
|
||||
gate.screen("shape: columns again")
|
||||
_, last = gate.screen("shape: columns once more")
|
||||
assert "accepted anyway" in last
|
||||
assert gate.screen(QUERY) == (QUERY, "")
|
||||
assert session_log.read(gate.log)[-1]["req_incomplete"] is True
|
||||
|
||||
|
||||
def test_algorithm_problems_keep_the_req_gate(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
gate = _gate(tmp_path, monkeypatch, MISSING_NUMBER)
|
||||
sent, note = gate.screen(QUERY)
|
||||
assert sent is None
|
||||
assert "req:" in note
|
||||
assert "shape" not in note
|
||||
assert gate.screen(SHAPE) == (SHAPE, "")
|
||||
assert _events(gate) == []
|
||||
|
||||
|
||||
def test_an_unfetched_statement_defaults_to_the_stricter_req_gate(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
gate = _gate(tmp_path, monkeypatch, "reformat-department-table")
|
||||
_, note = gate.screen(QUERY)
|
||||
assert "req:" in note
|
||||
|
||||
|
||||
def _sql_coach(tmp_path: Path) -> tuple[Any, str]:
|
||||
ask = FakeAsk([{"result": "Hi?"}] * 3, [{"result": json.dumps({"grade": "pass"})}])
|
||||
coach = make_coach(tmp_path, ask, fetch=lambda _s: ("Reformat", SQL_STATEMENT))
|
||||
return coach, started(coach)
|
||||
|
||||
|
||||
def test_step_two_grades_the_shape_mechanically(tmp_path: Path) -> None:
|
||||
coach, sid = _sql_coach(tmp_path)
|
||||
grade(coach, sid, 1, answer="n is big")
|
||||
partial = grade(coach, sid, 2, answer="the columns are id and name")["data"]
|
||||
assert partial["grade"] == "partial"
|
||||
assert "one row per" in partial["feedback"]
|
||||
assert "group" in partial["feedback"]
|
||||
passed = grade(coach, sid, 2, answer=SHAPE)["data"]
|
||||
assert (passed["grade"], passed["next_step"]) == ("pass", 3)
|
||||
assert coach.session is not None
|
||||
assert coach.session.info()["kind"] == "sql"
|
||||
assert coach.deps.ask.prompts("haiku") # step 1 only; step 2 asked no model
|
||||
assert len(coach.deps.ask.prompts("haiku")) == 1
|
||||
|
||||
|
||||
def test_step_two_still_rejects_code_as_a_shape(tmp_path: Path) -> None:
|
||||
coach, sid = _sql_coach(tmp_path)
|
||||
grade(coach, sid, 1, answer="n is big")
|
||||
got = grade(coach, sid, 2, answer=QUERY)["data"]
|
||||
assert (got["grade"], got["feedback"]) == ("fail", grading.READS_AS_CODE)
|
||||
|
||||
|
||||
def test_an_algorithm_session_reports_its_kind(tmp_path: Path) -> None:
|
||||
coach = make_coach(tmp_path, FakeAsk([{"result": "Hi?"}]))
|
||||
started(coach)
|
||||
assert coach.session is not None
|
||||
assert coach.session.info()["kind"] == "algorithm"
|
||||
241
leetcode_coach/pyproject.toml
Normal file
241
leetcode_coach/pyproject.toml
Normal file
@ -0,0 +1,241 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "leetcode-coach"
|
||||
version = "0.1.0"
|
||||
description = "The /leetcode coach as a library: phase gate, leak and stop gates, session log, grading, cards, relay"
|
||||
requires-python = ">=3.14"
|
||||
# Everything below loads lazily (rtdb, make_cards, relay.__main__): importing
|
||||
# phase, leak, toolbox, progress or session_log needs the stdlib only.
|
||||
# leetcode_guard is NOT a dependency (guard_bridge imports it on demand, so the
|
||||
# package never pulls the gate's ledger code into a tutor server).
|
||||
dependencies = [
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
|
||||
"genanki==0.13.1",
|
||||
"requests==2.34.2",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["leetcode_coach*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
# The skill body and the launcher are read/executed from the package dir.
|
||||
leetcode_coach = ["SKILL.md", "leetcode", "*.json", "py.typed"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
include = ["*.py", "**/*.py"]
|
||||
# leetcode_coach/hook_shims holds the <=3-line files installed into
|
||||
# ~/.claude/hooks: a docstring, an import and a one-line guarded exec. Three
|
||||
# lines cannot satisfy ruff ALL (or the formatter, which would split the exec
|
||||
# into five), and the shims carry no logic -- test_stop_gate.py runs the real
|
||||
# one end to end. force-exclude makes the exclusion hold for the explicit file
|
||||
# lists pre-commit passes.
|
||||
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs", "leetcode_coach/hook_shims"]
|
||||
force-exclude = true
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"CPY001",
|
||||
"D203",
|
||||
"D213",
|
||||
"COM812",
|
||||
"ISC001",
|
||||
"S603",
|
||||
]
|
||||
fixable = ["ALL"]
|
||||
unfixable = []
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"**/tests/**/*.py" = [
|
||||
"ANN", "ARG", "D", "DTZ011", "E501", "FBT",
|
||||
"PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "S104", "S105",
|
||||
"SLF001",
|
||||
]
|
||||
"**/test_*.py" = [
|
||||
"ANN", "ARG", "D", "DTZ011", "E501", "FBT",
|
||||
"PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "S104", "S105",
|
||||
"SLF001",
|
||||
]
|
||||
# Every one of these modules is a command line or a hook whose printed text IS
|
||||
# its interface: the REPL session, the offline session, the card builder's
|
||||
# table, the statement fetcher, the eval verdict table, `leetcode report`'s
|
||||
# rows, and the Stop hook's one JSON decision line. T201 is auto-fixable under
|
||||
# --unsafe-fixes, so without these entries the fixer deletes those prints and
|
||||
# still exits 0. Their tests assert on stdout.
|
||||
"leetcode_coach/leetcode_repl.py" = ["T201"]
|
||||
"leetcode_coach/local_session.py" = ["T201"]
|
||||
"leetcode_coach/make_cards.py" = ["T201"]
|
||||
"leetcode_coach/fetch_problem.py" = ["S310", "T201"]
|
||||
"leetcode_coach/eval_local.py" = ["T201"]
|
||||
"leetcode_coach/stop_gate.py" = ["T201"]
|
||||
"leetcode_coach/cluster_cli.py" = ["T201"]
|
||||
"leetcode_coach/toolbox_cards.py" = ["T201"]
|
||||
"leetcode_coach/report.py" = ["T201"]
|
||||
# S310 (urlopen scheme audit): all three open a fixed literal URL --
|
||||
# AnkiConnect on 127.0.0.1, LeetCode's https GraphQL endpoint, and Ollama on
|
||||
# localhost, where http_json additionally refuses any URL off OLLAMA_URL. They
|
||||
# stay on stdlib urllib on purpose: fetch_problem runs by path with no install.
|
||||
# ANN401: AnkiConnect's `result` is untyped JSON whose shape depends on the
|
||||
# action, so `_call` returns Any and each caller indexes what its action sends.
|
||||
"leetcode_coach/anki_connect.py" = ["ANN401", "S310"]
|
||||
# The coach relay's lane, assembly, phone link and feedback check take one
|
||||
# keyword argument per independently injectable seam (clock, pace, timings,
|
||||
# what the feedback may not say): the E2E tests run the real threads at test
|
||||
# speed through exactly these arguments.
|
||||
"leetcode_coach/relay/lane.py" = ["PLR0913"]
|
||||
"leetcode_coach/relay/app.py" = ["PLR0913"]
|
||||
"leetcode_coach/relay/phone.py" = ["PLR0913"]
|
||||
"leetcode_coach/relay/graders.py" = ["PLR0913"]
|
||||
"leetcode_coach/local_backend.py" = ["S310"]
|
||||
"leetcode_coach/guard_bridge.py" = ["D101", "D102", "D103"]
|
||||
"leetcode_coach/shape.py" = ["D103"]
|
||||
"leetcode_coach/gate_notes.py" = ["D103"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
force-single-line = false
|
||||
force-sort-within-sections = true
|
||||
known-first-party = ["leetcode_coach"]
|
||||
|
||||
[tool.ruff.lint.flake8-quotes]
|
||||
docstring-quotes = "double"
|
||||
inline-quotes = "double"
|
||||
|
||||
[tool.ruff.lint.flake8-tidy-imports]
|
||||
ban-relative-imports = "all"
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
skip-magic-trailing-comma = false
|
||||
line-ending = "auto"
|
||||
docstring-code-format = true
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.14"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_no_return = true
|
||||
warn_unreachable = true
|
||||
disallow_any_unimported = true
|
||||
disallow_any_explicit = false
|
||||
disallow_any_generics = true
|
||||
disallow_subclassing_any = true
|
||||
strict_equality = true
|
||||
extra_checks = true
|
||||
ignore_missing_imports = true
|
||||
show_error_codes = true
|
||||
color_output = true
|
||||
exclude = [".venv/", "leetcode_coach/hook_shims/"]
|
||||
|
||||
[tool.pylint.main]
|
||||
# The subproject root on the path, so pylint resolves the package's own
|
||||
# imports the same way pytest does instead of reporting import-error.
|
||||
init-hook = "import sys; sys.path.insert(0, 'leetcode_coach')"
|
||||
analyse-fallback-blocks = true
|
||||
persistent = true
|
||||
jobs = 0
|
||||
py-version = "3.14"
|
||||
ignore = [".venv", "__pycache__"]
|
||||
# Tests are linted by ruff (same exemptions as above); `ignore-paths`, not
|
||||
# `ignore`, because pre-commit hands pylint explicit file paths.
|
||||
# hook_shims: see the ruff exclude above.
|
||||
ignore-paths = ["^(.*/)?tests/.*$", "^(.*/)?hook_shims/.*$"]
|
||||
ignore-patterns = [".*\\.pyi$"]
|
||||
|
||||
[tool.pylint.basic]
|
||||
# snake_case, plus the ``do_<METHOD>`` names http.server dispatches on.
|
||||
method-rgx = "([^\\W\\dA-Z][^\\WA-Z]{2,}|_[^\\WA-Z]*|__[^\\W\\dA-Z][^\\WA-Z]+__|do_[A-Z]+)$"
|
||||
|
||||
[tool.pylint.messages_control]
|
||||
enable = "all"
|
||||
disable = [
|
||||
"missing-class-docstring",
|
||||
"missing-function-docstring",
|
||||
"use-implicit-booleaness-not-comparison",
|
||||
"use-implicit-booleaness-not-comparison-to-string",
|
||||
"use-implicit-booleaness-not-comparison-to-zero",
|
||||
]
|
||||
|
||||
[tool.pylint.design]
|
||||
min-public-methods = 0
|
||||
max-module-lines = 1000
|
||||
max-attributes = 10
|
||||
|
||||
[tool.pylint.typecheck]
|
||||
generated-members = [
|
||||
".*\\.assert_called_once_with",
|
||||
".*\\.assert_called_once",
|
||||
".*\\.assert_called",
|
||||
".*\\.assert_not_called",
|
||||
".*\\.assert_any_call",
|
||||
".*\\.call_args",
|
||||
".*\\.call_args_list",
|
||||
".*\\.call_count",
|
||||
]
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["tests", ".venv"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["leetcode_coach/tests"]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"-v",
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
"-ra",
|
||||
"--cov=leetcode_coach",
|
||||
"--cov-branch",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=lcov",
|
||||
]
|
||||
filterwarnings = [
|
||||
# "error" is the point: a warning in this package is a bug. The
|
||||
# DeprecationWarning exemption: urllib3/requests emit them from inside the
|
||||
# dependency.
|
||||
"error",
|
||||
"ignore::DeprecationWarning",
|
||||
"default::pytest.PytestUnraisableExceptionWarning",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["leetcode_coach"]
|
||||
branch = true
|
||||
omit = [
|
||||
# Exec'd into a fresh interpreter, so coverage cannot see them; the stop
|
||||
# gate's shim is run end to end by test_stop_gate.py instead.
|
||||
"*/hook_shims/*",
|
||||
"*/__pycache__/*",
|
||||
"*/tests/*",
|
||||
"*/.venv/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 100
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"raise NotImplementedError",
|
||||
"raise AssertionError",
|
||||
"if TYPE_CHECKING:",
|
||||
'if __name__ == "__main__":',
|
||||
]
|
||||
partial_branches = ["pragma: no branch"]
|
||||
20
leetcode_coach/requirements.txt
Normal file
20
leetcode_coach/requirements.txt
Normal file
@ -0,0 +1,20 @@
|
||||
# leetcode_coach: runtime + development dependencies, exact pins (the shared
|
||||
# dependency-freshness gate). requests = relay RTDB client, genanki = the card
|
||||
# builder, crdt-sync = the phone relay's Firebase sign-in. All three load
|
||||
# lazily, so a library consumer that only imports phase/leak/toolbox pays for
|
||||
# none of them at import time.
|
||||
bandit[toml]==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.17.0
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
genanki==0.13.1
|
||||
mypy==2.4.1
|
||||
pre-commit==4.7.0
|
||||
pylint==4.1.3
|
||||
pytest==9.2.0
|
||||
pytest-cov==7.1.0
|
||||
pytest-randomly==5.0.0
|
||||
pytest-xdist==3.8.0
|
||||
requests==2.34.2
|
||||
ruff==0.17.3
|
||||
types-requests==2.33.0.20261030
|
||||
Loading…
Reference in New Issue
Block a user