From e2bccc4b3cf7b35d4b168173dfac7e3533d52127 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 13:57:47 +0100 Subject: [PATCH] sql_drills: thin wrapper over utils package Engine, fixtures and reference solutions moved to kuhyx/utils sql_drills (tag sql-drills-v0.1.0); the CLI keeps its commands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo --- .pre-commit-config.yaml | 1 + .../app-icons-remote-wave-2026-09-08.json | 48 ------ .../sql-drills-thin-wrapper-2026-11-02.json | 33 +++++ meta/pyproject.toml | 3 + meta/requirements.txt | 1 + python_pkg/sql_drills/README.md | 24 +-- python_pkg/sql_drills/__main__.py | 61 +++++--- python_pkg/sql_drills/_check.py | 67 --------- python_pkg/sql_drills/_drills.py | 65 --------- python_pkg/sql_drills/_engine.py | 126 ---------------- python_pkg/sql_drills/_normalize.py | 41 ------ .../fixtures/01_pivot_sum/DOCS-task.md | 9 -- .../fixtures/01_pivot_sum/expected.txt | 4 - .../fixtures/01_pivot_sum/schema.sql | 12 -- .../fixtures/02_pivot_max/DOCS-task.md | 10 -- .../fixtures/02_pivot_max/expected.txt | 4 - .../fixtures/02_pivot_max/schema.sql | 12 -- .../fixtures/03_month_bucket/DOCS-task.md | 11 -- .../fixtures/03_month_bucket/expected.txt | 5 - .../fixtures/03_month_bucket/schema.sql | 19 --- .../fixtures/04_date_format/DOCS-task.md | 11 -- .../fixtures/04_date_format/expected.txt | 5 - .../fixtures/04_date_format/schema.sql | 11 -- .../fixtures/05_str_to_date/DOCS-task.md | 10 -- .../fixtures/05_str_to_date/expected.txt | 5 - .../fixtures/05_str_to_date/schema.sql | 13 -- .../fixtures/06_number_traps/DOCS-task.md | 15 -- .../fixtures/06_number_traps/expected.txt | 3 - .../fixtures/06_number_traps/schema.sql | 11 -- .../fixtures/07_number_format/DOCS-task.md | 13 -- .../fixtures/07_number_format/expected.txt | 4 - .../fixtures/07_number_format/schema.sql | 9 -- .../fixtures/08_anti_join/DOCS-task.md | 12 -- .../fixtures/08_anti_join/expected.txt | 3 - .../fixtures/08_anti_join/schema.sql | 16 -- .../fixtures/09_group_having/DOCS-task.md | 9 -- .../fixtures/09_group_having/expected.txt | 3 - .../fixtures/09_group_having/schema.sql | 16 -- .../fixtures/10_window_topn/DOCS-task.md | 9 -- .../fixtures/10_window_topn/expected.txt | 8 - .../fixtures/10_window_topn/schema.sql | 11 -- .../fixtures/11_lag_gap/DOCS-task.md | 10 -- .../fixtures/11_lag_gap/expected.txt | 4 - .../sql_drills/fixtures/11_lag_gap/schema.sql | 10 -- .../fixtures/12_concat_percent/DOCS-task.md | 11 -- .../fixtures/12_concat_percent/expected.txt | 7 - .../fixtures/12_concat_percent/schema.sql | 10 -- .../tests/solutions/01_pivot_sum.sql | 8 - .../tests/solutions/02_pivot_max.sql | 7 - .../tests/solutions/03_month_bucket.sql | 8 - .../tests/solutions/04_date_format.sql | 5 - .../tests/solutions/05_str_to_date.sql | 4 - .../tests/solutions/06_number_traps.sql | 7 - .../tests/solutions/07_number_format.sql | 4 - .../tests/solutions/08_anti_join.sql | 5 - .../tests/solutions/09_group_having.sql | 6 - .../tests/solutions/10_window_topn.sql | 8 - .../sql_drills/tests/solutions/11_lag_gap.sql | 10 -- .../tests/solutions/12_concat_percent.sql | 5 - python_pkg/sql_drills/tests/test_check.py | 105 -------------- python_pkg/sql_drills/tests/test_cli.py | 97 +++++++++++-- python_pkg/sql_drills/tests/test_drills.py | 68 --------- python_pkg/sql_drills/tests/test_engine.py | 137 ------------------ .../sql_drills/tests/test_focus_hook.py | 12 +- python_pkg/sql_drills/tests/test_live.py | 61 -------- python_pkg/sql_drills/tests/test_normalize.py | 76 ---------- 66 files changed, 173 insertions(+), 1265 deletions(-) delete mode 100644 docs/superpowers/evidence/app-icons-remote-wave-2026-09-08.json create mode 100644 docs/superpowers/evidence/sql-drills-thin-wrapper-2026-11-02.json delete mode 100644 python_pkg/sql_drills/_check.py delete mode 100644 python_pkg/sql_drills/_drills.py delete mode 100644 python_pkg/sql_drills/_engine.py delete mode 100644 python_pkg/sql_drills/_normalize.py delete mode 100644 python_pkg/sql_drills/fixtures/01_pivot_sum/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/01_pivot_sum/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/01_pivot_sum/schema.sql delete mode 100644 python_pkg/sql_drills/fixtures/02_pivot_max/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/02_pivot_max/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/02_pivot_max/schema.sql delete mode 100644 python_pkg/sql_drills/fixtures/03_month_bucket/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/03_month_bucket/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/03_month_bucket/schema.sql delete mode 100644 python_pkg/sql_drills/fixtures/04_date_format/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/04_date_format/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/04_date_format/schema.sql delete mode 100644 python_pkg/sql_drills/fixtures/05_str_to_date/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/05_str_to_date/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/05_str_to_date/schema.sql delete mode 100644 python_pkg/sql_drills/fixtures/06_number_traps/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/06_number_traps/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/06_number_traps/schema.sql delete mode 100644 python_pkg/sql_drills/fixtures/07_number_format/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/07_number_format/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/07_number_format/schema.sql delete mode 100644 python_pkg/sql_drills/fixtures/08_anti_join/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/08_anti_join/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/08_anti_join/schema.sql delete mode 100644 python_pkg/sql_drills/fixtures/09_group_having/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/09_group_having/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/09_group_having/schema.sql delete mode 100644 python_pkg/sql_drills/fixtures/10_window_topn/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/10_window_topn/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/10_window_topn/schema.sql delete mode 100644 python_pkg/sql_drills/fixtures/11_lag_gap/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/11_lag_gap/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/11_lag_gap/schema.sql delete mode 100644 python_pkg/sql_drills/fixtures/12_concat_percent/DOCS-task.md delete mode 100644 python_pkg/sql_drills/fixtures/12_concat_percent/expected.txt delete mode 100644 python_pkg/sql_drills/fixtures/12_concat_percent/schema.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/01_pivot_sum.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/02_pivot_max.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/03_month_bucket.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/04_date_format.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/05_str_to_date.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/06_number_traps.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/07_number_format.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/08_anti_join.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/09_group_having.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/10_window_topn.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/11_lag_gap.sql delete mode 100644 python_pkg/sql_drills/tests/solutions/12_concat_percent.sql delete mode 100644 python_pkg/sql_drills/tests/test_check.py delete mode 100644 python_pkg/sql_drills/tests/test_drills.py delete mode 100644 python_pkg/sql_drills/tests/test_engine.py delete mode 100644 python_pkg/sql_drills/tests/test_live.py delete mode 100644 python_pkg/sql_drills/tests/test_normalize.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6dda99d2..168e36b6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -273,6 +273,7 @@ repos: # js_anki builds the Anki .apkg; same isolated-venv reason as above. - genanki - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.0#subdirectory=gatelock + - sql_drills @ git+https://github.com/kuhyx/utils@sql-drills-v0.1.0#subdirectory=sql_drills - anthropic - rich - typer diff --git a/docs/superpowers/evidence/app-icons-remote-wave-2026-09-08.json b/docs/superpowers/evidence/app-icons-remote-wave-2026-09-08.json deleted file mode 100644 index 3fc4a130..00000000 --- a/docs/superpowers/evidence/app-icons-remote-wave-2026-09-08.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "intent": "Give the new signal-bot phone app (~/signal-bot/app) a launcher icon from the shared family, so it stops shipping the stock Flutter beach icon.", - "scope": [ - "python_pkg/app_icons/_glyph_art_more.py - the new SVG fragment", - "python_pkg/app_icons/glyphs.py - register the glyph in GLYPHS", - "python_pkg/app_icons/apps.py - register the app so --app signalbot_remote passes argument validation", - "Non-goal: no change to the shared accent #B8862E or the charcoal field; a glyph is distinguished by silhouette only", - "Non-goal: no change to any existing app's icon", - "Non-goal: the app itself, its Flutter code and its Cloudflare transport all live in ~/signal-bot and are committed there" - ], - "changes": [ - "Added _REMOTE_WAVE: a filled handset (x=412..612, y=440..792, r=56) with two punched-out buttons (r=40 at 512,540 and 512,680) under a detached stroked arc (r=132 about 512,440, drawn 135deg to 45deg). Total ink y=272..792, x=383..641, inside the 560 safe box.", - "The arc is detached from the body on purpose: an arc that meets the body is a padlock shackle, and padlock-closed is already in this family.", - "The body is filled rather than stroked, for the same reason as _QUILL_NIB and _ANVIL: a 200-wide stroked body leaves a 56px interior at STROKE_WIDTH=72 and fills in solid at 48dp.", - "Registered Glyph('remote-wave', ...) in GLYPHS and AppIcon(key='signalbot_remote', glyph='remote-wave', linux=False) in APPS, pointing at ~/signal-bot/app." - ], - "verification": [ - { - "command": "PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons list", - "result": "pass", - "evidence": "remote-wave appears under Glyphs; 'signalbot_remote #B8862E remote-wave /home/kuhy/signal-bot/app' appears under Apps." - }, - { - "command": "PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons generate --app signalbot_remote --android", - "result": "pass", - "evidence": "wrote 6 asset files to /home/kuhy/signal-bot/app/assets/icon and regenerated the Android mipmaps. The generator's safe-box and negative-space checks did not fire. A first attempt failed because flutter_launcher_icons was not yet in the app's pubspec; the config block was added and it then succeeded." - }, - { - "command": "visual review of the rendered preview sheet (full size, circle-masked, 48dp)", - "result": "pass", - "evidence": "Reads as a handset with a signal arc at all three sizes. The two buttons stay separate at 48dp (60px edge gap against MIN_NEGATIVE_SPACE=36) and the arc keeps it from reading as a domino." - }, - { - "command": "python3 -m coverage run --branch --source=python_pkg/app_icons -m pytest python_pkg/app_icons/tests", - "result": "pass", - "evidence": "44 passed; python_pkg/app_icons at 100% line and branch coverage. The registry invariants (every app references a real glyph, accents are shared, no glyph paints the background) iterate over APPS and GLYPHS, so the new entries are exercised by the existing tests." - } - ], - "risks": [ - "The glyph is new artwork, so only a visual check can confirm it reads correctly at 48dp. Mitigated by rendering and reviewing the actual preview sheet rather than trusting the SVG source.", - "Not yet verified on the phone: kuhy's device is unavailable until 2026-09-10, so the launcher has not rendered this icon on real hardware.", - "_glyph_art_more.py is now 160 lines. Together with _glyph_art.py it exists solely to stay under the 250-line cap, so a third file will be needed before long." - ], - "rollback": [ - "git revert the commit. The three edits are additive - no existing glyph, app or shared token is touched - so a revert cannot change any other app's icon.", - "After rollback, re-run 'python3 -m python_pkg.app_icons list' and confirm the remaining 16 glyphs still resolve. The already-generated PNGs in ~/signal-bot/app would then have no source in this repo, so revert that repo's icon commit too or regenerate from a restored glyph." - ] -} diff --git a/docs/superpowers/evidence/sql-drills-thin-wrapper-2026-11-02.json b/docs/superpowers/evidence/sql-drills-thin-wrapper-2026-11-02.json new file mode 100644 index 00000000..abe52d36 --- /dev/null +++ b/docs/superpowers/evidence/sql-drills-thin-wrapper-2026-11-02.json @@ -0,0 +1,33 @@ +{ + "intent": "python_pkg.sql_drills CLI keeps its commands but imports the shared sql_drills package from kuhyx/utils.", + "scope": [ + "python_pkg/sql_drills (engine, fixtures, solutions, engine tests removed)", + "meta/requirements.txt pin sql-drills-v0.1.0", + "Non-goal: changing CLI commands or exit codes" + ], + "changes": [ + "__main__.py calls sql_drills.ensure_running/stop/list_drills/get_drill/run", + "Fixtures, normaliser, docker engine and live tests moved to utils/sql_drills", + "check prints per-cell diffs instead of per-line diffs" + ], + "verification": [ + { + "command": "python3 -m pytest -q python_pkg/sql_drills --cov=python_pkg/sql_drills --cov-branch", + "result": "pass", + "evidence": "18 passed, 100% branch coverage" + }, + { + "command": "python3 -m python_pkg.sql_drills check 06 (live container)", + "result": "pass", + "evidence": "PASS 06 (3 rows), exit 0; wrong query: FAIL with cell diffs, exit 1" + } + ], + "risks": [ + "CI needs the utils git pin to resolve", + "The shared package must be installed in any python running the CLI" + ], + "rollback": [ + "git revert this commit to restore the in-repo engine", + "Re-run python3 -m python_pkg.sql_drills list and check 01 on a reference" + ] +} diff --git a/meta/pyproject.toml b/meta/pyproject.toml index 35b06a03..8c0748f8 100644 --- a/meta/pyproject.toml +++ b/meta/pyproject.toml @@ -243,6 +243,9 @@ min-similarity-lines = 10 spelling-dict = "" [tool.pylint.typecheck] +# The init-hook puts python_pkg/ on sys.path, so the shared `sql_drills` +# package (kuhyx/utils) resolves to the CLI folder python_pkg/sql_drills. +ignored-modules = ["sql_drills"] # cv2 (OpenCV) dynamically loads members from C extension at runtime. # unittest.mock.MagicMock generates assertion/introspection methods at runtime. # wave.open(path, "wb") returns a Wave_write, but pylint's stdlib stub infers the diff --git a/meta/requirements.txt b/meta/requirements.txt index e7d99369..69b81cdf 100644 --- a/meta/requirements.txt +++ b/meta/requirements.txt @@ -83,6 +83,7 @@ ruff==0.17.3 # which would hold the typer this repo actually imports below latest. secretstorage==3.5.0 selenium==4.52.0 +sql_drills @ git+https://github.com/kuhyx/utils@sql-drills-v0.1.0#subdirectory=sql_drills typer==0.27.3 types-python-dateutil==2.9.0.20261029 types-PyYAML==6.0.12.20261030 diff --git a/python_pkg/sql_drills/README.md b/python_pkg/sql_drills/README.md index b9f9d5e5..4a6f1d61 100644 --- a/python_pkg/sql_drills/README.md +++ b/python_pkg/sql_drills/README.md @@ -1,9 +1,7 @@ # sql_drills -MySQL 8 practice drills graded like an online judge: the query's output must -match the expected text exactly. Engine: Docker image `mysql:8.0.33` -(pinned on purpose; do not bump). Needs Docker and your user in the `docker` -group. +CLI over the shared `sql_drills` package (kuhyx/utils, tag `sql-drills-v0.1.0`): +drills, fixtures, reference solutions and grading rules live there. ```bash python3 -m python_pkg.sql_drills start # container + wait (<60 s) @@ -12,21 +10,3 @@ python3 -m python_pkg.sql_drills show 01 # task, schema, output samp python3 -m python_pkg.sql_drills check 01 my.sql # grade my.sql (exit 0 = pass) python3 -m python_pkg.sql_drills stop # remove the container ``` - -Drills are addressed by full id (`01_pivot_sum`) or leading number (`01`). - -## Grading rules - -Output is `mysql -B -N` style: no header, tab separators become single -spaces, `NULL` prints as `NULL`, trailing whitespace is stripped per line. -Nothing else is normalised: case, decimal places and row order are strict, so -add the `ORDER BY` the task asks for. On mismatch you get an expected vs -actual diff per line. A fresh database is built for every check. - -## Layout - -- `fixtures//` holds `DOCS-task.md`, `schema.sql`, `expected.txt`. -- Reference solutions live only in `tests/solutions/`; `show` and `check` - never print them. -- `tests/test_live.py` runs every reference against the container (marker - `docker`, skipped when the container is down). diff --git a/python_pkg/sql_drills/__main__.py b/python_pkg/sql_drills/__main__.py index 77b2a90a..16642e71 100644 --- a/python_pkg/sql_drills/__main__.py +++ b/python_pkg/sql_drills/__main__.py @@ -1,4 +1,7 @@ -"""CLI: ``python3 -m python_pkg.sql_drills start|stop|list|show|check``.""" +"""CLI: ``python3 -m python_pkg.sql_drills start|stop|list|show|check``. + +A thin wrapper over the shared ``sql_drills`` package (kuhyx/utils). +""" from __future__ import annotations @@ -8,9 +11,8 @@ import logging from pathlib import Path import sys -from python_pkg.sql_drills import _check, _drills, _engine +import sql_drills as sd -_EXIT_CODES = {"pass": 0, "fail": 1, "error": 2} _LOG = logging.getLogger(__name__) @@ -18,8 +20,7 @@ def _heartbeat() -> None: """Tell the optional focus tracker that practice is happening. Soft dependency: absent module -> silent no-op; a tracker error is logged - (file or registry errors) and never changes the drill's result or - exit code. + and never changes the drill's result or exit code. """ try: tracker = importlib.import_module("focus_project") @@ -53,24 +54,50 @@ def _build_parser() -> argparse.ArgumentParser: return parser +def _render_show(drill: sd.Drill) -> str: + tail = "## Query to predict" if drill.query else "## Expected output (sample)" + body = drill.query or "\n".join("\t".join(r) for r in drill.expected_preview) + return ( + f"{drill.task_md.strip()}\n\n## Schema\n\n{drill.schema_sql.strip()}\n\n" + f"{tail}\n\n{body}\n" + ) + + +def _check(drill_id: str, sql_file: Path) -> int: + if not sql_file.is_file(): + _out(f"query file not found: {sql_file}") + return 2 + res = sd.run(drill_id, sql_file.read_text()) + if res.error is not None and not res.match: + _out(f"SQL error: {res.error}") + return 2 + if res.match: + _out(f"PASS {drill_id} ({len(res.rows)} rows)") + return 0 + _out( + f"FAIL {drill_id}: expected {len(res.expected_rows)} rows, got {len(res.rows)}" + ) + for row, col, want, got in res.cell_diff: + _out(f"line {row + 1} cell {col + 1}: expected {want!r}, actual {got!r}") + return 1 + + def _dispatch(args: argparse.Namespace) -> int: if args.cmd == "start": - _engine.start() - _out(f"{_engine.CONTAINER} ready ({_engine.IMAGE})") + sd.ensure_running() + _out(f"{sd.CONTAINER} ready ({sd.IMAGE})") elif args.cmd == "stop": - _engine.stop() - _out(f"{_engine.CONTAINER} removed") + sd.stop() + _out(f"{sd.CONTAINER} removed") elif args.cmd == "list": - for drill in _drills.list_drills(): - _out(f"{drill.id} {_drills.title_of(drill)}") + for meta in sd.list_drills(): + _out(f"{meta.id} {meta.title}") elif args.cmd == "show": _heartbeat() - sys.stdout.write(_drills.render_show(_drills.get_drill(args.drill))) + sys.stdout.write(_render_show(sd.get_drill(args.drill))) else: _heartbeat() - result = _check.run_check(_drills.get_drill(args.drill), args.sql_file) - _out(result.message) - return _EXIT_CODES[result.status] + return _check(args.drill, args.sql_file) return 0 @@ -79,9 +106,9 @@ def main(argv: list[str] | None = None) -> int: args = _build_parser().parse_args(argv) try: return _dispatch(args) - except _drills.UnknownDrillError as exc: + except sd.UnknownDrillError as exc: _err(exc.args[0]) - except _engine.EngineError as exc: + except sd.EngineError as exc: _err(f"error: {exc}") return 2 diff --git a/python_pkg/sql_drills/_check.py b/python_pkg/sql_drills/_check.py deleted file mode 100644 index e2f16c14..00000000 --- a/python_pkg/sql_drills/_check.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Grade one query: fresh database, drill schema, user's SQL, exact compare.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from python_pkg.sql_drills import _engine -from python_pkg.sql_drills._normalize import diff, normalize - -if TYPE_CHECKING: - from pathlib import Path - - from python_pkg.sql_drills._drills import Drill - - -@dataclass(frozen=True) -class CheckResult: - """``status`` is ``pass``, ``fail`` (wrong output) or ``error``.""" - - status: str - message: str - - -class _StepError(Exception): - """A pipeline step failed; the message is shown to the user.""" - - -def _step(result: _engine.SqlResult, what: str) -> str: - """Return stdout of a successful step or raise with its stderr.""" - if result.returncode != 0: - msg = f"{what}: {result.stderr.strip()}" - raise _StepError(msg) - return result.stdout - - -def _execute(drill: Drill, query_file: Path) -> str: - """Fresh DB, drill schema, then the user's SQL; return the raw output.""" - if not _engine.is_ready(): - msg = "MySQL is not running; run `start` first" - raise _StepError(msg) - if not query_file.is_file(): - msg = f"query file not found: {query_file}" - raise _StepError(msg) - db = f"drill_{drill.id}" - reset = _engine.run_sql(f"DROP DATABASE IF EXISTS {db}; CREATE DATABASE {db};") - _step(reset, "could not reset database") - _step(_engine.run_sql(drill.schema.read_text(), database=db), "drill schema failed") - result = _engine.run_sql(query_file.read_text(), database=db) - return _step(result, "SQL error") - - -def run_check(drill: Drill, query_file: Path) -> CheckResult: - """Load the drill into a fresh DB, run the query, compare to expected.""" - try: - output = _execute(drill, query_file) - except _StepError as exc: - return CheckResult("error", str(exc)) - expected = normalize(drill.expected.read_text()) - actual = normalize(output) - if expected == actual: - return CheckResult("pass", f"PASS {drill.id} ({len(actual)} rows)") - return CheckResult( - "fail", - f"FAIL {drill.id}: expected {len(expected)} rows, got {len(actual)}\n" - f"{diff(expected, actual)}", - ) diff --git a/python_pkg/sql_drills/_drills.py b/python_pkg/sql_drills/_drills.py deleted file mode 100644 index 9dc4181a..00000000 --- a/python_pkg/sql_drills/_drills.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Drill discovery: one folder per drill under ``fixtures/``. - -Each folder holds ``DOCS-task.md`` (statement), ``schema.sql`` (tables and data) -and ``expected.txt`` (the exact judge output). Reference solutions are NOT -here; they live under ``tests/solutions`` so learning mode never shows them. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -DRILLS_DIR = Path(__file__).parent / "fixtures" -SAMPLE_ROWS = 5 - - -class UnknownDrillError(KeyError): - """Raised when a drill id or number prefix matches nothing.""" - - -@dataclass(frozen=True) -class Drill: - """A drill's id and the three files that define it.""" - - id: str - task: Path - schema: Path - expected: Path - - -def list_drills(root: Path = DRILLS_DIR) -> list[Drill]: - """All drills under ``root``, sorted by id (the priority order).""" - if not root.is_dir(): - return [] - return [ - Drill(d.name, d / "DOCS-task.md", d / "schema.sql", d / "expected.txt") - for d in sorted(root.iterdir()) - if d.is_dir() - ] - - -def get_drill(key: str, root: Path = DRILLS_DIR) -> Drill: - """Find a drill by full id or by its leading number (``03``).""" - for drill in list_drills(root): - if key in (drill.id, drill.id.split("_", 1)[0]): - return drill - msg = f"unknown drill: {key}" - raise UnknownDrillError(msg) - - -def title_of(drill: Drill) -> str: - """The task's first heading, without the leading ``#``.""" - return drill.task.read_text().splitlines()[0].lstrip("# ") - - -def render_show(drill: Drill) -> str: - """Task text, schema, and the first rows of the expected output.""" - rows = drill.expected.read_text().splitlines() - sample = "\n".join(rows[:SAMPLE_ROWS]) - more = f"\n... ({len(rows)} rows in total)" if len(rows) > SAMPLE_ROWS else "" - return ( - f"{drill.task.read_text().strip()}\n\n" - f"## Schema\n\n{drill.schema.read_text().strip()}\n\n" - f"## Expected output (sample)\n\n{sample}{more}\n" - ) diff --git a/python_pkg/sql_drills/_engine.py b/python_pkg/sql_drills/_engine.py deleted file mode 100644 index 5922ddff..00000000 --- a/python_pkg/sql_drills/_engine.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Thin Docker wrapper around one long-lived ``mysql:8.0.33`` container. - -The version is pinned on purpose (the one the judge documents); do not bump -it. The data dir is a tmpfs, so ``stop`` leaves nothing behind. Every call -here is short: ``start`` waits a bounded time and says "rerun" if MySQL is -still initialising, rather than blocking. -""" - -from __future__ import annotations - -from dataclasses import dataclass -import shutil -import subprocess -import time -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Callable - -IMAGE = "mysql:8.0.33" -CONTAINER = "sql-drills-mysql" -READY_TIMEOUT = 45.0 -QUERY_TIMEOUT = 30.0 -_DOCKER_TIMEOUT = 50.0 -_MYSQL = ["mysql", "-uroot", "--default-character-set=utf8mb4", "-B", "-N"] - - -class EngineError(RuntimeError): - """Docker or MySQL is unavailable or misbehaving.""" - - -@dataclass(frozen=True) -class SqlResult: - """Outcome of one ``mysql`` client invocation.""" - - returncode: int - stdout: str - stderr: str - - -def _docker( - *args: str, stdin: str | None = None, timeout: float = _DOCKER_TIMEOUT -) -> subprocess.CompletedProcess[str]: - """Run ``docker ``; map a missing binary or a timeout to EngineError.""" - exe = shutil.which("docker") - if exe is None: - msg = "docker binary not found" - raise EngineError(msg) - try: - return subprocess.run( - [exe, *args], - input=stdin, - capture_output=True, - text=True, - check=False, - timeout=timeout, - ) - except subprocess.TimeoutExpired as exc: - msg = f"docker {args[0]} timed out after {timeout:.0f}s" - raise EngineError(msg) from exc - - -def container_state() -> str: - """``running``, ``exited``, ... or ``missing`` when no such container.""" - res = _docker("inspect", "-f", "{{.State.Status}}", CONTAINER) - return res.stdout.strip() if res.returncode == 0 else "missing" - - -def is_ready() -> bool: - """True once MySQL accepts TCP connections (the init server does not).""" - res = _docker( - "exec", CONTAINER, "mysqladmin", "ping", "-h127.0.0.1", "-uroot", "--silent" - ) - return res.returncode == 0 - - -def start( - timeout: float = READY_TIMEOUT, - sleep: Callable[[float], None] = time.sleep, - clock: Callable[[], float] = time.monotonic, -) -> None: - """Create or resume the container and wait (bounded) until it is ready.""" - state = container_state() - if state == "missing": - res = _docker( - "run", "-d", "--name", CONTAINER, - "--tmpfs", "/var/lib/mysql:rw,size=512m", - "--memory", "1500m", - "-e", "MYSQL_ALLOW_EMPTY_PASSWORD=yes", - IMAGE, - ) # fmt: skip - elif state != "running": - res = _docker("start", CONTAINER) - else: - res = subprocess.CompletedProcess([], 0, "", "") - if res.returncode != 0: - msg = f"could not start container: {res.stderr.strip()}" - raise EngineError(msg) - deadline = clock() + timeout - while not is_ready(): - if clock() >= deadline: - msg = f"MySQL not ready after {timeout:.0f}s; rerun `start` to keep waiting" - raise EngineError(msg) - sleep(1.0) - - -def stop() -> None: - """Remove the container (and its tmpfs data). Idempotent.""" - _docker("rm", "-f", CONTAINER) - - -def run_sql( - sql: str, database: str | None = None, timeout: float = QUERY_TIMEOUT -) -> SqlResult: - """Pipe ``sql`` into the mysql client, ``-B -N`` style.""" - argv = ["exec", "-i", CONTAINER, *_MYSQL] - if database: - argv.append(database) - try: - res = _docker(*argv, stdin=sql, timeout=timeout) - except EngineError as exc: - if "timed out" in str(exc): - msg = f"query timed out after {timeout:.0f}s" - raise EngineError(msg) from exc - raise - return SqlResult(res.returncode, res.stdout, res.stderr) diff --git a/python_pkg/sql_drills/_normalize.py b/python_pkg/sql_drills/_normalize.py deleted file mode 100644 index eb49d1e5..00000000 --- a/python_pkg/sql_drills/_normalize.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Output normalisation and diffing, mirroring an exact-match judge. - -The engine prints ``mysql -B -N`` output (no header, tab-separated, ``NULL`` -literal). Before comparing we turn each tab into one space and strip trailing -whitespace per line -- nothing else. Case, decimals and row order stay strict. -""" - -from __future__ import annotations - -from itertools import zip_longest - -_ABSENT = "" - - -def normalize(text: str) -> list[str]: - """Return judge-style lines: tabs to single spaces, right-stripped.""" - return [line.replace("\t", " ").rstrip() for line in text.splitlines()] - - -def matches(expected: str, actual: str) -> bool: - """True when both outputs normalise to identical line lists.""" - return normalize(expected) == normalize(actual) - - -def _show(line: str | None) -> str: - """Render a line for the diff, making odd spacing visible via repr.""" - if line is None: - return _ABSENT - if line.startswith(" ") or " " in line: - return repr(line) - return line - - -def diff(expected: list[str], actual: list[str]) -> str: - """Line-by-line expected/actual report for differing lines only.""" - blocks = [ - f"line {no}:\n expected: {_show(exp)}\n actual : {_show(act)}" - for no, (exp, act) in enumerate(zip_longest(expected, actual), start=1) - if exp != act - ] - return "\n".join(blocks) diff --git a/python_pkg/sql_drills/fixtures/01_pivot_sum/DOCS-task.md b/python_pkg/sql_drills/fixtures/01_pivot_sum/DOCS-task.md deleted file mode 100644 index 139c877c..00000000 --- a/python_pkg/sql_drills/fixtures/01_pivot_sum/DOCS-task.md +++ /dev/null @@ -1,9 +0,0 @@ -# Pivot quarters into columns with SUM(CASE WHEN) - -`shop_sales` holds one row per sale. Report the total amount per region with -one column per quarter. - -Output columns (no header): `region`, `Q1`, `Q2`, `Q3`, `Q4`. - -- A region with no sales in a quarter shows `0`, never `NULL`. -- Order by `region` ascending. diff --git a/python_pkg/sql_drills/fixtures/01_pivot_sum/expected.txt b/python_pkg/sql_drills/fixtures/01_pivot_sum/expected.txt deleted file mode 100644 index 2848247a..00000000 --- a/python_pkg/sql_drills/fixtures/01_pivot_sum/expected.txt +++ /dev/null @@ -1,4 +0,0 @@ -East 0 300 0 25 -North 200 150 0 200 -South 90 0 100 0 -West 10 0 0 0 diff --git a/python_pkg/sql_drills/fixtures/01_pivot_sum/schema.sql b/python_pkg/sql_drills/fixtures/01_pivot_sum/schema.sql deleted file mode 100644 index 2596736b..00000000 --- a/python_pkg/sql_drills/fixtures/01_pivot_sum/schema.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE shop_sales ( - id INT PRIMARY KEY, - region VARCHAR(20) NOT NULL, - quarter CHAR(2) NOT NULL, - amount INT NOT NULL -); -INSERT INTO shop_sales (id, region, quarter, amount) VALUES - (1, 'North', 'Q1', 120), (2, 'North', 'Q1', 80), - (3, 'North', 'Q2', 150), (4, 'North', 'Q4', 200), - (5, 'South', 'Q1', 90), (6, 'South', 'Q3', 60), - (7, 'South', 'Q3', 40), (8, 'East', 'Q2', 300), - (9, 'East', 'Q4', 25), (10, 'West', 'Q1', 10); diff --git a/python_pkg/sql_drills/fixtures/02_pivot_max/DOCS-task.md b/python_pkg/sql_drills/fixtures/02_pivot_max/DOCS-task.md deleted file mode 100644 index 61702955..00000000 --- a/python_pkg/sql_drills/fixtures/02_pivot_max/DOCS-task.md +++ /dev/null @@ -1,10 +0,0 @@ -# Pivot text attributes with MAX(CASE WHEN) - -`person_attrs` stores one attribute per row (`email`, `phone`, `city`) in a -text column. Turn it into one row per person. - -Output columns (no header): `person`, `email`, `phone`, `city`. - -- A missing attribute prints as `NULL`. -- Order by `person` ascending. -- Hint: `SUM` cannot pivot text; think about which aggregate can. diff --git a/python_pkg/sql_drills/fixtures/02_pivot_max/expected.txt b/python_pkg/sql_drills/fixtures/02_pivot_max/expected.txt deleted file mode 100644 index afa1fe4e..00000000 --- a/python_pkg/sql_drills/fixtures/02_pivot_max/expected.txt +++ /dev/null @@ -1,4 +0,0 @@ -Ida ida@example.org 555-0103 Krakow -Max NULL 555-0102 Lodz -Ola ola@example.org NULL NULL -Zed zed@example.org 555-0101 Gdansk diff --git a/python_pkg/sql_drills/fixtures/02_pivot_max/schema.sql b/python_pkg/sql_drills/fixtures/02_pivot_max/schema.sql deleted file mode 100644 index cc163ee8..00000000 --- a/python_pkg/sql_drills/fixtures/02_pivot_max/schema.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE person_attrs ( - person VARCHAR(20) NOT NULL, - attr VARCHAR(10) NOT NULL, - val VARCHAR(40) NOT NULL, - PRIMARY KEY (person, attr) -); -INSERT INTO person_attrs (person, attr, val) VALUES - ('Zed', 'email', 'zed@example.org'), ('Zed', 'city', 'Gdansk'), - ('Zed', 'phone', '555-0101'), ('Ola', 'email', 'ola@example.org'), - ('Max', 'city', 'Lodz'), ('Max', 'phone', '555-0102'), - ('Ida', 'phone', '555-0103'), ('Ida', 'email', 'ida@example.org'), - ('Ida', 'city', 'Krakow'); diff --git a/python_pkg/sql_drills/fixtures/03_month_bucket/DOCS-task.md b/python_pkg/sql_drills/fixtures/03_month_bucket/DOCS-task.md deleted file mode 100644 index 678e61c4..00000000 --- a/python_pkg/sql_drills/fixtures/03_month_bucket/DOCS-task.md +++ /dev/null @@ -1,11 +0,0 @@ -# Month buckets with DATE_FORMAT and conditional aggregation - -`orders` has one row per order with a status of `shipped`, `pending` or -`cancelled`. Summarise per calendar month. - -Output columns (no header): `month` (as `YYYY-MM`), total orders, shipped -orders, cancelled orders, revenue of shipped orders only. - -- Revenue is `0.00` for a month with no shipped orders. -- Only months that have at least one order appear. -- Order by `month` ascending. diff --git a/python_pkg/sql_drills/fixtures/03_month_bucket/expected.txt b/python_pkg/sql_drills/fixtures/03_month_bucket/expected.txt deleted file mode 100644 index 59260e92..00000000 --- a/python_pkg/sql_drills/fixtures/03_month_bucket/expected.txt +++ /dev/null @@ -1,5 +0,0 @@ -2022-12 1 1 0 10.00 -2023-01 3 2 1 125.50 -2023-02 3 1 1 80.00 -2023-04 3 2 1 215.25 -2023-06 1 0 0 0.00 diff --git a/python_pkg/sql_drills/fixtures/03_month_bucket/schema.sql b/python_pkg/sql_drills/fixtures/03_month_bucket/schema.sql deleted file mode 100644 index 1324575c..00000000 --- a/python_pkg/sql_drills/fixtures/03_month_bucket/schema.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE orders ( - id INT PRIMARY KEY, - customer VARCHAR(20) NOT NULL, - order_date DATETIME NOT NULL, - status VARCHAR(10) NOT NULL, - total DECIMAL(8, 2) NOT NULL -); -INSERT INTO orders (id, customer, order_date, status, total) VALUES - (1, 'Ann', '2023-01-05 10:00:00', 'shipped', 100.00), - (2, 'Bob', '2023-01-20 11:30:00', 'cancelled', 50.00), - (3, 'Ann', '2023-01-31 23:59:59', 'shipped', 25.50), - (4, 'Cy', '2023-02-01 00:00:00', 'shipped', 80.00), - (5, 'Bob', '2023-02-14 09:15:00', 'pending', 60.00), - (6, 'Dan', '2023-02-28 18:00:00', 'cancelled', 30.00), - (7, 'Ann', '2023-04-02 08:00:00', 'shipped', 200.00), - (8, 'Cy', '2023-04-30 23:00:00', 'shipped', 15.25), - (9, 'Dan', '2023-04-30 12:00:00', 'cancelled', 99.99), - (10, 'Bob', '2022-12-31 22:00:00', 'shipped', 10.00), - (11, 'Eve', '2023-06-10 10:00:00', 'pending', 70.00); diff --git a/python_pkg/sql_drills/fixtures/04_date_format/DOCS-task.md b/python_pkg/sql_drills/fixtures/04_date_format/DOCS-task.md deleted file mode 100644 index 9be709db..00000000 --- a/python_pkg/sql_drills/fixtures/04_date_format/DOCS-task.md +++ /dev/null @@ -1,11 +0,0 @@ -# Date formatting: %i is minutes, %M is the month name - -`events` has a `DATETIME` column. Print each event in two display formats. - -Output columns (no header): `id`, then the start as -`Friday, 05 March 2021 09:07` (weekday name, 2-digit day, month name, year, -24-hour `HH:MM`), then the 12-hour time as `09:07 AM`. - -- Order by `id` ascending. -- Trap: mixing up `%i` (minutes) with `%M` (month name), and `%h` (12-hour) - with `%H` (24-hour). diff --git a/python_pkg/sql_drills/fixtures/04_date_format/expected.txt b/python_pkg/sql_drills/fixtures/04_date_format/expected.txt deleted file mode 100644 index 2539e607..00000000 --- a/python_pkg/sql_drills/fixtures/04_date_format/expected.txt +++ /dev/null @@ -1,5 +0,0 @@ -1 Friday, 05 March 2021 09:07 09:07 AM -2 Wednesday, 30 November 2022 17:45 05:45 PM -3 Saturday, 29 February 2020 00:05 12:05 AM -4 Tuesday, 04 July 2023 12:00 12:00 PM -5 Tuesday, 31 December 2019 23:59 11:59 PM diff --git a/python_pkg/sql_drills/fixtures/04_date_format/schema.sql b/python_pkg/sql_drills/fixtures/04_date_format/schema.sql deleted file mode 100644 index 1db92402..00000000 --- a/python_pkg/sql_drills/fixtures/04_date_format/schema.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE events ( - id INT PRIMARY KEY, - name VARCHAR(30) NOT NULL, - starts_at DATETIME NOT NULL -); -INSERT INTO events (id, name, starts_at) VALUES - (1, 'Launch party', '2021-03-05 09:07:00'), - (2, 'Retro', '2022-11-30 17:45:10'), - (3, 'Offsite', '2020-02-29 00:05:00'), - (4, 'Standup', '2023-07-04 12:00:00'), - (5, 'Closing', '2019-12-31 23:59:59'); diff --git a/python_pkg/sql_drills/fixtures/05_str_to_date/DOCS-task.md b/python_pkg/sql_drills/fixtures/05_str_to_date/DOCS-task.md deleted file mode 100644 index e7994c86..00000000 --- a/python_pkg/sql_drills/fixtures/05_str_to_date/DOCS-task.md +++ /dev/null @@ -1,10 +0,0 @@ -# Parse text dates with STR_TO_DATE - -`signups.signup_text` stores a timestamp as text in the US layout -`MM/DD/YYYY HH:MM` (for example `03/14/2022 18:45`). - -Output columns (no header): `id`, then the signup date as `YYYY-MM-DD`. - -- Keep only signups on or after 2022-03-01 (compare real dates, not text). -- Order by the parsed timestamp ascending. -- Trap: ordering the raw text sorts by month first, across years. diff --git a/python_pkg/sql_drills/fixtures/05_str_to_date/expected.txt b/python_pkg/sql_drills/fixtures/05_str_to_date/expected.txt deleted file mode 100644 index fdb4a0cc..00000000 --- a/python_pkg/sql_drills/fixtures/05_str_to_date/expected.txt +++ /dev/null @@ -1,5 +0,0 @@ -6 2022-03-01 -3 2022-03-02 -1 2022-03-14 -4 2022-11-30 -7 2023-02-28 diff --git a/python_pkg/sql_drills/fixtures/05_str_to_date/schema.sql b/python_pkg/sql_drills/fixtures/05_str_to_date/schema.sql deleted file mode 100644 index b7cf3f1e..00000000 --- a/python_pkg/sql_drills/fixtures/05_str_to_date/schema.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE signups ( - id INT PRIMARY KEY, - email VARCHAR(40) NOT NULL, - signup_text VARCHAR(20) NOT NULL -); -INSERT INTO signups (id, email, signup_text) VALUES - (1, 'a@example.org', '03/14/2022 18:45'), - (2, 'b@example.org', '12/01/2021 08:00'), - (3, 'c@example.org', '03/02/2022 23:10'), - (4, 'd@example.org', '11/30/2022 00:00'), - (5, 'e@example.org', '01/09/2022 12:30'), - (6, 'f@example.org', '03/01/2022 00:00'), - (7, 'g@example.org', '02/28/2023 07:05'); diff --git a/python_pkg/sql_drills/fixtures/06_number_traps/DOCS-task.md b/python_pkg/sql_drills/fixtures/06_number_traps/DOCS-task.md deleted file mode 100644 index 5aa54bd4..00000000 --- a/python_pkg/sql_drills/fixtures/06_number_traps/DOCS-task.md +++ /dev/null @@ -1,15 +0,0 @@ -# Number traps: AVG scale, ROUND, CAST AS DECIMAL - -`team_results` has one row per team per season. - -Output columns (no header), per team: - -1. `team` -2. average wins, `ROUND`ed to 2 decimals -3. average games, NOT rounded (print whatever MySQL gives you) -4. win percentage `100 * SUM(wins) / SUM(games)` as `DECIMAL(5,1)` - via `CAST` - -- Order by `team` ascending. -- Trap: `AVG()` of an integer column returns 4 decimals (`2.5000`), and - `/` is decimal division in MySQL (use `DIV` for the integer kind). diff --git a/python_pkg/sql_drills/fixtures/06_number_traps/expected.txt b/python_pkg/sql_drills/fixtures/06_number_traps/expected.txt deleted file mode 100644 index b62fbff8..00000000 --- a/python_pkg/sql_drills/fixtures/06_number_traps/expected.txt +++ /dev/null @@ -1,3 +0,0 @@ -Hawks 8.33 12.6667 65.8 -Lions 5.50 10.0000 55.0 -Owls 0.00 8.0000 0.0 diff --git a/python_pkg/sql_drills/fixtures/06_number_traps/schema.sql b/python_pkg/sql_drills/fixtures/06_number_traps/schema.sql deleted file mode 100644 index b0ab0d70..00000000 --- a/python_pkg/sql_drills/fixtures/06_number_traps/schema.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE team_results ( - id INT PRIMARY KEY, - team VARCHAR(20) NOT NULL, - season INT NOT NULL, - wins INT NOT NULL, - games INT NOT NULL -); -INSERT INTO team_results (id, team, season, wins, games) VALUES - (1, 'Hawks', 2021, 7, 12), (2, 'Hawks', 2022, 8, 12), - (3, 'Hawks', 2023, 10, 14), (4, 'Lions', 2021, 5, 10), - (5, 'Lions', 2022, 6, 10), (6, 'Owls', 2022, 0, 8); diff --git a/python_pkg/sql_drills/fixtures/07_number_format/DOCS-task.md b/python_pkg/sql_drills/fixtures/07_number_format/DOCS-task.md deleted file mode 100644 index 882439dd..00000000 --- a/python_pkg/sql_drills/fixtures/07_number_format/DOCS-task.md +++ /dev/null @@ -1,13 +0,0 @@ -# Thousands separators with FORMAT - -`invoices` has one row per invoice. - -Output columns (no header), per customer: - -1. `customer` -2. total amount with thousands separators and 2 decimals (`35,200.59`) -3. average amount with thousands separators and 0 decimals - -- Order by the numeric total descending (not the formatted text). -- Trap: `FORMAT` returns text, so sort on the `SUM`, and `FORMAT(x, 0)` is - not the same string as `ROUND(x)` (no separators). diff --git a/python_pkg/sql_drills/fixtures/07_number_format/expected.txt b/python_pkg/sql_drills/fixtures/07_number_format/expected.txt deleted file mode 100644 index 10603ba8..00000000 --- a/python_pkg/sql_drills/fixtures/07_number_format/expected.txt +++ /dev/null @@ -1,4 +0,0 @@ -Cog 1,002,500.25 501,250 -Acme 35,200.59 11,734 -Bolt 999.99 1,000 -Dyn 9.00 5 diff --git a/python_pkg/sql_drills/fixtures/07_number_format/schema.sql b/python_pkg/sql_drills/fixtures/07_number_format/schema.sql deleted file mode 100644 index 1d7f70c9..00000000 --- a/python_pkg/sql_drills/fixtures/07_number_format/schema.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE invoices ( - id INT PRIMARY KEY, - customer VARCHAR(20) NOT NULL, - amount DECIMAL(12, 2) NOT NULL -); -INSERT INTO invoices (id, customer, amount) VALUES - (1, 'Acme', 1200.50), (2, 'Acme', 33999.99), (3, 'Acme', 0.10), - (4, 'Bolt', 999.99), (5, 'Cog', 1000000.00), (6, 'Cog', 2500.25), - (7, 'Dyn', 5.00), (8, 'Dyn', 4.00); diff --git a/python_pkg/sql_drills/fixtures/08_anti_join/DOCS-task.md b/python_pkg/sql_drills/fixtures/08_anti_join/DOCS-task.md deleted file mode 100644 index a74f35b0..00000000 --- a/python_pkg/sql_drills/fixtures/08_anti_join/DOCS-task.md +++ /dev/null @@ -1,12 +0,0 @@ -# LEFT JOIN anti-join and NULL handling - -List customers who have never placed a valid order. An order is valid when -its status is not `cancelled`; a customer with only cancelled orders (or no -orders) qualifies. - -Output columns (no header): `name`, `phone`, where a missing phone prints as -`n/a`. - -- Order by `name` ascending. -- Trap: put the status condition in the `ON` clause, not in `WHERE`, or the - cancelled-only customers vanish. diff --git a/python_pkg/sql_drills/fixtures/08_anti_join/expected.txt b/python_pkg/sql_drills/fixtures/08_anti_join/expected.txt deleted file mode 100644 index e63b85c7..00000000 --- a/python_pkg/sql_drills/fixtures/08_anti_join/expected.txt +++ /dev/null @@ -1,3 +0,0 @@ -Boris n/a -Cleo 555-0103 -Dara n/a diff --git a/python_pkg/sql_drills/fixtures/08_anti_join/schema.sql b/python_pkg/sql_drills/fixtures/08_anti_join/schema.sql deleted file mode 100644 index f243b342..00000000 --- a/python_pkg/sql_drills/fixtures/08_anti_join/schema.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE customers ( - id INT PRIMARY KEY, - name VARCHAR(20) NOT NULL, - phone VARCHAR(20) NULL -); -CREATE TABLE orders ( - id INT PRIMARY KEY, - customer_id INT NOT NULL, - status VARCHAR(10) NOT NULL -); -INSERT INTO customers (id, name, phone) VALUES - (1, 'Alma', '555-0101'), (2, 'Boris', NULL), (3, 'Cleo', '555-0103'), - (4, 'Dara', NULL), (5, 'Egon', '555-0105'); -INSERT INTO orders (id, customer_id, status) VALUES - (1, 1, 'shipped'), (2, 3, 'cancelled'), (3, 3, 'cancelled'), - (4, 5, 'pending'), (5, 1, 'cancelled'); diff --git a/python_pkg/sql_drills/fixtures/09_group_having/DOCS-task.md b/python_pkg/sql_drills/fixtures/09_group_having/DOCS-task.md deleted file mode 100644 index bd8f5c8f..00000000 --- a/python_pkg/sql_drills/fixtures/09_group_having/DOCS-task.md +++ /dev/null @@ -1,9 +0,0 @@ -# GROUP BY with WHERE versus HAVING - -`tickets` holds support tickets with hours spent. Ignoring `low` priority -tickets, find teams with at least 3 tickets AND at least 10 total hours. - -Output columns (no header): `team`, ticket count, total hours. - -- Order by total hours descending, then `team` ascending. -- Row filters (`priority`) go in `WHERE`; aggregate filters in `HAVING`. diff --git a/python_pkg/sql_drills/fixtures/09_group_having/expected.txt b/python_pkg/sql_drills/fixtures/09_group_having/expected.txt deleted file mode 100644 index 0332b82b..00000000 --- a/python_pkg/sql_drills/fixtures/09_group_having/expected.txt +++ /dev/null @@ -1,3 +0,0 @@ -Gamma 4 16.0 -Alpha 3 11.0 -Eps 3 10.0 diff --git a/python_pkg/sql_drills/fixtures/09_group_having/schema.sql b/python_pkg/sql_drills/fixtures/09_group_having/schema.sql deleted file mode 100644 index ed149005..00000000 --- a/python_pkg/sql_drills/fixtures/09_group_having/schema.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE tickets ( - id INT PRIMARY KEY, - team VARCHAR(10) NOT NULL, - priority VARCHAR(10) NOT NULL, - hours DECIMAL(5, 1) NOT NULL -); -INSERT INTO tickets (id, team, priority, hours) VALUES - (1, 'Alpha', 'high', 5.0), (2, 'Alpha', 'medium', 3.5), - (3, 'Alpha', 'low', 9.0), (4, 'Alpha', 'high', 2.5), - (5, 'Beta', 'medium', 4.0), (6, 'Beta', 'high', 6.0), - (7, 'Gamma', 'high', 4.0), (8, 'Gamma', 'medium', 4.0), - (9, 'Gamma', 'high', 4.0), (10, 'Gamma', 'medium', 4.0), - (11, 'Delta', 'medium', 1.0), (12, 'Delta', 'medium', 1.0), - (13, 'Delta', 'low', 50.0), (14, 'Delta', 'high', 1.0), - (15, 'Eps', 'medium', 3.5), (16, 'Eps', 'medium', 3.5), - (17, 'Eps', 'high', 3.0); diff --git a/python_pkg/sql_drills/fixtures/10_window_topn/DOCS-task.md b/python_pkg/sql_drills/fixtures/10_window_topn/DOCS-task.md deleted file mode 100644 index ce696e4b..00000000 --- a/python_pkg/sql_drills/fixtures/10_window_topn/DOCS-task.md +++ /dev/null @@ -1,9 +0,0 @@ -# Top-N per group with DENSE_RANK - -Show employees whose salary is among the two highest DISTINCT salaries in -their department (ties share a rank, so more than two people can appear). - -Output columns (no header): `dept`, `name`, `salary`, the dense rank. - -- Order by `dept`, then rank, then `name` ascending. -- `RANK()` would skip numbers after ties; this drill wants `DENSE_RANK()`. diff --git a/python_pkg/sql_drills/fixtures/10_window_topn/expected.txt b/python_pkg/sql_drills/fixtures/10_window_topn/expected.txt deleted file mode 100644 index dc2e726a..00000000 --- a/python_pkg/sql_drills/fixtures/10_window_topn/expected.txt +++ /dev/null @@ -1,8 +0,0 @@ -Eng Ava 150 1 -Eng Ben 150 1 -Eng Cal 130 2 -Ops Fay 90 1 -Ops Gus 80 2 -Sales Ivy 100 1 -Sales Jon 100 1 -Sales Kim 100 1 diff --git a/python_pkg/sql_drills/fixtures/10_window_topn/schema.sql b/python_pkg/sql_drills/fixtures/10_window_topn/schema.sql deleted file mode 100644 index 4dc2e157..00000000 --- a/python_pkg/sql_drills/fixtures/10_window_topn/schema.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE employees ( - id INT PRIMARY KEY, - name VARCHAR(10) NOT NULL, - dept VARCHAR(10) NOT NULL, - salary INT NOT NULL -); -INSERT INTO employees (id, name, dept, salary) VALUES - (1, 'Ava', 'Eng', 150), (2, 'Ben', 'Eng', 150), (3, 'Cal', 'Eng', 130), - (4, 'Dev', 'Eng', 120), (5, 'Eli', 'Eng', 120), (6, 'Fay', 'Ops', 90), - (7, 'Gus', 'Ops', 80), (8, 'Hal', 'Ops', 70), (9, 'Ivy', 'Sales', 100), - (10, 'Jon', 'Sales', 100), (11, 'Kim', 'Sales', 100); diff --git a/python_pkg/sql_drills/fixtures/11_lag_gap/DOCS-task.md b/python_pkg/sql_drills/fixtures/11_lag_gap/DOCS-task.md deleted file mode 100644 index 4098918c..00000000 --- a/python_pkg/sql_drills/fixtures/11_lag_gap/DOCS-task.md +++ /dev/null @@ -1,10 +0,0 @@ -# LAG and DATEDIFF: find long gaps between logins - -`logins` has one row per user per day they logged in. Find every pair of -consecutive logins by the same user that are 7 or more days apart. - -Output columns (no header): `user_name`, previous login date, login date, -gap in days. - -- Order by `user_name`, then login date ascending. -- Window functions cannot appear in `WHERE`; wrap them in a CTE or subquery. diff --git a/python_pkg/sql_drills/fixtures/11_lag_gap/expected.txt b/python_pkg/sql_drills/fixtures/11_lag_gap/expected.txt deleted file mode 100644 index 8b5438b4..00000000 --- a/python_pkg/sql_drills/fixtures/11_lag_gap/expected.txt +++ /dev/null @@ -1,4 +0,0 @@ -amy 2023-01-02 2023-01-10 8 -amy 2023-01-10 2023-01-17 7 -amy 2023-01-17 2023-02-01 15 -dan 2024-02-20 2024-03-01 10 diff --git a/python_pkg/sql_drills/fixtures/11_lag_gap/schema.sql b/python_pkg/sql_drills/fixtures/11_lag_gap/schema.sql deleted file mode 100644 index 9eeeedcb..00000000 --- a/python_pkg/sql_drills/fixtures/11_lag_gap/schema.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE logins ( - user_name VARCHAR(10) NOT NULL, - login_on DATE NOT NULL, - PRIMARY KEY (user_name, login_on) -); -INSERT INTO logins (user_name, login_on) VALUES - ('amy', '2023-01-01'), ('amy', '2023-01-02'), ('amy', '2023-01-10'), - ('amy', '2023-01-17'), ('amy', '2023-02-01'), ('bo', '2023-03-01'), - ('bo', '2023-03-05'), ('cat', '2023-01-05'), ('dan', '2024-02-20'), - ('dan', '2024-03-01'); diff --git a/python_pkg/sql_drills/fixtures/12_concat_percent/DOCS-task.md b/python_pkg/sql_drills/fixtures/12_concat_percent/DOCS-task.md deleted file mode 100644 index ab461d6c..00000000 --- a/python_pkg/sql_drills/fixtures/12_concat_percent/DOCS-task.md +++ /dev/null @@ -1,11 +0,0 @@ -# CONCAT and percentage formatting - -`poll_votes` has the vote count per option for several polls. For each -option print its share of its own poll's votes with one decimal. - -Output columns (no header): `poll_id`, then text like `Red: 42.9%`. - -- Order by `poll_id`, then votes descending, then option name ascending. -- A share of exactly 50 prints `50.0%` and zero prints `0.0%`. -- Trap: `ROUND(x, 1)` keeps the trailing `.0` only on a DECIMAL, so do the - arithmetic in decimal, not by dividing integers with `DIV`. diff --git a/python_pkg/sql_drills/fixtures/12_concat_percent/expected.txt b/python_pkg/sql_drills/fixtures/12_concat_percent/expected.txt deleted file mode 100644 index 97f3e67f..00000000 --- a/python_pkg/sql_drills/fixtures/12_concat_percent/expected.txt +++ /dev/null @@ -1,7 +0,0 @@ -1 Green: 42.9% -1 Red: 42.9% -1 Blue: 14.3% -2 No: 50.0% -2 Yes: 50.0% -3 Sure: 100.0% -3 Maybe: 0.0% diff --git a/python_pkg/sql_drills/fixtures/12_concat_percent/schema.sql b/python_pkg/sql_drills/fixtures/12_concat_percent/schema.sql deleted file mode 100644 index f7dbfa02..00000000 --- a/python_pkg/sql_drills/fixtures/12_concat_percent/schema.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE poll_votes ( - poll_id INT NOT NULL, - option_name VARCHAR(10) NOT NULL, - votes INT NOT NULL, - PRIMARY KEY (poll_id, option_name) -); -INSERT INTO poll_votes (poll_id, option_name, votes) VALUES - (1, 'Red', 3), (1, 'Green', 3), (1, 'Blue', 1), - (2, 'Yes', 5), (2, 'No', 5), - (3, 'Maybe', 0), (3, 'Sure', 4); diff --git a/python_pkg/sql_drills/tests/solutions/01_pivot_sum.sql b/python_pkg/sql_drills/tests/solutions/01_pivot_sum.sql deleted file mode 100644 index 79040de2..00000000 --- a/python_pkg/sql_drills/tests/solutions/01_pivot_sum.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT region, - SUM(CASE WHEN quarter = 'Q1' THEN amount ELSE 0 END), - SUM(CASE WHEN quarter = 'Q2' THEN amount ELSE 0 END), - SUM(CASE WHEN quarter = 'Q3' THEN amount ELSE 0 END), - SUM(CASE WHEN quarter = 'Q4' THEN amount ELSE 0 END) -FROM shop_sales -GROUP BY region -ORDER BY region; diff --git a/python_pkg/sql_drills/tests/solutions/02_pivot_max.sql b/python_pkg/sql_drills/tests/solutions/02_pivot_max.sql deleted file mode 100644 index b41f0163..00000000 --- a/python_pkg/sql_drills/tests/solutions/02_pivot_max.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT person, - MAX(CASE WHEN attr = 'email' THEN val END), - MAX(CASE WHEN attr = 'phone' THEN val END), - MAX(CASE WHEN attr = 'city' THEN val END) -FROM person_attrs -GROUP BY person -ORDER BY person; diff --git a/python_pkg/sql_drills/tests/solutions/03_month_bucket.sql b/python_pkg/sql_drills/tests/solutions/03_month_bucket.sql deleted file mode 100644 index 621a6e0b..00000000 --- a/python_pkg/sql_drills/tests/solutions/03_month_bucket.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, - COUNT(*), - SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END), - SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END), - SUM(CASE WHEN status = 'shipped' THEN total ELSE 0 END) -FROM orders -GROUP BY month -ORDER BY month; diff --git a/python_pkg/sql_drills/tests/solutions/04_date_format.sql b/python_pkg/sql_drills/tests/solutions/04_date_format.sql deleted file mode 100644 index 3fccebdf..00000000 --- a/python_pkg/sql_drills/tests/solutions/04_date_format.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT id, - DATE_FORMAT(starts_at, '%W, %d %M %Y %H:%i'), - DATE_FORMAT(starts_at, '%h:%i %p') -FROM events -ORDER BY id; diff --git a/python_pkg/sql_drills/tests/solutions/05_str_to_date.sql b/python_pkg/sql_drills/tests/solutions/05_str_to_date.sql deleted file mode 100644 index 6227cd4b..00000000 --- a/python_pkg/sql_drills/tests/solutions/05_str_to_date.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT id, DATE_FORMAT(STR_TO_DATE(signup_text, '%m/%d/%Y %H:%i'), '%Y-%m-%d') -FROM signups -WHERE STR_TO_DATE(signup_text, '%m/%d/%Y %H:%i') >= '2022-03-01' -ORDER BY STR_TO_DATE(signup_text, '%m/%d/%Y %H:%i'), id; diff --git a/python_pkg/sql_drills/tests/solutions/06_number_traps.sql b/python_pkg/sql_drills/tests/solutions/06_number_traps.sql deleted file mode 100644 index cc4cdaf1..00000000 --- a/python_pkg/sql_drills/tests/solutions/06_number_traps.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT team, - ROUND(AVG(wins), 2), - AVG(games), - CAST(100 * SUM(wins) / SUM(games) AS DECIMAL(5, 1)) -FROM team_results -GROUP BY team -ORDER BY team; diff --git a/python_pkg/sql_drills/tests/solutions/07_number_format.sql b/python_pkg/sql_drills/tests/solutions/07_number_format.sql deleted file mode 100644 index 197c68b7..00000000 --- a/python_pkg/sql_drills/tests/solutions/07_number_format.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT customer, FORMAT(SUM(amount), 2), FORMAT(AVG(amount), 0) -FROM invoices -GROUP BY customer -ORDER BY SUM(amount) DESC; diff --git a/python_pkg/sql_drills/tests/solutions/08_anti_join.sql b/python_pkg/sql_drills/tests/solutions/08_anti_join.sql deleted file mode 100644 index b7c6af59..00000000 --- a/python_pkg/sql_drills/tests/solutions/08_anti_join.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT c.name, COALESCE(c.phone, 'n/a') -FROM customers c -LEFT JOIN orders o ON o.customer_id = c.id AND o.status <> 'cancelled' -WHERE o.id IS NULL -ORDER BY c.name; diff --git a/python_pkg/sql_drills/tests/solutions/09_group_having.sql b/python_pkg/sql_drills/tests/solutions/09_group_having.sql deleted file mode 100644 index 87304bfd..00000000 --- a/python_pkg/sql_drills/tests/solutions/09_group_having.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT team, COUNT(*), SUM(hours) -FROM tickets -WHERE priority <> 'low' -GROUP BY team -HAVING COUNT(*) >= 3 AND SUM(hours) >= 10 -ORDER BY SUM(hours) DESC, team; diff --git a/python_pkg/sql_drills/tests/solutions/10_window_topn.sql b/python_pkg/sql_drills/tests/solutions/10_window_topn.sql deleted file mode 100644 index 63b98bb0..00000000 --- a/python_pkg/sql_drills/tests/solutions/10_window_topn.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT dept, name, salary, rnk -FROM ( - SELECT dept, name, salary, - DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rnk - FROM employees -) ranked -WHERE rnk <= 2 -ORDER BY dept, rnk, name; diff --git a/python_pkg/sql_drills/tests/solutions/11_lag_gap.sql b/python_pkg/sql_drills/tests/solutions/11_lag_gap.sql deleted file mode 100644 index b27739f1..00000000 --- a/python_pkg/sql_drills/tests/solutions/11_lag_gap.sql +++ /dev/null @@ -1,10 +0,0 @@ -WITH gaps AS ( - SELECT user_name, - LAG(login_on) OVER (PARTITION BY user_name ORDER BY login_on) AS prev_on, - login_on - FROM logins -) -SELECT user_name, prev_on, login_on, DATEDIFF(login_on, prev_on) -FROM gaps -WHERE DATEDIFF(login_on, prev_on) >= 7 -ORDER BY user_name, login_on; diff --git a/python_pkg/sql_drills/tests/solutions/12_concat_percent.sql b/python_pkg/sql_drills/tests/solutions/12_concat_percent.sql deleted file mode 100644 index ddd68d45..00000000 --- a/python_pkg/sql_drills/tests/solutions/12_concat_percent.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT poll_id, - CONCAT(option_name, ': ', - ROUND(100 * votes / SUM(votes) OVER (PARTITION BY poll_id), 1), '%') -FROM poll_votes -ORDER BY poll_id, votes DESC, option_name; diff --git a/python_pkg/sql_drills/tests/test_check.py b/python_pkg/sql_drills/tests/test_check.py deleted file mode 100644 index 1d1b36f1..00000000 --- a/python_pkg/sql_drills/tests/test_check.py +++ /dev/null @@ -1,105 +0,0 @@ -"""``run_check`` orchestration with the engine mocked.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any -from unittest.mock import patch - -import pytest - -from python_pkg.sql_drills import _check, _drills, _engine -from python_pkg.sql_drills._engine import SqlResult - - -def _drill(tmp_path: Path, expected: str = "a 1\n") -> _drills.Drill: - (tmp_path / "DOCS-task.md").write_text("# T\n") - (tmp_path / "schema.sql").write_text("CREATE TABLE t (a INT);") - (tmp_path / "expected.txt").write_text(expected) - return _drills.Drill( - "09_x", - tmp_path / "DOCS-task.md", - tmp_path / "schema.sql", - tmp_path / "expected.txt", - ) - - -def _query(tmp_path: Path) -> Path: - q = tmp_path / "q.sql" - q.write_text("SELECT 'a', 1;") - return q - - -def _patch_engine(*results: SqlResult, ready: bool = True) -> tuple[Any, Any]: - return ( - patch.object(_engine, "is_ready", return_value=ready), - patch.object(_engine, "run_sql", side_effect=list(results)), - ) - - -OK = SqlResult(0, "", "") - - -def test_pass(tmp_path: Path) -> None: - ready, run = _patch_engine(OK, OK, SqlResult(0, "a\t1\n", "")) - with ready, run as run_sql: - res = _check.run_check(_drill(tmp_path), _query(tmp_path)) - assert res.status == "pass" - assert run_sql.call_args_list[0].args[0].startswith("DROP DATABASE IF EXISTS") - assert run_sql.call_args_list[2].kwargs["database"] == "drill_09_x" - - -def test_mismatch_includes_diff(tmp_path: Path) -> None: - ready, run = _patch_engine(OK, OK, SqlResult(0, "a\t2\n", "")) - with ready, run: - res = _check.run_check(_drill(tmp_path), _query(tmp_path)) - assert res.status == "fail" - assert "expected: a 1" in res.message - assert "actual : a 2" in res.message - - -def test_sql_error_reports_stderr(tmp_path: Path) -> None: - ready, run = _patch_engine(OK, OK, SqlResult(1, "", "ERROR 1064: syntax")) - with ready, run: - res = _check.run_check(_drill(tmp_path), _query(tmp_path)) - assert res.status == "error" - assert "ERROR 1064" in res.message - - -def test_not_ready_tells_user_to_start(tmp_path: Path) -> None: - ready, run = _patch_engine(ready=False) - with ready, run: - res = _check.run_check(_drill(tmp_path), _query(tmp_path)) - assert res.status == "error" - assert "start" in res.message - - -def test_schema_failure_is_error(tmp_path: Path) -> None: - ready, run = _patch_engine(OK, SqlResult(1, "", "bad schema")) - with ready, run: - res = _check.run_check(_drill(tmp_path), _query(tmp_path)) - assert res.status == "error" - assert "bad schema" in res.message - - -def test_reset_failure_is_error(tmp_path: Path) -> None: - ready, run = _patch_engine(SqlResult(1, "", "no perms")) - with ready, run: - res = _check.run_check(_drill(tmp_path), _query(tmp_path)) - assert res.status == "error" - - -def test_missing_query_file(tmp_path: Path) -> None: - ready, run = _patch_engine() - with ready, run: - res = _check.run_check(_drill(tmp_path), tmp_path / "nope.sql") - assert res.status == "error" - assert "nope.sql" in res.message - - -def test_engine_error_propagates_to_cli(tmp_path: Path) -> None: - with ( - patch.object(_engine, "is_ready", side_effect=_engine.EngineError("no docker")), - pytest.raises(_engine.EngineError), - ): - _check.run_check(_drill(tmp_path), _query(tmp_path)) diff --git a/python_pkg/sql_drills/tests/test_cli.py b/python_pkg/sql_drills/tests/test_cli.py index 6fd0e88b..4e41ccc5 100644 --- a/python_pkg/sql_drills/tests/test_cli.py +++ b/python_pkg/sql_drills/tests/test_cli.py @@ -1,4 +1,4 @@ -"""CLI wiring: subcommands, exit codes, output.""" +"""CLI wiring over the shared package: subcommands, exit codes, output.""" from __future__ import annotations @@ -6,9 +6,33 @@ from pathlib import Path from unittest.mock import patch import pytest +import sql_drills as sd from python_pkg.sql_drills import __main__ as cli -from python_pkg.sql_drills import _check, _engine + + +def _result( + *, + match: bool, + ok: bool = True, + error: str | None = None, + diff: list[sd.CellDiff] | None = None, +) -> sd.RunResult: + return sd.RunResult( + ok=ok, + error=error, + columns=[], + rows=[["a"]], + expected_rows=[["b"]], + match=match, + cell_diff=diff or [], + ) + + +def _sql(tmp_path: Path) -> str: + query = tmp_path / "q.sql" + query.write_text("SELECT 1;") + return str(query) def test_list_prints_ids_and_titles(capsys: pytest.CaptureFixture[str]) -> None: @@ -18,9 +42,22 @@ def test_list_prints_ids_and_titles(capsys: pytest.CaptureFixture[str]) -> None: assert len(out.strip().splitlines()) >= 10 -def test_show_prints_task(capsys: pytest.CaptureFixture[str]) -> None: +def test_show_prints_task_and_expected_sample( + capsys: pytest.CaptureFixture[str], +) -> None: assert cli.main(["show", "01"]) == 0 - assert "CREATE TABLE" in capsys.readouterr().out + out = capsys.readouterr().out + assert "CREATE TABLE" in out + assert "## Expected output (sample)" in out + + +def test_show_predict_drill_prints_the_query( + capsys: pytest.CaptureFixture[str], +) -> None: + assert cli.main(["show", "t03"]) == 0 + out = capsys.readouterr().out + assert "## Query to predict" in out + assert "LEFT JOIN" in out def test_show_unknown_drill_exit_2(capsys: pytest.CaptureFixture[str]) -> None: @@ -29,7 +66,10 @@ def test_show_unknown_drill_exit_2(capsys: pytest.CaptureFixture[str]) -> None: def test_start_and_stop_call_engine(capsys: pytest.CaptureFixture[str]) -> None: - with patch.object(_engine, "start") as start, patch.object(_engine, "stop") as stop: + with ( + patch.object(sd, "ensure_running") as start, + patch.object(sd, "stop") as stop, + ): assert cli.main(["start"]) == 0 assert cli.main(["stop"]) == 0 start.assert_called_once_with() @@ -38,28 +78,53 @@ def test_start_and_stop_call_engine(capsys: pytest.CaptureFixture[str]) -> None: def test_engine_error_is_exit_2(capsys: pytest.CaptureFixture[str]) -> None: - with patch.object(_engine, "start", side_effect=_engine.EngineError("boom")): + with patch.object(sd, "ensure_running", side_effect=sd.EngineError("boom")): assert cli.main(["start"]) == 2 assert "boom" in capsys.readouterr().err -@pytest.mark.parametrize(("status", "code"), [("pass", 0), ("fail", 1), ("error", 2)]) -def test_check_exit_codes( - status: str, code: int, tmp_path: Path, capsys: pytest.CaptureFixture[str] +def test_check_pass_is_exit_0( + tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - result = _check.CheckResult(status, "MSG") - with patch.object(_check, "run_check", return_value=result): - assert cli.main(["check", "01", str(tmp_path / "q.sql")]) == code - assert "MSG" in capsys.readouterr().out + with patch.object(sd, "run", return_value=_result(match=True)): + assert cli.main(["check", "01", _sql(tmp_path)]) == 0 + assert "PASS 01" in capsys.readouterr().out + + +def test_check_fail_prints_cell_diff_exit_1( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + res = _result(match=False, diff=[(0, 1, "x", "y")]) + with patch.object(sd, "run", return_value=res): + assert cli.main(["check", "01", _sql(tmp_path)]) == 1 + out = capsys.readouterr().out + assert "FAIL 01" in out + assert "line 1 cell 2: expected 'x', actual 'y'" in out + + +def test_check_sql_error_is_exit_2( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + res = _result(match=False, ok=False, error="ERROR 1064") + with patch.object(sd, "run", return_value=res): + assert cli.main(["check", "01", _sql(tmp_path)]) == 2 + assert "ERROR 1064" in capsys.readouterr().out + + +def test_check_missing_file_is_exit_2( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert cli.main(["check", "01", str(tmp_path / "nope.sql")]) == 2 + assert "not found" in capsys.readouterr().out def test_check_unknown_drill(tmp_path: Path) -> None: - assert cli.main(["check", "zzz", str(tmp_path / "q.sql")]) == 2 + assert cli.main(["check", "zzz", _sql(tmp_path)]) == 2 def test_check_engine_error_exit_2(tmp_path: Path) -> None: - with patch.object(_check, "run_check", side_effect=_engine.EngineError("x")): - assert cli.main(["check", "01", str(tmp_path / "q.sql")]) == 2 + with patch.object(sd, "run", side_effect=sd.EngineError("x")): + assert cli.main(["check", "01", _sql(tmp_path)]) == 2 def test_no_subcommand_is_usage_error() -> None: diff --git a/python_pkg/sql_drills/tests/test_drills.py b/python_pkg/sql_drills/tests/test_drills.py deleted file mode 100644 index fee33476..00000000 --- a/python_pkg/sql_drills/tests/test_drills.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Drill discovery and rendering over the bundled drills.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from python_pkg.sql_drills import _drills - -SOLUTIONS = Path(__file__).parent / "solutions" - - -def test_drills_are_listed_in_priority_order() -> None: - ids = [d.id for d in _drills.list_drills()] - assert ids == sorted(ids) - assert len(ids) >= 10 - - -def test_every_drill_has_all_parts_and_a_reference() -> None: - for d in _drills.list_drills(): - assert d.schema.read_text().strip(), d.id - assert d.task.read_text().strip(), d.id - assert d.expected.read_text().strip(), d.id - assert (SOLUTIONS / f"{d.id}.sql").is_file(), d.id - - -def test_get_by_full_id_and_by_number_prefix() -> None: - first = _drills.list_drills()[0] - assert _drills.get_drill(first.id) == first - assert _drills.get_drill(first.id.split("_")[0]) == first - - -def test_unknown_drill_raises() -> None: - with pytest.raises(_drills.UnknownDrillError): - _drills.get_drill("nope") - - -def test_show_has_task_schema_and_sample_but_no_solution() -> None: - d = _drills.list_drills()[0] - text = _drills.render_show(d) - assert d.task.read_text().strip() in text - assert "CREATE TABLE" in text - first_expected = d.expected.read_text().splitlines()[0] - assert first_expected in text - assert (SOLUTIONS / f"{d.id}.sql").read_text().strip() not in text - - -def test_show_truncates_long_expected_output(tmp_path: Path) -> None: - exp = tmp_path / "expected.txt" - exp.write_text("\n".join(f"row{i}" for i in range(20)) + "\n") - task = tmp_path / "DOCS-task.md" - task.write_text("T") - schema = tmp_path / "schema.sql" - schema.write_text("S") - text = _drills.render_show(_drills.Drill("x", task, schema, exp)) - assert "row0" in text - assert "row19" not in text - assert "20 rows in total" in text - - -def test_title_is_first_heading() -> None: - d = _drills.list_drills()[0] - assert _drills.title_of(d) == d.task.read_text().splitlines()[0].lstrip("# ") - - -def test_missing_drills_dir_lists_nothing(tmp_path: Path) -> None: - assert _drills.list_drills(tmp_path / "absent") == [] diff --git a/python_pkg/sql_drills/tests/test_engine.py b/python_pkg/sql_drills/tests/test_engine.py deleted file mode 100644 index efc7df55..00000000 --- a/python_pkg/sql_drills/tests/test_engine.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Docker plumbing with ``subprocess.run`` mocked: no daemon needed.""" - -from __future__ import annotations - -from contextlib import AbstractContextManager -import shutil -import subprocess -from typing import TYPE_CHECKING -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from python_pkg.sql_drills import _engine - -if TYPE_CHECKING: - from collections.abc import Iterator - - -@pytest.fixture(autouse=True) -def _fake_docker_on_path() -> Iterator[None]: - with patch.object(shutil, "which", return_value="docker"): - yield - - -def _cp(rc: int = 0, out: str = "", err: str = "") -> subprocess.CompletedProcess[str]: - return subprocess.CompletedProcess([], rc, out, err) - - -def _run( - *results: subprocess.CompletedProcess[str], -) -> AbstractContextManager[MagicMock | AsyncMock]: - return patch.object(subprocess, "run", side_effect=list(results)) - - -def test_docker_missing_binary_is_engine_error() -> None: - with ( - patch.object(shutil, "which", return_value=None), - pytest.raises(_engine.EngineError, match="docker"), - ): - _engine.container_state() - - -def test_container_state_running() -> None: - with _run(_cp(0, "running\n")): - assert _engine.container_state() == "running" - - -def test_container_state_missing_on_inspect_failure() -> None: - with _run(_cp(1, "", "No such object")): - assert _engine.container_state() == "missing" - - -def test_is_ready_follows_ping_exit_code() -> None: - with _run(_cp(0)): - assert _engine.is_ready() - with _run(_cp(1)): - assert not _engine.is_ready() - - -def test_start_creates_container_when_missing_then_waits() -> None: - with _run(_cp(1), _cp(0, "id"), _cp(1), _cp(0)) as run: - _engine.start(timeout=5, sleep=lambda _s: None) - run_args = run.call_args_list[1].args[0] - assert run_args[:3] == ["docker", "run", "-d"] - assert "--tmpfs" in run_args - assert _engine.IMAGE in run_args - assert "--name" in run_args - assert _engine.CONTAINER in run_args - - -def test_start_resumes_exited_container() -> None: - with _run(_cp(0, "exited\n"), _cp(0), _cp(0)) as run: - _engine.start(timeout=5, sleep=lambda _s: None) - assert run.call_args_list[1].args[0][:2] == ["docker", "start"] - - -def test_start_when_already_running_only_waits() -> None: - with _run(_cp(0, "running\n"), _cp(0)) as run: - _engine.start(timeout=5, sleep=lambda _s: None) - assert run.call_count == 2 - - -def test_start_run_failure_is_reported() -> None: - with ( - _run(_cp(1), _cp(125, "", "pull access denied")), - pytest.raises(_engine.EngineError, match="pull access denied"), - ): - _engine.start(timeout=5, sleep=lambda _s: None) - - -def test_start_times_out_with_rerun_hint() -> None: - ticks = iter([0.0, 1.0, 2.0, 99.0, 100.0]) - results = [_cp(0, "running\n")] + [_cp(1)] * 10 - with _run(*results), pytest.raises(_engine.EngineError, match="rerun"): - _engine.start(timeout=10, sleep=lambda _s: None, clock=lambda: next(ticks)) - - -def test_stop_removes_container() -> None: - with _run(_cp(0)) as run: - _engine.stop() - assert run.call_args.args[0] == ["docker", "rm", "-f", _engine.CONTAINER] - - -def test_run_sql_passes_stdin_and_database() -> None: - with _run(_cp(0, "1\n", "")) as run: - res = _engine.run_sql("SELECT 1;", database="d1") - argv = run.call_args.args[0] - assert argv[:3] == ["docker", "exec", "-i"] - assert "-B" in argv - assert "-N" in argv - assert argv[-1] == "d1" - assert run.call_args.kwargs["input"] == "SELECT 1;" - assert res.returncode == 0 - assert res.stdout == "1\n" - - -def test_run_sql_without_database() -> None: - with _run(_cp(0)) as run: - _engine.run_sql("SELECT 1;") - assert run.call_args.args[0][-1] == "-N" - - -def test_run_sql_timeout_becomes_engine_error() -> None: - boom = subprocess.TimeoutExpired(cmd="docker", timeout=1) - with ( - patch.object(subprocess, "run", side_effect=boom), - pytest.raises(_engine.EngineError, match="timed out"), - ): - _engine.run_sql("SELECT SLEEP(99);", timeout=1) - - -def test_run_sql_missing_docker_is_not_relabelled() -> None: - with ( - patch.object(shutil, "which", return_value=None), - pytest.raises(_engine.EngineError, match="docker binary"), - ): - _engine.run_sql("SELECT 1;") diff --git a/python_pkg/sql_drills/tests/test_focus_hook.py b/python_pkg/sql_drills/tests/test_focus_hook.py index 7aa22bd7..6f7ddccc 100644 --- a/python_pkg/sql_drills/tests/test_focus_hook.py +++ b/python_pkg/sql_drills/tests/test_focus_hook.py @@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any, cast from unittest.mock import patch import pytest +import sql_drills as sd from python_pkg.sql_drills import __main__ as cli -from python_pkg.sql_drills import _check if TYPE_CHECKING: from pathlib import Path @@ -42,9 +42,13 @@ def test_show_sends_heartbeat(monkeypatch: pytest.MonkeyPatch) -> None: def test_check_sends_heartbeat(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: calls = _fake_tracker(monkeypatch) - result = _check.CheckResult("fail", "MSG") - with patch.object(_check, "run_check", return_value=result): - assert cli.main(["check", "01", str(tmp_path / "q.sql")]) == 1 + result = sd.RunResult( + ok=True, error=None, columns=[], rows=[], expected_rows=[["x"]], match=False + ) + with patch.object(sd, "run", return_value=result): + query = tmp_path / "q.sql" + query.write_text("SELECT 1;") + assert cli.main(["check", "01", str(query)]) == 1 assert calls == [("sql_drills", "heartbeat")] diff --git a/python_pkg/sql_drills/tests/test_live.py b/python_pkg/sql_drills/tests/test_live.py deleted file mode 100644 index 38dc3dc8..00000000 --- a/python_pkg/sql_drills/tests/test_live.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Live E2E against the real container; skipped when it is not running. - -Start it first with ``python3 -m python_pkg.sql_drills start``. These tests are -the only place reference solutions are used, so learning mode never shows them. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from python_pkg.sql_drills import _check, _drills, _engine - -SOLUTIONS = Path(__file__).parent / "solutions" -DRILL_IDS = [d.id for d in _drills.list_drills()] - - -def _engine_up() -> bool: - try: - return _engine.is_ready() - except _engine.EngineError: - return False - - -pytestmark = [ - pytest.mark.docker, - pytest.mark.skipif(not _engine_up(), reason="sql-drills-mysql is not running"), -] - - -@pytest.mark.parametrize("drill_id", DRILL_IDS) -def test_reference_solution_matches_expected(drill_id: str) -> None: - result = _check.run_check( - _drills.get_drill(drill_id), SOLUTIONS / f"{drill_id}.sql" - ) - assert result.status == "pass", result.message - - -def test_wrong_query_fails_with_diff(tmp_path: Path) -> None: - query = tmp_path / "q.sql" - query.write_text("SELECT region, 1, 2, 3, 4 FROM shop_sales GROUP BY region;") - result = _check.run_check(_drills.get_drill("01"), query) - assert result.status == "fail" - assert "expected:" in result.message - - -def test_syntax_error_is_error(tmp_path: Path) -> None: - query = tmp_path / "q.sql" - query.write_text("SELECT * FROM;") - result = _check.run_check(_drills.get_drill("01"), query) - assert result.status == "error" - assert "1064" in result.message - - -def test_checks_are_isolated(tmp_path: Path) -> None: - query = tmp_path / "q.sql" - query.write_text("DELETE FROM shop_sales;") - _check.run_check(_drills.get_drill("01"), query) - again = _check.run_check(_drills.get_drill("01"), SOLUTIONS / "01_pivot_sum.sql") - assert again.status == "pass" diff --git a/python_pkg/sql_drills/tests/test_normalize.py b/python_pkg/sql_drills/tests/test_normalize.py deleted file mode 100644 index daf0fd40..00000000 --- a/python_pkg/sql_drills/tests/test_normalize.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Output normaliser and diff: pure functions, no Docker.""" - -from __future__ import annotations - -from python_pkg.sql_drills._normalize import diff, matches, normalize - - -def test_tabs_become_single_spaces() -> None: - assert normalize("a\tb\tc\n") == ["a b c"] - - -def test_trailing_whitespace_stripped_per_line() -> None: - assert normalize("a b \nc\t\n") == ["a b", "c"] - - -def test_leading_whitespace_kept() -> None: - assert normalize(" x\n") == [" x"] - - -def test_null_stays_literal() -> None: - assert normalize("1\tNULL\n") == ["1 NULL"] - - -def test_crlf_handled() -> None: - assert normalize("a\r\nb\r\n") == ["a", "b"] - - -def test_empty_output_is_no_lines() -> None: - assert normalize("") == [] - - -def test_matches_ignores_trailing_blank_final_newline() -> None: - assert matches("a\tb\n", "a b") - - -def test_case_is_strict() -> None: - assert not matches("Alice\n", "alice\n") - - -def test_decimals_are_strict() -> None: - assert not matches("2.5\n", "2.5000\n") - - -def test_row_order_is_strict() -> None: - assert not matches("a\nb\n", "b\na\n") - - -def test_diff_empty_when_equal() -> None: - assert diff(["a"], ["a"]) == "" - - -def test_diff_marks_differing_line() -> None: - out = diff(["a", "b"], ["a", "c"]) - assert "line 2" in out - assert "expected: b" in out - assert "actual : c" in out - assert "line 1" not in out - - -def test_diff_reports_missing_rows() -> None: - out = diff(["a", "b"], ["a"]) - assert "line 2" in out - assert "expected: b" in out - assert "actual : " in out - - -def test_diff_reports_extra_rows() -> None: - out = diff(["a"], ["a", "z"]) - assert "expected: " in out - assert "actual : z" in out - - -def test_diff_shows_whitespace_visibly() -> None: - out = diff(["a b"], [" a b"]) - assert "expected: a b" in out - assert "actual : ' a b'" in out