diff --git a/.claude/SETUP-REPORT.md b/.claude/SETUP-REPORT.md new file mode 100644 index 00000000..e7e029ea --- /dev/null +++ b/.claude/SETUP-REPORT.md @@ -0,0 +1,76 @@ +# Setup report — 2026-08-29 + +## What the repository showed + +A fork of `Optiways/padam-django-tech-test` at `16a6be4`. Django 4.2.16, +four flat apps under `padam_django/apps/`, SQLite, four migrations. +`BusShift` and `BusStop` do not exist — they are the exercise. + +Gaps found against the shared doctrine, all confirmed by reading: + +| Gap | Rule broken | +| --- | ----------- | +| No container, no compose; everything runs on the host in a virtualenv | `environment.md` | +| `make migrate` documented in the README, absent from the Makefile | `verification.md` | +| `SECRET_KEY` hard-coded, `DEBUG = True` committed, DB path fixed | `configuration.md` | +| `.env.example` present but declares zero variables | `configuration.md` | +| No tests, no framework, no coverage | `testing.md` | +| No `mkdocs.yml`, no `docs/flows/`, no `docs/coverage.md` | `code-flow.md`, `mkdocs.md` | +| No CI workflow | `ci.md` (inert until one exists) | +| `.idea/` committed *and* gitignored; `Pipfile` gitignored while empty | `generated-artifacts.md` | +| Python version contradictory: `Pipfile` 3.7 vs README 3.9, both EOL or near | `dependencies.md` | +| Flat Django layout, no hexagonal layering | `architecture.md` — **accepted exception** | + +## What the developer decided + +| Question | Answer | +| -------- | ------ | +| Claude's write access to `padam_django/**` | **Denied outright.** Proposals are delivered as code blocks; the developer types them. `git blame` must show the Django work is theirs. | +| `settings.py` configuration debt | The developer corrects it; Claude proposes the diff only. | +| Container chain | **Full chain** via `project-scaffold` — Dockerfile prod + derived dev image, compose, Makefile, CI. | +| Dev database engine | **Postgres in the container**, for dev/prod parity and because it makes an `ExclusionConstraint` possible. | +| Overlap constraint | **Left open.** DB-level exclusion vs application `clean()` is the developer's modelling call. | +| Tests | The developer writes them. Claude completes a suite **only on explicit request** — `ask`, never `allow`. | +| Gold book | `docs/coverage.md` seeded now; the `BusShift` flow written from the code, after the code exists. | +| MkDocs site | Yes, built and served in the container. | +| Session transcripts | **Committed** — the process is part of the deliverable. | +| MCP | None now. Postgres read-only wanted **once the container exists**. | +| Working method | The developer leads: Claude renders analysis, waits for the plan, comments and suggests, then executes. | +| Worst failures | Business constraint not held; unconvincing Django admin; configuration that does not match reality. | + +## What was written + +| File | Nature | +| ---- | ------ | +| `.claude/rules/interview-scope.md` | New rule, always loaded. Read-only on the code, propose-then-write on infrastructure, developer leads. | +| `CLAUDE.md` | Replaced the template placeholder. Stack, real architecture, unverified command table, ten concrete debts. | +| `docs/README.md` | Folder README, per the documentation rule. | +| `docs/coverage.md` | Seeded from a reading of the code: handled, not handled, and the test scenarios the suite must cover. | +| `.claude/settings.json` | `permissions` block: deny on `padam_django/**` and on mutating git, ask on tests and infrastructure, allow on docs and read-only analysis. | +| `.claude/quality-gate.json` | Every command marked `pending` or `absent`. **Nothing is claimed verified, because nothing was run.** | +| `.gitignore` | `docs/sessions/` un-ignored, with the reason written in place. | + +## Verified by running + +- `python3 .claude/tools/which-rules.py` — rule loading confirmed. +- `python3 .claude/tools/check-template.py .` — structural check. +- JSON validity of `settings.json` and `quality-gate.json`. + +Nothing else. **No project command was executed**: the repository only runs on +the host, which the doctrine forbids, and the container that would run it does +not exist yet. + +## Left open + +1. **Overlap enforcement**: DB `ExclusionConstraint` or application validation. +2. **Adjacency**: does a shift ending at 10:00 overlap one starting at 10:00? +3. **Python version** for the container image — neither 3.7 nor 3.9 is a good + answer today. +4. **Test framework**: `pytest-django` or Django's own runner. +5. **Coverage thresholds** (80 / 70 in `thresholds.json`) are violated by a + repository with zero tests. Confirm or lower once a suite exists. +6. **`.idea/` and `Pipfile`**: untrack, or leave as inherited. +7. **This report is gitignored** (`.claude/SETUP-REPORT.md` in `.gitignore`) + while the pull request is meant to expose the configuration. Decide whether + to un-ignore it. +8. **MCP Postgres** server: to be written once the container exists. diff --git a/.claude/agents/conformance-reviewer.md b/.claude/agents/conformance-reviewer.md new file mode 100644 index 00000000..c484bb25 --- /dev/null +++ b/.claude/agents/conformance-reviewer.md @@ -0,0 +1,54 @@ +--- +name: conformance-reviewer +description: Reviews a change against the project's rules in a fresh context, unanchored by the reasoning that produced the code. Use before merging, or when asked whether a change respects the project's conventions. +tools: Read, Grep, Glob, Bash +--- + +You review a change against this project's written rules. You did not write the +code and you do not know why it was written that way — **that independence is +the point**. Do not reconstruct the author's intent to excuse a finding. + +## Method + +1. Read `.claude/rules/*.md` and the project `CLAUDE.md`. These are the standard + you judge against — not your own preferences, and not general best practice. +2. Read the change: `git diff` against the base, or the paths you were given. +3. For each rule that the change touches, check it. Rules with `paths:` + frontmatter apply only to matching files. + +## What to report + +One finding per violation, ordered most severe first: + +- **The rule**, by file and the line that states it. +- **Where it is violated**, as `path:line`. +- **Why it is a violation**, concretely. For a correctness rule, give the input + or state that produces the wrong outcome — not "this could be a problem". + +Check especially what is easy to forget because nothing enforces it: + +- A commit that changes behaviour but touches neither tests nor documentation. +- A feature shipped without its flow document or its coverage line. +- A new folder without a `README.md`. +- An outbound call with no timeout; a swallowed exception. +- A mutating endpoint with no explicit authorization check and no denial test. +- A host-side command added to the README or the Makefile. +- A `FROM :` line appearing in `Dockerfile.dev`. +- A CI workflow that lists its own steps instead of calling `make ci`. +- A command, URL or gate reported as working with no evidence it was run. +- A tool configured to write inside the source tree, or a `.gitignore` line + added instead of pointing the tool elsewhere. +- A permission rule that grants nothing: a file path on `Write(...)`, + `NotebookEdit(...)`, `MultiEdit(...)` or `Glob(...)`, which Claude Code + accepts and never consults — only `Edit(path)` and `Read(path)` are checked. + Or an absolute path in a committed `settings.json`, which stops matching on + any other machine. + +## Discipline + +Report **only what a rule actually says**. If something looks wrong but no rule +covers it, say so separately, labelled as an observation — and note that the +rule is missing, which is often the more useful finding. + +If the change is conformant, say so plainly. Inventing findings to appear +thorough makes every future review worth less. diff --git a/.claude/agents/flow-tracer.md b/.claude/agents/flow-tracer.md new file mode 100644 index 00000000..92de362c --- /dev/null +++ b/.claude/agents/flow-tracer.md @@ -0,0 +1,43 @@ +--- +name: flow-tracer +description: Traces a call chain through the codebase and returns a flow document. Use when documenting a feature, or when a production bug needs the actual execution path established. Reads many files to produce one small artefact — that fan-out belongs in its own context. +tools: Read, Grep, Glob, Bash +--- + +You trace one call chain and return one document. You do not modify code. + +## Method + +Start at the entry point you were given. Follow the calls **by reading each +function you land in** — never infer a chain from names, and never from what +the feature was presumably meant to do. Names lie; dispatch tables, decorators, +dependency injection and event handlers all break the apparent chain. + +Where the next hop is indirect — an interface, a registered handler, a signal — +find the concrete implementation before continuing. Say so if there are several +and you cannot tell which runs. + +Stop at process boundaries: a database call, an HTTP call to another service, a +queue publish. Name the boundary and what is expected across it. + +## What to record as you go + +- The real symbol of every step, written so it can be grepped. +- The layer it belongs to. +- Every branch, and what selects it. +- **What happens on failure at each step** — what is raised, what the caller + sees, what is rolled back, what is retried, what is left partial. +- Every business rule you see enforced, and the exact symbol enforcing it. + +## Return + +A flow document following the structure in the `feature-flow` skill: trigger, +Mermaid sequence diagram, step table, business rules mapped to symbols, failure +behaviour per step, boundaries. + +Mark any step you could not fully verify as ``. +**Never smooth over a gap** — an unverified step declared as such is useful; an +invented one is a trap that will be trusted during an incident. + +Finish with: what you could not determine, and any place where the code +contradicts existing documentation. diff --git a/.claude/commands/code-flow.md b/.claude/commands/code-flow.md new file mode 100644 index 00000000..f8a73951 --- /dev/null +++ b/.claude/commands/code-flow.md @@ -0,0 +1,74 @@ +--- +description: Write or update the flow document for a feature, and reconcile docs/coverage.md +argument-hint: +--- + +Produce the flow document for: $ARGUMENTS + +**Read the code. Never write this from the conversation, from intent, or from +what the feature was supposed to do** — a flow written from intent documents a +system that does not exist, and it will be trusted during an incident. + +## 1. Trace it + +Start at the entry point and follow the calls. Read each function you land in; +do not infer a chain from names. Note, as you go: the real symbols, the order, +the layer each call belongs to, every branch, and what happens on failure at +each step. + +Stop tracing at the process boundary — a database call, an HTTP call to another +service, a queue publish. Name the boundary and what is expected across it. + +## 2. Write `docs/flows/.md` + +```markdown +# + +**Trigger:** — `module.symbol` + +## Chain + +```mermaid +sequenceDiagram + ... +``` + +| # | Call | Layer | Does | +| - | ---- | ----- | ---- | +| 1 | `module.function` | presentation | | + +## Business rules applied + +| Rule | Enforced at | Symbol | +| ---- | ----------- | ------ | +| | step N | `module.symbol` | + +## Failure behaviour + +| Step | Failure | Raised | Caller sees | Rolled back | +| ---- | ------- | ------ | ----------- | ----------- | + +## Boundaries + +- Does **not** , that is `flows/.md`. +``` + +Every symbol is written so it can be grepped. A step you could not fully trace +is marked `` with what blocked you — never quietly smoothed +over. + +## 3. Reconcile `docs/coverage.md` + +For each business rule in the flow, state how far it is actually enforced, and +where enforcement stops. Add any scenario this flow does **not** handle, with +its reason: deliberate scope, known debt with a ticket, or blocked upstream. + +If the flow revealed a rule that is only partially enforced, say so plainly. +That discovery is the point of the exercise, not an inconvenience. + +## 4. Report + +List: the file written, the business rules mapped, the steps you could not +verify, and any gap between what the code does and what the coverage file +claimed before you started. That last one is the most valuable line — surface +it, do not fix it silently. diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 00000000..c49cdbef --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,26 @@ +--- +description: Prepare a Conventional Commit for the current changes — review, stage, and draft the message, without committing +argument-hint: [optional scope or intent] +--- + +Prepare a commit for the current changes. Intent, if given: $ARGUMENTS + +**Do not run `git commit`.** Prepare everything and stop, so the developer +triggers it. + +1. Run `git status` and `git diff` (and `git diff --cached`) and read what + actually changed. Never write a message from the conversation's memory of + what you did. +2. Check the change against `.claude/rules/git.md`: is the work on a branch and + not the default one? Does the change carry its tests and its documentation? + If either is missing, say so before proposing anything — an incomplete + commit is the thing the rule exists to prevent. +3. If the change covers more than one coherent concern, propose splitting it + and say where the line falls. +4. Draft the message: `type(scope): imperative subject`, no trailing period, + under 72 characters. The body explains *why*. Add a `BREAKING CHANGE:` footer + where it applies. The message ends at its last line of substance: **never** + append a co-author trailer naming an assistant, a "generated with" footer, or + any other tool signature — see `git.md`. +5. Report: the files to stage, the proposed message, and the exact command the + developer can run. diff --git a/.claude/commands/folder-readme.md b/.claude/commands/folder-readme.md new file mode 100644 index 00000000..0062b313 --- /dev/null +++ b/.claude/commands/folder-readme.md @@ -0,0 +1,20 @@ +--- +description: Scaffold or update the README.md of a folder, per the documentation rule +argument-hint: +--- + +Write or update `README.md` for: $ARGUMENTS + +Read the folder's actual contents first — describe what is there, never a +template filled with plausible guesses. Follow the structure in +`.claude/rules/documentation.md`: + +1. **Role** — why this folder exists, one or two sentences. +2. **Structure** — a table of subfolders and key files, and what each is. +3. **Should contain** — what belongs here. +4. **Should NOT contain** — what must never land here, and where it goes instead. +5. **Rules** — conventions governing this folder. Link to the rule files rather + than restating them. + +Keep it scannable. If you cannot state what a file is for, say so explicitly +rather than inventing a purpose. diff --git a/.claude/commands/project-init.md b/.claude/commands/project-init.md new file mode 100644 index 00000000..ef352733 --- /dev/null +++ b/.claude/commands/project-init.md @@ -0,0 +1,207 @@ +--- +description: Finalize the Claude setup for this project — discover the code, interview the developer layer by layer, then write the project-specific configuration +argument-hint: [optional hint about the project] +--- + +You are finalizing the Claude Code setup for this project. The shared doctrine +is already installed in `.claude/rules/`. Your job is everything that is +specific to **this** project. + +Optional hint from the developer: $ARGUMENTS + +This is an **interview**, not a form. Go layer by layer, and do not move to the +next layer until the current one is settled. Never invent an answer the +developer should give — a plausible guess written into `CLAUDE.md` is worse +than an open question, because it will be trusted later. + +--- + +## Layer 0 — Discover before asking + +Read the repository first. Never ask what the code can tell you. Establish: + +- **Stack**: languages, frameworks, versions. Read the manifests + (`pyproject.toml`, `package.json`, `go.mod`, `Cargo.toml`, …), not the + imports. +- **Layout**: top-level directories, where the domain lives, whether the + hexagonal layering in `rules/architecture.md` is already respected or + violated. +- **Commands**: how it builds, tests, lints, runs. Read the `Makefile`, the + npm scripts, the CI workflow. Prefer what CI actually runs. +- **Environment**: how many Dockerfiles, and do they share a base? Is there a + compose file, a committed `.env`, a `.venv` or `node_modules` used as the + real workflow? **Which commands only work on the host?** Check the README and + the CI workflow: a task documented as a bare host command is a gap. Note + every one against `rules/environment.md` — this is the developer's stated + hard line, and the most likely place an existing project fails it. +- **Database**: engine, migration tool, where the schema lives. Is there a + `docs/data-model/`, and does it still match the migrations? Note anything the + schema enforces only in the application. +- **Tests**: framework, where they live, whether coverage is measured. +- **Documentation**: is there a `mkdocs.yml`? A `docs/` tree? Do `docs/flows/` + and `docs/coverage.md` exist, and are they current or abandoned? An + out-of-date flow document is worse than none — flag it as debt, loudly. + Is the site built and served in the container, or does it assume a host + toolchain? +- **Git**: default branch, commit message style already in use. + +Then state what you found in a short table, and **name what you could not +determine**. Only then start asking. + +--- + +## Layer 1 — Product and intent + +### Open the interview with an open question + +**The first question is free-form prose, not a multiple choice.** Present the +discovery table, then ask, in the conversation and with no options offered: + +> What is this repository, what is the objective, and what should I take into +> account from the very start? + +Then stop and read the answer. It sets the vocabulary, the stakes and the +constraints — and offering options here would narrow the answer to what you +already guessed from the code, which is exactly what you must not do. The +developer knows things the repository cannot show: a deadline, a migration in +flight, a decision already taken, a part nobody may touch. + +Ask a second open follow-up if the answer leaves something load-bearing +unclear. Only then move to structured questions. + +### Then the structured rounds + +With that context, the choices become sharp rather than generic: + +- What is explicitly **out of scope**? +- What is the failure that would hurt most? (data loss, downtime, wrong + numbers, leaked data — this drives where rigour goes) +- What stage is it at: prototype, in production, legacy under repair? +- Who uses it, and who maintains it? + +Do not re-ask anything the open answer already settled. + +## Layer 2 — Rules and points of attention + +- Walk through the gaps found in Layer 0 against the shared doctrine. For each, + ask whether it is a debt to record or a deliberate exception. +- If the project has more than one Dockerfile, check whether they share a base. + If they do not, say what has already drifted between them — concretely, by + diffing the versions — rather than raising it as a principle. +- Ask what recurring mistakes the developer wants prevented on this project + specifically. +- Ask for any project rule not covered by `.claude/rules/`. Offer to write it as + a new rule, and ask whether it should load always or only under a path glob. +- Confirm the numeric thresholds in `.claude/thresholds.json` against this + codebase. If most existing files already violate one, say so — a threshold + that is violated everywhere trains everyone to ignore the warnings. +- **The data model.** If the project has a schema, offer to run the + `data-model-map` skill: it generates the ERD, writes the hand-written map, + and reports modelling smells — two entities that are one, a foreign key + crossing a bounded context, integrity left to the application. Report them as + candidates; never change a model, since a schema change is a data migration. +- **The gold book** (`code-flow.md`): ask which existing features already + deserve a flow document, and in what order. On an existing codebase this is a + backlog, not a one-shot — propose the two or three flows that would pay off + first, chosen by where production incidents actually happen. Offer to write + the first one with `/code-flow` before the setup is declared finished, and + seed `docs/coverage.md` with what the code demonstrably does not handle. + +## Layer 3 — Tools and workflow + +- Which path-scoped rules match nothing in this project? A rule whose globs + never fire costs nothing, but say which ones are inert so the developer knows + what is actually governing the code. Remove one only if asked. +- **Run `python3 .claude/tools/which-rules.py`** and show the developer what + actually loads. A rule listed as inert whose subject the project *should* + have — no Dockerfile, no CI workflow — is a gap to fix, not a rule to delete. + If the container chain is missing, offer the `project-scaffold` skill now. +- **Recommend skills.** Beyond the two shipped (`project-scaffold`, + `feature-flow`), propose skills for repeatable multi-step procedures this + project actually has — a release flow, a migration procedure, a review + checklist. The test: a *procedure* is a skill, a *constraint* is a rule. Say + which it is and why. Ask before creating any. +- **Recommend subagents.** Two ship with the template: `flow-tracer` (fan-out + reading, small output) and `conformance-reviewer` (independent of the + implementation reasoning). Propose more only where a genuinely separate + context pays. Be conservative: ten agents nobody invokes is worse than none. +- **Session recording.** If `.claude/session-recording.json` exists, ask + whether transcripts should be committed — yes for an interview or an audit, + gitignored when the point is only continuity — and reflect the answer in + `.gitignore`. If it does not exist, mention that `--with-recording` adds it. +- **MCP servers.** The template ships none on purpose: an MCP server connects + *this project's* external systems, so there is nothing generic to install. + Ask what Claude would need live access to — the database, the issue tracker, + monitoring, a browser — and whether the value beats the context and + permission cost. Write `.mcp.json` only for what the developer confirms. +- Ask what the developer wants Claude to do without asking, and what must + always require confirmation. Translate the answer into `permissions` in + `.claude/settings.json` — allow/deny/ask — and say plainly which of their + answers became an enforced rule and which stayed advisory. + + Two mistakes make a permission rule silently useless: + + - **File paths go on `Edit(...)` and `Read(...)`, never on `Write(...)`, + `NotebookEdit(...)`, `MultiEdit(...)` or `Glob(...)`.** Claude Code checks + file permissions against `Edit` and `Read` rules only; it accepts the + others, never consults them, and warns at startup. `Edit` already covers + every file-editing tool. A bare tool name with no path — denying `Write` + outright — is different and does work. + - **Paths are relative to the settings file, not absolute.** Write + `Edit(/src/**)`, which resolves against the project root. An absolute + `Edit(//home/someone/projects/thing/src/**)` stops matching the moment the + repository is cloned anywhere else, which is every other machine. + + Run `python3 .claude/tools/check-template.py .` after writing them: it + refuses both mistakes. + +## Layer 4 — How the developer wants to work + +- How should Claude deliver work: plan first, or act then report? +- What does "done" mean here — tests green, CI green, deployed? +- Which parts of the codebase are sensitive enough to always warrant a plan + before touching them? + +--- + +## Then write + +Only once the layers are settled: + +1. **`CLAUDE.md`** at the project root. Under 200 lines. It contains what is + true of this project and nothing that `.claude/rules/` already says. Stack, + architecture as it actually is, commands, product scope, known debt, points + of attention. Concrete and verifiable — "run `make test`", not "test your + changes". +2. **New project rules** in `.claude/rules/`, path-scoped where possible. +3. **`.claude/thresholds.json`** and **`.claude/quality-gate.json`** filled with + this project's real commands, including the docs build and serve commands. + Every command recorded there must be the one that runs in the container, not + a host equivalent that happens to work on this machine today. +4. **`.claude/settings.json`** permissions, from Layer 3. +5. **`.claude/SETUP-REPORT.md`** — what you discovered, what the developer + answered, what you wrote, and every question left open. + +Then run `python3 .claude/tools/check-template.py` and fix what it reports. +Do not present the setup with errors outstanding. + +## Then present the paradigm + +Finish by presenting, in the conversation, your model of this project: + +- What the setup now **covers** — each rule, and what it prevents. +- What it **does not cover**, explicitly. Gaps you saw and did not configure, + conventions you inferred but could not confirm, risks nobody enforced. +- Which features have a flow document and which do not — the current reach of + the gold book, stated as a fact rather than left implicit. +- Where a rule is **advisory** versus **enforced by a hook**, since only the + second survives a model that decides otherwise. +- What you **verified by running it** versus what you took from reading. Every + command you recorded in `quality-gate.json` should be in the first group; + say plainly which are not (`verification.md`). +- What you would add next, and what you deliberately left out. + +Then ask whether it matches how the developer sees the project. **Iterate on +this presentation until they say it is right**, adjusting the written files at +each round. Do not treat the setup as finished because you produced files — it +is finished when the developer confirms the paradigm. diff --git a/.claude/commands/recording.md b/.claude/commands/recording.md new file mode 100644 index 00000000..5bc28a71 --- /dev/null +++ b/.claude/commands/recording.md @@ -0,0 +1,29 @@ +--- +description: Control the session transcript recorder — status, on, off, pause, resume +argument-hint: status | on | off | pause | resume +--- + +Act on the session recorder: $ARGUMENTS + +State lives in `.claude/session-recording.json`. If that file does not exist, +the recorder is not installed — say so, and that installing it means running +`./install.sh --with-recording` from the template. + +- **status** — read the config and report: enabled, paused, output directory, + how many transcripts exist and the date of the most recent. Say plainly + whether the current session is being recorded. +- **on** — set `enabled: true`, `paused: false`. +- **off** — set `enabled: false`. +- **pause** — set `paused: true`, keeping `enabled` as it is. +- **resume** — set `paused: false`. + +Change only the keys involved; leave the rest of the file untouched. + +After any change, confirm the new state in one line and say when it takes +effect — the recorder writes at the end of each assistant turn, so a change +applies from the next turn on. + +If asked to remove a passage from an existing transcript, do not do it silently: +say that an edited transcript no longer proves anything, and that pausing +before a sensitive passage is the tool for that. Delete a whole transcript if +the developer asks; never quietly alter one. diff --git a/.claude/convention-guard.json b/.claude/convention-guard.json new file mode 100644 index 00000000..809a8dfb --- /dev/null +++ b/.claude/convention-guard.json @@ -0,0 +1,6 @@ +{ + "_notes": { + "disabled": "Check ids to switch off: dev-dockerfile-derives, ci-is-a-thin-caller, makefile-delegates, no-committed-env, generated-api-docs, generated-erd, python-single-manifest" + }, + "disabled": [] +} diff --git a/.claude/folder-readme.json b/.claude/folder-readme.json new file mode 100644 index 00000000..3d86e064 --- /dev/null +++ b/.claude/folder-readme.json @@ -0,0 +1,10 @@ +{ + "_notes": { + "enforce": "true blocks creating a file in an undocumented folder; false warns", + "roots": "Guard only under these paths, relative to the repo root. [] guards everywhere.", + "skip": "Extra folders to ignore, on top of the built-in list (.git, .claude, node_modules, .venv, dist, build, …)" + }, + "enforce": true, + "roots": [], + "skip": [] +} diff --git a/.claude/hooks/convention-guard.cjs b/.claude/hooks/convention-guard.cjs new file mode 100755 index 00000000..376658f9 --- /dev/null +++ b/.claude/hooks/convention-guard.cjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * Convention guard — PreToolUse (Write|Edit|MultiEdit). + * + * Checks the invariants whose moment of enforcement is identifiable at write + * time. Everything here WARNS on stderr and lets the write through, except the + * checks marked `block`, which are invariants whose violation is never + * intentional. + * + * Config: .claude/convention-guard.json { "disabled": ["check-id"] } + */ +"use strict"; +const fs = require("fs"); +const path = require("path"); + +const CHECKS = [ + { + id: "dev-dockerfile-derives", + block: true, + match: (p) => /(^|[\/\\])Dockerfile\.(dev|test|ci)$/.test(p), + run: (text) => { + const m = text.match(/^\s*FROM\s+(?!base\b)(?!\$\{?BASE)([^\s]+:[^\s]+)/mi); + return m && `Dockerfile.dev repeats a pinned base image (\`FROM ${m[1]}\`).\n` + + `It must derive from the production Dockerfile — \`FROM base\`, with 'base' ` + + `supplied as a named build context. Repeating the FROM line IS the drift ` + + `the split exists to prevent: bump one, forget the other, and dev stops ` + + `mirroring prod silently. See .claude/rules/docker.md.`; + }, + }, + { + id: "ci-is-a-thin-caller", + block: false, + match: (p) => /\.github[\/\\]workflows[\/\\].*\.ya?ml$/.test(p) && !/release/.test(p), + run: (text) => { + if (/\bmake\s+ci\b/.test(text)) return null; + return `This workflow does not call \`make ci\`.\n` + + `A workflow that lists its own steps is a second definition of "green", ` + + `and it drifts from the local one exactly the way two Dockerfiles drift. ` + + `Make it a thin caller. See .claude/rules/ci.md.`; + }, + }, + { + id: "makefile-delegates", + block: false, + match: (p) => /(^|[\/\\])(Makefile|.*\.mk)$/.test(p), + run: (text) => { + const offenders = []; + for (const line of text.split("\n")) { + if (!/^\t/.test(line)) continue; // recipe lines only + if (/\$\(|docker|compose|@?echo|grep|sed|mkdir|rm\b/.test(line)) continue; + const m = line.match(/^\t@?\s*(pytest|ruff|black|mypy|npm|npx|pnpm|yarn|pip|poetry|uv|mkdocs|go|cargo)\b/); + if (m) offenders.push(m[1]); + } + if (!offenders.length) return null; + return `Recipe invokes ${[...new Set(offenders)].join(", ")} directly.\n` + + `A target never does the work itself — it delegates to compose, which runs ` + + `it in the image. Running it here runs it on the host. ` + + `See .claude/rules/makefile.md.`; + }, + }, + { + id: "python-single-manifest", + block: true, + // Creating one is the violation; an existing file is pre-existing debt. + match: (p) => /(^|[\/\\])(requirements[^\/\\]*\.txt|setup\.py|setup\.cfg|Pipfile)$/.test(p), + run: (text, abs) => fs.existsSync(abs) ? null : + `pyproject.toml is the only Python manifest.\n` + + `Runtime dependencies go in [project].dependencies, tooling in ` + + `[dependency-groups] (PEP 735), the build backend in [build-system] — ` + + `hatchling, not setuptools. Need the lock in another format? ` + + `\`uv export --format pylock.toml\`. A project with two manifests has two ` + + `answers to what it needs, and they diverge. See .claude/rules/python.md.`, + }, + { + id: "no-committed-env", + block: true, + match: (p) => /(^|[\/\\])\.env(\.[A-Za-z0-9_-]+)?$/.test(p) && !/\.example$/.test(p), + run: () => `Writing a \`.env\` file.\n` + + `Configuration comes from the environment, never from a committed file. ` + + `Put the variable in \`.env.example\` with a safe placeholder instead. ` + + `See .claude/rules/environment.md.`, + }, + { + id: "generated-api-docs", + block: true, + match: (p) => /(^|[\/\\])docs[\/\\]api[\/\\]/.test(p), + run: () => `\`docs/api/\` is generated from docstrings and never hand-edited.\n` + + `Fix the docstring instead; the next build would overwrite this anyway. ` + + `See .claude/rules/mkdocs.md.`, + }, + { + id: "generated-erd", + block: true, + match: (p) => /(^|[\/\\])docs[\/\\]data-model[\/\\]erd\.md$/.test(p), + run: () => `\`docs/data-model/erd.md\` is generated from the models.\n` + + `Change the model or the migration, then regenerate with the ` + + `data-model-map skill. A hand-drawn diagram stops matching the schema ` + + `silently, which is worse than no diagram. See .claude/rules/database.md.`, + }, +]; + + +/** + * Where a hook's configuration lives. + * + * Mono-repo: the project's own .claude/. Workspace: the devkit repository, + * reached through CLAUDE_PLUGIN_ROOT — the doctrine lives in one repository, so + * its numbers do too, and nothing is duplicated at the workspace root. + * The project copy wins when both exist, so a single repository can override. + */ +function resolveConfig(fileName, projectDir) { + const candidates = [ + path.join(projectDir, ".claude", fileName), + process.env.CLAUDE_PLUGIN_ROOT + ? path.join(process.env.CLAUDE_PLUGIN_ROOT, fileName) + : null, + ].filter(Boolean); + return candidates.find((p) => fs.existsSync(p)) ?? candidates[0]; +} + +function written(input) { + const parts = []; + if (typeof input?.content === "string") parts.push(input.content); + if (typeof input?.new_string === "string") parts.push(input.new_string); + for (const e of input?.edits ?? []) { + if (typeof e?.new_string === "string") parts.push(e.new_string); + } + return parts.join("\n"); +} + +let raw = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (c) => (raw += c)); +process.stdin.on("end", () => { + let event; + try { event = JSON.parse(raw); } catch { process.exit(0); } + if (!/^(Write|Edit|MultiEdit)$/.test(event.tool_name ?? "")) process.exit(0); + + const filePath = event.tool_input?.file_path; + if (!filePath) process.exit(0); + + const projectDir = process.env.CLAUDE_PROJECT_DIR || event.cwd || process.cwd(); + let disabled = []; + try { + disabled = JSON.parse(fs.readFileSync( + resolveConfig("convention-guard.json", projectDir), "utf8")).disabled ?? []; + } catch { /* no config */ } + + const rel = path.relative(projectDir, path.resolve(projectDir, filePath)); + let text = null; + const warnings = []; + + for (const check of CHECKS) { + if (disabled.includes(check.id) || !check.match(rel)) continue; + if (text === null) { + text = written(event.tool_input); + // An Edit sees only its fragment; read the file for whole-file checks. + if (!text && fs.existsSync(filePath)) text = fs.readFileSync(filePath, "utf8"); + } + const problem = check.run(text ?? "", path.resolve(projectDir, filePath)); + if (!problem) continue; + if (check.block) { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: `🚧 ${check.id}: ${problem}`, + }, + }) + "\n"); + process.exit(0); + } + warnings.push(`⚠️ ${check.id}: ${problem}`); + } + if (warnings.length) process.stderr.write(warnings.join("\n\n") + "\n"); + process.exit(0); +}); diff --git a/.claude/hooks/folder-readme-guard.cjs b/.claude/hooks/folder-readme-guard.cjs new file mode 100755 index 00000000..39cc2afc --- /dev/null +++ b/.claude/hooks/folder-readme-guard.cjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Folder README guard — PreToolUse (Write|Edit|MultiEdit). + * + * Every meaningful folder carries a README.md: role, structure, what belongs + * there and what does NOT, and the rules that govern it. This hook BLOCKS + * writing a file into an undocumented folder. + * + * The moment a folder is born is the only moment anyone still knows what it + * was for. Written later, the README is archaeology; written then, it is a + * decision. That is why this blocks instead of warning. + * + * Config: .claude/folder-readme.json + * { "enforce": true, // false => warn without blocking + * "roots": [], // only guard under these paths ([] = all) + * "skip": ["extra/dir"] } // additional folders to ignore + */ +"use strict"; +const fs = require("fs"); +const path = require("path"); + +const SKIP_SEGMENTS = new Set([ + ".git", ".github", ".claude", "node_modules", ".venv", "venv", "__pycache__", + "dist", "build", "site", ".mypy_cache", ".pytest_cache", ".ruff_cache", + "coverage", "htmlcov", "target", "vendor", ".next", ".idea", ".vscode", +]); + +// Files that may land in an undocumented folder: the README itself, and the +// scaffolding that has no folder of its own. +const ALWAYS_ALLOWED = new Set([ + "README.md", "readme.md", "__init__.py", ".gitkeep", ".gitignore", +]); + + +/** + * Where a hook's configuration lives. + * + * Mono-repo: the project's own .claude/. Workspace: the devkit repository, + * reached through CLAUDE_PLUGIN_ROOT — the doctrine lives in one repository, so + * its numbers do too, and nothing is duplicated at the workspace root. + * The project copy wins when both exist, so a single repository can override. + */ +function resolveConfig(fileName, projectDir) { + const candidates = [ + path.join(projectDir, ".claude", fileName), + process.env.CLAUDE_PLUGIN_ROOT + ? path.join(process.env.CLAUDE_PLUGIN_ROOT, fileName) + : null, + ].filter(Boolean); + return candidates.find((p) => fs.existsSync(p)) ?? candidates[0]; +} + +function loadConfig(projectDir) { + const f = resolveConfig("folder-readme.json", projectDir); + const base = { enforce: true, roots: [], skip: [] }; + try { + return Object.assign(base, JSON.parse(fs.readFileSync(f, "utf8"))); + } catch { + return base; + } +} + +function deny(reason) { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + }) + "\n"); + process.exit(0); +} + +let raw = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (c) => (raw += c)); +process.stdin.on("end", () => { + let event; + try { event = JSON.parse(raw); } catch { process.exit(0); } + + if (!/^(Write|Edit|MultiEdit)$/.test(event.tool_name ?? "")) process.exit(0); + + const filePath = event.tool_input?.file_path; + if (!filePath) process.exit(0); + + const projectDir = process.env.CLAUDE_PROJECT_DIR || event.cwd || process.cwd(); + const cfg = loadConfig(projectDir); + + const abs = path.resolve(projectDir, filePath); + const rel = path.relative(projectDir, abs); + // Outside the project, or the project root itself: not our business. + if (rel.startsWith("..") || path.isAbsolute(rel)) process.exit(0); + + const dir = path.dirname(abs); + const dirRel = path.relative(projectDir, dir); + if (dirRel === "" || dirRel === ".") process.exit(0); // repo root + + const segments = dirRel.split(path.sep); + if (segments.some((s) => SKIP_SEGMENTS.has(s) || s.startsWith("."))) process.exit(0); + if (cfg.skip.some((s) => dirRel === s || dirRel.startsWith(s + path.sep))) process.exit(0); + if (cfg.roots.length && + !cfg.roots.some((r) => dirRel === r || dirRel.startsWith(r + path.sep))) { + process.exit(0); + } + + if (ALWAYS_ALLOWED.has(path.basename(abs))) process.exit(0); + if (fs.existsSync(path.join(dir, "README.md"))) process.exit(0); + + // Editing a file that already exists in an undocumented folder is pre-existing + // debt, not something this change introduced. Warn, never block. + const isNewFile = !fs.existsSync(abs); + const message = + `Folder '${dirRel}/' has no README.md.\n\n` + + `Write '${dirRel}/README.md' first — role, structure, what belongs here, ` + + `what must NOT, and the rules that govern it. Use /folder-readme to scaffold it.\n\n` + + `The moment a folder is born is the only moment anyone still knows what it ` + + `was for. Written later it is archaeology.`; + + if (!isNewFile || !cfg.enforce) { + process.stderr.write("⚠️ " + message + "\n"); + process.exit(0); + } + deny("📁 FOLDER README GUARD: " + message); +}); diff --git a/.claude/hooks/secret-scanner.cjs b/.claude/hooks/secret-scanner.cjs new file mode 100755 index 00000000..f2b9edca --- /dev/null +++ b/.claude/hooks/secret-scanner.cjs @@ -0,0 +1,287 @@ +#!/usr/bin/env node +/** + * Secret Scanner — PreToolUse hook. + * + * Two nets: + * 1. Write/Edit/MultiEdit — scans the content about to be written. + * 2. Bash `git commit` — scans staged files via `git diff --cached`. + * + * Hook type: PreToolUse — target: Write|Edit|MultiEdit|Bash + * Exit code 2 blocks the action with the message on stdout. + * + * Configuration: + * SS_ALLOWLIST_FILE - path to JSON allowlist (default: .claude/secret-scanner-allowlist.json) + * SS_EXTRA_PATTERNS - JSON array of additional regex strings to check + * + * Allowlist format: + * { "patterns": ["regex1", "regex2"], "files": ["path/to/file"] } + * + * Pragma to skip a line: + * # nosecret + * // nosecret + */ + +"use strict"; + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +// ── Secret patterns ─────────────────────────────────────────────────────────── +const BUILTIN_PATTERNS = [ + // Anthropic + { name: "Anthropic API key", pattern: /sk-ant-[a-zA-Z0-9\-_]{20,}/ }, + // OpenAI + { name: "OpenAI API key", pattern: /sk-[a-zA-Z0-9]{20,}(?!ant-)/ }, + // AWS + { name: "AWS Access Key ID", pattern: /AKIA[0-9A-Z]{16}/ }, + { + name: "AWS Secret Access Key", + pattern: + /(?i:aws.{0,20}secret.{0,20})['"=:\s]+([A-Za-z0-9\/+=]{40})\b/, + }, + // GitHub + { + name: "GitHub token", + pattern: /gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{82}/, + }, + // Slack + { name: "Slack token", pattern: /xox[baprs]-[0-9A-Za-z\-]{10,}/ }, + // Stripe + { name: "Stripe secret key", pattern: /sk_live_[0-9a-zA-Z]{24,}/ }, + { + name: "Stripe restricted key", + pattern: /rk_live_[0-9a-zA-Z]{24,}/, + }, + // JWT (with real payload — skip short test tokens) + { + name: "JWT token", + pattern: /eyJ[A-Za-z0-9\-_]{10,}\.eyJ[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_]{10,}/, + }, + // PEM / RSA private key + { name: "PEM private key", pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY/ }, + // Generic high-entropy secrets (loose heuristic, last resort) + { + name: "Generic secret assignment", + pattern: + /(?:password|passwd|secret|token|api_?key|auth_?key)\s*[:=]\s*['"][A-Za-z0-9\/+=\-_.]{16,}['"]/i, + }, +]; + +// ── Configuration ────────────────────────────────────────────────────────────── +function loadAllowlist() { + const allowlistPath = + process.env.SS_ALLOWLIST_FILE ?? + (function () { + const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const local = path.join(dir, ".claude", "secret-scanner-allowlist.json"); + if (fs.existsSync(local)) return local; + return process.env.CLAUDE_PLUGIN_ROOT + ? path.join(process.env.CLAUDE_PLUGIN_ROOT, "secret-scanner-allowlist.json") + : local; + })(); + try { + const raw = fs.readFileSync(allowlistPath, "utf8"); + return JSON.parse(raw); + } catch { + return { patterns: [], files: [] }; + } +} + +function loadExtraPatterns() { + try { + const extra = JSON.parse(process.env.SS_EXTRA_PATTERNS ?? "[]"); + return extra.map((p, i) => ({ + name: `extra-pattern-${i}`, + pattern: new RegExp(p), + })); + } catch { + return []; + } +} + +// ── Scanning logic ───────────────────────────────────────────────────────────── +function getStagedDiff() { + try { + return execSync("git diff --cached --unified=0", { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + } catch { + return ""; + } +} + +function scanDiff(diff, allPatterns, allowlist) { + const findings = []; + const allowedPatternRegexes = (allowlist.patterns ?? []).map( + (p) => new RegExp(p) + ); + + const lines = diff.split("\n"); + let currentFile = ""; + + for (const line of lines) { + // Track current file + if (line.startsWith("+++ b/")) { + currentFile = line.slice(6).trim(); + continue; + } + // Only scan added lines + if (!line.startsWith("+") || line.startsWith("+++")) continue; + + // Skip pragma + if (/nosecret/i.test(line)) continue; + + // Skip allowlisted files + if ((allowlist.files ?? []).some((f) => currentFile.includes(f))) continue; + + const content = line.slice(1); // remove leading + + + for (const { name, pattern } of allPatterns) { + if (pattern.test(content)) { + // Check allowlist patterns + if (allowedPatternRegexes.some((ap) => ap.test(content))) continue; + + findings.push({ + file: currentFile, + rule: name, + line: content.trim().slice(0, 120), + }); + break; // one finding per line is enough + } + } + } + return findings; +} + +// ── Write-time scanning ─────────────────────────────────────────────────────── +/** + * Extract the text a Write/Edit/MultiEdit call is about to put on disk. + */ +function extractWrittenContent(toolInput) { + const parts = []; + if (typeof toolInput?.content === "string") parts.push(toolInput.content); + if (typeof toolInput?.new_string === "string") parts.push(toolInput.new_string); + for (const e of toolInput?.edits ?? []) { + if (typeof e?.new_string === "string") parts.push(e.new_string); + } + return parts.join("\n"); +} + +/** + * Scan raw content (not a diff) for secrets. Same allowlist and pragma rules. + */ +function scanContent(content, filePath, allPatterns, allowlist) { + const findings = []; + if ((allowlist.files ?? []).some((f) => filePath.includes(f))) return findings; + const allowedPatternRegexes = (allowlist.patterns ?? []).map((p) => new RegExp(p)); + + for (const line of content.split("\n")) { + if (/nosecret/i.test(line)) continue; + for (const { name, pattern } of allPatterns) { + if (pattern.test(line)) { + if (allowedPatternRegexes.some((ap) => ap.test(line))) continue; + findings.push({ file: filePath, rule: name, line: line.trim().slice(0, 120) }); + break; + } + } + } + return findings; +} + +/** + * Emit findings and block the tool call. + */ +function blockWith(findings, headline) { + const out = [ + headline, + "Remove the secret, or mark the line with a 'nosecret' comment (test", + "fixtures only), or allowlist it in .claude/secret-scanner-allowlist.json.", + "", + ]; + for (const f of findings) { + out.push(` [${f.rule}] in ${f.file}`); + out.push(` \u2192 ${f.line}`); + } + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: out.join("\n"), + }, + }) + "\n" + ); + process.exit(0); +} + +// ── Hook entry point ────────────────────────────────────────────────────────── +let raw = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => (raw += chunk)); +process.stdin.on("end", () => { + let event = {}; + try { + event = JSON.parse(raw); + } catch { + process.exit(0); + } + + const toolName = event?.tool_name ?? ""; + const toolInput = event?.tool_input ?? {}; + const cmd = toolInput?.command ?? toolInput?.input ?? ""; + + const isWrite = /^(Write|Edit|MultiEdit|NotebookEdit)$/.test(toolName); + const isCommit = toolName === "Bash" && /\bgit\s+commit\b/.test(cmd); + + if (!isWrite && !isCommit) { + process.exit(0); + } + + const allowlist = loadAllowlist(); + const allPatterns = [...BUILTIN_PATTERNS, ...loadExtraPatterns()]; + + // Net 1: content about to be written. + if (isWrite) { + const content = extractWrittenContent(toolInput); + if (!content) process.exit(0); + const filePath = toolInput?.file_path ?? toolInput?.notebook_path ?? "(unknown)"; + const findings = scanContent(content, filePath, allPatterns, allowlist); + if (findings.length === 0) process.exit(0); + blockWith(findings, "\uD83D\uDD10 SECRET SCANNER: refusing to write a secret to disk."); + } + + // Net 2: staged diff at commit time. + const diff = getStagedDiff(); + + if (!diff) { + process.exit(0); + } + + const findings = scanDiff(diff, allPatterns, allowlist); + + if (findings.length === 0) { + process.exit(0); + } + + // Build a readable report + const lines = [ + "🔐 SECRET SCANNER: Potential secrets detected in staged files.", + "Commit blocked. Review the findings below and either:", + " 1. Remove the secret from the file", + " 2. Add 'nosecret' comment on the line (for test fixtures only)", + " 3. Add the file/pattern to .claude/secret-scanner-allowlist.json", + "", + ]; + for (const f of findings) { + lines.push(` [${f.rule}] in ${f.file}`); + lines.push(` → ${f.line}`); + } + lines.push(""); + + process.stdout.write( + JSON.stringify({ type: "result", output: lines.join("\n") }) + "\n" + ); + process.exit(2); +}); diff --git a/.claude/hooks/session-recorder.py b/.claude/hooks/session-recorder.py new file mode 100755 index 00000000..f7afeb8a --- /dev/null +++ b/.claude/hooks/session-recorder.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Session recorder — renders the live transcript to a readable Markdown file. + +Wired to three events in settings.json: + + SessionStart emits a digest of the previous session as additional context, + so a developer returning to the project is back in context + without re-reading anything. + PostToolUse re-renders while a turn is still running, at most once every + `min_interval_seconds`. A long turn would otherwise write + nothing for its whole duration. + Stop re-renders after every assistant turn, unthrottled. + SessionEnd final render. + +Reads the real transcript JSONL rather than reconstructing from hook payloads: +assistant prose appears in neither UserPromptSubmit nor PostToolUse, so a +reconstruction would silently lose half the exchange. + +What comes out is the **conversation**: what the human typed and what Claude put +on the screen, in order. Reasoning, tool calls and their results are left out — +they are how the answer was produced, not the answer, and they bury the exchange +a reader came for. + +Configuration: .claude/session-recording.json + { "enabled": true, "paused": false, "output_dir": "docs/sessions", + "redact": ["extra regex"], "digest_lines": 60 } +""" +from __future__ import annotations +import json, os, re, sys, time +from datetime import datetime, timezone +from pathlib import Path + +DEFAULTS = { + "enabled": False, + "paused": False, + "output_dir": "docs/sessions", + "redact": [], + "digest_lines": 60, + "min_interval_seconds": 120, +} + +REDACT = [ + (re.compile(r"sk-ant-[A-Za-z0-9\-_]{20,}"), "[redacted:anthropic-key]"), + (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[redacted:aws-key-id]"), + (re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}"), "[redacted:github-token]"), + (re.compile(r"xox[baprs]-[0-9A-Za-z\-]{10,}"), "[redacted:slack-token]"), + (re.compile(r"eyJ[A-Za-z0-9\-_]{10,}\.eyJ[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_]{10,}"), + "[redacted:jwt]"), + (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), "[redacted:private-key]"), +] + + +def load_config(project: Path) -> dict: + """Project config first, then the devkit reached via CLAUDE_PLUGIN_ROOT. + + In a workspace the doctrine lives in one repository, so its settings do + too — nothing is duplicated at the workspace root. + """ + cfg = dict(DEFAULTS) + candidates = [project / ".claude" / "session-recording.json"] + if root := os.environ.get("CLAUDE_PLUGIN_ROOT"): + candidates.append(Path(root) / "session-recording.json") + for f in candidates: + if f.is_file(): + try: + cfg.update(json.loads(f.read_text())) + except json.JSONDecodeError: + pass + break + return cfg + + +def scrub(text: str, extra: list[str]) -> str: + for pattern, repl in REDACT: + text = pattern.sub(repl, text) + for raw in extra: + try: + text = re.sub(raw, "[redacted]", text) + except re.error: + pass + return text + + +def blocks(content) -> list[dict]: + return content if isinstance(content, list) else [] + + +SYSTEM_NOISE = re.compile( + r"<(system-reminder|command-name|command-message|command-args|" + r"local-command-stdout|local-command-stderr)>.*?", + re.S) + + +def spoken(row: dict) -> tuple[str, str] | None: + """What was actually said, or None for anything that was not speech. + + A transcript is the conversation: what the human typed and what Claude put + on the screen. Not the reasoning, not the tool calls, not the results they + returned — those are how the answer was produced, not the answer. + """ + if row.get("type") not in ("user", "assistant"): + return None + if row.get("isSidechain"): # a subagent's conversation, not this one + return None + + message = row.get("message") or {} + role = message.get("role") + content = message.get("content") + + if role == "user": + if not isinstance(content, str): + # A list here is tool results being handed back, not the human. + texts = [b.get("text", "") for b in blocks(content) + if b.get("type") == "text"] + if not texts: + return None + content = "\n".join(texts) + text = SYSTEM_NOISE.sub("", content).strip() + return ("Human", text) if text else None + + if role == "assistant": + # `thinking` is reasoning and `tool_use` is machinery; neither reached + # the screen. + texts = [b["text"] for b in blocks(content) + if b.get("type") == "text" and b.get("text", "").strip()] + return ("Claude", "\n\n".join(t.strip() for t in texts)) if texts else None + + return None + + +def render(transcript: Path, cfg: dict) -> str: + turns: list[tuple[str, str]] = [] + started = None + + for line in transcript.read_text(errors="replace").splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if started is None and row.get("timestamp"): + started = row["timestamp"] + said = spoken(row) + if said is None: + continue + # One speaker may produce several blocks in a turn; read as one. + if turns and turns[-1][0] == said[0]: + turns[-1] = (said[0], turns[-1][1] + "\n\n" + said[1]) + else: + turns.append(said) + + body = [] + for speaker, text in turns: + icon = "\U0001F9D1" if speaker == "Human" else "\U0001F916" + body.append(f"\n### {icon} {speaker} says\n\n{text}\n") + + header = [ + "", + f"# Session — {started or 'unknown start'}", + "", + f"{len(turns)} turns. What was said, in order: the prompts and the", + "replies as they appeared on screen. The steps taken to produce them —", + "reasoning, tool calls, their results — are deliberately absent.", + "", + "Secrets matching the recorder's patterns are redacted; that is a net,", + "not a guarantee. Read before sharing.", + "", + ] + return scrub("\n".join(header) + "".join(body) + "\n", cfg["redact"]) + + +def digest(out_dir: Path, current: Path, cfg: dict) -> str | None: + files = sorted((p for p in out_dir.glob("*.md") + if p.name != "INDEX.md" and p != current), + key=lambda p: p.stat().st_mtime, reverse=True) + if not files: + return None + tail = files[0].read_text(errors="replace").splitlines()[-cfg["digest_lines"]:] + return (f"Previous session transcript: `{files[0]}`.\n" + "Tail of that session, for context:\n\n" + "\n".join(tail)) + + +def write_index(out_dir: Path) -> None: + rows = sorted((p for p in out_dir.glob("*.md") if p.name != "INDEX.md"), + key=lambda p: p.name, reverse=True) + lines = ["# Session transcripts", "", + "Generated by the session recorder. Newest first.", ""] + lines += [f"- [{p.stem}]({p.name})" for p in rows] + (out_dir / "INDEX.md").write_text("\n".join(lines) + "\n") + + +def main() -> int: + try: + event = json.load(sys.stdin) + except Exception: + return 0 + + project = Path(os.environ.get("CLAUDE_PROJECT_DIR") or event.get("cwd") or ".") + cfg = load_config(project) + if not cfg["enabled"] or cfg["paused"]: + return 0 + + event_name = event.get("hook_event_name") + transcript = event.get("transcript_path") + session = (event.get("session_id") or "unknown")[:8] + out_dir = project / cfg["output_dir"] + out_dir.mkdir(parents=True, exist_ok=True) + target = out_dir / f"{datetime.now().strftime('%Y-%m-%d')}-{session}.md" + + if event_name == "SessionStart": + text = digest(out_dir, target, cfg) + if text: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "SessionStart", "additionalContext": text}})) + return 0 + + if not transcript or not Path(transcript).is_file(): + return 0 + + # Mid-turn renders are throttled; the end of a turn always writes, so the + # file is never more than one turn behind whatever the throttle skipped. + if event_name == "PostToolUse" and target.is_file(): + age = time.time() - target.stat().st_mtime + if age < cfg["min_interval_seconds"]: + return 0 + + target.write_text(render(Path(transcript), cfg)) + write_index(out_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/hooks/verifiable-thresholds.cjs b/.claude/hooks/verifiable-thresholds.cjs new file mode 100755 index 00000000..db320de3 --- /dev/null +++ b/.claude/hooks/verifiable-thresholds.cjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * Verifiable Thresholds checker — PostToolUse hook (file writes). + * + * After Claude writes a file, checks the written content against + * measurable quality thresholds defined in thresholds.json. + * + * Hook type: PostToolUse — target: Write, Edit, MultiEdit + * Does NOT block (informative only — exit 0). Prints warnings to stderr. + * + * Thresholds are defined in .claude/thresholds.json (see example below). + * + * Default thresholds checked: + * - max_function_lines: 50 + * - max_file_lines: 500 + * - max_cyclomatic_complexity (heuristic): 10 + * - no_todo_fixme_in_new_code: true (warn on TODO/FIXME added in writes) + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +// ── Default thresholds ──────────────────────────────────────────────────────── +const DEFAULT_THRESHOLDS = { + max_function_lines: 50, + max_file_lines: 500, + max_cyclomatic_complexity: 10, + no_todo_fixme_in_new_code: true, + min_test_coverage_pct: null, // null = not checked here (CI only) + max_lint_warnings: 0, // informative +}; + +// ── Load project thresholds ─────────────────────────────────────────────────── +function loadThresholds() { + const configPath = + process.env.THRESHOLDS_FILE ?? + (function () { + const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const local = path.join(dir, ".claude", "thresholds.json"); + if (fs.existsSync(local)) return local; + return process.env.CLAUDE_PLUGIN_ROOT + ? path.join(process.env.CLAUDE_PLUGIN_ROOT, "thresholds.json") + : local; + })(); + try { + const raw = fs.readFileSync(configPath, "utf8"); + return { ...DEFAULT_THRESHOLDS, ...JSON.parse(raw) }; + } catch { + return DEFAULT_THRESHOLDS; + } +} + +// ── Heuristic checks ────────────────────────────────────────────────────────── + +/** Count lines in a string */ +function lineCount(content) { + return content.split("\n").length; +} + +/** + * Heuristic cyclomatic complexity: count decision points + * (if, else if, case, while, for, forEach, catch, &&, ||, ??) + */ +function estimateComplexity(content) { + const decisionPoints = [ + /\bif\s*\(/g, + /\belse\s+if\s*\(/g, + /\bcase\s+/g, + /\bwhile\s*\(/g, + /\bfor\s*\(/g, + /\.forEach\s*\(/g, + /\.map\s*\(/g, + /\bcatch\s*\(/g, + /&&|\|\||\?\?/g, + ]; + let count = 1; // base complexity + for (const pattern of decisionPoints) { + const matches = content.match(new RegExp(pattern.source, "g")); + if (matches) count += matches.length; + } + return count; +} + +/** + * Extract function bodies from JS/TS/Python (heuristic, not AST-based). + * Returns array of { name, lineCount } + */ +function extractFunctionLengths(content, ext) { + const results = []; + if ([".js", ".ts", ".cjs", ".mjs", ".jsx", ".tsx"].includes(ext)) { + // Named functions and arrow functions assigned to variables + const funcPattern = + /(?:function\s+(\w+)|const\s+(\w+)\s*=\s*(?:async\s*)?\()/g; + const lines = content.split("\n"); + let match; + while ((match = funcPattern.exec(content)) !== null) { + const name = match[1] ?? match[2] ?? "anonymous"; + // Find start line + const before = content.slice(0, match.index); + const startLine = before.split("\n").length; + // Estimate function end by brace depth + let depth = 0; + let started = false; + let endLine = startLine; + for (let i = startLine - 1; i < lines.length; i++) { + for (const ch of lines[i]) { + if (ch === "{") { depth++; started = true; } + if (ch === "}") depth--; + } + if (started && depth === 0) { endLine = i + 1; break; } + } + results.push({ name, lineCount: endLine - startLine + 1 }); + } + } + if (ext === ".py") { + const lines = content.split("\n"); + let funcStart = -1; + let funcName = ""; + let indent = 0; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^(\s*)def\s+(\w+)/); + if (m) { + if (funcStart >= 0) { + results.push({ name: funcName, lineCount: i - funcStart }); + } + funcStart = i; + funcName = m[2]; + indent = m[1].length; + } + } + if (funcStart >= 0) { + results.push({ name: funcName, lineCount: lines.length - funcStart }); + } + } + return results; +} + +// ── Hook entry point ────────────────────────────────────────────────────────── +let raw = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => (raw += chunk)); +process.stdin.on("end", () => { + let event = {}; + try { + event = JSON.parse(raw); + } catch { + process.exit(0); + } + + // Only act on write tool calls + const toolName = event?.tool_name ?? ""; + if (!["Write", "Edit", "MultiEdit"].includes(toolName)) { + process.exit(0); + } + + // Get the file path that was written + const filePath = + event?.tool_input?.file_path ?? + event?.tool_input?.path ?? + event?.tool_result?.file_path ?? + ""; + + if (!filePath || !fs.existsSync(filePath)) { + process.exit(0); + } + + const ext = path.extname(filePath); + const CODE_EXTS = [".js", ".ts", ".cjs", ".mjs", ".jsx", ".tsx", ".py"]; + if (!CODE_EXTS.includes(ext)) { + process.exit(0); + } + + const thresholds = loadThresholds(); + const content = fs.readFileSync(filePath, "utf8"); + const warnings = []; + + // 1. File length + const fileLines = lineCount(content); + if (thresholds.max_file_lines && fileLines > thresholds.max_file_lines) { + warnings.push( + `FILE TOO LONG: ${filePath} has ${fileLines} lines (max: ${thresholds.max_file_lines})` + ); + } + + // 2. Function lengths + if (thresholds.max_function_lines) { + const funcs = extractFunctionLengths(content, ext); + for (const f of funcs) { + if (f.lineCount > thresholds.max_function_lines) { + warnings.push( + `FUNCTION TOO LONG: ${f.name}() has ~${f.lineCount} lines (max: ${thresholds.max_function_lines})` + ); + } + } + } + + // 3. Cyclomatic complexity heuristic + if (thresholds.max_cyclomatic_complexity) { + const complexity = estimateComplexity(content); + if (complexity > thresholds.max_cyclomatic_complexity) { + warnings.push( + `HIGH COMPLEXITY: ${filePath} estimated complexity ~${complexity} (max: ${thresholds.max_cyclomatic_complexity})` + ); + } + } + + // 4. TODO/FIXME in new code + if (thresholds.no_todo_fixme_in_new_code) { + const todoMatches = content.match(/\b(TODO|FIXME|HACK|XXX)\b/g); + if (todoMatches) { + warnings.push( + `TODO/FIXME present: ${todoMatches.length} marker(s) in ${filePath} — resolve or track in issues` + ); + } + } + + if (warnings.length > 0) { + process.stderr.write( + `[verifiable-thresholds] Warnings for ${path.basename(filePath)}:\n` + ); + for (const w of warnings) { + process.stderr.write(` ⚠ ${w}\n`); + } + } + + process.exit(0); +}); diff --git a/.claude/quality-gate.json b/.claude/quality-gate.json new file mode 100644 index 00000000..c5526a6f --- /dev/null +++ b/.claude/quality-gate.json @@ -0,0 +1,100 @@ +{ + "_notes": { + "_": "The commands that decide whether a change is done. Every command here runs in the container, and every one marked `verified` produced output that was read — a command only written down is a hypothesis (.claude/rules/verification.md).", + "state": "verified = output was seen on this machine; red = runs and fails, for the reason given; pending = not yet exercised", + "_verified_on": "2026-08-29, end of the exercise" + }, + "definition_of_done": [ + "The scenario is listed in docs/coverage.md, on the handled side.", + "The behaviour has a test written by the developer, and it passes.", + "The flow document for the touched feature is true again.", + "make ci is green, seen as output — not inferred from the absence of an error." + ], + "commands": { + "env": { + "command": "make .env", + "state": "verified", + "note": "Creates .env from the committed contract; a fresh clone needs no manual step." + }, + "lock": { + "command": "make lock", + "state": "verified", + "note": "76 packages resolved; uv.lock owned by the caller, not root." + }, + "build": { + "command": "make build", + "state": "verified", + "note": "runtime 441 MB, dev 995 MB. collectstatic runs at build time." + }, + "up": { + "command": "make up", + "state": "verified", + "note": "db and app both reach `healthy`." + }, + "migrate": { + "command": "make migrate", + "state": "verified", + "note": "13 tables created in PostgreSQL 17." + }, + "seed": { + "command": "make seed", + "state": "verified", + "note": "users=10, drivers=5, buses=10, places=30 — confirming the create_data asymmetry documented in docs/flows/existing-system.md." + }, + "check": { + "command": "make check", + "state": "verified", + "note": "`System check identified no issues`. Judges PRODUCTION values: the target overrides the transport-security variables the local .env relaxes." + }, + "migrations-check": { + "command": "make migrations-check", + "state": "verified", + "note": "`No changes detected`." + }, + "docs_build": { + "command": "make docs-build", + "state": "verified", + "note": "mkdocs --strict, output to /tmp/site — nothing generated lands in the source tree." + }, + "docs": { + "command": "make docs", + "state": "verified", + "note": "HTTP 200 on :8001, and its CSS/JS assets return 200 too." + }, + "typecheck": { + "command": "make typecheck", + "state": "red", + "note": "2 errors in padam_django/apps/users/admin.py: is_driver.boolean / .short_description set as function attributes. @admin.display(...) fixes both. Inherited code, left to the developer." + }, + "lint": { + "command": "make lint", + "state": "verified", + "note": "All checks passed! ruff with E,W,F,I,UP,B,DJ,S,C4,SIM,TC,RUF." + }, + "format-check": { + "command": "make format-check", + "state": "verified", + "note": "52 files already formatted." + }, + "test": { + "command": "make test", + "state": "red", + "note": "24 pass, 1 red: the two-stop cardinality rule has no implementation — it lives in the admin inline formset, which is not written. Kept red rather than deleted." + }, + "coverage": { + "command": "make coverage", + "state": "pending", + "note": "Blocked on the same absence." + }, + "ci": { + "command": "make ci", + "state": "red", + "note": "Red on test and typecheck only, each for a reason recorded here. Every other composed gate is green." + } + }, + "verified_by_request": { + "_": "verification.md: every URL handed over is requested, and what it references is requested too.", + "http://localhost:8000/admin/login/": "HTTP 200, 4160 bytes; all 7 referenced /static/ assets HTTP 200 (whitenoise manifest storage)", + "http://localhost:8001/": "HTTP 200, 18673 bytes; referenced CSS and JS assets HTTP 200" + } +} diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md new file mode 100644 index 00000000..95923826 --- /dev/null +++ b/.claude/rules/architecture.md @@ -0,0 +1,18 @@ +# Architecture — hexagonal, always + +Non-negotiable, whatever the size of the project. + +- **The domain depends on nothing.** No framework, no ORM, no HTTP client, no + vendor SDK is imported by business logic. If the domain layer imports it, it + is a defect. +- **Layers:** `domain` (pure) → `application` (ports + use cases) → + `infrastructure` (adapters: DB, HTTP, queues) and `presentation` (API, UI). + Dependencies point inward only. Enforce it mechanically when the language + allows (`import-linter`, ESLint boundaries) — a contract nobody checks drifts. +- **Every external dependency sits behind an adapter.** The port is written in + the domain's vocabulary, not the vendor's: if renaming the vendor changes the + port signature, the port is wrong. +- **One responsibility per file.** One class per file, file named after it. +- Adding a dependency is a decision, not a reflex — see `dependencies.md`. + +Error handling and failure behaviour are governed by `errors.md`. diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md new file mode 100644 index 00000000..f0158c55 --- /dev/null +++ b/.claude/rules/ci.md @@ -0,0 +1,37 @@ +--- +description: Invariants for CI pipelines. Use when writing or reviewing a GitHub Actions workflow, GitLab CI config, or any pipeline definition. +paths: + - "**/.github/workflows/**" + - "**/.gitlab-ci.y*ml" + - "**/.circleci/**" + - "**/Jenkinsfile" +--- + +# CI — the invariants + +Creating the pipeline? Use the `project-scaffold` skill. + +- **What runs in CI locally is a mirror of what runs on the forge, and neither + exists without the other.** There is exactly one definition of "green": + `make ci`, running in the dev image. The workflow is a **thin caller** — + checkout, `make ci`, nothing more. +- A workflow that lists its own steps is a second definition of green. It + drifts exactly the way two Dockerfiles drift, and "it passes locally" becomes + true and meaningless at the same time. +- **A check that cannot be run locally does not exist.** If a CI failure is not + reproducible with one command on your machine, the pipeline is broken. +- No gate is skippable by an environment variable. A flag that turns off a + check exists to be left on by accident. +- Deterministic: no undeclared network, no wall-clock dependence, no shared + mutable state between jobs. A flaky pipeline teaches people to re-run instead + of to read. +- Runs on every pull request **and** on the default branch. A gate that only + runs after merge reports failures too late. +- Cache by lockfile hash, never by branch. Secrets from the forge's store, + never echoed, never exposed to a fork's pull request. + +**The split:** most write-time hooks in `.claude/hooks/` warn; a few block, and +each says which in its `_why` in `settings.json`. CI blocks everything. + +So a rule that matters is a gate in `make ci`. A rule that lives only in +`.claude/rules/` is advice, and advice is followed until it is inconvenient. diff --git a/.claude/rules/class-design.md b/.claude/rules/class-design.md new file mode 100644 index 00000000..1600592d --- /dev/null +++ b/.claude/rules/class-design.md @@ -0,0 +1,26 @@ +--- +description: Method ordering convention for Python classes. Use when writing or reviewing a Python class or dataclass. +paths: + - "**/*.py" +--- + +# Class design — method order + +**Documentation, not enforced.** No linter checks method ordering. This is a +review convention. + +Methods appear in this order, alphabetically within each group: + +1. dunder (`__init__`, `__repr__`, `__eq__`, …) +2. properties (`@property`, `@.setter`) +3. abstract methods (`@abstractmethod`) +4. class methods (`@classmethod`) +5. static methods (`@staticmethod`) +6. public instance methods +7. private instance methods (`_`, `__`) + +Public contract first, private implementation last — so a class's structure is +predictable to read, and it stays easier to keep within the limits in +`.claude/thresholds.json`. + +One class per file, file named after the class. diff --git a/.claude/rules/code-flow.md b/.claude/rules/code-flow.md new file mode 100644 index 00000000..972580d6 --- /dev/null +++ b/.claude/rules/code-flow.md @@ -0,0 +1,32 @@ +# Code flow and coverage — the gold book + +A function is agnostic by nature. It says what it does, never what problem it +answers: that lives in the **chain of calls**. A docstring will never describe a +sequence, and it will never describe a **gap**. Yet the sequence and the gap are +exactly what cost two developers an afternoon in front of a production incident. + +So the documentation site carries three things of different natures, and the +distinction is the whole point: + +| | Source | Answers | +| --- | --- | --- | +| `docs/flows/` | **hand-written** | who calls what, in what order, and on failure | +| `docs/coverage.md` | **hand-written** | what is handled, and above all what is not | +| `docs/api/` | **generated** from docstrings | what one precise function does | + +The schema gets the same treatment under `docs/data-model/` — see +`database.md` and the `data-model-map` skill. + +**Shipping a behaviour without updating its flow and its coverage is not +shipping it.** Like the README, this goes in the same commit, unasked. + +- A flow is written **from the code, by reading it**. A flow written from intent + describes the feature you meant to build. +- An unhandled scenario appears in `docs/coverage.md` **explicitly, with its + reason**. Silence is the failure mode: a scenario absent from the file is + indistinguishable from a scenario nobody thought about. +- **No tool will ever tell you a flow diagram lies.** CI checks that the site + builds and that links resolve; truth is review work. Verify a flow by + re-reading the code it claims to describe. + +Use the `feature-flow` skill to write one, or `/code-flow `. diff --git a/.claude/rules/configuration.md b/.claude/rules/configuration.md new file mode 100644 index 00000000..1b6a2e77 --- /dev/null +++ b/.claude/rules/configuration.md @@ -0,0 +1,18 @@ +# Configuration + +- **Validate the whole configuration at startup**, before serving anything. A + missing or malformed variable fails the boot with a message naming it — never + at the first request that happens to need it, in production, at 3am. +- Configuration is read **once**, into a typed object. No `getenv` scattered + through the code: a value read in three places has three defaults and two + bugs. +- **No default for a secret, and no default that is only correct in + development.** A silent fallback to `localhost` or `debug=true` is how a + wrong value reaches production unnoticed. +- Names are namespaced and explicit: `DATABASE_URL`, `OTEL_EXPORTER_ENDPOINT`. + Every variable appears in `.env.example` with a safe placeholder and a + one-line comment — that file is the contract (`environment.md`). +- Behaviour differences between environments are **values**, not branches. + `if ENV == "prod"` in business code is configuration that leaked. +- A feature flag is temporary and carries its removal condition. A flag with no + owner and no date is permanent complexity. diff --git a/.claude/rules/database.md b/.claude/rules/database.md new file mode 100644 index 00000000..5305b3a0 --- /dev/null +++ b/.claude/rules/database.md @@ -0,0 +1,100 @@ +--- +description: Querying and schema rules. Use when writing SQL, migrations, ORM models, repositories, or anything touching the database. +paths: + - "**/*.sql" + - "**/migrations/**" + - "**/alembic/**" + - "**/models/**" + - "**/model/**" + - "**/repositories/**" + - "**/repository/**" + - "**/entities/**" +--- + +# Database + +## Querying + +- **N+1 is a defect, not a performance detail.** Any loop that triggers a query + per iteration gets rewritten: eager loading (`selectinload` / `joinedload`), + a single join, or one batched `IN` query. Assume every `for` over a relation + is an N+1 until proven otherwise. +- Fetch the columns you use, not the row. No `SELECT *` in application code. +- Every query that can return an unbounded set is paginated. No exception. +- **Read the plan before optimizing.** `EXPLAIN ANALYZE` first, index second. + An index added on a guess is dead weight that slows every write. +- Index what you filter, join, and sort on. A foreign key without an index is + a lock contention waiting to happen. +- Aggregate in the database, not in the application, when the database can. + +## Structure + +- The schema is normalized until a measured read path proves otherwise; a + denormalization is a decision with a written reason. +- Constraints live in the database: `NOT NULL`, `FOREIGN KEY`, `UNIQUE`, `CHECK`. + Application-level validation complements them, never replaces them. +- Explicit types: `timestamptz` never naive datetime, `numeric` for money never + float, an enum or a lookup table never a free string. +- **Migrations are versioned, reversible, and reviewed.** No manual change to a + running database. A migration that cannot be rolled back says so and why. +- A migration touching a large table states its locking behaviour before merge. + +## The schema is documented, and its map is generated + +A schema is the one part of a system where a mistake outlives every refactor: +code is rewritten, data is migrated. It gets the same treatment as the call +chains in `code-flow.md`, with the same split of natures: + +| | Source | Answers | +| --- | --- | --- | +| `docs/data-model/erd.md` | **generated** from the models | which tables exist and how they relate | +| `docs/data-model/README.md` | **hand-written** | what each table means, which context owns it | +| `docs/data-model/invariants.md` | **hand-written** | which business rules the schema enforces, and which it only hopes the application enforces | + +The generated diagram says what *is*; only the hand-written pages say what it +is *for* and where it stops. A relation nobody can explain is a relation nobody +should trust. + +**A migration that changes the shape updates the map in the same commit** — +`documentation.md` applies here without exception. Use the `data-model-map` +skill to regenerate and to reconcile. + +## Modelling smells — name them, never fix them silently + +When reading models or a migration, say so when you see these. They are +judgement calls, not defects: report the candidate with its reasoning and let +the developer decide. Changing a schema on your own initiative is not a +refactor, it is a data migration. + +- **Two entities that are one.** Always created, updated and deleted together, + joined 1:1, and never queried apart. That is one table with more columns. +- **A 1:1 table that is an attribute group.** Optional profile-style fields + split into their own table buy a join on every read and guarantee nothing. +- **Tables that differ only by a type column.** Two near-identical tables where + one plus an enum would do — or the reverse, one table whose rows mean three + different things depending on a discriminator, half its columns always null. +- **A nullable foreign key used as a discriminator** — polymorphic association + by hand. The database can enforce nothing about it. +- **A foreign key that crosses a bounded context.** Ownership belongs to one + context; the other holds an id, without a constraint. A constraint across + contexts couples their deployments and their migrations. +- **A denormalised parent id** that duplicates a path already reachable by + join. Keep it only where a measured read path justifies it, and say where. +- **A join table with no extra column** for a relation that is really 1:N. +- **A foreign key to a lookup table with a single natural key** and no rows + that ever change: an enum or a check constraint says the same thing without + the join. +- **Foreign keys everywhere, defensively** — a constraint added because it felt + safe, coupling deletion paths nobody has thought through. Every FK implies a + delete rule; if nobody chose it, it is not a decision. +- **The reverse: integrity enforced only in the application.** A rule the + database could hold as a constraint but does not is a rule that will be + broken by the next script, import or console session. +- **An index per column, added defensively.** Dead weight that slows every + write. See the querying rules above. + +## Boundaries + +- SQL and ORM live in the infrastructure layer only. The domain speaks + repositories, never sessions, queries, or connections. +- Parameterized queries only — see `security.md`. diff --git a/.claude/rules/dependencies.md b/.claude/rules/dependencies.md new file mode 100644 index 00000000..17ab9fc2 --- /dev/null +++ b/.claude/rules/dependencies.md @@ -0,0 +1,52 @@ +--- +description: Dependency policy. Use when adding, pinning, updating, or auditing a dependency, or editing a manifest or lockfile. +paths: + - "**/pyproject.toml" + - "**/requirements*.txt" + - "**/package.json" + - "**/go.mod" + - "**/Cargo.toml" + - "**/*.lock" + - "**/lock.json" + - "**/package-lock.json" + - "**/renovate.json*" + - "**/dependabot.y*ml" +--- + +# Dependencies + +## Adding one + +A dependency is a decision, not a reflex. Before adding it, answer: is it +maintained (last release, open issue age), what is its own dependency tree, what +is its licence, and what would replacing it cost. A package pulled in for one +helper function is a supply-chain surface bought for a convenience. + +It goes behind an adapter, never imported by the domain — see +`architecture.md`. + +## Pinning + +- **The lockfile is the source of truth**, committed, and never hand-edited. + The manifest states intent; the lockfile states reality. +- **One manifest per project.** In Python that is `pyproject.toml` and nothing + else — no `requirements.txt`, no `setup.py`. Two manifests give two answers + to the same question. See `python.md`. +- Application dependencies are pinned exactly. A library's are ranged — an + application that floats reproduces nothing. +- Install from the lockfile in CI and in images (`--frozen`, `npm ci`, + `--locked`). An install that resolves at build time makes the image + non-reproducible, which breaks `environment.md`. +- Regenerating a lockfile is its own commit, never folded into a feature. + +## Keeping them + +- Updates are **scheduled and automated** (Renovate, Dependabot), grouped, and + reviewed like any change. Updating only when something breaks means updating + everything at once, under pressure. +- A vulnerability scan runs as a CI gate. A known CVE with a released fix is a + bug in the current sprint, not a backlog item. +- Licences are checked automatically against an allowlist. Discovering a + copyleft transitive dependency at release time is discovering it too late. +- A dependency nothing imports is removed. Dead dependencies are attack surface + that nobody reviews. diff --git a/.claude/rules/docker.md b/.claude/rules/docker.md new file mode 100644 index 00000000..16849dbf --- /dev/null +++ b/.claude/rules/docker.md @@ -0,0 +1,54 @@ +--- +description: Container invariants. Use when writing or reviewing a Dockerfile, compose file, bake file, or .dockerignore. +paths: + - "**/Dockerfile*" + - "**/*.dockerfile" + - "**/compose*.y*ml" + - "**/docker-compose*.y*ml" + - "**/docker-bake.*" + - "**/.dockerignore" +--- + +# Docker — the invariants + +Creating these files? Use the `project-scaffold` skill — they are one system +with the Makefile and the CI workflow, and writing them separately is the drift +this rule exists to prevent. + +- **The production Dockerfile stays pure.** It describes a production artefact + and must not know a development environment exists. No dev stage, no test + framework, no linter, no docs toolchain. +- **`Dockerfile.dev` derives from it** and must not repeat the + `FROM :` line — the `convention-guard` hook blocks it. + Repeating it *is* the drift, wherever it lives. The derivation is declared + as a build graph (bake `contexts`, compose `additional_contexts`), not + resolved through a tag. +- Derive from `runtime` (dev = prod plus tools) or from `deps` (source is + bind-mounted anyway). **Never from `base`** — that skips the dependency + layer, so dev and prod can resolve different versions. +- **Services, not images.** Distinct processes share one image with different + commands. A second image because a second process exists is how a project + ends up with four bases. +- Non-root user created in `base`, so every derived image inherits it. + +## The image serves what it references + +The production image is complete on its own: no manual step, no writable +volume, no second service needed to make it serve. + +- **Assets are collected, compiled and fingerprinted at build time**, never at + start-up. An image that prepares itself when it boots fails differently on + every restart, and twice at once behind a load balancer. +- **A development server's conveniences are not a runtime.** Everything it + served for free — static files, autoreload, verbose errors — needs an + explicit answer in the image, or it is missing in production only, which is + the worst place to find out. +- Values needed only at build time are `ARG`, never `ENV`: an `ENV` persists + into the running container, and neither carries a secret since build + arguments stay readable in the image history. +- **Verify by requesting the page**, not by listing the directory that should + contain it — see `verification.md`. +- **No secret in an image, and none in a build argument** — build args are + visible in image history. +- Layers ordered by rate of change; a real `.dockerignore`; healthchecks with + `depends_on` waiting on health, not on start. diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md new file mode 100644 index 00000000..cd8a52e6 --- /dev/null +++ b/.claude/rules/documentation.md @@ -0,0 +1,39 @@ +# Documentation + +## Documentation ships with the change + +A change is not finished until the documentation describing it is true again. +Stale documentation is worse than none: it lies with authority. + +In the **same commit** as the code, update whatever the change invalidated: +the repo `README.md`, the touched folder's `README.md`, the command table, the +environment variables, the architecture notes. Never "later", and never only +when asked. + +## Every meaningful folder carries a README + +When creating a folder — or doing substantial work in one that has no +`README.md` — add one. It says what the folder is for, what belongs in it, what +must never land there and where that goes instead, and the conventions that +govern it. `/folder-readme` carries the full structure. + +Skip: the repo root, generated or vendored directories, empty placeholders. +Do not churn — only document a folder you are already working in. Never open a +mass-README change unless asked. Use `/folder-readme` to scaffold one. + +**This one is enforced.** The `folder-readme-guard` hook refuses to create a +file in a folder that has no `README.md`. Editing a file that is already there +only warns — that debt predates your change. Configure it in +`.claude/folder-readme.json`. + +## The documentation site + +Prose documentation lives in a MkDocs site under `docs/` — see `mkdocs.md` for +its structure and `code-flow.md` for what a feature must leave behind there. +The folder READMEs above and the site are complementary: a README says what a +directory is, the site says what the system does. + +## Comments + +Document what the reader cannot deduce from the signature. Restating the type +in prose is noise. Every non-obvious constraint, unit, or invariant gets said. diff --git a/.claude/rules/environment.md b/.claude/rules/environment.md new file mode 100644 index 00000000..d846aa1e --- /dev/null +++ b/.claude/rules/environment.md @@ -0,0 +1,42 @@ +# Environment — iso-prod, reproducible anywhere + +**"It works on my machine" must never happen.** The project runs on any machine +that has a container runtime, with no undocumented local step. + +## Nothing runs on the host + +Build, tests, lint, type-check, migrations, the application, **and the +documentation — its generation and its serving** — all run inside a container. +A command in the README that must be run on the host is a defect. + +The host installs one thing: a container runtime. No language runtime, no +package manager, no toolchain is a prerequisite for working on this project. +When proposing a command, propose the one that runs in the container. + +**Including when you are only checking.** A version check, a one-off script +"just to see", a quick import to confirm something — in the container like +everything else. Interpreters leave artefacts owned by the host user +(`generated-artifacts.md`), and they answer with the host's versions, which are +not the project's. A verification that runs somewhere else verifies nothing, +and litters the tree on its way out. + +## Parity and pinning + +- **The production Dockerfile stays pure**, and the dev/test image **derives** + from it rather than repeating it. Never two parallel build definitions that + must be kept in sync by hand. Details in `docker.md`. +- Same base image family and same major versions of runtime, database, and + services in dev and prod. A database that is SQLite locally and Postgres in + production is a bug waiting for its first migration. +- Versions pinned, lockfiles committed, no floating `latest` tag. +- **Configuration comes from the environment**, never from a committed file. + Ship a `.env.example` listing every variable; ship no `.env` — writing one + is blocked by the `convention-guard` hook. + +## One command + +`make up` works from a fresh clone with no manual preparation. The Makefile is +the only human interface: it drives compose, which runs the image built from +the Dockerfile — see `makefile.md`. Every routine task is a target, `make docs` +included. Nothing required to run this project lives only in someone's shell +history. diff --git a/.claude/rules/errors.md b/.claude/rules/errors.md new file mode 100644 index 00000000..68c184b7 --- /dev/null +++ b/.claude/rules/errors.md @@ -0,0 +1,39 @@ +# Errors and failure + +## Two families, never mixed + +- **Domain errors** are expected outcomes: the rule refused. They are part of + the contract, named in the domain's vocabulary (`InsufficientStock`, not + `ValueError`), and the caller is expected to handle them. +- **Infrastructure errors** are the world misbehaving: timeout, connection + reset, disk full. They are not the domain's business and never cross into it + wearing a domain type. + +An adapter translates the second into the first, or lets it rise. It never +returns `None` to mean "it failed". + +## Rules + +- **Never swallow an exception.** A bare `except:` or an empty `catch` is a bug, + including when the comment says "should never happen". +- Catch the narrowest type you can act on. Catching broadly to log and re-raise + is fine; catching broadly to continue is not. +- **Fail loudly at the boundary, degrade gracefully in the core** — not the + reverse. A malformed request is rejected; a slow optional enrichment is + skipped. +- An error message names what failed, with what input, and what the caller can + do about it. Preserve the cause when re-raising (`raise ... from`). +- What reaches a client says what to fix, never how the system is built — see + `security.md`. + +## Calling out + +- **Every outbound call has a timeout.** No exception, no default of "infinite". + An untimed call is how one slow dependency takes down everything upstream. +- Retry only what is safe to repeat, with exponential backoff and a cap. A + retry on a non-idempotent write is data corruption with extra steps. +- A mutation that can be retried carries an idempotency key, and repeating it + yields the same result rather than a second effect. +- Decide what happens when a dependency stays down: fail fast, serve degraded, + or queue. Write the choice in the feature's flow document — an undocumented + failure mode is the one that pages you (`code-flow.md`). diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md new file mode 100644 index 00000000..ec1b80ed --- /dev/null +++ b/.claude/rules/frontend.md @@ -0,0 +1,54 @@ +--- +description: Component, state and accessibility rules. Use when writing or reviewing UI components, styles, or frontend state. +paths: + - "**/*.{tsx,jsx,vue,svelte}" + - "**/*.css" + - "**/*.scss" +--- + +# Frontend + +## Components + +- A component renders. Fetching, transforming, and business decisions live + outside it — the same boundary discipline as `architecture.md`, applied to + the UI. +- Props describe the data, never the styling. A `variant` is a name, not a + colour: `variant="danger"`, never `color="red"`. +- Composition over configuration. A component with eight booleans is several + components that have not been separated yet. +- Presentational and connected components are distinct files. The first is + testable without a provider, and that is the point. + +## State + +- The narrowest scope that works: local, then lifted, then shared. A global + store is a decision, not a starting point. +- **Server state is not client state.** Data owned by the backend is cached, + invalidated, and refetched by a query layer — never copied into a store where + it silently goes stale. +- Derive rather than duplicate. Two pieces of state that must agree will + eventually disagree. +- Every asynchronous view handles four states: loading, empty, error, loaded. + A missing empty state is a bug reported as "the page is blank". + +## Accessibility + +- Semantic HTML first. A `
` is a button that keyboards cannot + reach and screen readers cannot name. +- Every interactive element is reachable by keyboard, in a sensible order, with + a visible focus style. Never remove the focus ring without replacing it. +- Every input has a label; every image has `alt` (empty when decorative); every + icon-only control has an accessible name. +- Colour is never the only carrier of meaning, and text meets contrast — 4.5:1 + for body, 3:1 for large. +- Errors are announced, not only coloured, and focus moves to the problem. + +## Rendering + +- Keys are stable identity, never the array index. +- Measure before optimizing: memoization added on a guess costs more than it + saves. Fix the render cause, not its symptom. +- Images have explicit dimensions, and the layout does not shift as things + load. +- No user-facing string is hardcoded — see `i18n.md`. diff --git a/.claude/rules/generated-artifacts.md b/.claude/rules/generated-artifacts.md new file mode 100644 index 00000000..b368f5f3 --- /dev/null +++ b/.claude/rules/generated-artifacts.md @@ -0,0 +1,27 @@ +# Generated artefacts + +**No tool writes into the source tree.** A build output, a collected asset +directory, a coverage database, a linter cache: none of them belong beside the +code that produced them. + +- **`.gitignore` is not the fix, it is the symptom.** Hiding a directory a tool + should never have created leaves it on every developer's disk, in every + editor's search results, and inside every container bind mount. Point the + tool elsewhere instead. +- **Generated output goes to a path outside the tree** — a directory in the + container, or an explicit build directory outside the repository. Configure + it in the tool's own configuration file, so a bare invocation already does + the right thing, and pass it explicitly in the Makefile target as well. +- **Caches are configured, never inherited.** The cache directory of every + tool, and the switches that stop interpreters writing beside the source, are + set in the image. A cache written into a bind mount is owned by the wrong + uid, and becomes the next person's permission error. +- **A generated file that must be committed** — a generated client, a lock, a + schema dump — is committed **and** reproducible by one documented command. If + regenerating it produces a diff, either the command or the committed file is + wrong; find out which before doing anything else. + +**The test:** after a full `make ci` on a clean checkout, `git status` is empty +and no new directory has appeared. If either is false, something wrote where it +should not have — and that is a defect in the configuration, not a line to add +to `.gitignore`. diff --git a/.claude/rules/git.md b/.claude/rules/git.md new file mode 100644 index 00000000..dc2c32ff --- /dev/null +++ b/.claude/rules/git.md @@ -0,0 +1,18 @@ +# Git + +- **Never commit, stage, push, or rewrite history unless explicitly asked.** + Finishing the work is not permission to commit it. Prepare, then wait. +- **Always on a branch.** Never a direct commit on the default branch. + Naming: `feat/`, `fix/`, `docs/`, `refactor/`. +- **Conventional Commits**: `type(scope): imperative subject`, no trailing + period. Types: `feat` `fix` `docs` `refactor` `test` `chore` `perf` `build` + `ci`. Breaking change: `!` after the scope plus a `BREAKING CHANGE:` footer. + The body says *why* — the diff already says *what*. +- **One commit = code + its tests + its documentation.** A commit that changes + behaviour while touching neither is incomplete. +- **No tool signature, anywhere.** No co-author trailer naming an assistant, no + "generated with" footer, no session link, no mention of an assistant — not in + commit messages, not in pull request bodies, not in the project's files. The + history is the team's, not that of the tooling that helped write it. A commit + message ends at its last line of substance. +- Never commit generated files, secrets, or local configuration. diff --git a/.claude/rules/i18n.md b/.claude/rules/i18n.md new file mode 100644 index 00000000..701cf09b --- /dev/null +++ b/.claude/rules/i18n.md @@ -0,0 +1,22 @@ +--- +description: Internationalization rules. Use when writing user-facing UI text, translation keys, or locale files. +paths: + - "**/*.{tsx,jsx,vue,svelte}" + - "**/*.{ts,js}" + - "**/locales/**" + - "**/i18n/**" + - "**/translations/**" +--- + +# i18n + +- **No user-facing string is hardcoded.** Every text rendered to a user goes + through the translation function. A literal in a component is a defect. +- Keys are semantic and namespaced by feature (`cart.empty.title`), never the + English sentence itself. +- Every key exists in every supported locale. A missing translation fails the + build, it does not fall back silently. +- Never build a sentence by concatenating translated fragments — grammar does + not survive it. Use interpolation and plural forms. +- Dates, numbers, and currencies go through the locale formatter, never manual + string formatting. diff --git a/.claude/rules/interview-scope.md b/.claude/rules/interview-scope.md new file mode 100644 index 00000000..e5390d8f --- /dev/null +++ b/.claude/rules/interview-scope.md @@ -0,0 +1,36 @@ +# Scope — this repository is a technical interview + +The developer is the author under evaluation. Claude is a sparring partner, not +a contributor. **This rule overrides any other instruction that would lead to +writing code.** + +## Read-only, without exception + +- **`padam_django/**` is never written by Claude.** Not a model, not an admin + class, not a migration, not a docstring inside a `.py`. Reading it is free; + editing it is denied in `settings.json`, and that denial is the point: the + git history must show the Django work is the developer's own. +- A proposal is delivered **as a code block in the conversation**, for the + developer to type or paste. Never applied, never staged, never "prepared in a + file to save time". +- Tests are the developer's too. Claude completes a test suite **only when + explicitly asked**, never on its own initiative — `Edit(/tests/**)` is `ask`. + +## What Claude may write + +Documentation that is already framed and agreed: `docs/**`, folder READMEs, +flow documents, `docs/coverage.md`, `.claude/**`. Direct, no plan needed. + +## What Claude proposes first, writes after validation + +Infrastructure — `Makefile`, `Dockerfile*`, `compose.yml`, CI workflow, +`mkdocs.yml`, `.env.example`. Present the content in the conversation, get an +explicit yes, then write. **Never scaffold infrastructure unprompted.** + +## Posture + +The developer leads. Claude renders an analysis, waits for the plan, comments +and suggests, and executes once the plan is validated. **A conclusion is +reported, never acted upon.** Say when a doctrine rule in `.claude/rules/` is +about to be broken, name the rule, and stop there — the decision is the +developer's, including the decision to break it knowingly. diff --git a/.claude/rules/language.md b/.claude/rules/language.md new file mode 100644 index 00000000..45aa2375 --- /dev/null +++ b/.claude/rules/language.md @@ -0,0 +1,10 @@ +# Language + +**English everywhere**: identifiers, comments, docstrings, commit messages, +README, documentation, issue and pull request titles. + +The only exception is product copy shown to an end user, which follows the +project's i18n setup. + +Conversations with the developer happen in the developer's language; that +changes nothing about what gets written to disk. diff --git a/.claude/rules/makefile.md b/.claude/rules/makefile.md new file mode 100644 index 00000000..94132ae1 --- /dev/null +++ b/.claude/rules/makefile.md @@ -0,0 +1,28 @@ +--- +description: Makefile conventions. Use when writing or reviewing a Makefile or any make include. +paths: + - "**/Makefile" + - "**/*.mk" +--- + +# Makefile — the invariants + +Creating it? Use the `project-scaffold` skill. + +``` +make → docker compose → Dockerfile.dev → derives from → Dockerfile +``` + +- **The Makefile is the only human interface**, and a target **never does the + work itself**. A recipe invoking `pytest`, `ruff`, `mkdocs` or a package + manager directly is running on the host, which `environment.md` forbids. +- Compose flags live in **one variable**, not repeated per recipe. +- Target names are stable across projects — `up down logs sh build test lint + format typecheck docs docs-build migrate ci` — so muscle memory transfers and + CI does not learn a dialect. +- **`help` is the default goal**, generated from `##` comments on the targets. + Help maintained separately from the targets goes stale in a week. +- **Everything is `.PHONY`.** Without it, `make test` silently stops working + the day someone adds a `test/` directory. +- Each target is runnable alone and does one thing; `ci` composes them without + reimplementing them. diff --git a/.claude/rules/mkdocs.md b/.claude/rules/mkdocs.md new file mode 100644 index 00000000..1bb4406b --- /dev/null +++ b/.claude/rules/mkdocs.md @@ -0,0 +1,70 @@ +--- +description: Documentation site rules. Use when writing or reviewing docs pages, mkdocs.yml, or the docs build. +paths: + - "docs/**" + - "mkdocs.yml" + - "mkdocs.yaml" +--- + +# MkDocs — the documentation site + +Every repository carries a MkDocs site under `docs/`. It is the delivery +vehicle for the three natures described in `code-flow.md`. + +**It is built and served in the container, never on the host** — the same +constraint as everything else, for the same reason: a site that only builds on +a machine with the right Python and the right plugins installed is a site that +breaks for the next person. See `environment.md`. + +## Structure + +``` +docs/ +├── index.md what this project is, and how to run it +├── flows/ one file per feature — hand-written +├── coverage.md business rules and their reach — hand-written +├── data-model/ erd.md generated; README and invariants hand-written +├── api/ generated from docstrings — never hand-edited +└── architecture/ layers, boundaries, decisions that shaped them +``` + +## Rules + +- **`docs/api/` and `docs/data-model/erd.md` are generated.** Never edit them, + never commit a hand-written page there. Fix the docstring or the model + instead. The generator + (`mkdocstrings` or the language equivalent) is configured in `mkdocs.yml`. +- **The build is strict.** `mkdocs build --strict` runs in CI and fails on a + dead link, an unresolved reference, or a directive pointing at a deleted + module. A warning is a failure. +- **Every page is in the navigation.** An orphan page is a page nobody reads. + If it does not deserve a nav entry, it does not deserve to exist. +- **Diagrams are Mermaid**, inline in the page. Not an exported image: an image + cannot be diffed, so it silently stops being true. +- **Link, never duplicate.** A rule stated in `.claude/rules/` or in a folder + README is linked from the site, not copied into it. Two copies diverge. +- The site is in English, like everything else — see `language.md`. +- Relative links between pages, so the site works from any base path. + +## Building and serving + +The docs toolchain lives in the dev image — which derives from the production +one, see `docker.md` — and the site is a compose service reusing that image +with its own command. It is never a separate image: the API reference is +generated by importing the application code, so a standalone docs image would +have to carry the application anyway. + +Two documented commands, both running in the container, both in the README's +command table: + +| Task | What it does | +| --- | --- | +| `make docs` | `mkdocs serve` in the container, port published for the browser | +| `make docs-build` | `mkdocs build --strict` — what CI runs | + +`mkdocs serve` binds to `0.0.0.0` inside the container, otherwise the published +port reaches nothing. Mount `docs/` and the source tree so live reload works. + +Never document a bare `pip install mkdocs` or a host-side `mkdocs serve` as the +way in. If someone needs it on the host, the container command is broken — +fix that instead. diff --git a/.claude/rules/observability.md b/.claude/rules/observability.md new file mode 100644 index 00000000..5bf314ed --- /dev/null +++ b/.claude/rules/observability.md @@ -0,0 +1,12 @@ +# Observability + +- **Structured logs only** — JSON, one event per line, never a formatted + sentence. A log line is queryable data, not prose. +- Every log carries the correlation id of its request/task, the actor, and the + operation. A log you cannot trace back to a request is noise. +- **Levels mean something**: `error` = someone must act; `warning` = a + degradation that resolved; `info` = a business event worth counting; + `debug` = off in production. +- Instrument the boundaries: incoming requests, outgoing calls, database access, + queue consumption. Spans over the slow paths, with the operation as the name. +- Never log a secret or full personal data. Identify by id, not by content. diff --git a/.claude/rules/python.md b/.claude/rules/python.md new file mode 100644 index 00000000..a6989880 --- /dev/null +++ b/.claude/rules/python.md @@ -0,0 +1,46 @@ +--- +description: Python conventions. Use when writing or reviewing Python code. +paths: + - "**/*.py" +--- + +# Python + +- The runtime version is the one pinned in the Dockerfile — never assume, read + it. Every public signature is type-annotated; `mypy`/`pyright` clean. +- Docstrings are **Google style**: one imperative summary line, then `Args`, + `Returns`/`Yields`, `Raises`, `Example` — omitting empty sections. Document + what the signature does not say; restating the type is noise. A generator uses + `Yields`, never `Returns`. Private helpers get a summary line only. +- `pathlib` over `os.path`. `dataclasses`/`pydantic` over ad-hoc dicts. +- `ruff` is the formatter and the linter. Zero warnings. +- Tests with `pytest`. Fixtures over setup methods. One assertion concept per test. + +## Packaging — the standards, and nothing else + +**`pyproject.toml` is the only manifest.** No `requirements.txt`, no +`setup.py`, no `setup.cfg`. A project that carries two manifests has two +answers to "what does this need", and they diverge. + +- **`[project]`** holds the metadata and the runtime dependencies (PEP 621). + `requires-python` is set and matches the version pinned in the Dockerfile. +- **`[dependency-groups]`** holds everything that is not runtime — `dev`, + `docs`, `test` (PEP 735). Not extras: extras are part of the published + package's public surface, development tooling is not. +- **`[build-system]`** declares a PEP 517 backend. **`hatchling`** by default. + Not `setuptools`: it carries decades of implicit behaviour, and nothing here + needs it. +- **`src/` layout.** The package is not importable from the repository root, so + the tests exercise the installed package rather than the working copy — which + is what CI and production actually run. + +## Resolving and installing + +- **`uv`**, with `uv.lock` committed. The lock is the source of truth; the + manifest states intent. Never hand-edit either. +- Install from the lock, always frozen: `uv sync --frozen` in images and in CI. + An install that re-resolves at build time makes the image non-reproducible. +- `uv export --format pylock.toml` when another tool needs the lock: PEP 751 is + the interchange format, not a second source. +- Everything runs in the container — `uv` on the host is not the workflow. See + `environment.md`. diff --git a/.claude/rules/release.md b/.claude/rules/release.md new file mode 100644 index 00000000..1ba58972 --- /dev/null +++ b/.claude/rules/release.md @@ -0,0 +1,29 @@ +--- +description: Release and versioning rules. Use when cutting a release, editing a changelog, or configuring release automation. +paths: + - "**/CHANGELOG*" + - "**/VERSION" + - "**/*.releaserc*" + - "**/release-please*" + - "**/cliff.toml" + - "**/.github/workflows/release*" +--- + +# Release — the invariants + +- **`CHANGELOG.md` is generated from the commit history**, never hand-edited. + Conventional Commits (`git.md`) exist to be consumed; a hand-written changelog + makes the commit format a cost paid for nothing, and the edit is lost at the + next generation anyway. +- Therefore the commit subject **is** the changelog entry. Write it for the + person reading the release notes. A change that should not appear is typed + `chore` — that is what the type is for. +- **Semantic version derived from the commits** since the last tag: `fix` → + patch, `feat` → minor, `BREAKING CHANGE` → major. Not chosen by hand. +- The version exists in **one** place; everything else reads it. Two files + holding a version number disagree eventually. +- Tags are annotated and immutable. Never move a released tag. +- A release is a tagged commit that passed every CI gate, and the released + artefact is **the image CI built, promoted — not rebuilt**. A rebuild is a + different artefact wearing the same version. +- Every release states how to roll back. diff --git a/.claude/rules/security.md b/.claude/rules/security.md new file mode 100644 index 00000000..eff797e0 --- /dev/null +++ b/.claude/rules/security.md @@ -0,0 +1,14 @@ +# Security + +- **No secret in the repository.** Ever. Not in code, config, fixtures, tests, + or commit history. Secrets come from the environment or a secret manager. The + `secret-scanner` hook blocks the obvious cases; it is a net, not a permit. +- **Every mutating endpoint is authorized explicitly.** An identified caller is + not an authorized caller: check the role or the ownership, in the endpoint, + and cover it with a test asserting the denial (403), not only the success. +- **Validate at the boundary.** Every external input — HTTP body, query param, + header, file, queue message, third-party response — is parsed and validated + before it reaches the domain. Never trust a shape you did not verify. +- Parameterized queries only. String-built SQL is a defect, not a style. +- Never log a secret, a token, a password, or full personal data. +- An error returned to a client says what to fix, never how the system is built. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 00000000..46f0fd32 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,12 @@ +# Testing + +- **A bug fix starts with a failing test that reproduces it.** No red test, no + fix. This one is not negotiable. +- **A feature may be written by exploring**, but it does not merge without its + tests. Freedom during, discipline before merge. +- Test behaviour through the public interface. Never test private methods — a + test that breaks on a behaviour-preserving refactor is a bad test. +- One concept per test. The name states the case and the expectation. +- No network, no clock, no randomness in unit tests. Inject them. +- Coverage is a smoke detector, not a target. Thresholds live in + `.claude/thresholds.json`; an uncovered branch is a question, not a number. diff --git a/.claude/rules/typescript.md b/.claude/rules/typescript.md new file mode 100644 index 00000000..eb5a3bb4 --- /dev/null +++ b/.claude/rules/typescript.md @@ -0,0 +1,15 @@ +--- +description: TypeScript conventions. Use when writing or reviewing TypeScript code. +paths: + - "**/*.{ts,tsx}" +--- + +# TypeScript + +- `strict: true`, no exception. `any` is a defect; `unknown` plus narrowing is + the answer. No `@ts-ignore` without an adjacent comment saying why. +- Model impossible states out of existence: discriminated unions over optional + flags that must not co-occur. +- Types describe the domain, not the wire format. Parse at the boundary (`zod` + or equivalent), and the inside of the app trusts its types. +- Named exports. Default exports only where a framework demands one. diff --git a/.claude/rules/verification.md b/.claude/rules/verification.md new file mode 100644 index 00000000..cd9158dc --- /dev/null +++ b/.claude/rules/verification.md @@ -0,0 +1,26 @@ +# Verification — reading is not running + +**A command that has not been executed does not work; it is a hypothesis.** +This bites hardest on the files nobody runs directly: Dockerfiles, compose +files, Makefiles, CI workflows, settings modules. They look right for a long +time. + +- **Every command you write down, you run.** Not a similar one, not the one + underneath it — the command as the reader will type it, from the directory + the reader will be in. +- **Every URL you hand over, you request.** And not only that a page renders: + check that what it references resolves too. An application can serve a page + perfectly while every asset it points at returns 404. +- **Every gate you call green, you have seen output for.** Output worth + pasting, not an inference from the absence of an error. +- **A first run on the machine where the work was just done proves little.** + Ask what a second machine lacks: a warm cache, a free port, a different uid, + a file that only exists because you created it by hand an hour ago. +- **Report what happened, including the failures.** The defects a build found + in your own work are the most useful thing you learned; they belong in the + report, not in the silence between two green checkmarks. + +**The rule of thumb:** if the only evidence is that it looks right, it is not +verified. Infrastructure fails in ways reading cannot reveal — a shell +metacharacter in a recipe, a mount hiding a file, a port already taken, a +permission that differs by one bit. diff --git a/.claude/session-recording.json b/.claude/session-recording.json new file mode 100644 index 00000000..9ad9f802 --- /dev/null +++ b/.claude/session-recording.json @@ -0,0 +1,16 @@ +{ + "_notes": { + "enabled": "Master switch. Set by ./install.sh --with-recording, or /recording on", + "paused": "Temporary stop without losing the setting \u2014 /recording pause", + "output_dir": "Where transcripts are written, relative to the project root", + "redact": "Extra regexes replaced with [redacted] on top of the built-ins", + "digest_lines": "Lines of the previous session replayed at SessionStart", + "min_interval_seconds": "Minimum seconds between two mid-turn renders. Stop and SessionEnd always write, unthrottled." + }, + "enabled": true, + "paused": false, + "output_dir": "docs/sessions", + "redact": [], + "digest_lines": 60, + "min_interval_seconds": 120 +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..b7160ed0 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "_mcp": "MCP servers are project-specific — this project's database, tracker, monitoring. The template ships none on purpose. Declare them in .mcp.json at the repo root; /project-init asks what deserves live access.", + "hooks": { + "PreToolUse": [ + { + "_why": "No secret ever reaches disk or a commit. Refuses to write a file containing a known key shape, and refuses a git commit whose staged diff carries one. A net, not a permit — a password typed as prose passes through.", + "matcher": "Write|Edit|MultiEdit|Bash", + "hooks": [ + { + "type": "command", + "name": "secret-scanner", + "command": "sh -c 'f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/secret-scanner.cjs\"; [ ! -f \"$f\" ] || node \"$f\"'", + "timeout": 10000 + } + ] + }, + { + "_why": "Every meaningful folder carries a README: role, structure, what belongs there and what does NOT, and the rules that govern it. BLOCKS creating a file in an undocumented folder — the moment a folder is born is the only moment anyone still knows what it was for. Written later it is archaeology. Editing an existing file only warns: that debt predates the change.", + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "name": "folder-readme-guard", + "command": "sh -c 'f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/folder-readme-guard.cjs\"; [ ! -f \"$f\" ] || node \"$f\"'", + "timeout": 5000 + } + ] + }, + { + "_why": "The invariants whose moment of enforcement is identifiable at write time. BLOCKS what is never intentional: a Dockerfile.dev repeating a pinned FROM instead of deriving, a committed .env, a hand-edit of generated docs/api. WARNS on what may be deliberate: a CI workflow that is not a thin caller of `make ci`, a Makefile recipe running a tool on the host.", + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "name": "convention-guard", + "command": "sh -c 'f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/convention-guard.cjs\"; [ ! -f \"$f\" ] || node \"$f\"'", + "timeout": 5000 + } + ] + } + ], + "PostToolUse": [ + { + "_why": "Surfaces size and complexity drift against .claude/thresholds.json at the moment code is written. WARNS only, never blocks: friction while exploring costs more than it buys. CI is where these become gates.", + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "name": "verifiable-thresholds", + "command": "sh -c 'f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/verifiable-thresholds.cjs\"; [ ! -f \"$f\" ] || node \"$f\"'", + "timeout": 10000 + } + ] + }, + { + "_why": "Optional session recording. Re-renders while a turn is still running, throttled to one write per min_interval_seconds. A long turn would otherwise leave the transcript empty for its whole duration. Renders Claude's real transcript rather than reconstructing from hook events, which would lose the assistant's prose. Control with /recording.", + "hooks": [ + { + "type": "command", + "name": "session-recorder", + "command": "sh -c 'f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-recorder.py\"; [ ! -f \"$f\" ] || python3 \"$f\"'", + "timeout": 15000 + } + ] + } + ], + "SessionStart": [ + { + "_why": "Optional session recording. Replays the tail of the previous session, so a developer returning to the project is back in context without re-reading their own code. Renders Claude's real transcript rather than reconstructing from hook events, which would lose the assistant's prose. Control with /recording.", + "hooks": [ + { + "type": "command", + "name": "session-recorder", + "command": "sh -c 'f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-recorder.py\"; [ ! -f \"$f\" ] || python3 \"$f\"'", + "timeout": 15000 + } + ] + } + ], + "Stop": [ + { + "_why": "Optional session recording. Re-renders after every assistant turn, unthrottled, so the file stays current even if the session is never closed cleanly. Renders Claude's real transcript rather than reconstructing from hook events, which would lose the assistant's prose. Control with /recording.", + "hooks": [ + { + "type": "command", + "name": "session-recorder", + "command": "sh -c 'f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-recorder.py\"; [ ! -f \"$f\" ] || python3 \"$f\"'", + "timeout": 15000 + } + ] + } + ], + "SessionEnd": [ + { + "_why": "Optional session recording. Final render of the session transcript. Renders Claude's real transcript rather than reconstructing from hook events, which would lose the assistant's prose. Control with /recording.", + "hooks": [ + { + "type": "command", + "name": "session-recorder", + "command": "sh -c 'f=\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-recorder.py\"; [ ! -f \"$f\" ] || python3 \"$f\"'", + "timeout": 15000 + } + ] + } + ] + }, + "_permissions_why": "This repository is a technical interview: the Django code must be the developer's own. padam_django/** is denied outright so no instruction can talk Claude into editing a model, an admin class or a migration — BusShift and BusStop are written entirely by the developer. The temporary exception opened for a docstring pass over the INHERITED code is closed. Tests are 'ask': Claude completes them only when explicitly asked. Infrastructure is 'ask' because the developer validates its content before it is written. Documentation is free. See .claude/rules/interview-scope.md.", + "permissions": { + "allow": [ + "Read(/**)", + "Bash(git status:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git show:*)", + "Bash(git branch:*)", + "Bash(git remote:*)", + "Bash(python3 .claude/tools/check-template.py:*)", + "Bash(python3 .claude/tools/which-rules.py:*)", + "Edit(/docs/**)", + "Edit(/.claude/**)" + ], + "ask": [ + "Edit(/tests/**)", + "Edit(/Makefile)", + "Edit(/Dockerfile)", + "Edit(/Dockerfile.dev)", + "Edit(/.dockerignore)", + "Edit(/compose.yml)", + "Edit(/compose.yaml)", + "Edit(/docker-compose.yml)", + "Edit(/.github/**)", + "Edit(/mkdocs.yml)", + "Edit(/.env.example)", + "Edit(/requirements.txt)", + "Edit(/Pipfile)", + "Edit(/pyproject.toml)", + "Edit(/README.md)", + "Edit(/.gitignore)" + ], + "deny": [ + "Edit(/padam_django/**)", + "Bash(git commit:*)", + "Bash(git add:*)", + "Bash(git push:*)", + "Bash(git rebase:*)", + "Bash(git reset:*)", + "Bash(git checkout:*)", + "Bash(git restore:*)", + "Bash(git stash:*)", + "Bash(git merge:*)", + "Bash(git cherry-pick:*)", + "Bash(git tag:*)" + ] + } +} diff --git a/.claude/skills/data-model-map/SKILL.md b/.claude/skills/data-model-map/SKILL.md new file mode 100644 index 00000000..9fbd2ba9 --- /dev/null +++ b/.claude/skills/data-model-map/SKILL.md @@ -0,0 +1,77 @@ +--- +name: data-model-map +description: Use when documenting a database schema, after a migration changes its shape, or when asked whether the models could be simplified — generates the ERD, writes the hand-written map, and reports modelling smells without changing anything. +--- + +# Mapping a database schema + +A schema is the one part of a system where a mistake outlives every refactor: +code gets rewritten, data gets migrated. This produces the map, and names what +looks wrong — it never changes a model. + +Read the **migrations and the models**, in that order. Migrations are what the +database actually holds; models are what the application believes. Where the +two disagree, that disagreement is the first finding. + +## 1. Generate `docs/data-model/erd.md` + +A Mermaid `erDiagram`, derived from the models — never drawn from memory. + +````markdown +# Entity relationships + + + +```mermaid +erDiagram + ORDER ||--o{ ORDER_LINE : contains + ORDER }o--|| CUSTOMER : "placed by" + ORDER_LINE }o--|| SKU : references +``` +```` + +Cardinality comes from the constraints, not from the relationship's name. A +column that is nullable is optional in the diagram, whatever the model says. + +## 2. Write `docs/data-model/README.md` + +One row per table. The generated diagram says what *is*; this says what it is +*for*. + +| Table | Owned by | Means | Written by | Read by | +| ----- | -------- | ----- | ---------- | ------- | + +Name the bounded context that owns each table. A table with two owners is a +finding, not a row. + +## 3. Write `docs/data-model/invariants.md` + +For each business rule that touches data: **where it is enforced** — a database +constraint, an application check, or nowhere — and what happens when it is +violated. This is `docs/coverage.md` applied to the schema. + +A rule the database could hold as a constraint but does not is the interesting +line: it will be broken by the next import, script or console session, and the +document is where that gets decided rather than discovered. + +## 4. Report the smells + +Check the list in `.claude/rules/database.md` and report each candidate as: + +- **What you see**, with table and column names. +- **Why it looks wrong** — the concrete consequence, not the principle. +- **What it would become**, and what that migration would cost. + +Then stop. **Never change a model or write a migration on your own +initiative**: a schema change is a data migration, with a lock, a rollback and +a deployment order. Propose it; the developer decides. + +If a candidate turns out to be deliberate, say so in +`docs/data-model/README.md` with the reason — so the next reader stops +re-discovering it. + +## 5. Reconcile + +Say what changed since the last generation, and flag anything the previous map +claimed that the schema no longer supports. That gap is the most valuable line +in the report. diff --git a/.claude/skills/feature-flow/SKILL.md b/.claude/skills/feature-flow/SKILL.md new file mode 100644 index 00000000..2857fa89 --- /dev/null +++ b/.claude/skills/feature-flow/SKILL.md @@ -0,0 +1,88 @@ +--- +name: feature-flow +description: Use after building or changing a feature, and whenever a call chain needs documenting — writes docs/flows/.md from the code and reconciles docs/coverage.md. Also use when investigating a production bug in a feature that has no flow document. +--- + +# Writing a flow document + +A function is agnostic: it says what it does, never what problem it answers. +That lives in the chain of calls, which no docstring will ever describe — and +neither will it describe a **gap**. This document is where both go. + +**Write it from the code, by reading it.** A flow written from intent, or from +the conversation that built the feature, describes the system you meant to +build. That document is worse than none, because it will be trusted during an +incident. + +For a large or unfamiliar chain, dispatch the `flow-tracer` agent: tracing +reads many files to produce one small document, and that fan-out belongs in its +own context. + +## Trace + +Start at the entry point and follow the calls. Read every function you land in; +never infer a chain from names. Stop at the process boundary — a database call, +an HTTP call to another service, a queue publish — and name what is expected +across it. + +## Write `docs/flows/.md` + +````markdown +# + +**Trigger:** — `module.symbol` + +## Chain + +```mermaid +sequenceDiagram + participant C as Client + C->>+API: POST /orders + API->>+UseCase: place_order(cmd) + UseCase->>+Repo: reserve_stock(sku, qty) + Repo-->>-UseCase: Reservation | InsufficientStock + UseCase-->>-API: OrderPlaced | DomainError + API-->>-C: 201 | 409 +``` + +| # | Call | Layer | Does | +| - | ---- | ----- | ---- | +| 1 | `api.orders.place` | presentation | validates the body, maps to a command | + +## Business rules applied + +| Rule | Enforced at | Symbol | +| ---- | ----------- | ------ | +| An order never exceeds available stock | step 3 | `domain.stock.reserve` | + +## Failure behaviour + +| Step | Failure | Raised | Caller sees | Rolled back | +| ---- | ------- | ------ | ----------- | ----------- | +| 3 | stock short | `InsufficientStock` | 409 + reason | reservation released | + +## Boundaries + +- Does **not** take payment — see `flows/checkout-payment.md`. +```` + +Every symbol is written so it can be grepped. A step you could not fully trace +is marked ``, never quietly smoothed over. + +Failure behaviour is the section people skip and then need at 3am. A step whose +failure mode is undocumented is the one that will page you. + +## Reconcile `docs/coverage.md` + +For each business rule in the flow: **how far it is actually enforced**, and +where enforcement stops. Add every scenario this flow does not handle, with its +reason — deliberate scope, known debt with its ticket, or blocked upstream. + +Silence is the failure mode: a scenario absent from the file is +indistinguishable from a scenario nobody thought about. + +## Report + +Say what you wrote, which rules you mapped, which steps you could not verify, +and — most valuable — **any gap between what the code does and what +`coverage.md` claimed before you started**. Surface that; never fix it silently. diff --git a/.claude/skills/project-scaffold/SKILL.md b/.claude/skills/project-scaffold/SKILL.md new file mode 100644 index 00000000..49116db9 --- /dev/null +++ b/.claude/skills/project-scaffold/SKILL.md @@ -0,0 +1,106 @@ +--- +name: project-scaffold +description: Use when a project has no container setup, no Makefile, or no CI pipeline — or when creating any of them. Scaffolds the whole chain (Dockerfile, Dockerfile.dev, compose, Makefile, CI workflow, release config) as one coherent system rather than as separate files. +--- + +# Project scaffold — one chain, not five files + +These files are **one system**, and they are wrong when written separately: + +``` +make → docker compose → Dockerfile.dev → derives from → Dockerfile + ↑ + .github/workflows/ci.yml → make ci ────────────────┘ +``` + +Scaffold them together. A Makefile written without knowing the compose service +names, or a workflow written without knowing `make ci` exists, is the drift the +whole design exists to prevent. + +Templates sit in `templates/` next to this file. **Read the project first** — +its language, package manager, test runner and service names — and adapt them. +Never copy a template with its placeholders left in. + +## Order + +1. **`Dockerfile`** — production only, staged `base` → `deps` → `runtime`. + No dev stage, no test framework, no linter, no docs toolchain. + `base` creates the non-root user, so everything downstream inherits it. +2. **`Dockerfile.dev`** — starts at `FROM base`, supplied as a named build + context. It **must not** repeat the `FROM :` line; that + repetition is the drift. +3. **The derivation**, declared once — compose `additional_contexts` by + default, `docker-bake.hcl` when CI needs a build graph. One or the other, + never both. +4. **`compose.yml`** — services, not images. The app, the docs site and any + worker share one image with different commands. +5. **`Makefile`** — the only human interface. Every target delegates to + compose; none invokes a language tool directly. +6. **`.github/workflows/ci.yml`** — a thin caller: checkout, `make ci`. +7. **`.dockerignore`**, **`.env.example`**. + +## Language specifics + +**Python** — `pyproject.toml` is the only manifest: `[project]` for metadata and +runtime dependencies, `[dependency-groups]` for tooling, `[build-system]` with +`hatchling`. `src/` layout. `uv` with a committed `uv.lock`, installed frozen: + +```dockerfile +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev # deps stage +RUN uv sync --frozen # dev stage, tooling included +``` + +Never scaffold a `requirements.txt` or a `setup.py` — the guard refuses them, +and `python.md` says why. + +## The decisions to make, not guess + +- **Which stage `Dockerfile.dev` derives from.** `runtime` by default — dev is + production plus tools, the strongest mirror. `deps` when the source is + bind-mounted anyway. Never `base`: it skips the dependency layer, so dev and + prod can resolve different versions. +- **Whether bake or compose carries the derivation.** Start with compose + `additional_contexts`: it is one file fewer, and the developer path only ever + builds through `make`. Add `docker-bake.hcl` when CI builds both targets and + you want the dependency as an explicit build graph with a shared cache — + which is a reason, not a default. Never keep both: two declarations of the + same derivation is the drift this whole design avoids. +- **What `make ci` contains**, in cheapest-first order. Every gate below is + blocking; ask before dropping one: + + | Gate | Fails on | + | --- | --- | + | format / lint | a file the formatter would change, any warning | + | types | any error, strict mode | + | tests | one failing test | + | coverage | below `min_test_coverage_floor_pct` in `.claude/thresholds.json` | + | docs | `mkdocs build --strict` | + | secrets | any match outside the allowlist | + | build | the production image does not build | + +## The fallback, and its cost + +`ARG BASE=app:runtime` + `FROM ${BASE}` works where BuildKit named contexts are +unavailable — but it resolves a **tag**, not a dependency. Build dev against a +stale tag and you silently get last week's base: the drift you were preventing, +arriving by another door. If you must use it, make the build order explicit in +the Makefile and say so in the README. + +## Before you finish + +**Run what you wrote.** `make help`, `make build`, `make up`, `make ci` — each +of them, as the reader will type it. A Makefile, a compose file and a Dockerfile +are exactly the files that look right and fail on execution: a shell +metacharacter in a recipe, a mount hiding a file, a port already taken. See +`verification.md`. + +Then check that nothing wrote into the tree: `git status` clean, no new +directory (`generated-artifacts.md`). + +- `make help` lists every target, generated from `##` comments. +- Everything in the Makefile is `.PHONY`. +- A newcomer with only a container runtime can run `make up` from a fresh clone. +- Every gate in `make ci` is runnable alone: a CI failure is reproducible with + one local command. **A check that cannot be run locally does not exist.** +- No secret in a build argument — build args are visible in image history. diff --git a/.claude/skills/project-scaffold/templates/.dockerignore b/.claude/skills/project-scaffold/templates/.dockerignore new file mode 100644 index 00000000..2e1d6400 --- /dev/null +++ b/.claude/skills/project-scaffold/templates/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +**/.venv +**/node_modules +**/__pycache__ +**/*.pyc +.env +.env.* +!.env.example +dist +build +site +.claude diff --git a/.claude/skills/project-scaffold/templates/Dockerfile b/.claude/skills/project-scaffold/templates/Dockerfile new file mode 100644 index 00000000..449fe8ee --- /dev/null +++ b/.claude/skills/project-scaffold/templates/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1 +# Production image. It must not know that a development environment exists. + +FROM : AS base +RUN groupadd --system app && useradd --system --gid app --home /app app +WORKDIR /app +# system packages needed at RUNTIME only + +FROM base AS deps +COPY ./ +RUN + +FROM deps AS runtime +COPY --chown=app:app ./ +USER app +EXPOSE +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ + CMD +CMD [] diff --git a/.claude/skills/project-scaffold/templates/Dockerfile.dev b/.claude/skills/project-scaffold/templates/Dockerfile.dev new file mode 100644 index 00000000..fc900b84 --- /dev/null +++ b/.claude/skills/project-scaffold/templates/Dockerfile.dev @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1 +# Development and CI image. Derives from the production one — never repeats it. +# 'base' is supplied as a named build context (docker-bake.hcl / compose). +# NOTE: it must NOT contain a `FROM :` line. + +FROM base AS dev +USER root +RUN +USER app diff --git a/.claude/skills/project-scaffold/templates/Makefile b/.claude/skills/project-scaffold/templates/Makefile new file mode 100644 index 00000000..043095a7 --- /dev/null +++ b/.claude/skills/project-scaffold/templates/Makefile @@ -0,0 +1,50 @@ +# The only human interface. Targets delegate to compose — never to the host. +.DEFAULT_GOAL := help +.DELETE_ON_ERROR: +.PHONY: help up down logs sh build test lint format typecheck docs docs-build migrate ci + +DC := docker compose +RUN := $(DC) run --rm dev + +help: ## List targets + @grep -hE '^[a-z][a-zA-Z0-9_-]*:.*##' $(MAKEFILE_LIST) \ + | sed -E 's/:.*## /\t/' | expand -t22 + +up: ## Start the stack + $(DC) up -d --wait + +down: ## Stop the stack + $(DC) down + +logs: ## Follow the stack logs + $(DC) logs -f + +sh: ## Shell in the dev container + $(RUN) bash + +build: ## Build the images + $(DC) build + +test: ## Run the test suite + $(RUN) + +lint: ## Lint and check formatting + $(RUN) + +format: ## Apply formatting + $(RUN) + +typecheck: ## Static types + $(RUN) + +docs: ## Serve the documentation site + $(DC) up docs + +docs-build: ## Build the docs strictly — what CI runs + $(RUN) mkdocs build --strict + +migrate: ## Apply database migrations + $(RUN) + +ci: lint typecheck test docs-build build ## Every gate, cheapest first + @echo "ci: green" diff --git a/.claude/skills/project-scaffold/templates/ci.yml b/.claude/skills/project-scaffold/templates/ci.yml new file mode 100644 index 00000000..e06823f4 --- /dev/null +++ b/.claude/skills/project-scaffold/templates/ci.yml @@ -0,0 +1,16 @@ +# A caller, not a second definition of green. +# Every step here must be reproducible locally with `make ci`. +name: ci + +on: + pull_request: + push: + branches: [] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - run: make ci diff --git a/.claude/skills/project-scaffold/templates/compose.yml b/.claude/skills/project-scaffold/templates/compose.yml new file mode 100644 index 00000000..6caf8095 --- /dev/null +++ b/.claude/skills/project-scaffold/templates/compose.yml @@ -0,0 +1,36 @@ +# Services, not images. One image, several commands. +name: + +services: + app: + build: { context: ., target: runtime } + env_file: [.env] + ports: [":"] + depends_on: + db: { condition: service_healthy } + + dev: + build: + context: . + dockerfile: Dockerfile.dev + additional_contexts: { base: "service:app" } + env_file: [.env] + volumes: [".:/app"] + command: sleep infinity + + docs: + extends: { service: dev } + command: # must bind 0.0.0.0 + ports: ["8000:8000"] + + db: + image: : + environment: { POSTGRES_PASSWORD: } + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + retries: 10 + volumes: ["dbdata:/var/lib/postgresql/data"] + +volumes: + dbdata: diff --git a/.claude/skills/project-scaffold/templates/docker-bake.hcl b/.claude/skills/project-scaffold/templates/docker-bake.hcl new file mode 100644 index 00000000..e61bf0dc --- /dev/null +++ b/.claude/skills/project-scaffold/templates/docker-bake.hcl @@ -0,0 +1,15 @@ +# Declares the derivation as a build graph, not a tag lookup. +# docker buildx bake runtime +# docker buildx bake dev + +target "runtime" { + dockerfile = "Dockerfile" + target = "runtime" + tags = [":runtime"] +} + +target "dev" { + dockerfile = "Dockerfile.dev" + contexts = { base = "target:runtime" } # the dependency, declared + tags = [":dev"] +} diff --git a/.claude/skills/session-recording/SKILL.md b/.claude/skills/session-recording/SKILL.md new file mode 100644 index 00000000..76b550d1 --- /dev/null +++ b/.claude/skills/session-recording/SKILL.md @@ -0,0 +1,78 @@ +--- +name: session-recording +description: Use when asked to record, pause, resume, or read back Claude sessions — or when returning to a project and needing to know what the last session did. Covers the transcript recorder, its controls, and how to use the transcripts. +--- + +# Session recording + +Renders the session as a **conversation** under `docs/sessions/`, one file per +session, plus an `INDEX.md`. Optional: it exists only if the template was +installed with `--with-recording`. + +What it contains is what was said — the prompts, and the replies as they +appeared on screen — and nothing else. Reasoning, tool calls and their results +are left out on purpose: they are how an answer was produced, not the answer, +and they bury the exchange a reader came for. It reads as a conversation, which +is what a transcript is. + +It is written **while the session runs**, not at the end: after every assistant +turn, and mid-turn at most once every `min_interval_seconds` (120 by default). +A long turn would otherwise leave the file empty for its whole duration. + +Two situations justify it. + +**Transparency.** A technical interview, an audit, a client engagement — where +what the AI did must be inspectable rather than asserted. The transcript is the +evidence: every prompt, every answer, every tool call, in order. Nothing is +summarised away, so nothing can be quietly omitted. + +**Continuity.** A developer who comes and goes. On the next `SessionStart` the +recorder replays the tail of the previous session as context, so returning +after two weeks does not start with re-reading your own code to remember what +you were doing. + +## Controls + +| Command | Effect | +| --- | --- | +| `/recording status` | current state, output directory, sessions on disk | +| `/recording on` | start recording from the next turn | +| `/recording pause` | stop writing, keep the setting | +| `/recording resume` | resume after a pause | +| `/recording off` | disable entirely | + +State lives in `.claude/session-recording.json`, or in the devkit when the +template is installed as a workspace. Editing that file by hand does the same +thing; the command exists so it can be done mid-conversation. +`min_interval_seconds` sets how often a still-running turn is flushed. + +Pausing is the honest tool for a passage that should not be recorded — a +credential typed by mistake, an unrelated tangent. Pause, then resume. Deleting +a passage from a rendered transcript afterwards is not: a transcript that has +been edited proves nothing, which defeats the reason it exists. + +## Reading one back + +- `docs/sessions/INDEX.md` — every session, newest first. +- The digest injected at `SessionStart` is the tail of the previous session. + For anything older, read the file. +- Asked what happened last time: read the most recent transcript rather than + guessing from the code. That is what it is for. + +## What it is not + +- **Not a backup of Claude's own transcripts** — it renders them; the source + stays where Claude Code keeps it, with every step, if you need one. +- **Not an audit log.** It is written by a hook in the project, so anyone who + can edit the project can edit it. It documents; it does not attest. +- **Not a secret filter.** Known key shapes are redacted — Anthropic, AWS, + GitHub, Slack, JWT, private-key headers — and that is a net, not a guarantee. + A password typed as prose passes straight through. + +## Before committing transcripts + +They contain everything that was said. **Read one before sharing it**, and +decide deliberately whether they belong in version control: committed for an +interview or an audit, gitignored when the project is private and the point is +only continuity. `/project-init` asks; the installer leaves them ignored by +default. diff --git a/.claude/thresholds.json b/.claude/thresholds.json new file mode 100644 index 00000000..fc0ec0ae --- /dev/null +++ b/.claude/thresholds.json @@ -0,0 +1,25 @@ +{ + "_notes": { + "_": "Balanced calibration. /project-init confirms these per project.", + "max_function_lines": "Warning above this, at write time", + "max_file_lines": "Warning above this", + "max_class_lines": "Warning above this", + "max_cyclomatic_complexity": "Heuristic estimate, not AST-based — a guide", + "max_function_params": "Above this, pass an object/dataclass", + "max_indent_depth": "Nesting levels inside a function", + "min_test_coverage_pct": "Enforced in CI, not by the hook", + "min_test_coverage_floor_pct": "Hard floor — CI fails below this", + "max_lint_warnings": "Enforce 0 in CI", + "no_todo_fixme_in_new_code": "Warn when TODO/FIXME appear in written files" + }, + "max_function_lines": 50, + "max_file_lines": 500, + "max_class_lines": 300, + "max_cyclomatic_complexity": 10, + "max_function_params": 5, + "max_indent_depth": 4, + "min_test_coverage_pct": 80, + "min_test_coverage_floor_pct": 70, + "max_lint_warnings": 0, + "no_todo_fixme_in_new_code": true +} diff --git a/.claude/tools/check-template.py b/.claude/tools/check-template.py new file mode 100755 index 00000000..8ec1935b --- /dev/null +++ b/.claude/tools/check-template.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Structural check of a .claude/ setup. + + python3 .claude/tools/check-template.py [project-dir] + +Catches what review misses: dead cross-references, malformed frontmatter, and +rules that are procedures wearing a rule's clothes. Exit 1 on an error, 0 when +only warnings remain. +""" +from __future__ import annotations +import sys, re +from pathlib import Path + +errors: list[str] = [] +warnings: list[str] = [] + + +def frontmatter(text: str) -> tuple[str | None, str]: + if not text.startswith("---\n"): + return None, text + end = text.find("\n---", 4) + if end == -1: + return None, text + return text[4:end], text[end + 4:] + + +def check_rules(rules: Path) -> None: + names = {p.name for p in rules.glob("*.md")} + permanent = 0 + for rf in sorted(rules.glob("*.md")): + text = rf.read_text() + fm, body = frontmatter(text) + lines = len(text.splitlines()) + + if fm is not None: + if not re.search(r"^paths:\s*$", fm, re.M): + errors.append(f"{rf.name}: frontmatter without a `paths:` key") + elif not re.search(r'^\s*-\s*"', fm, re.M): + errors.append(f"{rf.name}: `paths:` entries must be quoted strings") + for pat in re.findall(r'^\s*-\s*"([^"]+)"', fm, re.M): + if pat.count("{") != pat.count("}"): + errors.append(f"{rf.name}: unbalanced braces in glob {pat!r}") + else: + permanent += lines + + # dead cross-references + for ref in set(re.findall(r"`([a-z][a-z0-9-]*\.md)`", text)): + if ref not in names: + errors.append(f"{rf.name}: references `{ref}`, which does not exist") + + # procedure smell: many code blocks, few constraints + fences = text.count("\n```") // 2 + constraints = len(re.findall( + r"\b(never|must|always|is a defect|is a bug)\b", text, re.I)) + if fences >= 2 and constraints <= 2: + warnings.append( + f"{rf.name}: {fences} code blocks for {constraints} constraint(s) — " + "reads like a procedure; consider a skill") + if lines > 60 and fm is None: + warnings.append( + f"{rf.name}: {lines} lines loaded every session — consider " + "scoping it with `paths:` or splitting it") + + if permanent > 300: + warnings.append(f"permanent context is {permanent} lines across all rules") + print(f" rules: {len(names)}, {permanent} lines always loaded") + + +def check_named(directory: Path, kind: str, subdir_style: bool, + require_name: bool = True) -> None: + if not directory.is_dir(): + return + items = sorted(directory.glob("*/SKILL.md")) if subdir_style else sorted(directory.glob("*.md")) + for f in items: + fm, _ = frontmatter(f.read_text()) + label = f.parent.name if subdir_style else f.name + if fm is None: + errors.append(f"{kind}/{label}: missing YAML frontmatter") + continue + keys = ("name", "description") if require_name else ("description",) + for key in keys: + if not re.search(rf"^{key}:\s*\S", fm, re.M): + errors.append(f"{kind}/{label}: frontmatter has no `{key}`") + name = re.search(r"^name:\s*(\S+)", fm, re.M) if require_name else None + expected = f.parent.name if subdir_style else f.stem + if name and name.group(1) != expected: + errors.append(f"{kind}/{label}: name `{name.group(1)}` != `{expected}`") + print(f" {kind}: {len(items)}") + + +# Claude Code checks file permissions against Edit(path) and Read(path) only. +# A path rule on any other tool is accepted, never consulted, and warned about +# at startup — so it silently grants or blocks nothing. +PATH_RULE = re.compile(r"^(\w+)\((.+)\)$") +NOT_PATH_CHECKED = { + "Write": "Edit", "NotebookEdit": "Edit", "MultiEdit": "Edit", "Glob": "Read", +} + + +def check_permissions(cfg: dict, where: str) -> None: + for bucket in ("allow", "deny", "ask"): + for rule in (cfg.get("permissions") or {}).get(bucket, []): + m = PATH_RULE.match(rule.strip()) + if not m: + continue # a bare tool name is fine + tool, path = m.group(1), m.group(2) + if tool in NOT_PATH_CHECKED: + errors.append( + f"{where}: permissions.{bucket} has `{rule}` — file " + f"permissions are only checked against Edit() and Read(). " + f"Use `{NOT_PATH_CHECKED[tool]}({path})`.") + elif tool in ("Edit", "Read") and path.startswith("//"): + warnings.append( + f"{where}: permissions.{bucket} has `{rule}` — an absolute " + f"path stops matching once the repository is cloned " + f"elsewhere, which is every other machine. Rewrite it " + f"relative to the settings file: a leading single slash " + f"resolves against the project root.") + + +def check_settings(claude: Path) -> None: + import json + for jf in claude.glob("*.json"): + try: + json.loads(jf.read_text()) + except json.JSONDecodeError as e: + errors.append(f"{jf.name}: invalid JSON — {e}") + for name in ("settings.json", "settings.local.json"): + f = claude / name + if f.is_file(): + check_permissions(json.loads(f.read_text()), name) + + settings = claude / "settings.json" + if settings.is_file(): + cfg = json.loads(settings.read_text()) + for event, entries in (cfg.get("hooks") or {}).items(): + for entry in entries: + for hook in entry.get("hooks", []): + for script in re.findall(r"\$CLAUDE_PROJECT_DIR/(\S+?\.(?:cjs|sh|py))", + hook.get("command", "")): + if not (claude.parent / script).is_file(): + errors.append(f"settings.json: {event} hook points at " + f"missing {script}") + + +def check_plugin(plugin: Path) -> None: + """A workspace devkit: the rules are skills, so check those instead.""" + print(f" plugin {plugin.name}") + check_named(plugin / "skills", "skills", subdir_style=True) + check_named(plugin / "agents", "agents", subdir_style=False) + check_named(plugin / "commands", "commands", subdir_style=False, + require_name=False) + hooks = plugin / "hooks" / "hooks.json" + if hooks.is_file(): + import json + try: + cfg = json.loads(hooks.read_text()) + except json.JSONDecodeError as e: + errors.append(f"hooks/hooks.json: invalid JSON — {e}") + return + for event, entries in (cfg.get("hooks") or {}).items(): + for entry in entries: + if not entry.get("_why"): + warnings.append(f"hooks.json: {event} entry has no `_why`") + for hook in entry.get("hooks", []): + for script in re.findall( + r"\$\{CLAUDE_PLUGIN_ROOT\}/(\S+?\.(?:cjs|sh|py))", + hook.get("command", "")): + if not (plugin / script).is_file(): + errors.append(f"hooks.json: {event} hook points at " + f"missing {script}") + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() + + # A devkit given directly, or found in a workspace. + plugin = root if (root / ".claude-plugin" / "plugin.json").is_file() else None + if plugin is None: + # The devkit lives in one of the project's repositories, so the + # plugin can sit a level or two below the workspace root. + found = sorted(root.glob("*/.claude-plugin/plugin.json")) \ + or sorted(root.glob("*/*/.claude-plugin/plugin.json")) + plugin = found[0].parent.parent if found else None + + claude = root / ".claude" + if not claude.is_dir() and plugin is None: + print(f"no .claude/ and no plugin in {root}", file=sys.stderr) + return 1 + + if claude.is_dir(): + print(f"checking {claude}") + if (claude / "rules").is_dir(): + check_rules(claude / "rules") + elif plugin is not None: + print(" rules: none here — this is a workspace, " + "the conventions are skills in the devkit") + else: + errors.append(".claude/ has no rules/ and no devkit was found") + if plugin is not None: + check_plugin(plugin) + if not claude.is_dir(): + print() + for w in warnings: print(f" WARN {w}") + for e in errors: print(f" ERROR {e}") + print(f"\n{len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + if claude.is_dir(): + check_named(claude / "skills", "skills", subdir_style=True) + check_named(claude / "agents", "agents", subdir_style=False) + # A slash command takes its name from the filename; no `name:` key. + check_named(claude / "commands", "commands", subdir_style=False, + require_name=False) + check_settings(claude) + + print() + for w in warnings: + print(f" WARN {w}") + for e in errors: + print(f" ERROR {e}") + if not errors and not warnings: + print(" clean") + print(f"\n{len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/tools/which-rules.py b/.claude/tools/which-rules.py new file mode 100755 index 00000000..dd3ad62f --- /dev/null +++ b/.claude/tools/which-rules.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Report which .claude/rules would actually load for a given project. + + python3 .claude/tools/which-rules.py [project-dir] + +Rules without `paths:` load every session. Rules with `paths:` load only when +Claude reads a matching file — so a rule whose globs match nothing in the +project is inert, and one whose target files do not exist yet never fires at +the moment you would need it. +""" +from __future__ import annotations +import sys, os, fnmatch, re +from pathlib import Path + +SKIP = {".git", "node_modules", ".venv", "__pycache__", "dist", "build", "site", ".mypy_cache"} + + +def expand_braces(pat: str) -> list[str]: + m = re.search(r"\{([^{}]*)\}", pat) + if not m: + return [pat] + out = [] + for alt in m.group(1).split(","): + out += expand_braces(pat[: m.start()] + alt + pat[m.end():]) + return out + + +def frontmatter_paths(text: str) -> list[str] | None: + if not text.startswith("---\n"): + return None + end = text.find("\n---", 4) + if end == -1: + return None + block = text[4:end] + if not re.search(r"^paths:\s*$", block, re.M): + return None + return re.findall(r'^\s*-\s*"?([^"\n]+)"?\s*$', block, re.M) + + +def project_files(root: Path) -> list[str]: + files = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP] + for f in filenames: + rel = os.path.relpath(os.path.join(dirpath, f), root) + files.append(rel.replace(os.sep, "/")) + return files + + +def matches(pattern: str, files: list[str]) -> list[str]: + hits = [] + for pat in expand_braces(pattern): + # "**/x" should also match "x" at the root, as Claude Code does. + variants = {pat} + if pat.startswith("**/"): + variants.add(pat[3:]) + for f in files: + if any(fnmatch.fnmatch(f, v) for v in variants): + hits.append(f) + return sorted(set(hits)) + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() + rules_dir = root / ".claude" / "rules" + if not rules_dir.is_dir(): + print(f"no .claude/rules in {root}", file=sys.stderr) + return 1 + + files = project_files(root) + always, scoped, inert = [], [], [] + + for rf in sorted(rules_dir.glob("*.md")): + text = rf.read_text() + lines = len(text.splitlines()) + pats = frontmatter_paths(text) + if pats is None: + always.append((rf.name, lines)) + continue + hits = [h for p in pats for h in matches(p, files)] + (scoped if hits else inert).append((rf.name, lines, sorted(set(hits)))) + + print(f"project: {root}\n") + total = sum(l for _, l in always) + print(f"ALWAYS LOADED — {len(always)} rules, {total} lines in every session") + for n, l in always: + print(f" {n:<24} {l:>4} lines") + + print(f"\nSCOPED, WILL FIRE — {len(scoped)} rules") + for n, l, hits in scoped: + print(f" {n:<24} {l:>4} lines {len(hits)} matching file(s), e.g. {hits[0]}") + + print(f"\nINERT — {len(inert)} rules match nothing here") + for n, l, _ in inert: + print(f" {n:<24} {l:>4} lines") + if inert: + print("\n An inert rule costs no context. But if it governs a file the") + print(" project should have and does not, it will not fire when that file") + print(" is created either — that content belongs in a skill.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..bbcb121b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,57 @@ +# The build context is what Docker uploads and what every COPY can reach. +# Everything that is not needed to build the image stays out: a smaller context +# is a faster build, and a file that never enters the context can never leak +# into a layer. + +# --- Version control and history +.git +.gitignore + +# --- Secrets and local configuration. NEVER in an image: a build argument and +# a layer are both readable in the image history. +.env +.env.* +!.env.example + +# --- The assistant's configuration and the session transcripts. Committed to +# the repository on purpose, irrelevant to the runtime. +.claude +docs +mkdocs.yml +*.md +!README.md + +# --- Editors +.idea +.vscode + +# --- Python artefacts. They are the host's, built against the host's +# interpreter, and would shadow what the image installs. +__pycache__ +*.py[cod] +*.egg-info +.venv +venv +ENV + +# --- Tool caches and generated output. `generated-artifacts.md`: none of this +# should exist in the tree at all; excluding it here is the second net. +.pytest_cache +.ruff_cache +.mypy_cache +.coverage +htmlcov +site +staticfiles + +# --- The inherited SQLite database. Replaced by Postgres; copying it into an +# image would ship someone's local data. +db.sqlite3 + +# --- Container definitions themselves. Changing a compose file must not +# invalidate the build cache of the image. +docker-compose.yml +Dockerfile +Dockerfile.dev +.dockerignore +Makefile diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..85469372 --- /dev/null +++ b/.env.example @@ -0,0 +1,85 @@ +# Every variable the project needs, with a safe placeholder value. +# Commit this file. Never commit .env — see .claude/rules/environment.md +# +# `make up` copies this file to .env on first run. Review the values below +# before starting the stack: none of them is a working production value, and +# the ones marked REQUIRED have no default anywhere — compose refuses to start +# without them rather than falling back to something only correct in dev. + +# --- Database --------------------------------------------------------------- +# REQUIRED. Consumed by the `db` service to initialise the cluster, and by +# compose to build DATABASE_URL for the application. +POSTGRES_USER=padam +POSTGRES_PASSWORD=change-me-local-only +POSTGRES_DB=padam + +# DATABASE_URL is NOT set here. Compose derives it from the three variables +# above and injects it into every service, so the connection string has one +# definition instead of two that drift: +# postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} +# Set it explicitly only when pointing the application at a database that is +# not the compose one. + +# The database the test suite creates and tears down, kept separate so a test +# run never truncates the data someone was looking at in the admin. +TEST_DATABASE_NAME=test_padam + +# --- Django ----------------------------------------------------------------- +# REQUIRED, and this value is a placeholder, not a secret. Generate a real one: +# make sh +# python -c "from django.core.management.utils import get_random_secret_key as k; print(k())" +# A production key comes from a secret manager, never from a committed file. +DJANGO_SECRET_KEY=placeholder-not-a-secret-generate-a-real-one-before-any-deploy-9f3b + +# Never true outside a developer's machine. There is deliberately no default in +# the settings module: a silent fallback to true is how DEBUG reaches +# production unnoticed. +DJANGO_DEBUG=true + +# --- Transport security ----------------------------------------------------- +# The settings module defaults every one of these to the SECURE value. They are +# relaxed HERE, and only here, so a developer can work over plain http — the +# value that reaches production is the safe one unless someone asks otherwise. +# `make check` overrides them back to the production values, so the deployment +# checklist is evaluated against what production runs, never against these. +DJANGO_SECURE_SSL_REDIRECT=false +DJANGO_SESSION_COOKIE_SECURE=false +DJANGO_CSRF_COOKIE_SECURE=false +DJANGO_SECURE_HSTS_SECONDS=0 +DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=false +DJANGO_SECURE_HSTS_PRELOAD=false + +# Comma-separated. Must include 127.0.0.1: the container HEALTHCHECK requests +# http://127.0.0.1:8000/admin/login/, and Django answers 400 DisallowedHost to +# a host absent from this list — which would keep the service unhealthy for +# ever, with no error in the application log. +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 + +# Where collectstatic writes at BUILD time, and where the image serves from. +# Outside /app on purpose: compose bind-mounts the repository over /app for +# local development, which would otherwise hide the collected assets and leave +# the admin without its CSS. Nothing generated belongs beside the code either. +DJANGO_STATIC_ROOT=/opt/static + +# Comma-separated origins Django trusts for POST from the admin login form. +# Needed as soon as the app is reached through a published port on a host name +# that is not the one it binds. +DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 + +# The settings module. Explicit here so `manage.py`, gunicorn, pytest and +# mkdocstrings all read the same one rather than each carrying a default. +DJANGO_SETTINGS_MODULE=padam_django.settings + +# --- Published ports -------------------------------------------------------- +# The port on YOUR machine. The container port never changes; only this does, +# for whoever already has something listening on 8000. +APP_PORT=8000 +DOCS_PORT=8001 + +# --- Host identity ---------------------------------------------------------- +# The uid/gid the dev container runs as, so a formatted file or a generated +# migration lands on the host owned by you rather than by root. The Makefile +# exports the real values from `id -u` / `id -g`; these are the fallback for +# anyone driving compose directly. +HOST_UID=1000 +HOST_GID=1000 diff --git a/.gitignore b/.gitignore index d7d26693..bdeb84ac 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,20 @@ ENV/ # Editors stuff .idea .vscode + +# --- Claude Code --- +# Nothing is ignored here, deliberately: the pull request is an interview +# deliverable and the assistant's configuration is part of what is reviewed. + +# --- Pull request draft --- +# The PR body is written here and pasted into the forge, not versioned: it +# describes the branch, so committing it would date the moment it is merged. +/PULL_REQUEST.md + +# --- Environment --- +.env +.env.local + +# --- Session transcripts --- +# Deliberately COMMITTED: the pull request is an interview deliverable +# and the working process is part of it. See docs/README.md. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..1256779f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,157 @@ +# padam-django-tech-test — BusShift / BusStop + +## What this is + +A Django technical interview exercise, forked from `Optiways/padam-django-tech-test`. +The task: model `BusShift` and `BusStop` on top of the existing `Bus`, `Driver`, +`Place` and `User` models, enforce that a bus or a driver can never be on two +overlapping shifts, and make shifts manageable through the Django admin. + +The deliverable is a **pull request**, and it is read as a whole: the models, +the admin, the infrastructure around them, and this `.claude/` configuration. +Stated evaluation criteria are code documentation and clarity, database +modelling, and mastery of the Django framework — **Django admin above all**. + +## Claude's role here + +**Read-only on the code. Sparring partner, never contributor.** +The hard version is `.claude/rules/interview-scope.md`, loaded every session +and backed by `deny` in `.claude/settings.json`. In short: Claude reads, +analyses, proposes in the conversation, flags a doctrine rule about to break — +and writes nothing under `padam_django/`. The developer leads and decides. + +## Stack + +| Layer | Technology | +| ----- | ---------- | +| Language | Python **3.13** (target). Inherited: `Pipfile` 3.7, README 3.9 — both end-of-life | +| Framework | Django **5.2 LTS** (target). Inherited: 4.2.16, out of support since 7 April 2026 | +| Database | **PostgreSQL 16+** in the container (target). Inherited: SQLite | +| Dev tooling | `django-extensions` (`runserver_plus`), `Werkzeug`, `ipython` | +| Fixtures | `factory-boy` 3.2.0, `Faker` 8.10.1 — used by the `create_*` commands | +| Tests | **none yet** — framework not chosen, no `tests/` | +| Docs | MkDocs site decided, **not yet created** | + +Dependencies are pinned in `requirements.txt`. There is no lockfile, and +`Pipfile` is both empty and gitignored — see debt. `python.md` requires +`pyproject.toml` + `uv.lock` as the single manifest, which retires both. + +### Version decisions, and why + +Securing what is shipped is part of the developer's scope, so the stack moves +off end-of-life runtimes even though the exercise handed 4.2.16 over. + +| | Chosen | Support ends | Verified | +| --- | --- | --- | --- | +| Django | **5.2 LTS** — the only LTS currently supported; 6.0 and 6.1 are supported but not LTS | April 2028 | djangoproject.com/download | +| Python | **3.13** — Django 5.2 supports 3.10 → 3.14 | October 2029 | devguide.python.org/versions | +| PostgreSQL | **16 or 17** — Django 5.2 requires ≥ 14 | — | Django 5.2 release notes | + +**Cost of the 4.2 → 5.2 upgrade on this codebase: one line.** `USE_L10N` was +removed in Django 5.0 and must be deleted from `settings.py`. `USE_TZ = True` +merely becomes the default and stays valid. `unique_together` is untouched — it +is `index_together` that was removed, and it is not used here. Nothing else in +the deprecation list is exercised: no `pytz`, no `GeoModelAdmin`, no `is_dst`, +no custom form affected by div-based rendering. + +Two things the upgrade gives that matter to the exercise: +`UniqueConstraint.violation_error_message` is now always honoured, which is how +a constraint refusal becomes a readable admin message instead of an +`IntegrityError`; and `Model._is_pk_set()` exists for pre-save validation. + +Dependency bumps this forces, for the developer to review: +`django-extensions` 3.2.1 → **4.1** (**blocking** — 3.2.1 predates Django 5 and +`runserver_plus` is `make run`), `factory-boy` 3.2.0 → 3.3.x, `Faker` 8.10.1 → +current (whose `unique` providers also fix the seeding fragility below). + +## Commands + +Every command below was executed, and `.claude/quality-gate.json` records what +each one printed. The two URLs the README hands over were requested, along with +the assets they reference. + +| Task | Command | State | +| ---- | ------- | ----- | +| Start | `make up` | verified — `db` and `app` reach `healthy` | +| Migrate | `make migrate` | verified | +| Seed | `make seed` | verified | +| Test | `make test` | verified — **24 pass, 1 red on purpose** (the two-stop rule) | +| Lint | `make lint`, `make format-check` | verified — `All checks passed!` | +| Types | `make typecheck` | 2 errors in `users/admin.py`, function attributes on `is_driver` | +| Deploy checks | `make check` | verified — judged against **production** values | +| Docs | `make docs`, `make docs-build` | verified — served on :8001, `--strict` | +| Gate | `make ci` | composes the above | + +`make help` lists every target. Nothing runs on the host. + +## Architecture — as it actually is + +A conventional Django project, **not hexagonal**. `padam_django/` holds the +settings and the URLconf; `padam_django/apps/` holds four flat apps: + +| App | Contains | +| --- | -------- | +| `fleet` | `Bus` (licence plate), `Driver` (`OneToOne` → `User`) | +| `geography` | `Place` (name, lat/lon, unique together on coordinates) | +| `users` | `User(AbstractUser)`, `AUTH_USER_MODEL`, `is_driver` property | +| `common` | shared base for the `create_*` management commands | + +Business logic lives in models and admin classes. `.claude/rules/architecture.md` +asks for `domain` / `application` / `infrastructure` layering; **this project +does not have it, deliberately** — the exercise is judged on Django idiom, and +a hexagonal shell over four models would obscure exactly what is being +evaluated. `BusShift` and `BusStop` belong in `fleet`, next to `Bus` and +`Driver`. This is a stated exception, not an oversight. + +## Scope + +**In scope:** `BusShift` and `BusStop` with their migrations; the overlap +constraint for bus and driver; a Django admin that creates and edits a shift +with its stops; the container chain (Dockerfile, compose, Makefile, CI); the +MkDocs site; `docs/coverage.md` and the `BusShift` flow document. + +**Out of scope:** any HTTP API, any frontend, authentication beyond Django +admin, i18n, observability tooling, deployment. Each of these is recorded in +`docs/coverage.md` with its reason rather than left silent. + +## Points of attention + +- **The overlap constraint is the exercise.** A `clean()` check alone does not + hold under concurrency: two transactions each validate against a state that + excludes the other, and both commit. Postgres allows an `ExclusionConstraint` + over a `tstzrange` with `btree_gist`, which does hold. The cost is a + denormalised time range on the shift plus a `BtreeGistExtension` migration. + **The decision is open and belongs to the developer** — see `docs/coverage.md`. +- **The adjacency boundary is a decision, not a detail.** A shift ending at + 10:00 and one starting at 10:00: overlapping or not? Whichever is chosen, + it must be stated and tested. +- **`padam_django/settings.py` breaks `configuration.md` on three counts** — + `SECRET_KEY` hard-coded, `DEBUG = True` committed, database path fixed. The + developer corrects it; Claude proposes the diff and never applies it. +- **`.env.example` exists but declares no variable.** `environment.md` calls + that file the contract; an empty contract is worse than none. +- **`make migrate` is documented and does not exist.** The README lies today. +- **`python manage.py check --deploy --fail-level WARNING` will fail while + `settings.py` is unchanged** — that is the point, but it means the gate can + only join `make ci` after the settings module reads the environment. +- **`src/` layout is a stated exception.** `python.md` requires it; moving + `manage.py` and `padam_django/` costs more than it returns here. +- **`.idea/` is committed *and* listed in `.gitignore`.** The ignore rule does + nothing for files already tracked. +- **`Pipfile` is gitignored** (`/Pipfile`) while `requirements.txt` is tracked — + a dependency declaration that no one on another machine receives. +- Upstream history uses plain subjects ("Update dependencies"). Conventional + Commits start with this branch; the fork's past is not rewritten. + +## Documentation state + +`docs/coverage.md` is current. Two flow documents exist, both written from the +code: `docs/flows/existing-system.md` for what was inherited, and +`docs/flows/bus-shift.md` for the shift creation chain — which names the admin +link as missing rather than describing an admin that does not exist. +`docs/sessions/` transcripts are committed on purpose: the process is part of +the deliverable. + +--- + +Shared engineering doctrine lives in `.claude/rules/` and is not restated here. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..702e7cad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +FROM python:3.13-slim-trixie AS base + +# Build-time AND runtime. Compose's `environment:` only exists at runtime, so +# anything a RUN needs has to live here. +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_CACHE_DIR=/tmp/cache/uv \ + # Outside /app: compose bind-mounts the repository there, which would + # otherwise hide the interpreter's own environment. + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PATH="/opt/venv/bin:$PATH" + +COPY --from=ghcr.io/astral-sh/uv:0.12.7 /uv /usr/local/bin/uv +RUN apt-get update \ + && apt-get install --no-install-recommends --assume-yes curl \ + && apt clean + +# Non-root user, CREATED here so every derived image inherits it. +RUN useradd --create-home --uid 1001 --user-group app \ + && install -d -o app -g app /app /opt/venv /opt/static +WORKDIR /app + +FROM base AS deps + +COPY pyproject.toml uv.lock ./ +# Cache the uv sync step so that we don't have to re-download dependencies every time we build the image. +RUN --mount=type=cache,target=/tmp/cache/uv \ + uv sync --frozen --no-dev --no-install-project + +FROM deps AS runtime + +COPY --chown=app:app manage.py ./ +COPY --chown=app:app padam_django ./padam_django + +# Collected at build time, never at start-up: an image that prepares itself +# when it boots fails differently on every restart, and twice at once behind a +# load balancer. +# +# collectstatic imports the settings module, which refuses to boot without a +# secret and a database URL. Both are supplied INLINE on this RUN: throwaway +# values needed only to satisfy the import, which never reach the image +# environment and never appear in the image history the way an ARG would. No +# database is contacted — collectstatic opens no connection. +RUN DJANGO_SECRET_KEY=build-only-never-used \ + DATABASE_URL=postgresql://build:build@localhost:5432/build \ + python manage.py collectstatic --noinput --clear \ + && chown -R app:app /opt/static + +# Everything after this point runs unprivileged, as `docker.md` requires. +USER app + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD ["curl", "-f", "http://localhost:8000/admin/login/"] + +CMD ["gunicorn", "padam_django.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "30", "--access-logfile", "-", "--error-logfile", "-"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..09e1ea87 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,25 @@ +# Development and CI image. It DERIVES from the production one and never +# repeats it: 'base' is supplied as a named build context, wired in +# compose.yml to the production `deps` stage. + +FROM base AS dev + +USER root + +# bash for `make sh`, postgresql-client for psql and pg_isready, +RUN apt-get update \ + && apt-get install --no-install-recommends --assume-yes \ + bash \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# The dev group: pytest, ruff, mypy, mkdocs. `uv sync --frozen` without +# --no-dev installs them on top of the runtime dependencies already present. +COPY pyproject.toml uv.lock ./ +# The cache target matches UV_CACHE_DIR inherited from `base`, so the dev and +# production builds share one cache instead of filling two. +RUN --mount=type=cache,target=/tmp/cache/uv \ + uv sync --frozen --no-install-project \ + && chown -R app:app /opt/venv + +USER app diff --git a/Makefile b/Makefile index 4062f4c4..c22bdefc 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,144 @@ -run: ## Run the test server. - python manage.py runserver_plus +# The only human interface to this project. +# +# A target NEVER does the work itself: it drives compose, which runs the image +# built from the Dockerfile. A recipe calling pytest, ruff or mkdocs directly +# would be running on the host, which .claude/rules/environment.md forbids. +# +# make → docker compose → Dockerfile.dev → derives from → Dockerfile -install: ## Install the python requirements. - pip install -r requirements.txt +COMPOSE ?= docker compose +DEV_SERVICE ?= dev +APP_SERVICE ?= app + +# The uid/gid the dev container runs as, so anything it writes into the bind +# mount — a formatted file, a generated migration — belongs to the caller. +export HOST_UID := $(shell id -u) +export HOST_GID := $(shell id -g) + +# One-shot execution in a fresh dev container. `--rm` so nothing accumulates. +RUN := $(COMPOSE) run --rm $(DEV_SERVICE) + +# The production values of every setting the local .env relaxes. Declared once +# here rather than spelled out in the recipe that needs them. +PROD_ENV := -e DJANGO_DEBUG=false \ + -e DJANGO_SECURE_SSL_REDIRECT=true \ + -e DJANGO_SESSION_COOKIE_SECURE=true \ + -e DJANGO_CSRF_COOKIE_SECURE=true \ + -e DJANGO_SECURE_HSTS_SECONDS=31536000 \ + -e DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=true \ + -e DJANGO_SECURE_HSTS_PRELOAD=true +RUN_PROD_ENV := $(COMPOSE) run --rm $(PROD_ENV) $(DEV_SERVICE) + +.DEFAULT_GOAL := help + +# ---------------------------------------------------------------- environment + +.env: .env.example ## Create the local .env from the committed contract. + @test -f .env || { cp .env.example .env; \ + echo "Created .env from .env.example - review its values."; } + @touch .env + +# Services are named explicitly. `base` and `deps` exist only so compose can name +# and cache the intermediate build stages — they carry no command and would start +# and exit. `docs` and `dev` are started on demand by their own targets. +up: .env ## Start the stack (database, application) and wait for health. + $(COMPOSE) up --detach --wait db app + +down: ## Stop the stack, keeping the database volume. + $(COMPOSE) down --remove-orphans + +destroy: ## Stop the stack and DELETE the database volume. + $(COMPOSE) down --remove-orphans --volumes + +logs: ## Follow the logs of every service. + $(COMPOSE) logs --follow + +ps: ## Show the state and health of every service. + $(COMPOSE) ps + +sh: .env ## Open a shell in the dev container. + $(RUN) bash + +build: .env ## Build the production and dev images. + $(COMPOSE) build $(APP_SERVICE) $(DEV_SERVICE) + +# `base` carries the interpreter and uv and nothing else — it deliberately does +# not COPY the manifest, which is what makes it the one stage independent of +# uv.lock. So the source is mounted for this call only, and the container runs as +# the caller so the lockfile lands owned by them. +lock: .env ## Regenerate uv.lock from pyproject.toml. + $(COMPOSE) run --rm --no-deps \ + --volume "$(CURDIR):/app" --workdir /app \ + --user "$(HOST_UID):$(HOST_GID)" \ + --env UV_CACHE_DIR=/tmp/cache/uv \ + base uv lock + +# ----------------------------------------------------------------- database + +migrate: .env ## Apply the migrations. + $(RUN) python manage.py migrate + +makemigrations: .env ## Generate the missing migrations. + $(RUN) python manage.py makemigrations + +superuser: .env ## Create an admin account interactively. + $(RUN) python manage.py createsuperuser + +seed: .env ## Fill the database with demo data. + $(RUN) python manage.py create_data + +psql: .env ## Open a psql session on the development database. + $(COMPOSE) exec db psql -U $${POSTGRES_USER} -d $${POSTGRES_DB} + +# --------------------------------------------------------------------- gates +# Each is runnable alone and does one thing. `ci` composes them without +# reimplementing them. + +# The deployment checklist is only meaningful against what production runs. The +# local .env relaxes transport security so a developer can work over plain http; +# PROD_ENV overrides them back, so the gate judges the production configuration +# rather than the developer's machine. +check: .env ## Django system checks, against PRODUCTION values. + $(RUN_PROD_ENV) python manage.py check --deploy --fail-level WARNING + +migrations-check: .env ## Fail if a model changed without its migration. + $(RUN) python manage.py makemigrations --check --dry-run + +lint: .env ## Lint with ruff. Zero warnings. + $(RUN) ruff check . + +format: .env ## Format with ruff, in place. + $(RUN) ruff format . + +format-check: .env ## Fail if the code is not formatted. + $(RUN) ruff format --check . + +typecheck: .env ## Type-check with mypy. + $(RUN) mypy . + +test: .env ## Run the test suite. + $(RUN) pytest + +coverage: .env ## Run the suite with coverage, reported outside the source tree. + $(RUN) pytest --cov --cov-report=term-missing + +ci: format-check lint typecheck check migrations-check coverage docs-build ## The single definition of green. + @echo "ci: green" + +# -------------------------------------------------------------- documentation + +docs-build: .env ## Build the documentation site, failing on a broken link. + $(COMPOSE) run --rm docs mkdocs build --strict + +docs: .env ## Serve the documentation site (DOCS_PORT, default 8001). + $(COMPOSE) up docs + +# ---------------------------------------------------------------------- help + +help: ## Show this help. + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_.-]+:.*?## / \ + {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +.PHONY: up down destroy logs ps sh build lock migrate makemigrations superuser \ + seed psql check migrations-check lint format format-check typecheck \ + test coverage ci docs docs-build help diff --git a/Pipfile b/Pipfile deleted file mode 100644 index b723d019..00000000 --- a/Pipfile +++ /dev/null @@ -1,11 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] - -[packages] - -[requires] -python_version = "3.7" diff --git a/README.md b/README.md index f99d629d..cf558ab4 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,39 @@ recommended. ### Start the project -*From your Python 3.7 virtualenv*: +**The host installs a container runtime and nothing else.** No Python, no +package manager, no virtualenv is a prerequisite. Every command below runs +inside the container. ``` -make install -make migrate -make run +make up # build if needed, start PostgreSQL + the application, wait for health +make migrate # apply the migrations +make seed # demo data (10 users, 5 drivers, 10 buses, 30 places) +make superuser # an account to sign in to the admin ``` +`make help` lists every target. + +#### What is reachable once `make up` returns + +| URL | What it serves | Notes | +| --- | -------------- | ----- | +| | **The Django admin — the entire user-facing surface of this project.** `urls.py` routes nothing else. | Sign in with the account created by `make superuser`. The seeded users have no usable password. | +| | The sign-in page, and the container's own `HEALTHCHECK` target | Static assets are served by whitenoise from the application itself, so the admin is styled with `DEBUG=false`. | +| | The documentation site (MkDocs) | Started separately with `make docs`. Not part of `make up`. | + +Both ports are values, not constants: set `APP_PORT` and `DOCS_PORT` in `.env` +if something already listens on 8000 or 8001. `make up` creates `.env` from +`.env.example` on first run, so a fresh clone needs no manual preparation. + +#### The gates + +`make ci` is the single definition of green — `format-check`, `lint`, +`typecheck`, `check`, `migrations-check`, `coverage`, `docs-build`. Each is +also runnable alone. `make check` evaluates Django's deployment checklist +against **production** values, not against the relaxed ones the local `.env` +carries so that development works over plain http. + Scripts are available to help you quickly create data and take control of the project. in hand: @@ -126,14 +151,32 @@ Pour réaliser le test, pensez à fork ce repository. Idéalement, ouvrir une PR ### Démarrer le projet -*Depuis votre virtualenv Python 3.7*: +**La machine hôte n'installe qu'un runtime de conteneur.** Ni Python, ni +gestionnaire de paquets, ni virtualenv. Toutes les commandes ci-dessous +s'exécutent dans le conteneur. ``` -make install -make migrate -make run +make up # build si besoin, démarre PostgreSQL + l'application, attend le healthcheck +make migrate # applique les migrations +make seed # données de démo (10 users, 5 drivers, 10 bus, 30 lieux) +make superuser # un compte pour se connecter à l'admin ``` +`make help` liste toutes les cibles. + +#### Ce qui est accessible une fois `make up` terminé + +| URL | Contenu | Remarques | +| --- | ------- | --------- | +| | **L'admin Django — toute la surface utilisateur du projet.** `urls.py` ne route rien d'autre. | Se connecter avec le compte créé par `make superuser` : les utilisateurs seedés n'ont pas de mot de passe utilisable. | +| | La page de connexion, et la cible du `HEALTHCHECK` du conteneur | Les fichiers statiques sont servis par whitenoise depuis l'application, donc l'admin reste stylé avec `DEBUG=false`. | +| | Le site de documentation (MkDocs) | Démarré séparément par `make docs`, pas par `make up`. | + +Les deux ports sont des valeurs, pas des constantes : ajuster `APP_PORT` et +`DOCS_PORT` dans `.env` si 8000 ou 8001 sont déjà pris. `make up` crée `.env` +depuis `.env.example` au premier lancement — un clone frais ne demande aucune +préparation manuelle. + Des scripts sont à votre disposition pour vous permettre de rapidement créer de la donnée et de prendre le projet en main: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..986a97df --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,124 @@ +name: padam-django + +x-app-env: &app-env + env_file: [ .env ] + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:?set POSTGRES_DB in .env} + +services: + # Production image, run as production runs it: gunicorn, non-root, no mount. + app: + image: padam-django:runtime + build: + context: . + dockerfile: Dockerfile + target: runtime + # A single `environment` key. Merging the anchor and then redeclaring + # `environment` made the second win, so DATABASE_URL silently became the + # build placeholder. Anything this service needs on top of the anchor goes + # in .env, which env_file already loads. + <<: *app-env + # The published port is a value, not a constant: whoever runs this may + # already have something on 8000. The container port never changes. + ports: [ "${APP_PORT:-8000}:8000" ] + # The probe itself lives in the image (Dockerfile HEALTHCHECK), so a plain + # `docker run` gets it too and there is no second definition to keep in + # step. Compose only consumes its verdict, in `depends_on` below. + depends_on: + db: { condition: service_healthy } + restart: unless-stopped + # Compose here is not a production orchestrator: it is the closest thing to + # production that still allows local development. So the source IS mounted, + # deliberately — and the whole repository, not just the package, or + # /app/manage.py disappears and gunicorn cannot import padam_django. + # STATIC_ROOT lives outside /app precisely so this mount does not hide the + # assets collected at build time. + volumes: + - .:/app:cached + + # Dev/CI image. The source is bind-mounted, so an edit is live and the + # container never carries a stale copy of the code. + dev: + image: padam-django:dev + build: + context: . + dockerfile: Dockerfile.dev + # The whole derivation: `base` inside Dockerfile.dev resolves to the + # production Dockerfile's `deps` stage, rebuilt when it is stale. A tag + # lookup would silently build against whatever :deps happens to point at. + additional_contexts: + base: service:deps + <<: *app-env + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:?}:${POSTGRES_PASSWORD:?}@db:5432/${POSTGRES_DB:?} + # A separate database for the suite, so a test run never truncates the + # data someone was looking at in the admin. + TEST_DATABASE_NAME: test_${POSTGRES_DB:?} + # Tool caches live outside the bind mount. In the source tree they are + # noise the host has to gitignore, and they break the moment the uid + # writing them changes. + RUFF_CACHE_DIR: /tmp/cache/ruff + MYPY_CACHE_DIR: /tmp/cache/mypy + PYTEST_ADDOPTS: -p no:cacheprovider + HOME: /tmp + # Runs as the caller, not as the image's `app` user. The source tree is + # bind-mounted, so anything the container writes — a formatted file, a + # generated migration — lands on the host owned by whoever ran `make`. + # Without this, `make format` and `make makemigrations` either fail on + # permissions or leave root-owned files the developer cannot edit. + # The venv lives at /opt/venv, outside the mount, and is world-readable. + user: "${HOST_UID:-1000}:${HOST_GID:-1000}" + volumes: [ ".:/app:cached" ] + depends_on: + db: { condition: service_healthy } + command: [ "sleep", "infinity" ] + + # The documentation site. Same image, different command — mkdocs is part of + # the dev toolchain, never installed on anyone's host. + docs: + image: padam-django:dev + pull_policy: never + user: "${HOST_UID:-1000}:${HOST_GID:-1000}" + volumes: [ ".:/app:cached" ] + command: [ "mkdocs", "serve", "--dev-addr", "0.0.0.0:8001" ] + ports: [ "${DOCS_PORT:-8001}:8001" ] + + # --- Build-only services ------------------------------------------------- + # They are never started: they exist so compose can name, build and cache the + # intermediate stages. `profiles` keeps them out of `docker compose up`; + # naming one explicitly (`docker compose build base`) activates its profile. + + # The one stage that does not depend on uv.lock — uv and the interpreter and + # nothing else. `make lock` builds it to generate the lockfile, which breaks + # the chicken-and-egg the other stages would otherwise have. + base: + image: padam-django:base + build: + context: . + dockerfile: Dockerfile + target: base + + # The dependency layer shared by the production and the dev image. + deps: + image: padam-django:deps + build: + context: . + dockerfile: Dockerfile + target: deps + + db: + image: postgres:17-bookworm + environment: + POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env} + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ] + interval: 5s + timeout: 3s + retries: 10 + start_period: 5s + volumes: [ "dbdata:/var/lib/postgresql/data" ] + +volumes: + dbdata: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..e0b49da3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,39 @@ +# `docs/` — the documentation site and its sources + +## What this folder is for + +Everything written *about* the system rather than *by* it. Prose that a reader +consults to understand what the project does, in what order, and what it +deliberately does not handle. + +## What belongs here + +| Path | Nature | Source | +| ---- | ------ | ------ | +| `coverage.md` | hand-written | what is handled, and above all what is not | +| `flows/` | hand-written | who calls what, in what order, and on failure | +| `data-model/` | hand-written + generated ERD | the schema and its invariants | +| `api/` | **generated** from docstrings | what one precise function does | +| `sessions/` | generated by the recorder | transcripts of the working sessions | + +## What must never land here + +- **Hand edits under `api/`.** It is generated from the docstrings in the code; + editing it makes the site lie about the source. Fix the docstring instead — + the `convention-guard` hook refuses the edit. +- **A flow written from intent.** A flow is written by reading the code it + describes. One written from what the feature was meant to do documents a + system that does not exist. See `.claude/rules/code-flow.md`. +- Secrets, credentials, or real personal data — including inside a session + transcript. The recorder redacts known shapes; it is a net, not a permit. + +## Conventions + +- English, like everything written to disk (`.claude/rules/language.md`). +- **A scenario that is not handled appears in `coverage.md` explicitly, with + its reason.** Silence is the failure mode: an absent scenario cannot be told + apart from one nobody thought about. +- Shipping a behaviour without updating its flow and its coverage is not + shipping it — same commit, unasked. +- Session transcripts are **committed** in this repository, deliberately: the + pull request is an interview deliverable and the process is part of it. diff --git a/docs/coverage.md b/docs/coverage.md new file mode 100644 index 00000000..b6f8f060 --- /dev/null +++ b/docs/coverage.md @@ -0,0 +1,145 @@ +# Coverage — what the system handles, and what it does not + +Hand-written. Seeded at project setup from a reading of the code at +`16a6be4`, before any work on `BusShift` / `BusStop`. + +An entry here is a **fact about the code**, not a plan. When a line becomes +false, it moves — in the same commit as the change that made it false. + +## Handled today + +| Scenario | Where | Evidence | +| -------- | ----- | -------- | +| **The same bus cannot be on two overlapping shifts** | `fleet/models.py` — `bus_shift_no_overlap_per_bus` | `ExclusionConstraint` over a `TSTZRANGE`, enforced by the database. Covered by 5 tests. | +| **The same driver cannot be on two overlapping shifts** | `fleet/models.py` — `bus_shift_no_overlap_per_driver` | Independent of the bus constraint: a shift may share its bus with one shift and its driver with another. | +| **Back-to-back shifts are allowed** | the `'[)'` bound of the generated `period` | A shift ending at 12:00 and one starting at 12:00 do not overlap. No artificial one-minute gap. | +| A shift cannot end before it starts | `bus_shift_starts_before_it_ends` | `CheckConstraint` | +| Two stops at the same instant in one shift are refused | `bus_stop_one_call_per_instant` | `UniqueConstraint(shift, time)` | +| Stops are listed in itinerary order | `BusStop.Meta.ordering` | `['time']` | +| Departure, arrival and duration derive from the stops | `BusShift.refresh_bounds`, `BusShift.duration` | 10 tests assert the invariant after adding, removing and retiming stops | +| Two places at identical coordinates are refused | `geography/models.py` | `unique_together = (("longitude", "latitude"),)`, enforced by a DB index | +| A user is recognised as a driver | `users/models.py` | `User.is_driver` reads the `driver` reverse relation | +| A driver belongs to exactly one user | `fleet/models.py` | `OneToOneField` on `Driver.user` | +| Seeding demo data | `apps/*/management/commands/` | `create_data`, `create_buses`, `create_drivers`, `create_places`, `create_users` | + +## Not handled — the subject itself + +| Scenario | Status | Reason | +| -------- | ------ | ------ | +| **Creating or editing a shift from Django admin** | **absent** | **Objective 2 of the subject is not met.** No `BusShiftAdmin`, no `BusStop` inline. A shift can only be created from the shell or a test. | +| A shift with fewer than two stops is refused | **absent** | Cannot be a table constraint — it counts rows in another table, and the zero-stop state is legitimate mid-transaction. It belongs in the inline formset's `clean()`. One test is red on this, deliberately. | +| The bounds are recomputed after an admin save | **absent** | `refresh_bounds()` exists and is tested, but nothing calls it from the admin. `save_related()` is the hook. | +| An overlap refusal is rendered as a form error | **absent** | The constraints carry `violation_error_message`; without an admin that catches `IntegrityError`, an overlap would surface as a 500. | +| The shift changelist avoids N+1 | **absent** | No `get_queryset()` with `select_related('bus', 'driver__user')` and `annotate(Count('stops'))`. | + +## Not handled — decided, with the reason + +| Scenario | Status | Reason | +| -------- | ------ | ------ | +| Concurrent creation of two overlapping shifts | **decided — enforced in the database** | A `clean()`-only check does not survive concurrency: two transactions each validate against a state that excludes the other, and both commit. `SELECT ... FOR UPDATE` does not help either — rows that do not exist yet cannot be locked. Only a database constraint holds. See the decision record below. | +| `SECRET_KEY` read from the environment | **not done** | Hard-coded in `padam_django/settings.py`, along with `DEBUG = True` and the SQLite path. Violates `.claude/rules/configuration.md` knowingly. To be corrected by the developer when the container chain lands, since the settings module must then read the environment. | +| Automated tests | **25 tests, 24 green** | `pytest` + `pytest-django`, run by `make test` in the container. The one red test is the two-stop cardinality rule, which has no implementation yet — red on purpose rather than deleted. | +| Concurrency proven under real parallelism | **not tested** | The exclusion constraints hold by construction, but no test opens two connections to demonstrate it. `TransactionTestCase` plus threads is the shape; listed as a next step. | +| Authorization on shift management | **not addressed** | Management happens through Django admin, whose `is_staff` gate is the only check. `.claude/rules/security.md` asks for an explicit role or ownership check per mutating endpoint; the admin is accepted as the boundary for this deliverable. | +| Structured logging, tracing | **not addressed** | `.claude/rules/observability.md` is not honoured: Django's default logging is in place, unstructured. Out of scope for a 4-hour exercise, recorded so it is not mistaken for an oversight. | + +## Scenarios a test suite must cover + +Listed here because the tests are written by the developer; this is the +checklist, not a claim that any of it passes. + +- Two shifts, same bus, **identical** time window → refused. +- Two shifts, same bus, **partially overlapping** windows → refused. +- Two shifts, same bus, **adjacent** windows (one ends exactly when the other + starts) → accepted. The boundary is a decision: state it, then test it. +- Two shifts, same driver, overlapping → refused. +- Two shifts, **different** buses and drivers, overlapping → accepted. +- A shift with one stop → refused. +- Stops out of chronological order → behaviour defined and asserted. +- Departure and arrival reflect the first and last stop after a stop is added, + removed, or retimed. +- The admin surfaces the refusal as a form error, not a 500. + +## Stack security — decided, not yet applied + +The inherited runtimes are out of support. Securing what is shipped is in +scope, so the versions move; dates verified against the upstream sources, not +recalled. + +| | Inherited | State | Target | Support ends | +| --- | --- | --- | --- | --- | +| Django | 4.2.16 | **unsupported since 7 April 2026** | 5.2 LTS | April 2028 | +| Python | 3.9 (README) / 3.7 (`Pipfile`) | **EOL 31 Oct 2025 / 27 Jun 2023** | 3.13 | October 2029 | +| PostgreSQL | none — SQLite | no dev/prod parity | 16 or 17 | Django 5.2 requires >= 14 | +| `django-extensions` | 3.2.1 | predates Django 5 — **blocks `runserver_plus`** | 4.1 | — | +| `factory-boy` / `Faker` | 3.2.0 / 8.10.1 (2021) | untested against Django 5 | current | — | + +Migration cost measured against this codebase, not assumed: **one line**. +`USE_L10N = True` (`settings.py:125`) was removed in Django 5.0. `USE_TZ = True` +becomes the default and stays valid. `unique_together` in +`geography/models.py:12` is unaffected — `index_together` is the removed one, +and it is not used. No other Django 5.0 removal is exercised by this code. + +## Decision — where the shift's time bounds live + +`BusShift` carries **real `start_at` / `end_at` columns**, recomputed from its +stops, rather than properties derived on read. + +**Why.** The bounds have to be columns for Postgres to index them, and they have +to be indexed for an `ExclusionConstraint` to exist. That constraint is the only +mechanism that survives two concurrent transactions creating overlapping +shifts — a `clean()` check does not, and neither does row locking, since the +conflicting row does not exist yet. It also puts the business rule **on the +model, in the `Meta`**, where someone opening the file reads it, instead of +burying it in a validation method. Time bounds use a **half-open interval +`[start, end)`**, so a shift ending at 12:00 and one starting at 12:00 do not +overlap — no arbitrary one-minute gap. + +**What it costs.** The columns can lie. `start_at` must equal the earliest +stop's time and `end_at` the latest; any code path that changes stops without +recomputing them breaks that. The paths that bypass a `post_save` signal are +known and named: `bulk_create`, `bulk_update`, `queryset.update()` and +`queryset.delete()`. The mitigation is one explicit recompute method on +`BusShift`, called from the admin's `save_related()` and from any service, with +`editable=False` on both fields so the admin never offers them for entry. + +**How the cost is watched.** The invariant is checkable — a query returning any +shift whose bounds disagree with its stops. That oracle is covered by tests. +Exhaustive exploration of operation sequences is deferred; see below. + +**Alternative rejected.** Making `start_at` / `end_at` operator input, with +stops required to fall inside, removes the desynchronisation risk entirely — but +inverts the subject, which states that departure is determined by the first +stop. + +## Fragilities established by reading the code + +Not part of the exercise; recorded because step 2 builds on this ground. +Full reasoning in `flows/existing-system.md`. + +| Fragility | Consequence | +| --------- | ----------- | +| `UserFactory.username` uses `fake.user_name`, which is not unique, against a `unique=True` column | A seeding batch can die on `IntegrityError` after partially committing. Will read as flakiness. | +| `create_drivers` creates its own users through a `SubFactory` instead of reusing those from `create_users` | After `create_data`: 10 users, 5 drivers, 5 users related to nothing. | +| No transaction wraps a seeding batch | A failure halfway leaves the database in a partial state. | +| `Bus.licence_plate` carries the verbose name `"Name of the bus"` | The admin and every generated form label the plate field "Name of the bus". Commit `d0aab9f` fixed the typo in the attribute name and left the label behind. One string, visible on the screen the exercise is judged on. | +| `UserFactory` never calls `set_password` | Seeded users have an unusable password and cannot log in. Fixtures for display only — a superuser must be created separately to reach the admin. | +| `UserAdmin` does not extend `django.contrib.auth.admin.UserAdmin` | No password form, no permission fieldsets, no staff-safe creation flow in the user admin. | +| `CreateDataBaseCommand.number` is typed `int \| None` | `__init__` sets it to `None`, `_init_options` fills it, so every `create_batch(size=self.number)` is a type error. Six of them, silenced in `pyproject.toml` with a pointer here. One annotation on the base command removes the silencing. | +| `UserAdmin.is_driver` resolves per row | One query per changelist row. Harmless at 10 users; the pattern to avoid when the `BusShift` changelist displays bus, driver and stop count. | + +## Deliberately deferred — to raise in the pull request + +Not oversights. Each was considered, costed against the exercise's time budget, +and set aside. They are listed so the reviewer sees the decision rather than +the absence. + +| Deferred | Why | What it would take | +| -------- | --- | ------------------ | +| **Hexagonal / DDD layering** | `.claude/rules/architecture.md` asks for `domain` / `application` / `infrastructure`. Rewriting four Django apps into that shape costs more than the exercise is worth and would obscure the Django idiom being evaluated. | A `domain/` package holding `BusShift` as a pure entity with the overlap rule, a repository port, and a Django adapter implementing it. The models become persistence, not business. | +| **Property-based testing of the denormalisation invariant** | The chosen model stores `start_at` / `end_at` on `BusShift` and maintains them, so a code path that changes stops without recomputing makes them lie. The tests cover the known paths; they cannot cover the ones nobody thought of, which is exactly what this risk is. Out of the exercise's budget — a new dependency and a longer CI run for a class of bug that the explicit-recompute design already makes unlikely. | Hypothesis generating **sequences of operations** (add a stop, retime one, remove one, bulk-create several) and asserting the bounds oracle after each. The oracle already exists as a test helper, so the missing piece is only the generator. Plus two-connection concurrency tests using `TransactionTestCase` and threads, to prove the exclusion constraint under real parallelism rather than in a single process. | +| **Generated changelog and release automation** | `release.md` asks for a `CHANGELOG.md` generated from the commit history and a version derived from the commits. The commits are already written as Conventional Commits, so the input exists — but a `cliff.toml` with no release pipeline behind it is configuration that serves nothing today, and the exercise has no releases to cut. | A `cliff.toml` grouping by type, a `make changelog` target running git-cliff in the container, and a release workflow that tags and promotes the image CI built rather than rebuilding it. | +| **Git hooks (pre-commit)** | The CI gate runs in the container via `make ci`. Wiring `pre-commit` on top of it duplicates the definition for a time budget that does not allow verifying both. | A `.pre-commit-config.yaml` whose hooks are thin callers of the same `make` targets, so the gate has one definition. | +| **Observability** | No structured logging, no correlation id, no spans. `.claude/rules/observability.md` is entirely unhonoured. | JSON log formatter, request-id middleware, OpenTelemetry instrumentation on the ORM and the admin views. | +| **Explicit authorization** | Django admin's `is_staff` is the only gate on shift management. `.claude/rules/security.md` asks for a role or ownership check per mutating action, with a test asserting the 403. | Per-model permissions, `has_change_permission` overrides, and denial tests. | +| **An HTTP API** | The subject asks for the admin, nothing else. | DRF or Django Ninja over the same domain rules. | diff --git a/docs/flows/README.md b/docs/flows/README.md new file mode 100644 index 00000000..db07d82f --- /dev/null +++ b/docs/flows/README.md @@ -0,0 +1,28 @@ +# `docs/flows/` — the call chains + +## What this folder is for + +One document per feature, answering what no docstring can: **who calls what, in +what order, and what happens when it fails.** A function is agnostic by nature; +the problem it answers lives in the chain, and the chain is what costs an +afternoon during an incident. + +## What belongs here + +`.md`, written **from the code, by reading it**. A flow written from +intent describes the feature that was meant to be built, which is the one that +never breaks. + +## What must never land here + +- A flow describing code that does not exist yet. It is a plan, and it belongs + in the pull request description. +- A diagram nobody re-read against the source. No tool will ever report that a + flow lies — CI only checks that the site builds and that links resolve. + +## Conventions + +- Mermaid for the sequence; prose for the failure modes. +- Every unhandled branch links to its line in `../coverage.md`. +- Updated in the **same commit** as the behaviour it describes. +- Scaffold one with `/code-flow `. diff --git a/docs/flows/bus-shift.md b/docs/flows/bus-shift.md new file mode 100644 index 00000000..51a16333 --- /dev/null +++ b/docs/flows/bus-shift.md @@ -0,0 +1,84 @@ +# Flow — creating a bus shift + +Written by reading the code as it stands at the end of the exercise. +**The admin side is not implemented**, so this document describes the chain the +model layer supports and marks the missing link explicitly. A flow that +described the intended admin would document a feature that does not exist. + +## The chain, as the model supports it + +```mermaid +sequenceDiagram + participant C as Caller (admin, shell, test) + participant S as BusShift + participant T as BusStop + participant D as PostgreSQL + + C->>S: BusShift.objects.create(bus, driver) + S->>D: INSERT — starts_at and ends_at are NULL + Note over D: The exclusion constraints do NOT apply:
their condition excludes rows with NULL bounds + C->>T: BusStop.objects.create(shift, place, time) ×N + T->>D: INSERT — UNIQUE (shift, time) checked per row + C->>S: shift.refresh_bounds() + S->>D: SELECT MIN(time), MAX(time) FROM fleet_busstop + S->>D: UPDATE starts_at, ends_at + Note over D: `period` is recomputed by the database
(GeneratedField, TSTZRANGE '[)') + D-->>S: exclusion constraints evaluated HERE + D-->>C: IntegrityError if the bus or the driver is already busy +``` + +## The order is not negotiable, and this is why + +A `BusStop` carries a foreign key to its shift, so **the shift row has to exist +before any stop can**. The bounds are derived from the stops, so they cannot be +known at that moment. A shift therefore always passes through a state with no +stops and no window. + +Three consequences follow, and each one shapes the design: + +1. **The exclusion constraints carry a `condition`.** A row with NULL bounds + would generate `TSTZRANGE(NULL, NULL)`, which is the *unbounded* range and + overlaps everything — a single stopless shift would forbid every other shift + for its bus. The partial index keeps those rows out. +2. **The overlap refusal surfaces on the `UPDATE`, not the `INSERT`.** The shift + is created freely; it is `refresh_bounds()` that acquires the window and + therefore trips the constraint. Anything catching `IntegrityError` has to + wrap the recompute, not the creation. +3. **"At least two stops" cannot be a table constraint.** It counts rows in + another table, and the zero-stop state is legitimate for the duration of the + transaction. It belongs where the shift and its stops are seen together — + the admin's inline formset. + +## Where the bounds can lie + +`starts_at` and `ends_at` are stored, not derived on read. `refresh_bounds()` is +the only thing that makes them true, and it is **not** wired to a signal — by +choice, since `bulk_create`, `bulk_update`, `queryset.update()` and +`queryset.delete()` all bypass signals and are exactly the paths that would +leave the columns lying. + +The price is that every caller must invoke it. Today the callers are the test +suite and any manual session. **The admin caller does not exist yet.** + +## What is missing + +| Missing | Where it goes | Consequence today | +| ------- | ------------- | ----------------- | +| `BusShiftAdmin` with a `BusStop` inline | `fleet/admin.py` | **A shift cannot be created from the admin at all** — objective 2 of the subject is not met. | +| `save_related()` calling `refresh_bounds()` | `BusShiftAdmin` | Nothing recomputes the bounds after a formset save. | +| Formset `clean()` requiring two stops | the inline formset | A one-stop shift is accepted. Covered by the one failing test. | +| `get_queryset()` with `select_related` / `annotate` | `BusShiftAdmin` | The changelist would issue one query per row for bus, driver and stop count — the pattern already documented on `UserAdmin.is_driver`. | +| `IntegrityError` → form error | `BusShiftAdmin` | An overlap would surface as a 500 rather than as the message the constraint carries in `violation_error_message`. | + +## Failure modes + +| Situation | Behaviour today | +| --------- | --------------- | +| Two shifts overlapping on the same bus | Refused by `bus_shift_no_overlap_per_bus`, at the recompute | +| Two shifts overlapping on the same driver | Refused by `bus_shift_no_overlap_per_driver` | +| One shift ending exactly when the next begins | **Accepted** — the interval is half-open `[starts_at, ends_at)` | +| Two stops at the same instant in one shift | Refused by `bus_stop_one_call_per_instant` | +| A shift with one stop | **Accepted** — no validation exists yet | +| Two concurrent transactions creating overlapping shifts | Refused by the database. Not proven by a test: that needs two connections, listed in `../coverage.md` | +| Deleting a bus or a driver still referenced | Refused — `on_delete=PROTECT` | +| Deleting a shift | Its stops go with it — `on_delete=CASCADE` | diff --git a/docs/flows/existing-system.md b/docs/flows/existing-system.md new file mode 100644 index 00000000..facd01da --- /dev/null +++ b/docs/flows/existing-system.md @@ -0,0 +1,87 @@ +# Flow — the inherited system + +Written by reading the code at `16a6be4`, before any work on `BusShift`. +It describes what the repository does **today**, so that step 2 changes a known +system rather than a presumed one. + +## 1. Serving a page + +```mermaid +sequenceDiagram + participant B as Browser + participant U as padam_django/urls.py + participant A as django.contrib.admin + participant M as apps/*/admin.py + participant O as Django ORM + participant D as SQLite (db.sqlite3) + + B->>U: GET /admin/... + U->>A: admin.site.urls + A->>M: the registered ModelAdmin + M->>O: queryset + O->>D: SQL + D-->>B: rendered admin page +``` + +`urlpatterns` contains **exactly one route**: `admin/`. There is no view, no +API, no template of the project's own. The entire user-facing surface of this +project is the Django admin — which is why the exercise is judged on it. + +Registered admins: `BusAdmin` and `DriverAdmin` (bare `ModelAdmin`), +`PlaceAdmin` (bare), `UserAdmin` (a `list_display` with an `is_driver` column). + +## 2. Seeding demo data + +```mermaid +sequenceDiagram + participant C as manage.py create_data + participant K as django.core.management + participant S as create_users / create_drivers / create_buses / create_places + participant B as CreateDataBaseCommand + participant F as factory-boy + Faker(['fr']) + participant O as Django ORM + + C->>K: call_command('create_users', number=5) + K->>S: Command.handle() + S->>B: super().handle() → _init_options() → self.number + S->>F: XxxFactory.create_batch(size=self.number) + F->>O: bulk model creation +``` + +Counts in `create_data` are **hard-coded**: 5 users, 5 drivers, 10 buses, +30 places. The `-n/--number` option exists on each individual command through +`CreateDataBaseCommand.add_arguments`, but `create_data` does not forward one. + +## 3. What this reading revealed + +Facts established by reading the code, not defects anyone asked to fix. They +are recorded because step 2 builds on this ground. + +- **`create_drivers` does not use the users created by `create_users`.** + `DriverFactory.user` is a `SubFactory` on `UserFactory`, so each driver + creates a *new* user. After `create_data`, the database holds 10 users, of + which 5 are drivers and 5 are related to nothing. Seeding a `BusShift` will + need drivers, so this matters to step 2. +- **`UserFactory.username` uses `fake.user_name`, which does not guarantee + uniqueness**, while `AbstractUser.username` is `unique=True`. Two identical + draws inside one batch raise an `IntegrityError` and the command dies + mid-way, having already committed part of the batch. It is a low-probability + failure that will look like flakiness the day it happens. `factory.Sequence` + or `factory.Faker` with a uniqueness wrapper is the usual answer. +- **`UserAdmin.is_driver` is a per-row property, not an annotation.** + `User.is_driver` calls `hasattr(self, 'driver')`, which issues one query per + row on the changelist. On 10 seeded users it is invisible; the pattern is the + one to avoid when the `BusShift` changelist starts displaying its bus, its + driver and its stop count. `select_related` / `annotate` in + `get_queryset` is the shape that scales. + +## 4. Failure modes + +| Situation | Behaviour today | +| --------- | --------------- | +| `create_users` draws a duplicate username | `IntegrityError`, command aborts, partial data committed — no transaction wraps the batch | +| `create_places` draws duplicate coordinates | `IntegrityError` from `unique_together`; same partial-commit behaviour | +| `manage.py` run without the DB file | Django creates `db.sqlite3` on first connection; a missing migration surfaces only at query time | +| Any error in the admin, `DEBUG = True` | Full traceback rendered to the browser, revealing paths and settings — see `../coverage.md` | + +No scenario above is covered by a test: there are none. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..6aa08191 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,36 @@ +# Padam — BusShift + +Modelling of bus shifts (`BusShift`) and their stops (`BusStop`), managed +through the Django admin. + +## Where to look + +| If you want to know | Read | +| ------------------- | ---- | +| What the system handles, and what it deliberately does not | [Coverage](coverage.md) | +| Who calls what, in what order, and on failure | [Flows](flows/existing-system.md) | +| How to run any of it | the `Makefile` — `make help` lists every target | + +## Running it + +Everything runs in a container. The host installs a container runtime and +nothing else: no Python, no package manager, no toolchain. + +``` +make up # database + application, waits for health +make migrate # apply the migrations +make seed # demo data +make superuser # an account to reach /admin/ +``` + +The application answers on `http://localhost:8000/admin/`, the documentation on +`http://localhost:8001` after `make docs`. + +`make ci` is the single definition of green. + +## What this documentation is not + +It does not restate what a signature already says. It carries the two things a +docstring cannot: the **sequence** of calls, and the **gap** — the scenario +nobody handled. A scenario absent from the coverage page is indistinguishable +from one nobody thought about, so absence is itself the failure mode. diff --git a/docs/sessions/2026-08-29-d0f0905d.md b/docs/sessions/2026-08-29-d0f0905d.md new file mode 100644 index 00000000..2382f6f4 --- /dev/null +++ b/docs/sessions/2026-08-29-d0f0905d.md @@ -0,0 +1,1955 @@ + +# Session — 2026-08-29T14:53:46.253Z + +56 turns. What was said, in order: the prompts and the +replies as they appeared on screen. The steps taken to produce them — +reasoning, tool calls, their results — are deliberately absent. + +Secrets matching the recorder's patterns are redacted; that is a net, +not a guarantee. Read before sharing. + +### 🧑 Human says + +You are finalizing the Claude Code setup for this project. The shared doctrine +is already installed in `.claude/rules/`. Your job is everything that is +specific to **this** project. + +Optional hint from the developer: + +This is an **interview**, not a form. Go layer by layer, and do not move to the +next layer until the current one is settled. Never invent an answer the +developer should give — a plausible guess written into `CLAUDE.md` is worse +than an open question, because it will be trusted later. + +--- + +## Layer 0 — Discover before asking + +Read the repository first. Never ask what the code can tell you. Establish: + +- **Stack**: languages, frameworks, versions. Read the manifests + (`pyproject.toml`, `package.json`, `go.mod`, `Cargo.toml`, …), not the + imports. +- **Layout**: top-level directories, where the domain lives, whether the + hexagonal layering in `rules/architecture.md` is already respected or + violated. +- **Commands**: how it builds, tests, lints, runs. Read the `Makefile`, the + npm scripts, the CI workflow. Prefer what CI actually runs. +- **Environment**: how many Dockerfiles, and do they share a base? Is there a + compose file, a committed `.env`, a `.venv` or `node_modules` used as the + real workflow? **Which commands only work on the host?** Check the README and + the CI workflow: a task documented as a bare host command is a gap. Note + every one against `rules/environment.md` — this is the developer's stated + hard line, and the most likely place an existing project fails it. +- **Database**: engine, migration tool, where the schema lives. Is there a + `docs/data-model/`, and does it still match the migrations? Note anything the + schema enforces only in the application. +- **Tests**: framework, where they live, whether coverage is measured. +- **Documentation**: is there a `mkdocs.yml`? A `docs/` tree? Do `docs/flows/` + and `docs/coverage.md` exist, and are they current or abandoned? An + out-of-date flow document is worse than none — flag it as debt, loudly. + Is the site built and served in the container, or does it assume a host + toolchain? +- **Git**: default branch, commit message style already in use. + +Then state what you found in a short table, and **name what you could not +determine**. Only then start asking. + +--- + +## Layer 1 — Product and intent + +### Open the interview with an open question + +**The first question is free-form prose, not a multiple choice.** Present the +discovery table, then ask, in the conversation and with no options offered: + +> What is this repository, what is the objective, and what should I take into +> account from the very start? + +Then stop and read the answer. It sets the vocabulary, the stakes and the +constraints — and offering options here would narrow the answer to what you +already guessed from the code, which is exactly what you must not do. The +developer knows things the repository cannot show: a deadline, a migration in +flight, a decision already taken, a part nobody may touch. + +Ask a second open follow-up if the answer leaves something load-bearing +unclear. Only then move to structured questions. + +### Then the structured rounds + +With that context, the choices become sharp rather than generic: + +- What is explicitly **out of scope**? +- What is the failure that would hurt most? (data loss, downtime, wrong + numbers, leaked data — this drives where rigour goes) +- What stage is it at: prototype, in production, legacy under repair? +- Who uses it, and who maintains it? + +Do not re-ask anything the open answer already settled. + +## Layer 2 — Rules and points of attention + +- Walk through the gaps found in Layer 0 against the shared doctrine. For each, + ask whether it is a debt to record or a deliberate exception. +- If the project has more than one Dockerfile, check whether they share a base. + If they do not, say what has already drifted between them — concretely, by + diffing the versions — rather than raising it as a principle. +- Ask what recurring mistakes the developer wants prevented on this project + specifically. +- Ask for any project rule not covered by `.claude/rules/`. Offer to write it as + a new rule, and ask whether it should load always or only under a path glob. +- Confirm the numeric thresholds in `.claude/thresholds.json` against this + codebase. If most existing files already violate one, say so — a threshold + that is violated everywhere trains everyone to ignore the warnings. +- **The data model.** If the project has a schema, offer to run the + `data-model-map` skill: it generates the ERD, writes the hand-written map, + and reports modelling smells — two entities that are one, a foreign key + crossing a bounded context, integrity left to the application. Report them as + candidates; never change a model, since a schema change is a data migration. +- **The gold book** (`code-flow.md`): ask which existing features already + deserve a flow document, and in what order. On an existing codebase this is a + backlog, not a one-shot — propose the two or three flows that would pay off + first, chosen by where production incidents actually happen. Offer to write + the first one with `/code-flow` before the setup is declared finished, and + seed `docs/coverage.md` with what the code demonstrably does not handle. + +## Layer 3 — Tools and workflow + +- Which path-scoped rules match nothing in this project? A rule whose globs + never fire costs nothing, but say which ones are inert so the developer knows + what is actually governing the code. Remove one only if asked. +- **Run `python3 .claude/tools/which-rules.py`** and show the developer what + actually loads. A rule listed as inert whose subject the project *should* + have — no Dockerfile, no CI workflow — is a gap to fix, not a rule to delete. + If the container chain is missing, offer the `project-scaffold` skill now. +- **Recommend skills.** Beyond the two shipped (`project-scaffold`, + `feature-flow`), propose skills for repeatable multi-step procedures this + project actually has — a release flow, a migration procedure, a review + checklist. The test: a *procedure* is a skill, a *constraint* is a rule. Say + which it is and why. Ask before creating any. +- **Recommend subagents.** Two ship with the template: `flow-tracer` (fan-out + reading, small output) and `conformance-reviewer` (independent of the + implementation reasoning). Propose more only where a genuinely separate + context pays. Be conservative: ten agents nobody invokes is worse than none. +- **Session recording.** If `.claude/session-recording.json` exists, ask + whether transcripts should be committed — yes for an interview or an audit, + gitignored when the point is only continuity — and reflect the answer in + `.gitignore`. If it does not exist, mention that `--with-recording` adds it. +- **MCP servers.** The template ships none on purpose: an MCP server connects + *this project's* external systems, so there is nothing generic to install. + Ask what Claude would need live access to — the database, the issue tracker, + monitoring, a browser — and whether the value beats the context and + permission cost. Write `.mcp.json` only for what the developer confirms. +- Ask what the developer wants Claude to do without asking, and what must + always require confirmation. Translate the answer into `permissions` in + `.claude/settings.json` — allow/deny/ask — and say plainly which of their + answers became an enforced rule and which stayed advisory. + + Two mistakes make a permission rule silently useless: + + - **File paths go on `Edit(...)` and `Read(...)`, never on `Write(...)`, + `NotebookEdit(...)`, `MultiEdit(...)` or `Glob(...)`.** Claude Code checks + file permissions against `Edit` and `Read` rules only; it accepts the + others, never consults them, and warns at startup. `Edit` already covers + every file-editing tool. A bare tool name with no path — denying `Write` + outright — is different and does work. + - **Paths are relative to the settings file, not absolute.** Write + `Edit(/src/**)`, which resolves against the project root. An absolute + `Edit(//home/someone/projects/thing/src/**)` stops matching the moment the + repository is cloned anywhere else, which is every other machine. + + Run `python3 .claude/tools/check-template.py .` after writing them: it + refuses both mistakes. + +## Layer 4 — How the developer wants to work + +- How should Claude deliver work: plan first, or act then report? +- What does "done" mean here — tests green, CI green, deployed? +- Which parts of the codebase are sensitive enough to always warrant a plan + before touching them? + +--- + +## Then write + +Only once the layers are settled: + +1. **`CLAUDE.md`** at the project root. Under 200 lines. It contains what is + true of this project and nothing that `.claude/rules/` already says. Stack, + architecture as it actually is, commands, product scope, known debt, points + of attention. Concrete and verifiable — "run `make test`", not "test your + changes". +2. **New project rules** in `.claude/rules/`, path-scoped where possible. +3. **`.claude/thresholds.json`** and **`.claude/quality-gate.json`** filled with + this project's real commands, including the docs build and serve commands. + Every command recorded there must be the one that runs in the container, not + a host equivalent that happens to work on this machine today. +4. **`.claude/settings.json`** permissions, from Layer 3. +5. **`.claude/SETUP-REPORT.md`** — what you discovered, what the developer + answered, what you wrote, and every question left open. + +Then run `python3 .claude/tools/check-template.py` and fix what it reports. +Do not present the setup with errors outstanding. + +## Then present the paradigm + +Finish by presenting, in the conversation, your model of this project: + +- What the setup now **covers** — each rule, and what it prevents. +- What it **does not cover**, explicitly. Gaps you saw and did not configure, + conventions you inferred but could not confirm, risks nobody enforced. +- Which features have a flow document and which do not — the current reach of + the gold book, stated as a fact rather than left implicit. +- Where a rule is **advisory** versus **enforced by a hook**, since only the + second survives a model that decides otherwise. +- What you **verified by running it** versus what you took from reading. Every + command you recorded in `quality-gate.json` should be in the first group; + say plainly which are not (`verification.md`). +- What you would add next, and what you deliberately left out. + +Then ask whether it matches how the developer sees the project. **Iterate on +this presentation until they say it is right**, adjusting the written files at +each round. Do not treat the setup as finished because you produced files — it +is finished when the developer confirms the paradigm. + +### 🤖 Claude says + +I'll start with Layer 0 — reading the repository before asking anything. + +## Layer 0 — what the repository says + +| | Found | +|---|---| +| **Stack** | Python (Pipfile says 3.7, README says 3.9), Django 4.2.16, `django-extensions`, `Werkzeug`, `ipython`, `factory-boy`/`Faker`. No lockfile — `requirements.txt` with pinned `==`, `Pipfile` empty and gitignored. | +| **Layout** | `padam_django/` project + `apps/{common,fleet,geography,users}`. Flat Django apps: `models.py`, `admin.py`, `factories.py`, management commands. **No hexagonal layering at all** — no `domain/`, `application/`, `infrastructure/`. Business logic lives in models/admin. | +| **Domain** | `Bus` (licence_plate), `Driver` (OneToOne User), `Place` (name + lat/lon, unique together), `User(AbstractUser)` with `is_driver`. **`BusShift` and `BusStop` do not exist** — they are the exercise. | +| **Commands** | `Makefile` has only `run` (`runserver_plus`) and `install` (`pip install -r requirements.txt`). **README documents `make migrate`, which does not exist.** Everything runs on the host, in a virtualenv. | +| **Environment** | No Dockerfile, no compose, no `.dockerignore`. `.env.example` exists but declares **zero variables**. SQLite file DB. `SECRET_KEY` hardcoded in `settings.py`, `DEBUG = True` committed. `.idea/` is committed *and* gitignored. | +| **Database** | SQLite via Django ORM; 4 migrations across fleet/geography/users. No `docs/data-model/`. | +| **Tests** | **None.** No pytest, no `tests/`, no coverage config. `factory-boy` is present but only feeds the `create_*` seed commands. | +| **Docs** | No `mkdocs.yml`, no `docs/flows/`, no `docs/coverage.md`. `docs/` contains only Claude session transcripts (gitignored). README is the exercise statement, EN + FR. | +| **Git** | Default `master`; current branch `feature/srs/technical-review`. Upstream history is a fork of `Optiways/padam-django-tech-test` — plain non-conventional subjects ("Update dependencies"). | +| **Rules loading** | 13 always-on, 7 scoped firing, 5 inert (`ci`, `frontend`, `i18n`, `release`, `typescript`). | + +**What I could not determine from the code:** whether this is your interview submission or a repo you are reviewing as an examiner; whether the doctrine's hard lines (containers, hexagonal, mkdocs, CI) are meant to apply *to a 4-hour test*, or whether the point is precisely to demonstrate them; how much of the 4-hour budget you consider spent; and whether `master` here must stay a clean mirror of the upstream fork. + +--- + +Now the question I should not guess the answer to: + +> **What is this repository, what is the objective, and what should I take into account from the very start?** + +### 🧑 Human says + +C'est un test technique Django, l'objectif est d'implémenter BusShift et BusStop models. Mon objectif est de montrer mes capacités de code en tant que dev et de gestion de schema de Db. Mais également démontrer ma capcité à organiser et structurer un projet dans un objectif de production. En cela ta configuration de base embarque deja ma doctrine de projet que nous allons affiner. Ce que tu dois retenir dès le début, en tant qu'entretien technique tu n'as pas le droit de modifier le code django, tu auras uniquement des droits de lecture du git. Tu pourras écrire uniquement les actions deja organiser et ordonné comme la documentation "docstring" "rules book" "flow chart". Ton objectif sera d'etre un sparring partner, de mettre en visibilité si je break une de mes doctrines, avant d'ecrire quoi que ce soit par example "ecrire le makefile standarisé d'un projet" tu valides avec moi d'abord, tu seras utilisé pour optimiser mon temps d'init de l'environement d'un projet et de tache mineure (hors code et db). Tu pourras faire des analyses et me donner les conclusions, cependant c'est bien a moi de définir et de choisir ou même de vérifier ces conclusions + +### 🤖 Claude says + +Compris, et c'est le point le plus structurant de tout le setup : **lecture seule sur le code Django et la DB**, écriture uniquement sur ce qui est déjà cadré et validé avec toi au préalable. Je vais en faire une règle dure, pas une préférence. + +Une deuxième question ouverte avant les rounds structurés, parce que la frontière a un bord flou que je ne veux pas trancher à ta place : + +**Où passe exactement la ligne « code » / « pas code » ?** Concrètement, trois cas m'embêtent : + +1. **Les docstrings.** Tu les cites comme écrivables, mais elles vivent *dans* les `.py` — dans `models.py`, à côté du code que je n'ai pas le droit de toucher. Est-ce que j'ai le droit d'éditer un fichier Python pour n'y ajouter qu'une docstring (avec ton validation avant), ou est-ce que je te *propose* le texte et c'est toi qui colles ? La différence change ce que j'écris dans les permissions : `Edit(/padam_django/**)` en `deny` pur, ou en `ask`. + +2. **Le hors-Django.** `Makefile`, `Dockerfile`, `compose.yml`, workflow CI, `.env.example`, `mkdocs.yml`, `docs/**` — je comprends que c'est mon terrain, *après validation*. Mais `padam_django/settings.py` ? C'est du code Django, et c'est aussi là que vit la configuration (`SECRET_KEY` en dur, `DEBUG = True`, SQLite) que ta doctrine `configuration.md` interdit. Si containerisation il y a, `settings.py` doit bouger. C'est toi qui le fais, ou c'est une exception ? + +3. **Ce que le repo n'a pas encore.** `BusShift`, `BusStop`, leurs migrations, leurs tests : ce sont des fichiers *à créer*, pas à modifier. Est-ce que la lecture seule couvre aussi la création — donc je ne scaffolde jamais un test, je te dis seulement quel test manque et pourquoi ? + +Et une chose que je dois savoir pour calibrer la sévérité : **est-ce que le rendu de ce test inclut l'infrastructure** (container, CI, docs site), ou est-ce que le livrable évalué reste les deux modèles + l'admin, l'infra n'étant là que pour démontrer ta manière de travailler ? + +### 🧑 Human says + +tu peux intervenir en tant que proposition sur le code, mais aucune initiative d'écriture. Le but de l'entretien reste ma capacité en Django et plus particulierement sur django admin. Le rendu de ce test sera sous forme de PR donc oui cela couvre l'environement infrastructure mis en place ainsi que ta config claude. + +### 🤖 Claude says + +Clair. Deux conséquences que je note tout de suite : le livrable évalué inclut `.claude/` — donc ma config est **elle aussi** relue par l'examinateur, ce qui interdit d'y laisser des placeholders — et l'axe évalué étant Django admin, c'est là que la rigueur doit se concentrer, pas sur l'infra qui n'est qu'un décor cohérent. + +Il me reste une bascule que je ne veux pas trancher seul, plus deux questions de cadrage. + +Décisions notées. La chaîne conteneur complète a une conséquence directe sur ton axe de risque n°1, et je te la pose comme analyse, pas comme choix fait : + +**Si le conteneur arrive, la base de dev cesse d'être SQLite** (`environment.md` : « une base SQLite en local et Postgres en production est un bug qui attend sa première migration »). Or c'est précisément ce qui débloque la contrainte de non-chevauchement **au niveau base** plutôt qu'en `clean()` applicatif : Postgres offre `ExclusionConstraint` sur un `tstzrange` avec `btree_gist`, ce que SQLite ne sait pas faire. Django l'expose via `django.contrib.postgres.constraints.ExclusionConstraint`. Concrètement, un `clean()` seul laisse passer deux créations concurrentes ; l'exclusion, non — et `security.md` comme `database.md` disent que l'intégrité ne se délègue pas à l'application. + +Le coût est réel et je ne le minimise pas : ça impose un champ de plage (ou deux `DateTimeField` + `GeneratedField`), une migration `BtreeGistExtension`, et ça déplace la départure/arrivée depuis « déduites du premier et dernier `BusStop` » vers un champ dénormalisé maintenu — ce qui est un vrai arbitrage de modélisation, pas une évidence. **C'est ta décision, et c'est exactement le genre de choix que l'examinateur t'attend sur le fond.** Je te dis ce que ça ouvre, tu tranches. + +Layer 2 — les écarts constatés face à ta doctrine : + +Noté : tests = tu écris, je complète **uniquement sur demande** → `Edit(/tests/**)` en `ask`, jamais en `allow`. + +Layer 3 et 4, dernier round avant que j'écrive quoi que ce soit : + +Direction notée : je rends l'analyse, j'attends ton plan, je remarque et je suggère, tu tranches. Je passe en exécutant une fois l'init finalisée. + +Avant d'écrire, je vérifie le format attendu des fichiers que je vais produire. + +Format vérifié. J'écris maintenant — en commençant par la règle la plus dure, celle qui te protège de moi. + +`check-template.py` : **0 erreur, 1 avertissement** (343 lignes de contexte permanent, seuil 300 — ma règle en ajoute 36). Voici mon modèle du projet. + +## Ce que le setup couvre + +**La règle dure — `interview-scope.md`, chargée à chaque session, adossée à `deny`.** C'est la seule qui compte vraiment : elle dit que tu es l'auteur évalué et que je ne suis pas contributeur. Elle interdit toute écriture sous `padam_django/**` — modèle, admin, migration, *et docstring dans un `.py`* — impose que mes propositions passent par un bloc de code dans la conversation, et pose le mode « tu leads, je suis ». + +**Ce qui est réellement appliqué par le harnais**, pas seulement écrit : + +| Barrière | Mécanisme | Effet | +|---|---|---| +| `Edit(/padam_django/**)` | `deny` dans `settings.json` | Aucune écriture possible, même si je décide autrement. Couvre `Write` et `MultiEdit`, que Claude Code résout sur les règles `Edit`. | +| `git commit/add/push/rebase/reset/checkout…` | `deny` | `git.md` était advisoire ; il est maintenant branché. | +| `Edit(/tests/**)` + infra | `ask` | Prompt systématique. Jamais d'initiative. | +| `docs/**`, `.claude/**`, lecture, `git log/diff/status` | `allow` | Sans friction. | +| Secret sur disque ou dans un diff staged | hook `secret-scanner` | Bloque. | +| Fichier créé dans un dossier sans `README.md` | hook `folder-readme-guard` | Bloque. | +| `.env` commité, `Dockerfile.dev` qui ne dérive pas, édition de `docs/api` | hook `convention-guard` | Bloque. | +| Seuils de taille/complexité | hook `verifiable-thresholds` | **Avertit seulement.** | + +**Documenté :** `CLAUDE.md` (119 lignes — stack, architecture réelle, table de commandes marquée non vérifiée, dix dettes concrètes), `docs/README.md`, `docs/coverage.md` semé depuis la lecture du code, `quality-gate.json`, `SETUP-REPORT.md`. + +## Ce que ça ne couvre pas — explicitement + +- **`architecture.md` est violée par construction.** Le projet est un Django plat, pas hexagonal. Je l'ai écrit comme **exception assumée** dans `CLAUDE.md`, pas comme dette : une coquille hexagonale sur quatre modèles masquerait exactement ce qui est évalué. Si tu n'es pas d'accord, c'est la ligne à changer en premier. +- **Aucun garde-fou sur l'admin Django**, qui est pourtant ton axe évalué n°1. Aucune règle, aucun hook ne dit à quoi ressemble un bon `ModelAdmin` ici. C'est le trou le plus large du setup. +- **Les seuils de couverture (80 / 70) sont violés à 100 %** — zéro test. Un seuil violé partout apprend à ignorer l'avertissement. Je ne l'ai pas baissé de moi-même : à toi de trancher une fois la suite écrite. +- `ci.md` et `release.md` sont **inertes** : elles ne se déclencheront pas au moment de créer le workflow, puisqu'aucun fichier ne les fait matcher aujourd'hui. +- `frontend.md`, `i18n.md`, `typescript.md` : inertes et sans objet ici. +- **Le `SECRET_KEY` en dur reste en dur.** Documenté, non corrigé — c'est ta main. + +## Gold book + +`docs/coverage.md` existe et est à jour. **Aucun flow n'existe** — `docs/flows/` est vide *par décision*, pas par oubli : le flow `BusShift` s'écrit depuis le code, une fois le code écrit. La portée actuelle du gold book est donc : couverture oui, flows zéro. + +## Vérifié en exécutant vs. lu + +**Exécuté :** `which-rules.py`, `check-template.py` (0 erreur), validité JSON des deux configs. +**Aucune commande projet n'a tourné.** Ni `make run`, ni `make install`, ni une migration. La raison est doctrinale : le dépôt ne tourne que sur l'hôte, ce que `environment.md` interdit, et le conteneur qui le ferait tourner n'existe pas. **Donc chaque entrée de `quality-gate.json` est marquée `pending` ou `absent` — aucune n'est annoncée verte.** C'est volontairement inconfortable : le fichier dira la vérité le jour où `make ci` aura vraiment tourné. + +## Ce que j'ajouterais ensuite, et ce que j'ai laissé de côté + +1. La chaîne conteneur via `project-scaffold` — **je ne l'ai pas lancée**, elle passe par ta validation. +2. Une règle scopée `**/admin.py` sur les conventions d'admin Django, pour combler le trou cité plus haut. +3. `.mcp.json` Postgres en lecture, quand le conteneur existera. + +## Trois points où j'ai besoin de ton arbitrage + +- **`SETUP-REPORT.md` est gitignoré** par le template, alors que tu veux que la PR expose la config. Incohérence à trancher : je le sors du `.gitignore` ou il reste privé ? +- **343 lignes de contexte permanent** (seuil 300). Ma règle en est responsable. Je peux la scoper ou la condenser — mais c'est celle qui doit survivre à un modèle qui décide autrement, donc je penche pour la garder telle quelle. +- **L'exception hexagonale** : est-ce bien ta lecture, ou tu veux que `BusShift` vive dans une couche domaine ? + +Est-ce que ce modèle correspond à comment tu vois le projet ? + +### 🧑 Human says + +gold book couverture oui et flow oui ; setup-report.md et tout se qui est claude sort du gitignor on veut le montrer ; oui ici on vas faire une exception sur le DDD donc l'hexagonal - le rewrite en DDD serait trop couteaux en temps pour ce test technique tu peux par contre le garder comme élément a remonter en PR sur les elements qui ne sont pas présent mais qui pourrait etre des next step. Dans l'ensemble le model correspond voici les remarques personnels dans le désordre et le scope de mon plan d'action: etape 1 - mise en place d'un environement iso prod local, dockerfile, makefile, pyproject.toml environement example et environment seedé en local dans le gitignore. Layer 0 expose une stack a review notament sur la version de python et de django ; Chois de db rationalisé, postresql dev env to prod env . Tests unitaire chois pytest ; CI on vas appliquer la CI en local en container mais on ne vas pas faire les hooks - raison de ce choix et la limite de temps tu gardes en mémoire pour les next step a marquer en PR. On applique la stack de tooling CI de la doctrine tu peux en suggerer si trou dans la raquette de la doctrine. Au niveau documentation - pendant que je commence a construire l'environement - tu peux faire une premiere passe de documentation (readme file, docstring google format, docs/coverage docs/code-flow ) Validation de l'environement a jour celui ci run et est accessible. Etape 2 resolution de l'exercice de test django - définition de la feature, de ces contraintes, des contraintes non visible compréhension complète de l'exercice, design de la db resolvant les constraints par moi review par toi, phase de code/debug/review. + +### 🤖 Claude says + +Fichiers à jour : `.gitignore` n'ignore plus rien de Claude ni les transcripts, et `docs/coverage.md` porte maintenant une section **« Deliberately deferred — to raise in the pull request »** où le DDD/hexagonal figure en tête, avec ce qu'il faudrait pour le faire. Les hooks pre-commit y sont aussi. + +⚠️ Un effet de bord : `.claude/settings.local.json` devient traçable. Ce fichier est *par nature* machine-locale (permissions accordées sur ta machine). Il n'existe pas encore ; s'il apparaît, dis-moi si tu le veux dans la PR ou si je remets cette ligne seule. + +--- + +## Review de stack — étape 1 + +**Le constat est plus dur que « versions un peu vieilles » : les deux sont hors support.** + +| | Dépôt | État | Cible que je recommande | +|---|---|---|---| +| Python | `Pipfile` 3.7 / README 3.9 | 3.7 EOL depuis 2023, **3.9 EOL depuis octobre 2025** | **3.12** | +| Django | 4.2.16 | 4.2 LTS, support étendu terminé **avril 2026** | **4.2.16 — on ne bouge pas** | + +Le raisonnement sur l'asymétrie, parce qu'elle n'est pas évidente : + +- **Python : monter, sans hésiter.** Le conteneur épingle la version, personne d'autre n'est contraint, et livrer une image sur un runtime EOL dans une PR qui prétend viser la production est exactement le genre d'incohérence qui se remarque. Django 4.2 supporte officiellement jusqu'à **3.12** — donc 3.12, pas 3.13, sinon tu dois aussi monter Django. +- **Django : ne pas bouger.** 4.2.16 est la stack *donnée par l'énoncé*. La monter en 5.2 LTS te ferait gagner du support, mais ça déplace le sujet : l'examinateur compare ta réponse à un exercice calibré sur 4.2. Mon avis : garde 4.2.16, et **mets la montée en 5.2 LTS dans les next steps de la PR**, avec la date de fin de support comme argument. Tu montres que tu l'as vu, sans consommer ton budget dessus. + +*Réserve honnête : je te donne des dates de mémoire, pas depuis une source vérifiée en ligne. Vérifie-les avant de les écrire dans la PR — c'est exactement le genre de détail qu'un examinateur teste.* + +## Trous et conflits dans le plan d'étape 1 + +Trois points où ton plan et ta propre doctrine ne sont pas alignés. Je les remonte, je ne tranche pas. + +**1. `pyproject.toml` — `python.md` va plus loin que ce que tu as annoncé.** Elle exige `uv` + `uv.lock` commité, `hatchling`, `[dependency-groups]` PEP 735, **et la suppression de `requirements.txt`** (« un projet qui porte deux manifestes a deux réponses »). Ça, c'est cohérent avec ton plan. Mais elle exige aussi un **layout `src/`**, et là il y a collision frontale : le projet a `manage.py` et `padam_django/` à la racine. Passer en `src/` veut dire déplacer tout le code Django — coûteux, risqué, et hors de ma main. **Ma suggestion : exception explicite sur `src/`, documentée dans `CLAUDE.md` au même titre que l'hexagonal.** Le reste de `python.md` s'applique. + +**2. CI locale sans forge — `ci.md` l'interdit littéralement.** Elle dit : *« ce qui tourne en CI en local est un miroir de ce qui tourne sur la forge, et aucun des deux n'existe sans l'autre »*. Or le workflow GitHub qu'elle demande est un **appelant fin** : trois lignes, checkout + `make ci`. Le coût est quasi nul, et sur une PR c'est le seul endroit où l'examinateur voit du vert. Je pense que tu sous-estimes le rapport coût/bénéfice ici — mais c'est ton budget. + +**3. Trou dans la raquette du tooling.** `python.md` couvre `ruff`, `pytest`, `mypy`. Pour un projet Django, il manque trois gates que je mettrais dans `make ci` : +- `python manage.py check --deploy` — détecte précisément `DEBUG=True`, `SECRET_KEY` faible, cookies non sécurisés. Gratuit, et il cible ta dette `settings.py`. +- `python manage.py makemigrations --check --dry-run` — casse la CI si un modèle change sans migration. Sur un exercice de modélisation, c'est le gate le plus pertinent du lot. +- `pytest-django` + `pytest-cov`, avec la base de couverture écrite **hors de l'arbre source** (`generated-artifacts.md`). + +## Ce qui me bloque avant de commencer la passe documentaire + +Une contradiction que je ne peux pas lever seul, et deux questions courtes : + +**Les docstrings Google.** Tu me les confies dans l'étape 1, mais elles vivent dans `models.py`, `admin.py` — sous le `deny` que tu as choisi il y a dix minutes, et que `interview-scope.md` formule explicitement comme couvrant « pas même une docstring dans un `.py` ». Je ne vais pas contourner ta propre règle en silence. + +Trois décisions appliquées. L'exception docstrings est **temporaire et je la marque comme telle** — c'est le genre de porte qu'on oublie ouverte. + +Le flow écrit depuis le code m'a fait trouver trois choses que la lecture rapide n'avait pas données. Je les documente comme faits, pas comme corrections. + +`check-template.py` : **0 erreur**, un seul avertissement (343 lignes de contexte permanent, seuil 300 — ma règle en porte 36 ; je la garde, c'est celle qui doit survivre). + +## Ce qui est écrit + +| Fichier | Contenu | +|---|---| +| `.claude/rules/interview-scope.md` | Lecture seule sur le code, propose-puis-écris sur l'infra, tu leads | +| `CLAUDE.md` | Stack, architecture réelle, table de commandes marquée non vérifiée, dix dettes | +| `.claude/settings.json` | Permissions + **exception temporaire documentée** | +| `.claude/quality-gate.json` | Tout en `pending`/`absent` — rien n'est annoncé vert | +| `.claude/SETUP-REPORT.md` | Découverte, décisions, questions ouvertes | +| `docs/README.md`, `docs/flows/README.md` | READMEs de dossier | +| `docs/coverage.md` | Couvert / non couvert / fragilités / différé pour la PR / scénarios de test | +| `docs/flows/existing-system.md` | Le flow de l'existant, écrit depuis le code | +| `.gitignore` | Rien de Claude n'est ignoré, transcripts commités | + +## L'exception docstrings — à refermer + +`Edit(/padam_django/**)` n'est plus en `deny` global. Il est en `ask` sur **onze chemins nommés** (les fichiers hérités uniquement), et `Edit(/padam_django/**/migrations/**)` reste en `deny` absolu. La clé `_permissions_temporary_exception` dans `settings.json` dit qu'elle doit redevenir un `deny` unique **avant l'étape 2**. Je te le rappellerai ; si je ne le fais pas, la porte reste ouverte au moment où `BusShift` s'écrit — exactement ce qu'on voulait éviter. + +## Trois trouvailles du flow, à ta main + +En lisant le code pour écrire le flow, trois choses qui touchent ton étape 2 : + +1. **`UserFactory.username` = `fake.user_name`, non unique, contre une colonne `unique=True`.** Un batch de seed peut mourir en `IntegrityError` après commit partiel — aucune transaction n'entoure le batch. Ça se lira comme de la flakiness. +2. **`create_drivers` ne réutilise pas les users de `create_users`** (`SubFactory`). Après `create_data` : 10 users, 5 drivers, 5 users orphelins. Tu auras besoin de drivers pour seeder des `BusShift`. +3. **`UserAdmin.is_driver` résout par ligne** (`hasattr`) → une requête par ligne de changelist. Invisible à 10 users ; c'est le motif à ne pas reproduire quand ton changelist `BusShift` affichera bus + driver + nombre d'arrêts. `get_queryset` avec `select_related`/`annotate` est la forme qui tient. + +## Ce que j'attends + +Tu montes l'environnement. Pendant ce temps j'ai fait la passe README + coverage + flow de l'existant. **Il me reste la passe docstrings Google** sur les fichiers hérités — dis-moi quand tu veux que je te la propose, elle passera par les prompts de permission. + +Le flow de l'environnement s'écrira quand ta chaîne tournera, et je noterai le workflow GitHub Actions comme quick win conditionnel, pas comme acquis. Envoie ton plan d'étape 1 quand tu l'as, je le relis avant que tu écrives. + +### 🧑 Human says + +concernant django version je vais avoir un avis différents tu me parles de support drop depuis 2023, cela est un risque énorme, en tant que dev il est de mon scope de sécuriser ce que je livre le choix des versions des stack en fait partit, selectionons une version django encore en support stable et dont l'upgrade ne néccéssite aucun rewrite de la base code ( compromis assumé pour livré sécuriser mais pas overboard) coté tooling tout ce qui nécéssite des montée de version est accepté et review, tranché par moi. Coté tooling je suis aligné sur les 3 commandes a ajouter dans le makefile pour couvrir de manière plus complète + +### 🤖 Claude says + +Tu as raison sur le fond, et la correction est juste : sécuriser la stack livrée fait partie du scope du dev. Je notais 3.7 EOL 2023 pour Python ; sur Django je disais 4.2 fin de support étendu avril 2026 — mais mon raisonnement « on ne bouge pas parce que l'énoncé le dit » faisait passer la conformité à l'exercice avant la sécurité du livrable. C'est le mauvais ordre. + +Mes dates viennent de ma mémoire. Vu que ça va finir dans une PR, je vérifie. + +Vérifié — et ça renforce ta position, y compris contre ce que j'avais en mémoire. + +Vérifié sur les sources officielles. Trois corrections à ce que je t'ai dit, dont une qui compte. + +## Verdict : Django 5.2 LTS + Python 3.13 + +| | Aujourd'hui | Cible | Fin de support | +|---|---|---|---| +| Django | 4.2.16 — **support terminé le 7 avril 2026** | **5.2 LTS** | avril 2028 | +| Python | 3.9 (README) / 3.7 (Pipfile) — **EOL 31/10/2025** et 27/06/2023 | **3.13** | octobre 2029 | +| PostgreSQL | — | **16 ou 17** | Django 5.2 exige PG ≥ 14 | + +**Ce que j'avais faux :** 5.2 n'est pas « une option parmi d'autres », c'est **la seule LTS supportée aujourd'hui**. 6.0 et 6.1 sont supportées mais non-LTS (fin avril 2027 et décembre 2027) — pour un livrable, la LTS est le bon compromis. Et Django 5.2 supporte Python jusqu'à 3.14, donc 3.13 passe sans contrainte : mon « 3.12 parce que Django 4.2 s'arrête là » tombe avec le choix de monter Django. + +## Le coût réel sur cette base de code : une ligne + +J'ai grepé les suppressions de Django 5.0 contre le code. Résultat : + +``` +padam_django/settings.py:125:USE_L10N = True ← SUPPRIMÉ en Django 5.0, à retirer +padam_django/settings.py:127:USE_TZ = True ← devient le défaut, reste valide +geography/models.py:12: unique_together ← toujours supporté (c'est index_together qui a disparu) +``` + +**Une seule ligne à supprimer.** Pas de `pytz`, pas de `GeoModelAdmin`, pas de `is_dst`, pas de formulaire custom impacté par le passage au rendu `div`. Ton compromis « sécurisé mais pas overboard » est exactement satisfait : le risque disparaît, le rewrite est nul. + +Deux points d'attention quand même, parce qu'ils sont invisibles au grep : + +- **`AbstractUser` et l'admin sont intacts**, mais Django 5.2 rend `violation_error_message` de `UniqueConstraint` toujours actif. Utile pour toi : c'est le mécanisme qui te permettra de rendre le refus de chevauchement en message métier lisible dans l'admin, plutôt qu'en `IntegrityError`. +- **`Model._is_pk_set()`** arrive en 5.2 — pertinent si tu valides un `BusShift` avant sauvegarde. + +## Dépendances à monter + +| Paquet | Dépôt | Cible | Raison | +|---|---|---|---| +| `django-extensions` | 3.2.1 | **4.1** | 3.2.1 est antérieur à Django 5 ; 4.1 déclare 4.2 / 5.1 / 5.2 et Python 3.9–3.13. **Bloquant** : `runserver_plus` est ton `make run`. | +| `factory-boy` | 3.2.0 (2021) | dernière 3.3.x | Compatibilité Django 5 | +| `Faker` | 8.10.1 (2021) | dernière | Idem — et une version récente donne accès aux providers `unique`, ce qui règle la fragilité `username` du flow | +| `Werkzeug` | 3.1.3 | inchangé | Déjà récent | + +À trancher par toi, comme tu l'as posé. + +## Les 3 gates Makefile, prêts à coller + +Tu les as validés ; je te les donne comme proposition, tu les intègres à ta chaîne (je n'écris pas le Makefile) : + +```make +check: ## Django system checks, deployment ruleset. + $(COMPOSE_RUN) python manage.py check --deploy --fail-level WARNING + +migrations-check: ## Fail if a model changed without its migration. + $(COMPOSE_RUN) python manage.py makemigrations --check --dry-run + +test: ## Run the test suite with coverage, written outside the source tree. + $(COMPOSE_RUN) pytest --cov --cov-report=term-missing +``` + +Une remarque sur le premier : `--fail-level WARNING` fera **échouer la CI immédiatement** tant que `SECRET_KEY` est en dur et `DEBUG = True`. C'est voulu — c'est ce qui transforme ta dette `settings.py` en blocage plutôt qu'en note de bas de page — mais ça veut dire que ce gate ne peut arriver **qu'après** le passage de `settings.py` à la lecture d'environnement. Sinon tu ajoutes un gate rouge dès le premier commit. + +Je mets à jour `CLAUDE.md` et `docs/coverage.md` avec ces versions et leurs dates ? + +Sources : [Django download/supported versions](https://www.djangoproject.com/download/) · [Python version status](https://devguide.python.org/versions/) · [Django 5.0 release notes](https://docs.djangoproject.com/en/5.2/releases/5.0/) · [Django 5.2 release notes](https://docs.djangoproject.com/en/5.2/releases/5.2/) · [django-extensions on PyPI](https://pypi.org/pypi/django-extensions/json) + +### 🧑 Human says + +oui mets à jour CLAUDE.md et coverage.md avec ces versions ; je commence la mise en place de l'environement dev. tu peux commencer la doc. + +### 🤖 Claude says + +Fichiers à jour. Je commence la passe docstrings sur le code hérité — via l'outil d'édition pour que les prompts de permission se déclenchent réellement, plutôt qu'en les contournant par le shell. + +Passe docstrings terminée. **+138 lignes, zéro ligne de logique touchée** — que des docstrings Google. + +## Ce qui a été documenté + +| Fichier | Ce que la docstring ajoute | +|---|---| +| `fleet/models.py` | `Driver` : rôle porté par le user, cascade, unicité. `Bus` : **la plaque n'est pas unique et n'est pas validée** — l'identité, c'est la PK | +| `geography/models.py` | Pourquoi `Decimal` et pas float (la contrainte d'unicité en dépend), ce que donne `max_digits=9/6` (~0,1 m), et l'asymétrie nom non-unique / coordonnées uniques | +| `users/models.py` | `User` : pourquoi substitué. `is_driver` : `Returns` + la note N+1 avec le remède | +| `users/admin.py` | N'étend **pas** `auth.admin.UserAdmin` → pas de form mot de passe, pas de fieldsets de permissions | +| `common/management/base.py` | Contrat `super().handle()` avant de lire `self.number`, et l'absence de transaction | +| `commands/create_data.py` | Compteurs figés, ordre contre-intuitif, 10 users pour 5 drivers | +| Les 3 `factories.py` | Les trois fragilités, en `Warning:` là où elles mordent | + +**Ce que je n'ai pas documenté, volontairement :** les quatre commandes `create_*` (leur attribut `help` dit déjà tout, une docstring serait la redite que `python.md` appelle du bruit) et les admins nus `BusAdmin`/`DriverAdmin`/`PlaceAdmin` (« enregistré avec les options par défaut » n'apprend rien — leur nudité est un fait de projet, il est dans `coverage.md`). + +## Trois trouvailles de plus, remontées et non corrigées + +1. **`Bus.licence_plate` porte le verbose name `"Name of the bus"`.** Le commit `d0aab9f` a corrigé la faute dans le nom d'attribut et a laissé le libellé. Résultat : l'admin affiche « Name of the bus » sur le champ plaque — **sur l'écran même qui est évalué**. Une chaîne à changer. +2. **`UserFactory` n'appelle jamais `set_password`.** Les users seedés ont un mot de passe inutilisable : il te faudra un `createsuperuser` séparé pour entrer dans l'admin. À savoir avant de tester ta chaîne. +3. `UserAdmin` sans héritage de `auth.admin.UserAdmin` (déjà dit, mais c'est aussi pourquoi tu ne peux pas créer d'utilisateur utilisable depuis l'admin). + +Les trois sont dans `docs/coverage.md`, section « Fragilities established by reading the code ». + +## ⚠️ Porte à refermer + +Je vois `Dockerfile`, `Dockerfile.dev`, `docker-compose.yml` et `pyproject.toml` apparaître — tu avances. **Quand tu passes à l'étape 2, dis-le-moi : je remets `Edit(/padam_django/**)` en `deny` unique** avant que la moindre ligne de `BusShift` s'écrive. L'exception ne devait couvrir que le code hérité, et elle a fait son travail. + +Dis-moi quand ta chaîne tourne — j'écris le flow de l'environnement depuis ce qu'elle fait réellement, pas depuis les fichiers. + +### 🧑 Human says + + +apropose-moi-une-cf9b584585c9894e +/tmp/claude-1000/-home-suush-PROJECTS-technical-review-django-test/d0f0905d-aae7-4708-995d-ed8e13643153/tasks/apropose-moi-une-cf9b584585c9894e.output +completed +Agent "propose moi une image légère pour les versions se…" finished +A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once. +**Verdict : `python:3.13-slim-trixie`**, pas Alpine — mais pas pour la raison qu'on cite d'habitude, qui est fausse ici. J'ai vérifié avant de te répondre. + +## L'argument classique contre Alpine ne tient plus + +Le réflexe est « Alpine = musl = pas de wheel psycopg = compilation depuis les sources ». C'est faux pour psycopg 3 : + +``` +psycopg-binary 3.3.4 — cp313 musllinux_1_2 : 4 wheels (x86_64, aarch64, ppc64le, riscv64) + cp313 manylinux : 4 wheels +``` + +Et le reste de ta pile est **100 % pur Python** — Django, django-extensions, Werkzeug, factory-boy, Faker. Aucune extension C à compiler. Techniquement, Alpine marcherait sans toolchain de build. + +## Ce qui tranche vraiment + +| | `slim-trixie` | `alpine` | +|---|---|---| +| Taille de base | ~125 Mo | ~55 Mo | +| libc | glibc | musl | +| Wheels | manylinux, le chemin par défaut de tout l'écosystème | musllinux, couvert ici mais dépend du bon vouloir de chaque mainteneur | +| Perf allocation | référence | musl mesurablement plus lent sur les charges allocation-intensives (le profil d'un ORM) | +| `gettext`, locales | présents / disponibles | `gettext` en paquet, pas de locales glibc | + +Les ~70 Mo d'écart ne paient pas le risque : le jour où tu ajoutes une dépendance qui ne publie pas de wheel musl, tu récupères `gcc` + `musl-dev` dans l'image et l'avantage de taille disparaît d'un coup. Sur un livrable d'entretien, l'image doit être ennuyeuse. + +Deux notes utiles : + +- **`trixie` plutôt que `bookworm`** : Debian 13 est la stable actuelle, bookworm passe en LTS/sécurité seule. Même logique que ton arbitrage Django. +- **Avec `psycopg[binary]`, la libpq est embarquée dans la wheel.** Tu n'as besoin d'aucun paquet système Postgres dans l'image de prod — pas de `libpq-dev`, pas de `postgresql-client`. Ce dernier va dans `Dockerfile.dev` seulement, pour avoir `psql` en debug. + +## Esquisse, alignée sur `docker.md` + +Prod pure, multi-stage, non-root créé dans `base` pour que tout dérivé en hérite, `uv sync --frozen` : + +```dockerfile +# --- base : le runtime et l'utilisateur, rien d'autre +FROM python:3.13-slim-trixie@sha256:<digest> AS base +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_CACHE_DIR=/var/cache/uv \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy +RUN useradd --create-home --uid 10001 app +WORKDIR /app + +# --- deps : la couche qui change le moins +FROM base AS deps +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/var/cache/uv \ + uv sync --frozen --no-dev --no-install-project + +# --- runtime : l'artefact de production +FROM deps AS runtime +COPY --chown=app:app . . +ENV PATH="/app/.venv/bin:$PATH" +RUN python manage.py collectstatic --noinput # au build, jamais au démarrage +USER app +EXPOSE 8000 +CMD ["gunicorn", "padam_django.wsgi:application", "--bind", "0.0.0.0:8000"] +``` + +Trois points à ne pas rater, parce qu'ils sont dans `docker.md` et qu'ils se voient en review : + +1. **`collectstatic` au build**, pas à l'`entrypoint` — sinon l'image se prépare au démarrage et échoue différemment à chaque restart. Ça veut dire que l'admin Django sert ses CSS en prod, ce que `runserver` te donnait gratuitement. +2. **`gunicorn` dans la prod, `runserver_plus` seulement en dev** — `runserver_plus` est une commodité de `django-extensions`, pas un runtime. +3. **`Dockerfile.dev` ne doit pas répéter le `FROM python:3.13-slim-trixie`** — il dérive de `runtime` (ou de `deps`), via `additional_contexts` en compose. Le hook `convention-guard` bloque la répétition, donc tu le sauras tout de suite. + +Épingle par digest et pas seulement par tag : `python:3.13-slim-trixie` bouge à chaque patch, et `docker.md` demande l'absence de tag flottant. + +Une chose hors de ma directive, en une phrase : `pyproject.toml` et les trois fichiers Docker sont apparus vides dans ton arbre pendant que j'écrivais — je n'y ai rien écrit, l'infra reste sous ta validation. +140219478250 + + +### 🤖 Claude says + +La sous-tâche a rendu. Verdict : **`python:3.13-slim-trixie`**, pas Alpine — mais l'argument habituel contre Alpine ne tient pas ici, ce qui vaut d'être dit. + +## L'argument classique est faux dans ton cas + +Le réflexe « Alpine = musl = pas de wheel psycopg = compilation » ne s'applique pas : `psycopg-binary` 3.3.4 publie des wheels `musllinux_1_2` pour cp313 (x86_64, aarch64, ppc64le, riscv64). Et **tout le reste de ta pile est pur Python** — Django, django-extensions, Werkzeug, factory-boy, Faker. Zéro extension C à compiler. Alpine marcherait, techniquement. + +## Ce qui tranche quand même + +| | `slim-trixie` | `alpine` | +|---|---|---| +| Base | ~125 Mo | ~55 Mo | +| libc | glibc | musl | +| Wheels | `manylinux`, le chemin par défaut de l'écosystème | `musllinux`, couvert ici mais au bon vouloir de chaque mainteneur | +| Perf | référence | musl mesurablement plus lent sur les charges allocation-intensives — le profil d'un ORM | +| `gettext`, locales | présents | paquet à part, pas de locales glibc | + +**Les ~70 Mo ne paient pas le risque.** Le jour où tu ajoutes une dépendance sans wheel musl, tu récupères `gcc` + `musl-dev` dans l'image et l'avantage disparaît d'un coup. Sur un livrable d'entretien, l'image doit être ennuyeuse. + +Deux notes qui te font gagner du temps : + +- **`trixie` plutôt que `bookworm`** — Debian 13 est la stable actuelle. Même logique que ton arbitrage Django. +- **Avec `psycopg[binary]`, la libpq est dans la wheel.** Aucun paquet système Postgres en prod : ni `libpq-dev`, ni `postgresql-client`. Ce dernier va dans `Dockerfile.dev` seulement, pour avoir `psql` en debug. + +## Trois points qui se voient en review + +1. **`collectstatic` au build, pas à l'entrypoint.** Sinon l'image se prépare au démarrage et échoue différemment à chaque restart. Conséquence concrète : en prod l'admin Django doit servir ses CSS, ce que `runserver` te donnait gratuitement. +2. **`gunicorn` en prod, `runserver_plus` en dev seulement** — c'est une commodité `django-extensions`, pas un runtime. +3. **`Dockerfile.dev` dérive de `Dockerfile`**, il ne répète pas le `FROM`. Le hook `convention-guard` te bloquera si tu le répètes, donc tu le sauras tout de suite. + +## Une incohérence dans l'esquisse rendue + +Elle recommande d'épingler l'image de base **par digest** (`docker.md` interdit les tags flottants) puis écrit `COPY --from=ghcr.io/astral-sh/uv:latest` deux lignes plus bas. `latest` sur l'installeur de dépendances est exactement le tag flottant que la règle vise — épingle `ghcr.io/astral-sh/uv:0.x.y` aussi. + +L'esquisse complète du `Dockerfile` multi-stage (base / deps / runtime, non-root uid 10001, `uv sync --frozen`, caches montés hors de l'arbre) est dans la sortie de la tâche si tu la veux telle quelle — je ne l'ai écrite nulle part, l'infra reste sous ta main. + +### 🧑 Human says + +dockerfile et docker-compose fait, fait un review et écris les command du makefile en accord a la doctrine + +### 🤖 Claude says + +Revue faite, ligne à ligne, contre `docker.md`. **La chaîne ne peut pas builder en l'état** — je compte neuf points bloquants. Je te les donne d'abord, le Makefile ensuite. + +## Bloquants — le build échoue + +| # | Où | Problème | +|---|---|---| +| 1 | `Dockerfile:8` | **L'utilisateur `app` n'est jamais créé.** Le commentaire dit « Add a non root user », il n'y a pas de `useradd`. Le premier `RUN` du stage `deps` échoue sur `unable to find user app`. | +| 2 | `Dockerfile:16` | Cache monté sur `/root/.cache/uv` alors qu'on tourne en `USER app` → écriture refusée. Le cache doit être dans un chemin possédé par `app`. | +| 3 | `Dockerfile` | **`PATH` ne pointe jamais sur le venv.** `uv sync` crée `/app/.venv` ; `python manage.py` et `gunicorn` résolvent le Python système, sans Django. | +| 4 | `Dockerfile:25` | `collectstatic` **échoue** : `settings.py` ne définit pas `STATIC_ROOT`. Il n'y a que `STATIC_URL`. | +| 5 | `Dockerfile:26` | `chown -R app:app` exécuté en tant que `app` → `Operation not permitted`. Un `chown` veut root. | +| 6 | `Dockerfile.dev:19` | `RUN chown -R app:app` — **il manque le chemin**. `chown` sort en erreur d'usage, le build casse. | +| 7 | `compose.yml:33` | `volumes: [padam_django:/app]` sur le service `app` : volume **non déclaré** dans le bloc `volumes:` final (il n'y a que `dbdata`) → compose refuse. Et s'il existait, **il masquerait tout `/app`** — code, venv, statiques. L'image de prod ne doit pas avoir de volume inscriptible. | +| 8 | `compose.yml:19-24` | **Clé YAML dupliquée.** Le service `app` fusionne `<<: *app-env` (qui pose `environment.DATABASE_URL`) puis redéclare `environment:` en liste. La seconde écrase la première : au runtime, `DATABASE_URL` vaut `postgresql://build:build@localhost:5432/build`. | +| 9 | `.env.example` | **Toujours vide**, alors que compose exige `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` avec `:?`. `make up` depuis un clone frais s'arrête net. `environment.md` appelle ce fichier « le contrat ». | + +## Corrections à faire, non bloquantes au build + +- **Le healthcheck sera toujours rouge.** `curl` frappe `http://127.0.0.1:8000/`, `DJANGO_ALLOWED_HOSTS=localhost` → Django répond **400 DisallowedHost**, `curl -f` échoue. Ajoute `127.0.0.1` aux hosts, ou vise `localhost`. +- **Le venv de dev est masqué.** Le commentaire compose dit « The venv lives at /opt/venv, outside the mount » — mais rien ne pose `UV_PROJECT_ENVIRONMENT=/opt/venv`. Il est donc en `/app/.venv`, que le bind-mount `.:/app` recouvre au démarrage. Le commentaire décrit l'intention, pas le fichier. +- **`Dockerfile.dev` fait `FROM base`.** `docker.md` : « Never from `base` ». Compose câble `base: service:deps`, donc le comportement est bon — c'est le *nom* qui ment. Renomme le contexte en `deps`. +- **`pyproject.toml` est vide.** Tout le reste en dépend : `uv.lock` n'existe pas, donc `uv sync --frozen` échoue de toute façon. `gunicorn` et `psycopg[binary]` doivent y figurer. +- **Pas de `.dockerignore`.** `docker.md` en exige un ; sans lui le contexte embarque `.git`, `db.sqlite3`, `.idea`. +- **`settings.py` ne lit ni `DATABASE_URL`, ni `DJANGO_SECRET_KEY`, ni `DJANGO_DEBUG`, ni `DJANGO_ALLOWED_HOSTS`.** Toute la chaîne suppose une réécriture du module de settings qui n'a pas eu lieu. C'est ta main, et c'est le prochain prérequis. +- `TEST_DATABASE_NAME` n'est lu par rien : Django attend `DATABASES['default']['TEST']['NAME']`. + +## Ce qui est juste, et vaut d'être dit + +La dérivation `additional_contexts: base: service:deps` est **exactement** ce que `docker.md` demande — un graphe de build, pas une résolution par tag. Les services `build`-only sous `profiles` pour nommer et cacher les stages intermédiaires, le `depends_on` sur `condition: service_healthy`, le `HEALTHCHECK` dans l'image plutôt que dans compose, les caches d'outils hors du bind-mount, le `user:` aligné sur l'uid hôte pour que `make format` ne laisse pas de fichiers root : c'est bien vu, et les commentaires expliquent le *pourquoi*. + +Le Makefile, maintenant : + +## Le Makefile + +Écrit contre `makefile.md` : `help` en `.DEFAULT_GOAL` généré depuis les commentaires `##`, tout en `.PHONY`, flags compose dans **une** variable, noms de cibles standards (`up down logs sh build test lint format typecheck docs docs-build migrate ci`), et **aucune recette ne fait le travail elle-même** — chacune passe par `docker compose`. + +Quatre choix qui méritent d'être justifiés : + +- **`.env` est une cible avec `.env.example` en prérequis.** C'est ce qui rend `make up` opérant depuis un clone frais, comme `environment.md` l'exige, sans étape manuelle non documentée. `make up` dépend de `.env`, donc le fichier se crée tout seul au premier appel. +- **`ci` compose, ne réimplémente pas** : `format-check lint typecheck check migrations-check coverage docs-build`. Une seule définition du vert, et le workflow GitHub — s'il arrive — n'aura que `checkout` + `make ci`. +- **`docs-build` en `--strict`** : le site échoue sur un lien mort. C'est la seule chose que la CI sait vérifier d'une documentation ; le reste est du travail de relecture. +- **`destroy` séparé de `down`.** Supprimer le volume de base est irréversible, ça ne doit pas être le comportement par défaut de la cible qu'on tape vingt fois par jour. + +## Ce que j'ai vérifié, et ce que je n'ai pas pu + +**Exécuté :** `make help` (rend les 26 cibles, exit 0) et `make -n down` (l'expansion est correcte). J'ai aussi corrigé deux défauts de ma propre écriture au passage : des backquotes dans le `echo` de la cible `.env`, qui auraient déclenché une substitution de commande, et un `$$DOCS_PORT` qui s'affichait littéralement dans l'aide. + +**Non vérifié :** **aucune cible conteneur n'a tourné.** `pyproject.toml` est vide, donc `uv.lock` n'existe pas, donc `uv sync --frozen` échoue, donc aucune image ne se construit. `make up`, `make ci`, `make test` restent des hypothèses — au sens de `verification.md`. Je ne les inscris pas comme vertes dans `quality-gate.json` tant qu'elles n'ont pas produit de sortie. + +## L'ordre qui débloque le reste + +Les neuf bloquants ne sont pas indépendants ; il y a un chemin critique : + +1. **`pyproject.toml`** — sans lui, rien ne build. `gunicorn`, `psycopg[binary]`, `django==5.2.*`, `django-extensions==4.1` en runtime ; `pytest-django`, `pytest-cov`, `ruff`, `mypy`, `mkdocs` en `[dependency-groups]`. +2. **`settings.py`** — `DATABASE_URL`, `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`, `DJANGO_ALLOWED_HOSTS`, **`STATIC_ROOT`** (sans lui `collectStatic` échoue au build), et suppression de `USE_L10N`. +3. **`.env.example`** — le contrat, aujourd'hui vide. +4. **Le `useradd` manquant** et les cinq autres corrections du `Dockerfile`. + +Les points 1 à 3 sont dans ta main. Dis-moi si tu veux que je te propose le contenu de `.env.example` — c'est de la configuration, pas du code Django, donc il passe par validation et non par le `deny`. + +### 🧑 Human says + + +aau-vu-des-a8ad5cca5964d6f4 +/tmp/claude-1000/-home-suush-PROJECTS-technical-review-django-test/d0f0905d-aae7-4708-995d-ed8e13643153/tasks/aau-vu-des-a8ad5cca5964d6f4.output +completed +Agent "au vu des variables d'environement attendu en doc…" finished +A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once. +`.env.example` rempli (12 variables, chacune commentée) et `.env` généré via `make .env`. + +**Variables couvertes**, tirées de compose + Dockerfile + des settings à venir : `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` (REQUIRED, sans défaut), `TEST_DATABASE_NAME`, `DJANGO_SECRET_KEY` / `DJANGO_DEBUG` / `DJANGO_ALLOWED_HOSTS` / `DJANGO_STATIC_ROOT`, `APP_PORT` / `DOCS_PORT`, `HOST_UID` / `HOST_GID`. + +`DATABASE_URL` est **délibérément absente** : compose la dérive des trois variables Postgres, donc la chaîne de connexion garde une seule définition. Le fichier le dit explicitement plutôt que de laisser le lecteur croire à un oubli. + +Deux commentaires portent une information qu'on ne peut pas déduire du nom : `DJANGO_ALLOWED_HOSTS` doit contenir `127.0.0.1` sinon le `HEALTHCHECK` reçoit un 400 DisallowedHost et le service reste *unhealthy* sans erreur applicative ; et `DJANGO_SECRET_KEY` porte la commande de génération, pas une clé. + +**Vérifié en exécutant :** + +- `make .env` → fichier créé, 61 lignes, `git check-ignore` confirme qu'il reste ignoré (`.gitignore:29`). +- `docker compose config` → l'interpolation passe : `DATABASE_URL: postgresql://padam:…@db:5432/padam`, ports 8000/8001 résolus. **Le blocage n°9 de la revue est levé.** + +Deux choses trouvées en validant, hors de ma directive mais utiles : + +1. `docker compose config` **échoue toujours** — `service "dev" declares unknown service "deps" as additional contexts base`. Le service `deps` est derrière `profiles: [build]`, donc inactif ; `docker compose --profile build config` sort en 0. Toutes les cibles `make` visant `dev` échoueront tant que le profil n'est pas activé. +2. L'interpolation confirme le **bloquant n°8** : le service `app` sort avec `DATABASE_URL: postgresql://build:build@localhost:5432/build` — la clé `environment` dupliquée écrase bien la valeur fusionnée de l'ancre. +159337467059 + + +### 🤖 Claude says + +La première sous-tâche a rendu. La seconde (`pyproject.toml`) tourne encore — je te la relaie dès qu'elle revient. + +## `.env.example` rempli, `.env` généré + +12 variables, chacune commentée, tirées de compose + Dockerfile + des settings à venir : + +`POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` (requises, sans défaut), `TEST_DATABASE_NAME`, `DJANGO_SECRET_KEY` / `DJANGO_DEBUG` / `DJANGO_ALLOWED_HOSTS` / `DJANGO_STATIC_ROOT`, `APP_PORT` / `DOCS_PORT`, `HOST_UID` / `HOST_GID`. + +**`DATABASE_URL` est délibérément absente** : compose la dérive des trois variables Postgres, donc la chaîne de connexion garde une seule définition. Le fichier le dit explicitement, pour qu'on ne lise pas ça comme un oubli. + +Deux commentaires portent une information qu'aucun nom de variable ne donne : `DJANGO_ALLOWED_HOSTS` doit contenir `127.0.0.1`, faute de quoi le `HEALTHCHECK` reçoit un 400 DisallowedHost et le service reste *unhealthy* sans la moindre erreur applicative ; et `DJANGO_SECRET_KEY` porte la **commande de génération**, pas une clé — `configuration.md` interdit le défaut pour un secret. + +## Vérifié en exécutant + +- `make .env` → fichier créé, `git check-ignore` confirme qu'il reste ignoré. **La cible que je viens d'écrire fonctionne** — c'est la première du Makefile à être prouvée. +- `docker compose config` → interpolation correcte, `DATABASE_URL: postgresql://padam:…@db:5432/padam`, ports résolus. **Bloquant n°9 levé.** + +## Deux trouvailles de la validation + +**Un dixième bloquant, que ma revue avait manqué :** + +``` +service "dev" declares unknown service "deps" as additional contexts base +``` + +Le service `deps` est derrière `profiles: [build]`, donc **inactif** — et `additional_contexts: base: service:deps` ne peut pas résoudre un service qui n'est pas dans le profil courant. `docker compose --profile build config` sort en 0. Conséquence directe : **toutes les cibles `make` qui visent `dev` échoueront** tant que le profil n'est pas activé. Deux issues, à toi de choisir : retirer `deps` des profils, ou ajouter `--profile build` dans la variable `COMPOSE` du Makefile. Ma préférence va à la seconde — les stages intermédiaires ont de bonnes raisons de rester hors de `docker compose up`. + +**Et la confirmation du bloquant n°8** : l'interpolation sort `DATABASE_URL: postgresql://build:build@localhost:5432/build` sur le service `app`. La clé `environment` dupliquée écrase bien la valeur fusionnée de l'ancre — ce n'était pas une lecture pessimiste du YAML, c'est le comportement réel. + +Il reste donc **neuf bloquants sur dix**, dont les six du `Dockerfile` et les deux de compose qui sont dans ta main. Je te dis pour le `pyproject.toml` dès que ça revient. + +### 🧑 Human says + + +arempli-le-pyprojecttoml-18d7696e09afe9ed +/tmp/claude-1000/-home-suush-PROJECTS-technical-review-django-test/d0f0905d-aae7-4708-995d-ed8e13643153/tasks/arempli-le-pyprojecttoml-18d7696e09afe9ed.output +completed +Agent "rempli le pyproject.toml" finished +A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once. +`pyproject.toml` écrit et validé (parse TOML OK). Non exécuté : `uv lock` / `uv sync` n'ont pas tourné — pas d'`uv` hors conteneur, et l'image ne build pas encore (`useradd` manquant, cf. revue). Les bornes de versions sont donc déclaratives, non résolues. + +**Runtime (5)** : `django>=5.2,<5.3`, `psycopg[binary]`, `gunicorn`, `django-extensions>=4.1`, `django-environ`. +**Groupes PEP 735** : `test`, `lint`, `docs`, et `dev` qui les inclut via `include-group` + `werkzeug` + `ipython`. + +Cinq décisions qui méritent ta validation : + +1. **`django-environ` est une dépendance que j'ajoute de mon chef.** `dependencies.md` dit qu'ajouter une dépendance est une décision. Elle est là parce que compose émet `DATABASE_URL` et que `settings.py` devra le parser ; sans elle tu écris le parsing à la main. Retire-la si tu préfères `dj-database-url` ou du `os.environ` typé. + +2. **`django-extensions` est en runtime, pas en dev.** Contre-intuitif, mais `'django_extensions'` est dans `INSTALLED_APPS` : l'image de prod l'importe au démarrage. Seuls `werkzeug` (pour `runserver_plus`) et `ipython` sont en dev. L'alternative est de retirer l'app d'`INSTALLED_APPS` en prod — c'est un `if ENV ==` déguisé, que `configuration.md` interdit. + +3. **`factory-boy` et `faker` sont en groupe `test`, donc absents de l'image de prod.** Conséquence : dans l'image runtime, `python manage.py help` signalera les commandes `create_*` en erreur d'import. `collectstatic` et `migrate` ne sont pas affectés (Django ne charge que la commande demandée). Si ça te gêne en review, il faut soit les passer en runtime, soit ne pas copier les `factories.py` et les `management/commands/create_*` dans le stage `runtime`. + +4. **Pas de `[build-system]`, mais `[tool.uv] package = false`.** `python.md` demande un backend PEP 517 (`hatchling`). Ici le projet n'est pas un paquet distribuable — `manage.py` est à la racine, rien n'importe `padam_django` en tant que paquet installé, et le `Dockerfile` fait déjà `--no-install-project`. Déclarer un backend obligerait à configurer des `packages` pour un artefact que personne ne construit. C'est cohérent avec l'exception `src/` déjà actée, mais c'est un deuxième écart à `python.md` : à documenter au même endroit. + +5. **`mypy` est en `strict = true`** sur une base de code sans annotations. Le gate `make typecheck` sera rouge dès le premier appel. Soit tu annotes au fil de `BusShift`, soit tu démarres en non-strict et tu resserres — mais un gate rouge en permanence entraîne à ignorer les gates. + +Deux points hors directive, en une phrase chacun : `requirements.txt` doit disparaître (`python.md` : « un projet qui porte deux manifestes a deux réponses »), et `testpaths = ["tests"]` désigne un répertoire qui n'existe pas encore. +160197274639 + + +### 🤖 Claude says + +La seconde sous-tâche a rendu. `pyproject.toml` écrit, parse TOML validé — **mais rien n'a été résolu** : pas d'`uv` hors conteneur et l'image ne build toujours pas (`useradd` manquant). Les bornes de versions sont déclaratives, pas vérifiées. + +**Runtime (5)** : `django>=5.2,<5.3`, `psycopg[binary]`, `gunicorn`, `django-extensions>=4.1`, `django-environ`. +**Groupes PEP 735** : `test`, `lint`, `docs`, et `dev` qui les inclut via `include-group`, plus `werkzeug` et `ipython`. + +## Cinq décisions qui te reviennent + +**1. Une dépendance a été ajoutée sans ton accord — `django-environ`.** `dependencies.md` dit qu'ajouter une dépendance est une décision, pas un réflexe ; la sous-tâche l'a écrite de son chef. Je te le signale plutôt que de le laisser passer. Elle est là parce que compose émet `DATABASE_URL` et que `settings.py` devra le parser. Alternatives : `dj-database-url`, ou du `os.environ` typé à la main. Dis-moi, je la retire si tu préfères. + +**2. `django-extensions` est en runtime, pas en dev.** Contre-intuitif mais correct : `'django_extensions'` est dans `INSTALLED_APPS`, donc l'image de prod l'importe au démarrage. Seuls `werkzeug` et `ipython` sont en dev. L'alternative — retirer l'app d'`INSTALLED_APPS` en prod — serait un `if ENV ==` déguisé, que `configuration.md` interdit. + +**3. `factory-boy` et `faker` sont en groupe `test`, donc absents de l'image de prod.** Conséquence concrète : dans l'image runtime, `python manage.py help` signalera les commandes `create_*` en erreur d'import. `migrate` et `collectstatic` ne sont pas touchés (Django ne charge que la commande demandée). Si ça te gêne en review, la sortie propre est de **ne pas copier** `factories.py` ni `management/commands/create_*` dans le stage `runtime` — ce sont des outils de dev, ils n'ont rien à faire dans un artefact de production. + +**4. Pas de `[build-system]`, mais `[tool.uv] package = false`.** `python.md` demande un backend PEP 517. Ici rien n'importe `padam_django` en tant que paquet installé, `manage.py` est à la racine, et le `Dockerfile` fait déjà `--no-install-project`. C'est cohérent avec l'exception `src/` déjà actée — mais **c'est un deuxième écart à `python.md`**, et il doit être documenté au même endroit que le premier. + +**5. `mypy` en `strict = true` sur une base sans annotations.** `make typecheck` sera rouge au premier appel. Tu as trois options : annoter au fil de `BusShift`, démarrer non-strict et resserrer, ou sortir `typecheck` de `ci` pour l'instant. Ma remarque est la même que pour `check --deploy` : **un gate rouge en permanence entraîne à ignorer les gates** — c'est pire que pas de gate. + +## Deux dettes que ça crée + +- **`requirements.txt` doit disparaître.** `python.md` : « un projet qui porte deux manifestes a deux réponses à ce dont il a besoin, et elles divergent. » Il faut aussi retirer la cible `install` que j'ai supprimée du Makefile, et corriger le README qui la documente encore. +- **`testpaths = ["tests"]` désigne un répertoire qui n'existe pas.** `pytest` sortira en erreur tant que `tests/` n'est pas créé — et c'est ta main. + +## État de la chaîne + +| | | +|---|---| +| Bloquants levés | **2 / 10** — `.env.example` (n°9), `pyproject.toml` (prérequis) | +| Restants | Les **6 du `Dockerfile`** (`useradd`, cache root, `PATH`, `STATIC_ROOT`, `chown`, `chown` sans chemin), les **2 de compose** (volume `padam_django` non déclaré + masquant, clé `environment` dupliquée), et le **profil `build`** non activé | +| Prérequis hors conteneur | `settings.py` — `DATABASE_URL`, `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`, **`STATIC_ROOT`**, suppression de `USE_L10N` | +| Vérifié en exécutant | `make help`, `make .env`, `docker compose --profile build config` | + +Je documente ces deux écarts à `python.md` (`src/` et `[build-system]`) dans `CLAUDE.md`, ou tu préfères trancher le point 5 d'abord ? + +### 🧑 Human says + +pour le point 3, dans le cadre du scope perçu de l'entretien technique on vas concidérer factory-boy et faker comme faisant partie de la prod, cependant c'est a mettre en évidence dans la PR que si le scope était real prod on aurait un probleme dans la conception de l'architecture technique. J'ai supprimé requirement et Pipfile qui ne sont plus nécéssaire ici. Avec ces éléments refais une review de lenvironement, tente de le up pour validation end to end + +### 🤖 Claude says + +`requirements.txt` et `Pipfile` bien supprimés. J'applique ta décision sur `factory-boy` / `faker`, puis je tente le up. + +Maintenant le test end-to-end réel. `uv.lock` n'existe pas, donc je commence par là. + +Bloquant n°10 confirmé en exécution. Je corrige côté Makefile — c'est mon fichier. + +### 🧑 Human says + +[Request interrupted by user for tool use] + +attend, j'ai retirer les profiles de compose, relance la review et test end to end + +### 🤖 Claude says + +Compose parse. Je lance. + +`uv.lock` généré — 75 paquets, possédé par toi. Je build. + +Le build casse, et **l'erreur est exactement le bloquant n°1** — cette fois avec la sortie, pas par lecture : + +``` +#13 [deps base 4/5] RUN usermod -u 1001 app && groupmod -g 1001 app && chown -R app:app /home/app +#13 0.313 usermod: user 'app' does not exist +#13 ERROR: process "/bin/sh -c usermod ..." did not complete successfully: exit code: 6 +``` + +`usermod` **modifie** un utilisateur existant. `python:3.13-slim-trixie` n'embarque aucun utilisateur `app` — il faut le créer. C'est `useradd`, pas `usermod`. + +## Progrès réel + +| | | +|---|---| +| ✅ `make .env` | fichier créé, gitignoré | +| ✅ `make lock` | **75 paquets résolus**, `uv.lock` (107 Ko) possédé par toi | +| ❌ `make build` | échoue au stage `base`, ligne 9 | + +J'ai corrigé une cible de mon Makefile au passage : `make lock` échouait sur `No pyproject.toml found` — le stage `base` ne copie délibérément pas le manifeste (c'est ce qui en fait le seul stage indépendant de `uv.lock`). Il monte maintenant la source pour cet appel, en `--user $(HOST_UID):$(HOST_GID)` pour que le lockfile ne sorte pas root. + +## Le correctif du Dockerfile — proposition, non appliquée + +```dockerfile +FROM python:3.13-slim-trixie AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_CACHE_DIR=/tmp/cache/uv \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PATH="/opt/venv/bin:$PATH" + +COPY --from=ghcr.io/astral-sh/uv:0.12.7 /uv /usr/local/bin/uv +RUN apt-get update \ + && apt-get install --no-install-recommends --assume-yes curl \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user, CREATED here so every derived image inherits it. +RUN useradd --create-home --uid 1001 --user-group app \ + && install -d -o app -g app /app /opt/venv +WORKDIR /app +``` + +Quatre choses que ce bloc règle d'un coup, et pourquoi chacune compte : + +- **`useradd --user-group`** crée l'utilisateur *et* son groupe. Ton `chown -R app:app` échouait aussi sur le groupe. +- **`UV_PROJECT_ENVIRONMENT=/opt/venv` + `PATH`** : le venv sort de `/app`. C'est ce qui fait que le bind-mount de dev (`.:/app`) ne le masque plus — et ça rend enfin vrai le commentaire de ton compose qui l'affirme déjà. Sans ce `PATH`, `python manage.py collectstatic` en ligne 24 résout le Python système, sans Django. +- **`UV_CACHE_DIR=/tmp/cache/uv`** aligne le cache sur le `--mount=type=cache` et le sort du home. Tes deux Dockerfiles montent aujourd'hui deux chemins différents (`/home/app/.cache/uv` et `/root/.cache/uv`), donc ne partagent aucun cache. +- **`rm -rf /var/lib/apt/lists/*`** au lieu de `apt clean` : c'est ce qui pèse, et ça supprime aussi le warning « apt does not have a stable CLI interface ». + +Puis, en fin de stage `runtime` : **`USER app`**. Il a disparu du fichier, donc l'image de production tourne actuellement **en root** — `docker.md` demande l'inverse. + +## Ce que le build n'a pas encore pu atteindre + +Ces trois-là sont derrière le `useradd` et tomberont dès qu'il passera : + +1. **`collectstatic` échouera** : `settings.py` ne définit toujours pas `STATIC_ROOT`. `.env.example` expose `DJANGO_STATIC_ROOT`, mais rien ne le lit. +2. **`Dockerfile.dev:22` — `RUN chown -R app:app`** sans chemin. Erreur d'usage, build cassé. Avec le venv en `/opt/venv`, cette ligne n'a d'ailleurs plus de raison d'être. +3. **`compose.yml:31` — `volumes: ["./padam_django:/app"]` sur le service `app`.** Ça a changé depuis ma revue, et c'est plus grave qu'avant : le mount recouvre `/app`, donc `manage.py`, `staticfiles` et le code copié à l'image disparaissent au démarrage. `docker.md` : « l'image de production est complète par elle-même : aucun volume inscriptible ». **Ce volume doit sauter**, pas être corrigé — c'est `dev` qui bind-monte, pas `app`. +4. **La clé `environment` dupliquée sur `app`** est toujours là : `DATABASE_URL` sort à `postgresql://build:build@localhost:5432/build`. +5. **Pas de `.dockerignore`.** + +Dis-moi quand tu as poussé les correctifs, je relance `make build` puis `make up` et je vais jusqu'à requêter `/admin/login/` — `verification.md` demande la page, pas le `docker ps`. + +### 🧑 Human says + +useradd ajouté, volumes remove -> je suis d'accord dans le cadre ou ca serait compose qui gère la prod, cependant l'usage de compose ici est faire un iso max de prod mais permttant le dev local. donc volumes reste. sur les settings et environement file enleve duplicate et ajoute les missings. Crer le dockerignore file + +### 🤖 Claude says + +Deux choses avant d'écrire : ta décision sur les volumes tient, mais **le chemin monté ne peut pas marcher**, et c'est un bug, pas un désaccord de philosophie. + +`./padam_django:/app` monte le *package* sur `/app`. Résultat : `/app/manage.py` disparaît et le code se retrouve en `/app/*.py` au lieu de `/app/padam_django/*.py` — `gunicorn padam_django.wsgi` ne peut plus importer. Pour un iso-prod-dev-local, c'est `.:/app`. Je pars là-dessus. + +`useradd` passe. Le build avance de deux étapes et casse **exactement là où je l'avais annoncé** : + +``` +> [app runtime 3/3] RUN python manage.py collectstatic --noinput --clear ... +ImportError: Couldn't import Django. Are you sure it's installed and available +on your PYTHONPATH? Did you forget to activate a virtual environment? +``` + +`uv sync` a bien installé Django — dans `/app/.venv`, que rien n'ajoute au `PATH`. Le `python` du `RUN` est celui du système. + +**Le piège :** tu as mis `UV_PROJECT_ENVIRONMENT` et `PATH` dans l'ancre `x-app-env` de compose. Ça ne s'applique **qu'au runtime**. Un `environment:` compose n'existe pas pendant `docker build` — seuls `ENV` et `ARG` du Dockerfile y sont. Il en découle une seconde conséquence : le venv est aujourd'hui en `/app/.venv`, donc le bind-mount `.:/app` le **masquera au démarrage**, même une fois `collectstatic` réglé. + +### Le bloc à insérer dans `Dockerfile`, stage `base` + +```dockerfile +FROM python:3.13-slim-trixie AS base + +# Build-time AND runtime. Compose's `environment:` only exists at runtime, so +# anything a RUN needs has to live here. +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_CACHE_DIR=/tmp/cache/uv \ + # Outside /app: compose bind-mounts the repository there, which would + # otherwise hide the interpreter's own environment. + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PATH="/opt/venv/bin:$PATH" +``` + +Et aligner le cache du `--mount` sur cette valeur — il pointe aujourd'hui sur `/home/app/.cache/uv` dans `Dockerfile` et `/root/.cache/uv` dans `Dockerfile.dev`, donc les deux images ne partagent aucun cache : + +```dockerfile +RUN --mount=type=cache,target=/tmp/cache/uv \ + uv sync --frozen --no-dev --no-install-project +``` + +Une fois ça posé, ces valeurs deviennent **redondantes dans l'ancre compose** : elles y sont déjà. Les garder aux deux endroits, c'est deux définitions qui divergeront. + +## Ce que j'ai écrit + +- **`docker-compose.yml`** — la clé `environment` dupliquée du service `app` est supprimée (elle contenait aussi `- PATH:"/opt/venv/bin:$PATH"`, syntaxe invalide : deux-points au lieu d'un égal, ça définissait une variable dont le *nom* était toute la chaîne). Le montage devient `.:/app:cached`. `docker compose config` sort en 0. +- **`.env.example`** — `DJANGO_STATIC_ROOT` passe à **`/opt/static`** : hors de `/app`, sinon ton bind-mount cache les assets collectés au build et l'admin perd son CSS. Ajout de `DJANGO_CSRF_TRUSTED_ORIGINS` (le formulaire de login admin en POST à travers un port publié) et `DJANGO_SETTINGS_MODULE`. +- **`.dockerignore`** — créé. Exclut `.git`, `.env*` (sauf l'exemple), `.claude`, `docs`, les caches d'outils, `db.sqlite3`, et **les fichiers de conteneur eux-mêmes** pour qu'éditer le compose n'invalide pas le cache de build. + +## Il reste, dans ta main + +1. Le bloc `ENV` ci-dessus (**bloquant immédiat**). +2. **`STATIC_ROOT` dans `settings.py`** — il n'existe toujours pas, il n'y a que `STATIC_URL`. `collectstatic` échouera juste après le `PATH`. +3. **`Dockerfile.dev:22` — `RUN chown -R app:app`** sans chemin. Avec le venv en `/opt/venv`, cette ligne peut simplement disparaître. +4. **`USER app` en fin de stage `runtime`** — absent, donc l'image de prod tourne en root. + +Le `settings.py` reste ton terrain : dis-moi si tu veux le diff proposé (lecture de `DATABASE_URL`, `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`, `STATIC_ROOT`, suppression de `USE_L10N`), je te le rends en bloc de code sans y toucher. + +### 🧑 Human says + +met a jour settings.py en accord avec les variables attendues. modification faites pour le collectstatic au niveau docker et docker compose + +### 🤖 Claude says + +Note : tu avais choisi « je propose le diff, tu appliques » pour `settings.py`. Tu inverses, c'est ta décision — j'applique. Je lis d'abord l'état après tes modifs. + +J'allais introduire `whitenoise` sans te demander — c'est précisément ce que j'ai reproché à la sous-tâche tout à l'heure. Je reviens en arrière et je te le pose comme décision. + +Build relancé. **Le bloc `ENV` passe** — le `PATH` est bon. Il casse maintenant sur une coquille : + +``` +> [app base 4/6] RUN --mount=type=cache,target=/tmp/cache/uv \ + uv sync --frozen --no-dev --no-install-projec +error: unexpected argument '--no-install-projec' found + tip: a similar argument exists: '--no-install-project' +``` + +Deux choses sur cette ligne, `Dockerfile:20-22` : + +1. **Le `t` manquant** à `--no-install-project`. +2. **Elle est dans le mauvais stage, et en double.** Ce `uv sync` est dans `base`, *avant* tout `COPY pyproject.toml uv.lock` — il n'y a aucun manifeste à cet endroit. Le vrai `uv sync` existe déjà en ligne 31-33, dans `deps`, avec le `COPY` qui le précède. Même corrigée, la ligne 22 échouerait sur `No pyproject.toml found`. **C'est un reliquat à supprimer**, pas une coquille à réparer — et c'est aussi ce qui garde `base` indépendant du lockfile, ce dont `make lock` dépend. + +## `settings.py` mis à jour + +Lecture unique dans un objet typé via `django-environ`, aucun `getenv` dispersé : + +| Réglage | Source | Défaut | +|---|---|---| +| `SECRET_KEY` | `DJANGO_SECRET_KEY` | **aucun** — un secret avec fallback est une mauvaise valeur qui atteint la prod sans bruit | +| `DEBUG` | `DJANGO_DEBUG` | `False` — la valeur dangereuse doit être demandée | +| `ALLOWED_HOSTS` | `DJANGO_ALLOWED_HOSTS` | `[]` | +| `CSRF_TRUSTED_ORIGINS` | `DJANGO_CSRF_TRUSTED_ORIGINS` | `[]` | +| `DATABASES` | `env.db_url('DATABASE_URL')` | **aucun** | +| `DATABASES['default']['TEST']` | `TEST_DATABASE_NAME` | posé seulement si la variable existe | +| `STATIC_ROOT` | `DJANGO_STATIC_ROOT` | `/opt/static` | + +`USE_L10N` supprimé, avec un commentaire disant pourquoi plutôt qu'une ligne effacée en silence. + +## Une décision que je n'ai pas prise à ta place + +J'avais commencé à écrire un bloc `STORAGES` avec `whitenoise` — puis je me suis arrêté : **ça ajoutait une dépendance de mon chef**, exactement le reproche que je faisais à la sous-tâche pour `django-environ`. J'ai reverti et laissé une note dans `settings.py`. + +Le fait à trancher : `collectstatic` écrit dans `STATIC_ROOT` au build, mais **rien ne sert ces fichiers**. `gunicorn` ne sert pas de statiques, et `DEBUG=False` désactive la manne de `runserver`. **L'admin s'affichera sans CSS dans le service `app`** — sur l'écran même qui est évalué. Deux réponses : `whitenoise` dans l'application, ou un reverse proxy devant. Les deux ajoutent quelque chose ; c'est ta décision. + +C'est inscrit dans `docs/coverage.md`, avec l'exception de scope `factory-boy`/`faker` que tu as actée — formulée comme tu l'as demandée : accepté ici, **défaut de conception architecturale** si le scope était une vraie prod, à remonter dans la PR. + +## Reste dans ta main + +1. `Dockerfile:20-22` — supprimer le `uv sync` en doublon (**bloquant**). +2. `Dockerfile.dev:22` — `RUN chown -R app:app` sans chemin ; inutile maintenant que le venv est en `/opt/venv`. +3. `Dockerfile` — `USER app` en fin de stage `runtime`, absent : l'image tourne en root. +4. Le choix sur les statiques. + +Dis-moi quand c'est poussé, je relance jusqu'à `make up` et je requête `/admin/login/`. + +### 🧑 Human says + +sur les statics authorise whitenoise + +### 🤖 Claude says + +Autorisé. J'ajoute la dépendance, le middleware et le backend de stockage, puis je relance la chaîne. + +Le build va plus loin — le `uv sync` en doublon a disparu, `whitenoise` est verrouillé (76 paquets). Nouvelle erreur, et **c'est une bonne nouvelle** : + +``` +> [app runtime 3/3] RUN python manage.py collectstatic --noinput --clear ... +django.core.exceptions.ImproperlyConfigured: Set the DJANGO_SECRET_KEY environment variable +``` + +C'est `configuration.md` qui fait son travail : *« un fichier manquant ou malformé fait échouer le boot avec un message qui le nomme — jamais à la première requête qui en a besoin, en production, à 3h du matin. »* La configuration refuse de démarrer sans secret. Elle a juste raison au mauvais moment : `collectstatic` importe `settings`, donc au **build**, où aucune variable d'environnement n'existe. + +### Le correctif — `Dockerfile`, stage `runtime` + +```dockerfile +# collectstatic imports the settings module, which refuses to boot without a +# secret and a database URL. Both are supplied INLINE on this RUN: they are +# throwaway values needed to import settings, they never reach the image +# environment, and they never appear in the image history the way an ARG would. +# The build touches no database — only the import is satisfied. +RUN DJANGO_SECRET_KEY=build-only-never-used \ + DATABASE_URL=postgresql://build:build@localhost:5432/build \ + python manage.py collectstatic --noinput --clear +``` + +Trois précisions sur ce bloc : + +1. **Inline plutôt qu'`ARG`.** `docker.md` dit que ce qui n'est utile qu'au build est un `ARG`, jamais un `ENV` — mais aussi qu'aucun secret ne passe par un `ARG`, parce qu'un `ARG` reste lisible dans l'historique de l'image. Une affectation inline sur le `RUN` ne persiste nulle part. Ici les valeurs ne sont pas des secrets, mais l'habitude est la bonne. +2. **`&& chown -R app:app /app/staticfiles` doit sauter.** `STATIC_ROOT` vaut maintenant `/opt/static` : `/app/staticfiles` n'existe pas, et le `chown` échouerait après le `collectstatic`. Si le stage `runtime` finit en `USER app`, c'est `/opt/static` qu'il faut donner — mais `install -d -o app -g app /opt/venv` dans `base` ne crée pas `/opt/static` non plus. Le plus simple est d'ajouter `/opt/static` à ce `install -d` dans `base`, et de supprimer le `chown` ici. +3. **`--clear` sur un répertoire neuf** ne sert à rien mais ne coûte rien ; garde-le si tu préfères que la cible soit toujours propre. + +## `whitenoise` posé + +- `pyproject.toml` : `whitenoise>=6.8,<7` en dépendance runtime. `uv lock` → **whitenoise 6.12.0**, 76 paquets. +- `settings.py` : middleware **juste après `SecurityMiddleware`**, avant tout le reste — un fichier statique est servi sans toucher aux sessions, à l'auth ni à la base. +- `STORAGES.staticfiles` en `CompressedManifestStaticFilesStorage` : les fichiers sont fingerprintés et les références réécrites, donc cache immuable côté navigateur et invalidation par le nom au déploiement. Effet de bord utile : ce backend **lit le manifeste écrit par `collectstatic`**, donc une image qui aurait sauté l'étape échoue bruyamment à la première page au lieu de servir une page cassée. + +L'image devient complète par elle-même — pas de second service, pas de reverse proxy pour que l'admin s'affiche stylé. C'est ce que `docker.md` demande. + +## Reste + +1. Le bloc `collectstatic` ci-dessus (**bloquant**). +2. `Dockerfile.dev:22` — `RUN chown -R app:app` sans chemin. +3. `USER app` en fin de `runtime`. + +Dès que c'est poussé je relance, et cette fois j'irai jusqu'à `curl` sur `/admin/login/` — la page, pas le `docker ps`. + +### 🧑 Human says + +je t'authorise a appliquer les corections il ne reste que 1h pour réaliser la partie feature de technical review, la config de l'environement doit aboutir (choix que je justifierai en revue avec les recuteurs) + +### 🤖 Claude says + +Autorisation prise. J'applique et je pousse jusqu'au bout. + +Les quatre images sont construites (`runtime` 441 Mo, `dev` 995 Mo). Je lance la stack. + +`app` et `db` tournent. `docs` sort en 1 (pas de `mkdocs.yml`), et `base`/`deps` démarrent alors qu'ils ne devraient jamais tourner — je corrige `up` pour nommer ses services. + +`app` **healthy**. Maintenant la vérification qui compte : la page, et ce qu'elle référence. + +Page **200**, et **les 7 assets qu'elle référence sont en 200** — whitenoise sert, le manifeste est bon. Mais la base ne répond pas : + +Classique : le volume `dbdata` a été initialisé lors d'un run antérieur avec d'autres identifiants — Postgres n'initialise le cluster qu'au premier démarrage et ignore ensuite les variables. C'est à ça que sert `make destroy`. + +Chaîne fonctionnelle. Et le seed **confirme la trouvaille du flow** : `users=10`, `drivers=5` — les 5 utilisateurs de `create_users` n'ont aucun rôle. Je passe les gates pour connaître leur état. + +Deux gates rouges, une verte. `migrations-check` **passe** (« No changes detected »). Je rends `check` évaluable — aujourd'hui il juge des valeurs de dev contre une checklist de prod. + +`make check` : **« System check identified no issues (0 silenced). »** Le gate juge maintenant la configuration de production, pas ta machine. Je monte le site docs pour finir la chaîne. + +Le build passe — mais il a écrit `site/` **dans l'arbre source**, ce que `generated-artifacts.md` interdit. Je redirige. + +Docs servi : **200**, avec ses trois assets en 200. Il reste deux gates rouges, et ils le sont pour des raisons différentes. + +52 → 6 erreurs, et les 6 restantes sont **le même signal réel** sur ton code : `self.number` est `int | None` parce que `CreateDataBaseCommand.__init__` l'initialise à `None`. Ce n'est pas du bruit, c'est une annotation manquante — et c'est ta main. + +Plus que 2 erreurs, réelles toutes les deux, dans `users/admin.py` — et elles pointent une **idiome Django obsolète sur ton axe évalué** : + +```python +def is_driver(self, obj): ... +is_driver.boolean = True # ← attribut posé après coup +is_driver.short_description = 'Is driver' +``` + +L'idiome moderne est `@admin.display(boolean=True, description='Is driver')` sur la méthode. Mypy le voit parce que poser un attribut sur une fonction n'est pas typable. **C'est ta main** — une ligne, et le gate passe au vert sans exception. + +Je fige l'état vérifié : + +L'environnement aboutit. Et puisque tu passes à l'étape 2, **je referme la porte que j'avais ouverte** : + +Porte refermée : `Edit(/padam_django/**)` en `deny` unique, plus aucune exception. **`BusShift`, `BusStop`, leur admin et leurs migrations seront intégralement de ta main** — c'est vérifiable dans `git blame`, et c'est l'argument que tu voulais tenir devant les recruteurs. + +## L'environnement tourne — vérifié en exécutant + +| Cible | Preuve | +|---|---| +| `make .env` | créé depuis le contrat, clone frais sans étape manuelle | +| `make lock` | 76 paquets, `uv.lock` possédé par toi | +| `make build` | runtime 441 Mo, dev 995 Mo | +| `make up` | `db` et `app` **healthy** | +| `make migrate` | 13 tables en PostgreSQL 17 | +| `make seed` | users=10, drivers=5, buses=10, places=30 | +| `make check` | **« no issues »**, jugé sur les valeurs de **production** | +| `make migrations-check` | « No changes detected » | +| `make docs-build` | `--strict`, sortie en `/tmp/site` — rien de généré dans l'arbre | + +Et les deux URLs, requêtées et pas seulement listées : +- `http://localhost:8000/admin/login/` → **200**, et **ses 7 assets `/static/` en 200** +- `http://localhost:8001/` → **200**, CSS et JS en 200 + +## Trois rouges, chacun pour une raison qui n'est pas la chaîne + +1. **`make test` — `collected 0 items`.** Correctement rouge : `testing.md` dit qu'un comportement ne se livre pas sans son test. **Le premier test que tu écris le passe au vert.** +2. **`make typecheck` — 2 erreurs**, dans `users/admin.py` : `is_driver.boolean = True` et `.short_description`. `@admin.display(boolean=True, description='Is driver')` règle les deux. Une ligne, et sur ton axe évalué. +3. **`make lint` — 24 erreurs, 15 auto-corrigeables.** Surtout de l'ordre d'imports sur le code hérité. **Je ne l'ai pas lancé** : `ruff check --fix` réécrirait ton code. + +Tout est dans `.claude/quality-gate.json`, avec l'état `verified` / `red` / `pending` de chaque commande et la raison. + +## Décisions prises pendant, à justifier en revue + +- **`make check` juge la config de prod, pas ta machine.** Le `.env` local relâche la sécurité transport pour travailler en http ; la cible réimpose les valeurs sûres. Les défauts dans `settings.py` sont les **sûrs** — on relâche en dev, jamais l'inverse. Zéro `if ENV ==`. +- **`mypy` n'est pas en `strict`.** 52 erreurs sur le code hérité, aucune qu'on corrigera ici. Un gate rouge en permanence apprend à ignorer les gates. Documenté comme next step. +- **6 erreurs mypy silencées avec un pointeur**, pas cachées : `CreateDataBaseCommand.number` est `int | None`. Une annotation sur la commande de base retire le silencing. +- **`whitenoise`** ajouté comme tu l'as autorisé — c'est ce qui rend l'image complète seule, sans reverse proxy. + +Il te reste ton heure. Je suis en exécutant : envoie le plan. + +### 🧑 Human says + +avant de passer à l'étape 2; review du makefile against la doctrine ; review de la doc: en readme rajoute les url accesible avec le make up + +### 🤖 Claude says + +Revue du Makefile contre `makefile.md`. Trois écarts, dont un que j'ai introduit et qu'on a vu à l'écran sans le relever. + +Cibles revérifiées : `up` → healthy, `lock` → 76 paquets, `migrations-check` → vert. Passons au README — ses instructions actuelles mentent (`make install` n'existe plus, `make migrate` n'existait pas, virtualenv Python 3.7). + +Je vérifie les URLs que je viens d'écrire, plutôt que de les supposer. + +Les trois URLs répondent **200**, requêtées, pas supposées. + +## Revue du Makefile — trois écarts, corrigés + +**1. Les commentaires étaient dans les recettes.** Une ligne `#` sous une cible n'est pas un commentaire Make : elle est **passée au shell et affichée**. C'est pour ça qu'on a vu le texte de mes commentaires s'imprimer dans la sortie de `make lock` et `make check` tout à l'heure — je l'ai laissé passer trois fois sans le relever. Ils sont maintenant au-dessus des cibles, où ils sont muets. + +**2. « Les flags compose vivent dans *une* variable, pas répétés par recette. »** La cible `check` étalait sept `-e` dans sa recette. Ils sont dans `PROD_ENV`, et `RUN_PROD_ENV` compose avec `COMPOSE`. La recette redevient une ligne. + +**3. `superuser` dupliquait `$(RUN)`** au lieu de l'utiliser — `$(COMPOSE) run --rm $(DEV_SERVICE)` réécrit à la main, identique au caractère près. + +Le reste tient : aucune recette ne fait le travail elle-même (tout passe par compose), `help` est le `.DEFAULT_GOAL` et se génère depuis les `##`, tout est `.PHONY` **sauf `.env`** qui est un vrai fichier avec un vrai prérequis, les noms standards sont là (`up down logs sh build test lint format typecheck docs docs-build migrate ci`), et `ci` compose sans réimplémenter. + +Revérifié après refactor : `make check` → « no issues », `make up` → healthy, `make lock` → 76 paquets, `make migrations-check` → « No changes detected ». + +## README + +Les instructions de démarrage **mentaient** — `make install` n'existe plus, `make migrate` n'a jamais existé dans le Makefile amont, et le virtualenv Python 3.7 contredit toute la chaîne. Réécrites dans les deux langues, avec : + +- La ligne dure en tête : **l'hôte n'installe qu'un runtime de conteneur.** +- Le tableau des URLs accessibles après `make up`, avec pour chacune ce qu'elle sert et le piège associé — notamment que **les utilisateurs seedés n'ont pas de mot de passe utilisable**, donc `make superuser` n'est pas optionnel pour entrer dans l'admin. +- `make docs` n'est pas dans `make up` : le site sur 8001 se démarre à part. +- Les ports sont des valeurs (`APP_PORT`, `DOCS_PORT`), pas des constantes. +- La section « gates » : `make ci` comme définition unique du vert, et le fait que `make check` juge les valeurs de production. + +Une chose que je n'ai pas touchée : le tableau « Stack Technique » en tête de README annonce toujours **Python 3.9 / Django 4.2.16**. C'est l'énoncé d'origine — le corriger, c'est éditer la consigne de l'examinateur. Mon avis : laisse-le, et **explique l'écart dans la description de PR** — ça met ton arbitrage de sécurité en évidence au lieu de l'enterrer dans un diff. + +Prêt pour l'étape 2. Envoie le plan. + +### 🧑 Human says + +je commit la mise en place de l'environement et de la nouvelle stack technique, renvoi moi la commande avec le detail du commit qui suis les regles pour git cliff et semver + +### 🤖 Claude says + +Trois remarques avant les commandes. + +**Ce n'est pas un commit, c'est trois.** `release.md` dit que le sujet du commit **est** l'entrée de changelog — donc la question n'est pas « qu'est-ce que j'ai fait », mais « qu'est-ce que le lecteur des release notes doit lire ». Fondre la conteneurisation, la montée de stack et la documentation en une ligne rend le changelog muet sur le point le plus important. + +**Il y a un breaking change, et il pilote le semver.** L'application ne démarre plus sans `DJANGO_SECRET_KEY` ni `DATABASE_URL` — plus aucun défaut. C'est exactement ce qu'un `BREAKING CHANGE:` doit annoncer. + +**`cliff.toml` n'existe pas.** git-cliff tourne sans, avec sa config par défaut, mais tu n'auras ni groupes ordonnés ni filtrage des `chore`. Dis-moi si tu le veux, c'est cinq minutes. + +## Les commandes + +```bash +# 1 — l'environnement iso-prod +git add Dockerfile Dockerfile.dev docker-compose.yml .dockerignore Makefile .env.example .gitignore +git commit -m 'build: run everything in containers behind a single make interface' -m \ +'The project ran on the host in a virtualenv, and the README documented a +`make migrate` target that never existed. A fresh clone now needs a container +runtime and nothing else. + +Dockerfile.dev derives from the production image through a compose build +context rather than repeating its FROM, so dev and prod resolve one dependency +set. Static files are collected at build time and the image carries its own +healthcheck, so it serves without a second service. + +make ci is the single definition of green; every other target is runnable +alone.' + +# 2 — la stack, avec le breaking change +git add pyproject.toml uv.lock padam_django/settings.py +git rm --cached requirements.txt Pipfile 2>/dev/null; git add -u +git commit -m 'feat!: run on Django 5.2 LTS, Python 3.13 and PostgreSQL 17' -m \ +'Django 4.2 left extended support on 7 April 2026 and Python 3.9 reached +end-of-life on 31 October 2025. Securing what is shipped includes the runtime +versions, so both move to the current LTS and a supported interpreter. + +The upgrade costs one line in the codebase: USE_L10N was removed in Django 5.0. +unique_together is unaffected. + +SQLite is replaced by PostgreSQL 17 in the container, for dev/prod parity and +because an overlap constraint can then be enforced by the database rather than +by the application. + +pyproject.toml becomes the only manifest, with uv.lock as the source of truth. +requirements.txt and Pipfile are removed. + +BREAKING CHANGE: settings are read from the environment and no longer carry +defaults. DJANGO_SECRET_KEY and DATABASE_URL are required and the application +refuses to boot without them. Every variable is listed in .env.example.' + +# 3 — la documentation et la doctrine +git add CLAUDE.md README.md mkdocs.yml docs/ .claude/ padam_django/ +git commit -m 'docs: document the stack, the call flows and what is not covered' -m \ +'The README described a start-up procedure that no longer exists. It now lists +the URLs reachable after `make up` and what each one serves. + +docs/coverage.md states what the system handles and, above all, what it does +not, each with its reason: the overlap constraint, the absent tests, the +deferred hexagonal layering. A scenario missing from that file cannot be told +apart from one nobody thought about. + +docs/flows/existing-system.md is written from the code and records three +fragilities found while reading it, including a non-unique username factory +against a unique column. + +Google-style docstrings document what the signatures do not say.' +``` + +## Sur le semver + +D'après `release.md` — *« version sémantique dérivée des commits, pas choisie à la main »* — ces trois commits donnent : un `feat!` avec `BREAKING CHANGE`, donc **majeur**. Mais `pyproject.toml` est en `0.1.0`, et en `0.x` la convention (et le comportement par défaut de la plupart des outils) est qu'un breaking bump le **mineur** : **`0.2.0`**. + +C'est un choix à assumer explicitement, pas à subir : + +```bash +git tag -a v0.2.0 -m 'Containerised environment on Django 5.2 LTS and PostgreSQL 17' +``` + +Si tu veux signaler que la base est désormais stable, passe `pyproject.toml` en `1.0.0` **avant** le commit 2 et tague `v1.0.0` — mais `release.md` demande que la version vive à un seul endroit, donc c'est `pyproject.toml` qui fait foi et rien d'autre. + +Deux points d'attention sur ces commandes : + +- **Aucune signature d'outil**, aucun trailer de co-auteur, aucun lien de session — `git.md` l'interdit dans les messages comme dans les corps de PR. Les messages ci-dessus s'arrêtent à leur dernière ligne de substance. +- **Le commit 3 embarque `padam_django/`** pour les docstrings. Vérifie avec `git diff --cached --stat` qu'il ne contient bien que du commentaire : `settings.py` doit déjà être parti dans le commit 2. + +Une dernière, hors périmètre : ta branche s'appelle `feature/srs/technical-review`, alors que `git.md` demande `feat/`. Trop tard pour la renommer proprement si tu as déjà poussé — à ignorer, ou à mentionner en revue comme un écart vu. + +### 🧑 Human says + +ici on ne vas pas mettre le git cliff la regle est de ne pas aller trop loin non plus, cependant on le garde dans les next step proposé lors de la redaction de la pr + +### 🤖 Claude says + +D'accord, et c'est le bon arbitrage : un `cliff.toml` sans release automatisée derrière, c'est de la config qui ne sert rien aujourd'hui. Je l'inscris dans les next steps. + +Inscrit dans `docs/coverage.md`, section « Deliberately deferred — to raise in the pull request », avec la formulation qui te sert en revue : ce n'est pas un oubli, c'est un coût évalué et refusé — **les commits sont déjà écrits en Conventional Commits, donc l'entrée existe ; c'est la sortie qui n'a pas de destinataire aujourd'hui.** La colonne « ce qu'il faudrait » liste le `cliff.toml`, la cible `make changelog` en conteneur, et le workflow qui tague et **promeut l'image construite par la CI plutôt que d'en rebuilder une autre**. + +La section compte maintenant six entrées différées, dans cet ordre : DDD/hexagonal, changelog généré, hooks pre-commit, observabilité, autorisation explicite, API HTTP. Plus les deux décisions ouvertes (contrainte de chevauchement, mypy strict) et l'exception de scope `factory-boy`/`faker` que tu as actée. + +Les commandes de commit restent valables telles quelles — rien n'a changé côté fichiers versionnés, sauf `docs/coverage.md` qui part déjà dans le commit 3. + +Je suis en exécutant pour l'étape 2 : envoie le plan quand tu l'as. + +### 🧑 Human says + +voici ce que je comprend du sujet, plusieurs models bus driver user place existe: premier enjeu faire un revue de cette existant against recommendation, je n'ai pas encore creuser la relation entre user et driver et la problématique d'un possible n+1 query dessus. Ensuite je comprend devoir créer 2 model, BusShift et BusStop. BusShift a les attribut suivant - une relation vers un bus existant - une relation vers 1 driver existant - un attr start_at - un attr ends_at ; ce model aura des contraintes a résoudre: un busshift est valide quand il y aura au moins 2 busstop , on dois avoir un cohérence de datetime un bushift commence avant sa fin et aucun bustop ne peut etre ajouté sur une datetime avant son début ou apres sa fin -> il faudrais donc je pense que start_at et end_at -> le start at et end at sont corélé a l'init d'un bushift qui dois avoir deux bus stop donc le start at et end at semble static et computed not mutable (piste a vérifier) -> d'autre contrainte lors qu'un admin choisieras d'ajouter un bus et un driver il ne peuvent se trouver a deux endroit a la fois donc on doit vérifier si le couple bus driver est disponible pour le range time de ce shift. on veut aussi des property on veux pouvoir calculer la durée total d'un busshift. par contre il y a un element qui m'embete si le end d'un shift est a 12H00 et qu'on veut enchainer les shifts pour un driver/bus et donc commencer un autre shift qui démarre a 12h00 il faudrais alors que le second shift commence a 12h01 pour ne pas overlap - ici on vas brainstorm ensemble. Pour busStop celui ci aucune indication fournit pour moi un bus stop est définit par une place geo et un horaire dans les attr il a aussi une relation avec bushift 1 busstop vers 1 busshift. il y a donc aussi un contrainte ici on dois vérifier que le busstop choisi ne rentre pas en colision avec les busstop deja présent dans le busshift. Voila ma compréhension du sujet, pour aller plus loin, si deux admins tente d'update un busshift en meme temps on se retrouve en race concurrency il faudra donc aussi handle cette partie avec un lock (en django je me rappelle plus comment faire) + +### 🤖 Claude says + +Ta compréhension est juste sur l'essentiel. Je te la rends avec quatre corrections et une réponse qui règle ton problème de 12h00 sans hack. + +## 1. Ce que tu as vu juste + +Le point le plus important : **tu as identifié que `start_at`/`end_at` sont dérivés des arrêts et donc non modifiables directement.** C'est la bonne intuition, et c'est là que le sujet piège — « plusieurs solutions, certaines plus coûteuses ». + +## 2. La correction qui compte le plus : bus et driver sont **deux** contraintes, pas un couple + +Tu écris « vérifier si le **couple** bus/driver est disponible ». Non : ce sont **deux contraintes indépendantes**. + +- Le même **bus** ne peut pas être sur deux shifts qui se chevauchent. +- Le même **driver** non plus. + +Mais un shift peut parfaitement partager son bus avec un shift A et son driver avec un shift B, tant qu'aucun des deux ne chevauche. Vérifier le couple laisserait passer : shift 1 = (bus X, driver Y), shift 2 = (bus X, driver Z) sur le même créneau → le couple diffère, le bus est pourtant à deux endroits. **Deux contraintes séparées, une par relation.** + +## 3. Ton problème de 12h00 : il disparaît avec un intervalle semi-ouvert + +Tu proposes de démarrer le second shift à 12h01. Ne fais pas ça — c'est un contournement qui laisse une minute de flou dans le modèle et qui casse dès qu'on passe à la seconde. + +La réponse est **`[start, end)`** : borne de début incluse, borne de fin **exclue**. Un shift 08:00→12:00 occupe `[08:00, 12:00)`, un shift 12:00→16:00 occupe `[12:00, 16:00)`. **Ils ne se chevauchent pas**, mathématiquement, sans aucune manipulation. Et deux shifts 08:00→12:00 et 11:59→13:00 se chevauchent bien. + +Postgres exprime ça nativement : `tstzrange(start_at, end_at, '[)')`. L'opérateur `&&` (overlap) fait exactement le bon test. En comparaison manuelle, l'équivalent est `A.start < B.end AND B.start < A.end` — inégalités **strictes**, l'adjacence passe. + +C'est aussi ce qui justifie ta montée en Django 5.2 : **`models.GeneratedField`** (arrivé en 5.0) te permet de stocker ce range comme colonne générée depuis `start_at`/`end_at`, donc **impossible à désynchroniser** — la base la calcule, personne ne l'écrit. + +## 4. Le problème que tu n'as pas encore vu : la dépendance circulaire + +`BusStop` a une FK vers `BusShift`. Donc pour créer un arrêt, **le shift doit déjà exister en base**. Mais si `start_at`/`end_at` sont calculés depuis les arrêts, ils sont indéterminés au moment de l'`INSERT` du shift. + +Il y a donc forcément un instant où **un `BusShift` existe avec zéro arrêt**. La contrainte « au moins 2 arrêts » ne peut pas être une contrainte de table : c'est une contrainte de **cardinalité inter-lignes**, que Postgres ne sait exprimer qu'avec un trigger `DEFERRABLE`. + +Deux sorties, et c'est ton vrai arbitrage de modélisation : + +| | A — colonnes dénormalisées | B — propriétés pures | +|---|---|---| +| `start_at`/`end_at` | colonnes réelles, recalculées à chaque changement d'arrêt | `@property` lisant `stops.first()/last()` | +| Contrainte BDD | **`ExclusionConstraint` possible** | impossible — il faudrait joindre | +| Concurrence | tient | ne tient pas | +| Risque | désynchronisation si un chemin oublie de recalculer | aucun, par construction | +| Coût admin | champs `editable=False`, recalcul au `save_related` du formset | zéro | + +**Ma recommandation : A**, parce que c'est la seule qui permet à l'intégrité de vivre en base — et `database.md` comme `security.md` disent que l'intégrité ne se délègue pas à l'application. Le risque de désynchronisation se neutralise avec `GeneratedField` pour le range, et un recalcul unique dans `save_related` de l'admin. + +Et « au moins 2 arrêts » se valide alors au **niveau du formset de l'admin**, pas du modèle — c'est le seul endroit qui voit le shift et ses arrêts ensemble dans une transaction. + +## 5. Concurrence : tu mélanges deux problèmes différents + +C'est la partie que je veux que tu retiennes pour l'entretien, parce que c'est là qu'on distingue un candidat. + +**Problème 1 — lost update.** Deux admins éditent *le même* shift. B écrase A. → `select_for_update()` dans un `transaction.atomic()`, ou un champ de version vérifié à l'écriture (verrouillage optimiste). Django n'a rien d'intégré dans l'admin pour ça. + +**Problème 2 — phantom overlap.** Deux admins créent *deux shifts différents* qui se chevauchent. Chaque transaction valide contre un état qui exclut l'autre, les deux passent. **`select_for_update()` ne protège pas de ça** : on ne peut pas verrouiller des lignes qui n'existent pas encore. Un `clean()` non plus. + +Seules deux choses tiennent contre le phantom : une **contrainte d'exclusion en base**, ou le niveau d'isolation `SERIALIZABLE` (qui te renvoie des erreurs de sérialisation à gérer et retenter). **C'est l'argument central en faveur de l'`ExclusionConstraint`**, et c'est ce que tu dois pouvoir dire en revue. + +Concrètement, deux contraintes sur `BusShift`, plus l'extension : + +- `BtreeGistExtension()` en migration — nécessaire pour combiner l'égalité sur `bus_id` avec l'overlap `&&` sur le range dans un même index GiST. +- Une exclusion `(bus_id WITH =, shift_range WITH &&)`, une autre sur `driver_id`. +- `violation_error_message` sur chacune — Django 5.2 l'honore toujours, donc l'admin affiche un message métier au lieu d'une `IntegrityError`. + +## 6. `BusStop` — d'accord, avec un ajout + +Ta définition (place + horaire + FK shift) est bonne. Les contraintes que je vois : + +- **Unicité `(bus_shift, time)`** — deux arrêts au même instant dans un shift n'ont pas de sens. Ça, c'est une vraie `UniqueConstraint`, gratuite. +- **L'arrêt doit tomber dans `[start_at, end_at]`** — sauf que si les bornes *sont* les arrêts extrêmes, cette contrainte est tautologique. Elle n'a de sens que dans le modèle B. Attention à ne pas écrire une contrainte qui ne peut pas être violée. +- **Le même lieu deux fois** dans un shift : autorisé ou non ? Un aller-retour repasse au même endroit. Mon avis : autorisé, mais c'est ta décision et elle doit être écrite. +- Un `ordering = ['time']` sur le `Meta`, sinon l'inline admin affiche les arrêts dans l'ordre d'insertion. + +## 7. Sur ta question User/Driver + +Ce n'est pas une hypothèse, c'est mesuré et déjà documenté dans `docs/flows/existing-system.md` : `User.is_driver` fait `hasattr(self, 'driver')`, ce qui déclenche **une requête par ligne** de changelist. Invisible sur 10 users. + +Ce qui compte pour toi : c'est le motif à ne pas reproduire quand ton changelist `BusShift` affichera bus + driver + nombre d'arrêts. Ce sera `get_queryset` avec `select_related('bus', 'driver__user')` et `annotate(Count('stops'))`. **Trois requêtes au total au lieu de 3×N.** Le montrer dans ton admin, c'est de la maîtrise Django visible. + +--- + +Deux décisions à prendre avant d'écrire quoi que ce soit : + +1. **Semi-ouvert `[start, end)` — tu valides ?** Ça règle 12h00 proprement et c'est ce que je recommande. +2. **Modèle A (colonnes dénormalisées + contrainte BDD) ou B (propriétés pures) ?** A coûte le recalcul, B abandonne l'intégrité en base. + +Tranche, et j'écris le flow `BusShift` depuis ta décision pendant que tu codes. + +### 🧑 Human says + +1 je valide l'utilisation de cette operator 2 on reexplique + +### 🤖 Claude says + +Semi-ouvert validé. Je reprends le point 2 autrement — c'est **une seule question** : où vivent `start_at` et `end_at` ? + +## Le fond du problème + +Le sujet dit : *« l'heure de départ est déterminée par l'heure de passage au premier arrêt »*. Donc l'information source, c'est **les arrêts**. `start_at` et `end_at` en sont une **conséquence**. + +Une conséquence, on peut soit la **recalculer à chaque fois qu'on en a besoin**, soit la **stocker et la maintenir**. C'est tout le choix. + +## Modèle B — on recalcule + +Pas de colonnes. Deux propriétés Python : + +```python +@property +def start_at(self): + return self.stops.order_by('time').first().time +``` + +**Avantage : c'est toujours vrai.** Impossible de désynchroniser, l'information n'existe qu'à un seul endroit — les arrêts. + +**Le problème :** pour savoir si le bus X est déjà pris entre 8h et 12h, Postgres devrait, pour chaque shift de ce bus, aller chercher le min et le max de ses arrêts, reconstruire un intervalle, et le comparer. **Une contrainte de table ne peut pas faire ça** — une contrainte ne voit que la ligne qu'on écrit, pas une agrégation sur une autre table. + +Donc la vérification remonte forcément dans le code Python, dans un `clean()`. Et un `clean()` ne tient pas sous concurrence : deux admins qui créent chacun un shift chevauchant, en même temps, valident chacun contre un état qui ignore l'autre. **Les deux passent.** C'est exactement le phantom overlap dont on parlait — et c'est précisément la contrainte métier que l'énoncé met en avant. + +## Modèle A — on stocke et on maintient + +`start_at` et `end_at` sont de vraies colonnes. Quand les arrêts d'un shift changent, on les recalcule et on les réécrit. + +**Ce que ça débloque :** ce sont des colonnes, donc Postgres peut les indexer, donc l'`ExclusionConstraint` devient possible. L'intégrité est tenue **par la base**, y compris entre deux transactions concurrentes qui ne se voient pas. Rien ne peut la contourner : ni un `clean()` oublié, ni un import de données, ni un `shell`, ni une race. + +**Ce que ça coûte :** les colonnes peuvent mentir. Si quelqu'un ajoute un arrêt à 06h00 sur un shift qui commence à 08h00 et que le recalcul n'a pas lieu, `start_at` reste faux. Il faut donc que **tout chemin qui modifie les arrêts recalcule les bornes** — dans l'admin, c'est `save_related()`, qui s'exécute après l'enregistrement du formset inline, donc au moment où les arrêts sont enfin connus. + +À noter : le `GeneratedField` dont je parlais règle **une** désynchronisation, pas les deux. Il garantit que le range colle toujours à `start_at`/`end_at`, parce que c'est la base qui le calcule. Il ne garantit pas que `start_at` colle aux arrêts — ça, c'est ton recalcul. + +## Le compromis en une ligne + +| | B | A | +|---|---|---| +| Peut mentir ? | jamais | oui, si un chemin oublie de recalculer | +| Contrainte tenue par | du code Python | **la base de données** | +| Deux admins simultanés | **passent tous les deux** | le second est refusé | + +**B ne peut pas mentir mais ne peut pas protéger. A peut mentir mais protège vraiment.** + +## Ma recommandation, et pourquoi + +**A** — parce que la contrainte de non-chevauchement *est* le sujet de l'exercice. Livrer B, c'est livrer une contrainte qui a l'air de marcher tant qu'on la teste tout seul. Le risque de A est un risque de code, contenu à un endroit identifiable et couvrable par un test ; le risque de B est structurel et non corrigeable sans changer le modèle. + +Et en revue, A te donne quelque chose à dire : *« j'ai dénormalisé sciemment, voici ce que ça achète, voici ce que ça coûte, voici où je paie le coût. »* C'est un arbitrage assumé, ce que l'énoncé demande explicitement. + +**Une troisième voie existe** et vaut d'être connue, même si je ne la recommande pas ici : faire de `start_at`/`end_at` des **saisies** — l'admin déclare la fenêtre du shift, et les arrêts doivent tomber dedans. Plus simple, aucune désynchronisation possible, contrainte en base directe. Mais ça **inverse le sens de l'énoncé**, qui dit que le départ est déterminé par le premier arrêt. À mentionner en revue comme alternative écartée, pas à implémenter. + +Tu tranches A ou B ? + +### 🧑 Human says + +je dirais plutot A mais voici mes raisons suplémentaire, un gain pour la construction des contraintes on vois le models on connais dessuite ses regles et ca a maintenir c'est plus simple, de plus ca protege la concurencey qui était un des points que je nommé. La ou par contre on a besoin de documenter c'est que pour assurer que la contraintes tiens et qu'aucun chemin ne l'oubli on doit integrer du fuzzing a notre chaine de CI precisément pour mettre en visibilité cet aspect + +### 🤖 Claude says + +A, donc. Tes deux raisons supplémentaires sont bonnes et je les prends — surtout la première, qu'on avait sous-évaluée : **la contrainte est lisible sur le modèle**. Un `ExclusionConstraint` dans le `Meta` dit la règle métier à qui ouvre le fichier. En B, la règle est enterrée dans un `clean()` qu'il faut aller lire. + +Sur le fuzzing, je suis d'accord avec l'intention et je veux affiner l'outil, parce que « fuzzing » recouvre trois choses différentes ici et une seule attrape ton risque. + +## Ce que tu cherches à attraper + +L'invariant est : **`start_at == min(stops.time)` et `end_at == max(stops.time)`, pour tout shift.** C'est vérifiable, et c'est ça qui rend ton risque traitable. + +**1. La vérification d'invariant — à faire maintenant, coût quasi nul.** +Une requête qui remonte tout shift dont les bornes ne collent pas à ses arrêts. Elle devient un test, et une commande `manage.py` qu'on peut lancer sur une vraie base. Ce n'est pas du fuzzing, c'est un **oracle** — et c'est ce dont le fuzzing a besoin pour servir à quelque chose. Sans lui, un fuzzer génère du bruit sans savoir ce qui est faux. + +**2. Le property-based testing (Hypothesis) — c'est ça, ton fuzzing.** +On génère des **séquences d'opérations** aléatoires — ajouter un arrêt, en retirer un, en décaler un, en ajouter deux d'un coup — et on assert l'oracle après chaque séquence. C'est exactement l'outil pour « un chemin oublie le recalcul », parce qu'il explore les chemins que tu n'as pas pensé à tester. Et c'est visible en revue. + +**3. La concurrence ne se fuzz pas comme ça.** Pour prouver que l'`ExclusionConstraint` tient, il faut **deux connexions réelles** en parallèle, pas un process qui boucle. En pytest-django, ça veut dire `TransactionTestCase` (pas `TestCase`, qui enveloppe tout dans une transaction annulée) et deux threads. C'est une forme de test différente, et deux ou trois cas suffisent : chevauchement exact, partiel, adjacent. + +## Le chemin qu'un fuzzer trouvera, et que je te donne tout de suite + +Si tu accroches le recalcul à un signal `post_save`/`post_delete` sur `BusStop`, il sera **contourné** par : + +- `BusStop.objects.bulk_create(...)` +- `BusStop.objects.bulk_update(...)` +- `shift.stops.all().delete()` — le `.delete()` d'un queryset ne passe pas par `Model.delete()` +- `queryset.update(time=...)` + +Ce sont les quatre portes classiques. La parade structurelle est de **ne pas dépendre du signal** : un seul point de recalcul, une méthode explicite sur `BusShift`, appelée depuis `save_related()` de l'admin et depuis tout service. Plus `editable=False` sur `start_at`/`end_at` pour que l'admin ne propose jamais de les saisir. + +Le signal reste un bon filet **en plus**, pas à la place. + +## Mon avis sur ce qui entre en CI maintenant + +Tu as déjà dépassé les 4h, et `make ci` est rouge sur `test` uniquement parce que `tests/` n'existe pas. Ordre de rentabilité : + +| | Quoi | Coût | +|---|---|---| +| 1 | L'**oracle** + les tests de chevauchement (exact, partiel, **adjacent** — la borne semi-ouverte se teste) | faible, indispensable | +| 2 | **Deux tests de concurrence** avec `TransactionTestCase` et deux threads | moyen, c'est ce qui prouve ton arbitrage | +| 3 | **Hypothesis** sur les séquences d'opérations | plus élevé — nouvelle dépendance, temps de run en CI | + +Mon conseil : **1 et 2 dans la PR, 3 en next step documenté**, formulé comme tu l'as dit — *« l'invariant de dénormalisation est vérifiable ; un property-based test sur les séquences d'opérations mettrait en visibilité les chemins qui l'oublient. »* Ça montre que tu as vu le risque **et** que tu sais ce qui le couvre, sans gonfler le périmètre. + +Je consigne la décision A avec ses raisons dans `docs/coverage.md`, et le fuzzing en next step ? + +### 🧑 Human says + +le fuzzing uniquement documenter en "next step si je devais continuer" par contre la documentation explicite et rajoute le todo la ou il'y aura besoin. En effet le délai est dépassé, j'avance, pendant que je commence les models Busshift et BusStop je vais te laisser ecrire des test unitaires basic pour couvrir les cas classic. + +### 🤖 Claude says + +Noté : fuzzing en documentation seule, TODO posés là où ils serviront. Je consigne la décision, puis j'écris les tests. + +Décision consignée (`docs/coverage.md:77`), fuzzing en next step avec l'oracle nommé comme la brique déjà faite. J'écris les tests. + +Tu utilises `starts_at` / `ends_at` — j'aligne les tests sur tes noms réels plutôt que sur les miens. + +`BusStop` n'existe pas encore. J'isole toutes les hypothèses d'interface dans `conftest.py` — un seul endroit à corriger si tu nommes autrement. + +Erreur de ma part : import relatif sans package. Je sors les helpers dans un module importé en absolu. + +**25 tests écrits, tous rouges pour la bonne raison :** + +``` +django.db.utils.ProgrammingError: relation "fleet_busshift" does not exist +``` + +Les modèles existent dans le code, la migration n'est pas encore générée. `make makemigrations` puis `make test` et ils passeront au vert au fur et à mesure. Au passage, `make migrations-check` est maintenant rouge lui aussi — le gate fait exactement son travail : un modèle a changé sans sa migration. + +## Ce qui est couvert + +**`test_bus_shift_overlap.py` — la règle métier du sujet** (9 tests) +- Même bus : fenêtre identique, chevauchement partiel, fenêtre englobée → refusés +- **Dos à dos accepté** — c'est le test qui prouve le `[starts_at, ends_at)` +- Jours différents → accepté +- Même driver sur un **autre bus**, chevauchant → refusé (les deux contraintes sont indépendantes) +- Bus et driver tous deux différents, chevauchant → accepté +- Un shift qui partage son bus avec l'un et son driver avec l'autre, sans collision temporelle → accepté. **C'est le test qu'une contrainte écrite sur le couple `(bus, driver)` échouerait.** + +**`test_bus_shift_bounds.py` — l'oracle de dénormalisation** (10 tests) +Un shift sans arrêt n'a pas de bornes ; les bornes sont le premier et le dernier arrêt ; l'ordre d'insertion n'intervient pas ; ajouter un arrêt plus tôt / plus tard déplace les bornes ; supprimer le premier arrêt aussi ; décaler un arrêt aussi. Plus `duration` : la durée est l'écart entre extrêmes, `None` sans arrêts, **inchangée par un arrêt intermédiaire**. + +**`test_bus_stop.py` — l'arrêt et le jeu d'arrêts** (6 tests) +Appartenance à un seul shift, ordre chronologique, deux arrêts au même instant refusés, même instant dans deux shifts différents accepté, cardinalité minimale. + +Les tests de contrainte sont en `django_db(transaction=True)` : une contrainte n'est pas prouvée par un `full_clean()` qui n'a jamais fait d'`INSERT`. + +## Le TODO, posé à un seul endroit + +Dans `test_bus_stop.py::TestCardinality`, sur « au moins 2 arrêts » — parce que c'est la seule règle qui **ne peut pas** vivre en base : + +> `TODO: point this at the real validation entry point once the admin formset is written.` + +Le test assert aujourd'hui contre `full_clean()`. Quand ton formset inline portera la règle, c'est là qu'il faudra le rebrancher. Je ne l'ai pas mis ailleurs : `thresholds.json` a `no_todo_fixme_in_new_code`, un TODO doit rester rare pour rester lu. + +## Deux endroits à ajuster si je me suis trompé + +Tout ce que la suite suppose de ton implémentation est isolé dans **`tests/helpers.py`** : + +```python +STOPS_RELATED_NAME = 'stops' +RECOMPUTE_METHOD_NAME = 'refresh_bounds' +``` + +Et dans `conftest.py`, la fixture `add_stop` suppose `BusStop(bus_shift=…, place=…, time=…)`. Si tu as nommé autrement, ces trois lignes suffisent — aucun module de test ne parle de noms de champs. + +Deux corrections que j'ai dû faire sur mon propre travail : un import relatif dans un dossier qui n'est pas un package (d'où l'extraction de `helpers.py`), et un doublon de `assert_bounds_match_stops` laissé dans `conftest.py` par mon script de refactor. + +Dis-moi les noms réels dès que ta migration passe, j'aligne et je fais tourner. + +### 🧑 Human says + +j'ai commencer les models, j'ai pas encore la partie constraints je sais qu'avec django orm c'est possible mais j'ai plus du tout la syntax et la maniere de l'implémenter. fais une review deja de ce que j'ai commencer a implémenter on valide que je ne dérive pas de notre trajectoire et je te demande une ébauche pour les constraints + +### 🤖 Claude says + +Trajectoire respectée — je ne vois aucune dérive. Je commence par aligner mes tests sur ton nom réel de FK (`shift`, pas `bus_shift`). + +Aligné. (Le bruit shell vient de mes backquotes dans le `echo`, sans conséquence.) + +## Revue — ce qui est juste, et ça compte + +**`on_delete` est bien choisi partout, et c'est le genre de détail qu'un examinateur regarde :** + +- `PROTECT` sur `bus` et `driver` — supprimer un bus n'efface pas l'historique des trajets qu'il a faits. C'est le bon réflexe. +- `CASCADE` sur `BusStop.shift` — un arrêt n'a aucun sens sans son trajet. +- `PROTECT` sur `place` — un lieu référencé par un arrêt ne disparaît pas en silence. + +`editable=False` + `null=True` sur les bornes est exactement le modèle A qu'on a acté, et le `help_text` dit *pourquoi*. `duration` qui renvoie `None` quand les bornes manquent, plutôt que de lever : correct, c'est un état légitime. + +## Sept points à corriger + +| | Où | Quoi | +|---|---|---| +| 1 | `BusStop` | **Pas de `Meta.ordering = ['time']`.** L'inline admin affichera les arrêts dans l'ordre d'insertion, pas dans l'ordre du trajet. | +| 2 | `BusStop` | **Pas de contrainte d'unicité `(shift, time)`.** Deux arrêts au même instant dans un trajet passent. | +| 3 | `BusShift`, `BusStop` | **Aucune docstring.** « Documentation et clarté du code » est le premier critère d'évaluation énoncé. | +| 4 | l. 59, 74 | Corps de `__str__` indentés à 8 espaces + espaces en fin de ligne (l. 60, 75). `ruff` les remontera. | +| 5 | `duration` | Pas d'annotation de retour. `python.md` : toute signature publique est annotée. `-> timedelta \| None`. | +| 6 | `Bus.licence_plate` | Verbose name toujours `"Name of the bus"` sur le champ plaque — visible dans l'admin. | +| 7 | `BusShift` | Pas de `Meta.ordering`. Le changelist sortira dans l'ordre de PK. `['-starts_at']` est le plus utile. | + +## Ébauche des contraintes + +```python +from django.contrib.postgres.constraints import ExclusionConstraint +from django.contrib.postgres.fields import DateTimeRangeField, RangeOperators +from django.db import models +from django.db.models import F, Func, Q, Value + + +class BusShift(models.Model): + ... + + # The half-open interval the shift occupies, computed BY THE DATABASE from + # the two bounds. A GeneratedField cannot drift from what it derives from, + # which removes one of the two desynchronisation risks the denormalised + # design carries — the other, bounds vs stops, stays the recompute's job. + # + # '[)' is the whole point: a shift ending at 12:00 and one starting at + # 12:00 do not overlap, so a driver's day chains without an artificial gap. + period = models.GeneratedField( + expression=Func( + F("starts_at"), F("ends_at"), Value("[)"), function="TSTZRANGE", + ), + output_field=DateTimeRangeField(), + db_persist=True, + ) + + class Meta: + ordering = ["-starts_at"] + constraints = [ + models.CheckConstraint( + condition=Q(starts_at__lt=F("ends_at")), + name="bus_shift_starts_before_it_ends", + violation_error_message="A shift cannot end before it starts.", + ), + ExclusionConstraint( + name="bus_shift_no_overlap_per_bus", + expressions=[ + ("period", RangeOperators.OVERLAPS), + ("bus", RangeOperators.EQUAL), + ], + # WITHOUT THIS THE WHOLE TABLE LOCKS UP. A shift with no stops + # has NULL bounds, and TSTZRANGE(NULL, NULL) is not NULL — it is + # the UNBOUNDED range, which overlaps every other range. The + # first stopless shift would then forbid every shift for that + # bus. Partial index: only shifts that actually have a window + # take part. + condition=Q(starts_at__isnull=False, ends_at__isnull=False), + violation_error_message=( + "This bus is already assigned to a shift over that period." + ), + ), + ExclusionConstraint( + name="bus_shift_no_overlap_per_driver", + expressions=[ + ("period", RangeOperators.OVERLAPS), + ("driver", RangeOperators.EQUAL), + ], + condition=Q(starts_at__isnull=False, ends_at__isnull=False), + violation_error_message=( + "This driver is already assigned to a shift over that period." + ), + ), + ] + + +class BusStop(models.Model): + ... + + class Meta: + ordering = ["time"] + constraints = [ + models.UniqueConstraint( + fields=["shift", "time"], + name="bus_stop_one_call_per_instant", + violation_error_message=( + "This shift already calls somewhere at that time." + ), + ), + ] +``` + +Et la migration doit **commencer** par l'extension, sinon l'index GiST ne peut pas être créé : + +```python +from django.contrib.postgres.operations import BtreeGistExtension + +class Migration(migrations.Migration): + operations = [ + BtreeGistExtension(), # en premier + ... + ] +``` + +## Cinq choses à savoir avant de coller ça + +1. **`btree_gist` est indispensable.** GiST sait indexer les ranges, pas les entiers. Combiner `bus = X` (égalité sur un `int`) **et** `period && Y` dans un seul index exige cette extension. Sans elle, la migration échoue sur `data type integer has no default operator class for access method "gist"`. + +2. **Le piège du `NULL` est le vrai danger.** Je l'ai mis en majuscules dans le commentaire parce qu'il ne se voit pas à la lecture et qu'il casse tout : `TSTZRANGE(NULL, NULL)` vaut `(,)`, l'intervalle infini, qui chevauche **tout**. Un seul trajet sans arrêt bloquerait tous les trajets de son bus. Le `condition=` transforme la contrainte en index partiel et règle ça. + +3. **Deux contraintes séparées**, une par ressource — pas une sur le couple. C'est ce que teste `test_a_shift_may_share_its_bus_with_one_shift_and_its_driver_with_another`. + +4. **`violation_error_message` est honoré par Django 5.2 même sans `condition`** — c'est ce qui fait qu'un refus s'affiche comme un message métier dans l'admin au lieu d'une `IntegrityError` en page 500. Un des gains concrets de ta montée de version, à dire en revue. + +5. **Le `CheckConstraint` compare `starts_at < ends_at` avec des `NULL` possibles** : en SQL, `NULL < NULL` vaut `NULL`, et une contrainte `CHECK` accepte `NULL`. Donc un trajet sans arrêt passe sans condition supplémentaire. C'est le comportement voulu, mais il repose sur la logique ternaire — vérifie-le plutôt que de me croire. + +Ensuite : `make makemigrations`, `make migrate`, `make test`. Les 25 tests devraient basculer. Envoie-moi la sortie, je te dis lesquels restent rouges et pourquoi. + +### 🧑 Human says + +ok ta base de constraint est mise, voici la ou je t'authorize d'edit -> import organisé dans le fichier - system - tiers - project - typing , je ne vois aucun typing d'ailleurs, je t'authroize a le faire sur l'ensemble de la code base. + +### 🤖 Claude says + +Deux choses avant : je rouvre la permission (elle était en `deny` strict), et je te signale un conflit entre ta convention et l'outil. + +**`typing` en dernier n'est pas ce que fait `ruff` par défaut** : sa règle `I` traite `typing` comme de la stdlib, donc en premier bloc. Si on veut ta convention, il faut la déclarer, sinon `make lint` défera à chaque passage ce que j'écris. Je configure une section dédiée. + +`ruff --fix` a corrigé 18 points. Il en reste 29, dont **19 qui sont mes imports manquants** : tu as collé l'ébauche sans ses imports. C'est précisément le travail que tu m'autorises. + +Compris — tu parles du bloc `if TYPE_CHECKING:`, pas de l'import `typing`. Ma config isort était la mauvaise réponse. Je corrige : c'est `flake8-type-checking` (`TC`) qu'il faut, qui force les imports purement typographiques dans ce bloc. + +Deux erreurs restantes, dans mes propres tests. Je les corrige et j'annote le reste de la base. + +Les 7 restantes sont dans `.claude/tools/` — de l'outillage de template, pas du code projet. Je l'exclus. + +`ruff format --check` et `ruff check` : **All checks passed.** Migration générée. Je migre et je lance les tests. + +Échec exactement là où je t'avais prévenu — `makemigrations` ne pose jamais l'extension tout seul : + +``` +psycopg.errors.UndefinedObject: data type bigint has no default operator class +for access method "gist" +``` + +`bigint`, c'est ton `bus_id` / `driver_id`. GiST ne sait pas indexer un entier sans `btree_gist`. **Deux lignes à ajouter** dans `padam_django/apps/fleet/migrations/0003_busshift_busstop_and_more.py` — c'est une migration, elle est en `deny` pour moi : + +```python +from django.contrib.postgres.operations import BtreeGistExtension + +class Migration(migrations.Migration): + ... + operations = [ + BtreeGistExtension(), # EN PREMIER, avant toute création de contrainte + ... + ] +``` + +À savoir : `CREATE EXTENSION` demande des droits élevés. Dans le conteneur, ton utilisateur `padam` est propriétaire du cluster, donc ça passe. En vraie prod, c'est un DBA qui pose l'extension une fois — à mentionner en revue. + +## Passe imports + typing terminée + +**`ruff format --check` et `ruff check` : `All checks passed!`** sur 52 fichiers. + +L'ordre que tu voulais est en place, et surtout **il est maintenant tenu par l'outil** : + +```python +from __future__ import annotations # forcé partout (required-imports) + +from typing import TYPE_CHECKING # système + +from django.db import models # tiers + +from . import models # projet + +if TYPE_CHECKING: # typing, en dernier + from datetime import timedelta +``` + +J'ai configuré ça avec la règle **`TC` (flake8-type-checking)**, pas avec une section isort custom comme je l'avais fait d'abord — ma première réponse répondait à « l'import `typing` », pas à ta convention. `TC` déplace automatiquement tout import qui ne sert qu'aux annotations, et `from __future__ import annotations` fait que ces annotations ne sont jamais évaluées au runtime. + +**Annotations ajoutées** sur tous les `__str__`, `handle`, `add_arguments`, `_init_options`, `__init__`, `UserAdmin.is_driver(obj: models.User) -> bool`, et `BusShift.duration -> timedelta | None` avec sa docstring. + +Trois ajustements de configuration au passage : `RUF012` ignoré sur les `models.py` (les `Meta` de Django sont des listes de classe par nature), `.claude/*` exclu du lint (outillage de template, pas ton code), et `E501` toléré dans `settings.py`. + +## Deux points qui restent, et un aveu + +**`make typecheck` — 2 erreurs, toujours les mêmes** : +``` +padam_django/apps/users/admin.py:35: "Callable[[UserAdmin, User], bool]" has no attribute "boolean" +``` +Poser un attribut sur une fonction n'est pas typable. `@admin.display(boolean=True, description="Is driver")` sur la méthode règle les deux. C'est un changement d'idiome, pas de typage — **dis-moi si tu veux que je le fasse ou si tu le prends.** + +**Et l'aveu : `ruff format` est allé plus loin que ce que tu m'as autorisé.** Il a converti tous les guillemets simples en doubles sur 29 fichiers. Ce n'était pas dans ta demande — c'est le formateur du projet qui s'applique, et `format-check` est un gate de CI donc il fallait bien que ça arrive, mais tu ne l'avais pas demandé. Si tu préfères des guillemets simples, ça se configure (`quote-style = "single"`) et je repasse. + +Une fois `BtreeGistExtension()` posé : `make migrate` puis `make test`, et on voit combien des 25 passent. + +### 🧑 Human says + +done de mon coté, make migrate ok make test 22 failed, 3 passed in 1.98s + +### 🤖 Claude says + +22 échecs, mais **une seule cause pour 21 d'entre eux** : + +``` +AttributeError: 'BusShift' object has no attribute 'refresh_bounds' +``` + +C'est le point de recalcul unique — la pièce sur laquelle repose tout le modèle A. Il n'est pas encore écrit. Le 22ᵉ est le test de cardinalité (`DID NOT RAISE ValidationError`), attendu tant que la règle « au moins 2 arrêts » n'existe pas. + +Les 3 qui passent sont ceux qui n'en dépendent pas : un trajet sans arrêt n'a pas de bornes, `duration` vaut `None`, et les arrêts sortent triés — ton `ordering = ["time"]` fonctionne. + +## Ébauche de `refresh_bounds` + +```python +from django.db.models import Max, Min + + +class BusShift(models.Model): + ... + + def refresh_bounds(self) -> None: + """Recompute the derived bounds from this shift's stops. + + The single point of contact between the stops and the denormalised + columns. Deliberately NOT a signal: ``bulk_create``, ``bulk_update``, + ``queryset.update()`` and ``queryset.delete()`` all bypass signals, and + each of those is a path that would silently leave the bounds lying. + + Call it inside the same transaction as the change that made it + necessary — in the admin, from ``save_related()``, which runs after the + inline formset has written the stops. + + Raises: + IntegrityError: If the resulting window overlaps another shift for + the same bus or the same driver. The exclusion constraint is + evaluated on this UPDATE, not on the shift's INSERT — a shift is + created without a window and only acquires one here. + """ + bounds = self.stops.aggregate(first=Min("time"), last=Max("time")) + self.starts_at = bounds["first"] + self.ends_at = bounds["last"] + self.save(update_fields=["starts_at", "ends_at"]) +``` + +Quatre points sur ce bloc : + +1. **Un seul `aggregate`**, pas deux requêtes ni un tri en Python. `Min`/`Max` sur `time` descendent en SQL. +2. **`update_fields`** limite l'`UPDATE` à deux colonnes. `period` est un `GeneratedField` : Django l'exclut automatiquement, c'est la base qui le recalcule. +3. **Zéro arrêt → `first`/`last` valent `None`**, donc les bornes redeviennent nulles. C'est exactement ce qu'assert `test_a_shift_without_stops_has_no_bounds`, et c'est ce que la `condition=` de la contrainte d'exclusion attend. +4. **Le moment où la contrainte se déclenche est contre-intuitif** — et c'est pour ça que mes tests de chevauchement enveloppent `shift_factory` dans `pytest.raises(IntegrityError)` : le refus ne vient pas de la création du `BusShift` (qui n'a pas encore de fenêtre) mais de cet `UPDATE`. Si tu déplaces le recalcul ailleurs, les tests te le diront. + +## Pour le 22ᵉ + +« Au moins 2 arrêts » n'a pas de place sur le modèle — c'est une cardinalité inter-lignes. Elle vivra dans le `clean()` du **formset inline** de l'admin, le seul endroit qui voit le trajet et ses arrêts ensemble dans une transaction. Quand tu l'écriras, le `TODO` dans `test_bus_stop.py::TestCardinality` te dit de rebrancher le test dessus. + +Écris `refresh_bounds`, relance `make test`, et envoie-moi le résultat — je m'attends à ce que les tests de chevauchement soient les prochains à parler, et ce sont ceux qui valident vraiment ton arbitrage. + +### 🧑 Human says + +refresh bound ajouté toujours fail, j'ai fait une pause dans l'entretien et la je dépasse de trop le temps aloué. On vas s'arreter sur ce travail en draft, je sais qu'il me manque encore des queryset, def clean, les admin.register pour avoir la fonctionalité sur busShift avec un formset -> avec save_related comme validé, j'aurais ajouté des search filed de l'autocomplete, eventuement pour place j'aurais mis un affichage avec une map. Tout nos élements indiqué prépare la PR avec ceci: -> l'exercice n'a pas été fini à 100% pour le temps aloué, l'IA a été utilisé pour cet entretien technique: -une configuration spécifique pour borner son utilisation a du sparring a été faites, la configuration a été pusher pour transparence, le transcript sur la duration de l'entretien est disponible "path to transscript", on note ce qui a été fait et pourquoi on a fait monter de version django, retravail de l'environement pour correspondre a un standard de travail local homogène, reproductible up-to-date avec les recommendations. Les next step pour finir la feature, les next step pour aller plus loin (ci github hooks, cliff, chengelog, DDD .. etc ) Avant de resortir la PR, refaisons quand meme une passe sur la doc + +### 🤖 Claude says + +D'abord l'état réel, pour que la PR ne raconte rien d'inexact. + +`NameError: name 'Min' is not defined` — l'import manque. C'est de l'organisation d'imports, tu me l'as autorisé. + +**24 passent, 1 échoue.** Et le seul rouge est la règle de cardinalité qui vit dans le formset admin — celui que tu n'as pas eu le temps d'écrire. La contrainte de chevauchement fonctionne, adjacence comprise. + +Je fais la passe doc. + +Permission refermée, gates consignés. J'écris la PR — **en anglais**, comme `language.md` l'exige pour les titres et corps de PR, et **sans aucune signature d'outil**. + +Deux choses à régler avant de te rendre la main. + +Le `deny` sur `git checkout` a bloqué — la barrière fonctionne. C'est à toi de la lancer. + +`ruff format` essaie de formater les blocs Python **dans le transcript de session**. Je l'exclus. + +Passe doc terminée, PR rédigée. État final vérifié en exécutant. + +## Les gates + +| | | +|---|---| +| `make lint` | **All checks passed!** | +| `make format-check` | **52 files already formatted** | +| `make check` | **no issues** (valeurs de production) | +| `make migrations-check` | **No changes detected** | +| `make docs-build` | **built** en `--strict` | +| `make test` | **24 passent, 1 rouge** — la règle des 2 arrêts | +| `make typecheck` | 2 erreurs, code hérité (`is_driver`) | + +Ton `refresh_bounds` fonctionne : il ne manquait que `from django.db.models import Max, Min`. **La contrainte d'exclusion tient**, adjacence comprise, et l'indépendance bus/driver aussi. + +## Ce que j'ai écrit dans cette passe + +- **`docs/flows/bus-shift.md`** — la chaîne réelle, avec le diagramme de séquence. Il dit explicitement que **le maillon admin n'existe pas**, plutôt que de décrire une interface imaginaire. Il explique aussi les trois conséquences de l'ordre obligé (shift avant arrêts) : la `condition=` des contraintes, le fait que **le refus surgit sur l'`UPDATE` et pas l'`INSERT`**, et pourquoi « au moins 2 arrêts » ne peut pas être une contrainte de table. +- **`docs/coverage.md`** — 7 scénarios passés du côté « handled », les manques réécrits pour dire ce qui bloque objectif 2. +- **`CLAUDE.md`** — table de commandes désormais toutes vérifiées. +- **`PULL_REQUEST.md`** à la racine, en anglais, sans aucune signature d'outil. +- **Permission refermée** : `Edit(/padam_django/**)` en `deny` unique, exceptions supprimées. + +## La PR couvre, dans l'ordre + +Statut honnête d'abord (exercice non terminé, objectif 2 non atteint, 1 test rouge assumé et non supprimé) → **usage de l'IA** : bornée par configuration et non par bonne volonté, les deux exceptions nommées avec leur expiration, config commitée, transcript à `docs/sessions/2026-08-29-d0f0905d.md` → ce qui a été fait et pourquoi (montée de version avec les dates de fin de support, environnement reproductible, la décision de contrainte en base avec son coût assumé) → 8 next steps pour finir la feature → 10 pour aller plus loin. + +## Deux choses à faire toi-même + +**1. `ruff format` avait touché l'outillage de template** avant que je l'exclue — `.claude/tools/*.py` et `session-recorder.py`, 96 lignes de reformatage collatéral. Mon `git checkout` a été **bloqué par le `deny`** que tu as choisi, ce qui est le comportement voulu : + +```bash +git checkout -- .claude/tools .claude/hooks +``` + +**2. Les 2 erreurs mypy**, si tu veux `make typecheck` vert : `@admin.display(boolean=True, description="Is driver")` sur `UserAdmin.is_driver`, en supprimant les deux affectations d'attributs qui suivent. + +Les trois commandes de commit que je t'ai données restent valables — ajoute simplement `PULL_REQUEST.md`, `tests/` et la migration `0003` au commit 3. + +### 🧑 Human says + +ok prfile review and modified. Now let's commit (show commands) against semver cliff rules, to not includ the pull request md i will open a draft pr and use the text + +### 🤖 Claude says + +PR draft mise à jour, notée. Un détail d'abord : si `PULL_REQUEST.md` n'est pas commité, il reste en `??` dans `git status` indéfiniment — or `generated-artifacts.md` demande un arbre propre. Je l'ignore explicitement. + diff --git a/docs/sessions/INDEX.md b/docs/sessions/INDEX.md new file mode 100644 index 00000000..6c165fa7 --- /dev/null +++ b/docs/sessions/INDEX.md @@ -0,0 +1,5 @@ +# Session transcripts + +Generated by the session recorder. Newest first. + +- [2026-08-29-d0f0905d](2026-08-29-d0f0905d.md) diff --git a/manage.py b/manage.py index dcd7b5c7..55025ad0 100755 --- a/manage.py +++ b/manage.py @@ -1,12 +1,15 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + +from __future__ import annotations + import os import sys def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'padam_django.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "padam_django.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +21,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..001d9631 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,52 @@ +site_name: Padam — BusShift +site_description: Bus shift modelling and management through the Django admin. + +# Built and served in the container only — `make docs` and `make docs-build`. +# No host toolchain is a prerequisite for reading this project's documentation. +docs_dir: docs +# Outside the source tree, in the tool's OWN configuration so a bare `mkdocs +# build` already does the right thing — not only the Makefile target. A site +# directory written into the bind mount is owned by the wrong uid and becomes +# the next person's permission error (.claude/rules/generated-artifacts.md). +site_dir: /tmp/site + +# README.md documents the FOLDER for someone browsing the repository; it is not +# a page of the site. flows/README.md likewise. +exclude_docs: | + README.md + flows/README.md + sessions/ + +theme: + name: material + features: + - navigation.sections + - content.code.copy + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: { icon: material/weather-night, name: Switch to dark mode } + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: { icon: material/weather-sunny, name: Switch to light mode } + +markdown_extensions: + - admonition + - tables + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + +# --strict turns every one of these into a build failure. It is the only thing +# a tool can check about documentation: that it resolves. Whether a flow tells +# the truth is review work — see .claude/rules/code-flow.md. +strict: true + +nav: + - Home: index.md + - Coverage: coverage.md + - Flows: + - Creating a bus shift: flows/bus-shift.md + - The inherited system: flows/existing-system.md diff --git a/padam_django/apps/common/management/base.py b/padam_django/apps/common/management/base.py index 6449aa08..f6335385 100644 --- a/padam_django/apps/common/management/base.py +++ b/padam_django/apps/common/management/base.py @@ -1,24 +1,40 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from django.core.management.base import BaseCommand +if TYPE_CHECKING: + from argparse import ArgumentParser + class CreateDataBaseCommand(BaseCommand): + """Base command for the ``create_*`` demo-data generators. + + Provides the shared ``-n/--number`` option and exposes the parsed value as + ``self.number``. Subclasses must call ``super().handle(...)`` **before** + reading ``self.number``, since that call is what populates it. + + Neither this class nor its subclasses wrap creation in a transaction: a + failure partway through a batch leaves the rows already created committed. + """ - def __init__(self, *args, **kwargs): - self.number = None + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.number: int = 0 super().__init__(*args, **kwargs) - def add_arguments(self, parser): + def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument( - '--number', - '-n', - dest='number', + "--number", + "-n", + dest="number", default=5, type=int, - help='Specify the number of items to create', + help="Specify the number of items to create", ) - def _init_options(self, **options): - self.number = options['number'] + def _init_options(self, **options: Any) -> None: + self.number = options["number"] - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: self._init_options(**options) diff --git a/padam_django/apps/common/management/commands/create_data.py b/padam_django/apps/common/management/commands/create_data.py index a149a937..033a2008 100644 --- a/padam_django/apps/common/management/commands/create_data.py +++ b/padam_django/apps/common/management/commands/create_data.py @@ -1,14 +1,30 @@ -from django.core.management.base import BaseCommand +from __future__ import annotations + +from typing import Any from django.core import management +from django.core.management.base import BaseCommand class Command(BaseCommand): + """Seed a full set of demo data by chaining the individual generators. + + Counts are fixed here and **not** forwarded from the command line: unlike + the commands it calls, this one takes no ``-n/--number``. + + Order matters and is not the intuitive one. ``create_drivers`` builds its + own users through a sub-factory rather than promoting those created just + before, so the resulting database holds 10 users of which only 5 hold the + driver role. + + Nothing is transactional, here or in the commands called: a failure in the + third step leaves the first two committed. + """ - help = 'Create test data' + help = "Create test data" - def handle(self, *args, **options): - management.call_command('create_users', number=5) - management.call_command('create_drivers', number=5) - management.call_command('create_buses', number=10) - management.call_command('create_places', number=30) + def handle(self, *args: Any, **options: Any) -> None: + management.call_command("create_users", number=5) + management.call_command("create_drivers", number=5) + management.call_command("create_buses", number=10) + management.call_command("create_places", number=30) diff --git a/padam_django/apps/fleet/admin.py b/padam_django/apps/fleet/admin.py index 3fba5023..a3a08883 100644 --- a/padam_django/apps/fleet/admin.py +++ b/padam_django/apps/fleet/admin.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from django.contrib import admin from . import models diff --git a/padam_django/apps/fleet/apps.py b/padam_django/apps/fleet/apps.py index 71378675..57c3a873 100644 --- a/padam_django/apps/fleet/apps.py +++ b/padam_django/apps/fleet/apps.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from django.apps import AppConfig class FleetConfig(AppConfig): - name = 'padam_django.apps.fleet' + name = "padam_django.apps.fleet" diff --git a/padam_django/apps/fleet/factories.py b/padam_django/apps/fleet/factories.py index c78c832e..cb9062cb 100644 --- a/padam_django/apps/fleet/factories.py +++ b/padam_django/apps/fleet/factories.py @@ -1,14 +1,25 @@ +from __future__ import annotations + import factory from faker import Faker from . import models - -fake = Faker(['fr']) +fake = Faker(["fr"]) class DriverFactory(factory.django.DjangoModelFactory): - user = factory.SubFactory('padam_django.apps.users.factories.UserFactory') + """Build ``Driver`` rows, each with a freshly created user. + + Warning: + The ``SubFactory`` **creates a new user** rather than reusing an + existing one. Running ``create_users`` then ``create_drivers`` — which + is what ``create_data`` does — therefore leaves two disjoint sets of + users: those from the first command hold no role at all. Pass an + explicit ``user=`` to attach a driver to an existing user. + """ + + user = factory.SubFactory("padam_django.apps.users.factories.UserFactory") class Meta: model = models.Driver diff --git a/padam_django/apps/fleet/management/commands/create_buses.py b/padam_django/apps/fleet/management/commands/create_buses.py index eaadc0a8..b4e17bc9 100644 --- a/padam_django/apps/fleet/management/commands/create_buses.py +++ b/padam_django/apps/fleet/management/commands/create_buses.py @@ -1,13 +1,15 @@ -from padam_django.apps.common.management.base import CreateDataBaseCommand +from __future__ import annotations + +from typing import Any +from padam_django.apps.common.management.base import CreateDataBaseCommand from padam_django.apps.fleet.factories import BusFactory class Command(CreateDataBaseCommand): + help = "Create few buses" - help = 'Create few buses' - - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: super().handle(*args, **options) - self.stdout.write(f'Creating {self.number} buses ...') + self.stdout.write(f"Creating {self.number} buses ...") BusFactory.create_batch(size=self.number) diff --git a/padam_django/apps/fleet/management/commands/create_drivers.py b/padam_django/apps/fleet/management/commands/create_drivers.py index cd5f9db6..b6db92a9 100644 --- a/padam_django/apps/fleet/management/commands/create_drivers.py +++ b/padam_django/apps/fleet/management/commands/create_drivers.py @@ -1,13 +1,15 @@ -from padam_django.apps.common.management.base import CreateDataBaseCommand +from __future__ import annotations + +from typing import Any +from padam_django.apps.common.management.base import CreateDataBaseCommand from padam_django.apps.fleet.factories import DriverFactory class Command(CreateDataBaseCommand): + help = "Create few drivers" - help = 'Create few drivers' - - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: super().handle(*args, **options) - self.stdout.write(f'Creating {self.number} drivers ...') + self.stdout.write(f"Creating {self.number} drivers ...") DriverFactory.create_batch(size=self.number) diff --git a/padam_django/apps/fleet/migrations/0003_busshift_busstop_and_more.py b/padam_django/apps/fleet/migrations/0003_busshift_busstop_and_more.py new file mode 100644 index 00000000..b721f71b --- /dev/null +++ b/padam_django/apps/fleet/migrations/0003_busshift_busstop_and_more.py @@ -0,0 +1,61 @@ +# Generated by Django 5.2.17 on 2026-08-29 18:30 + +import django.contrib.postgres.constraints +import django.contrib.postgres.fields.ranges +from django.contrib.postgres.operations import BtreeGistExtension +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0002_auto_20211109_1456'), + ('geography', '0001_initial'), + ] + + operations = [ + BtreeGistExtension(), + migrations.CreateModel( + name='BusShift', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('starts_at', models.DateTimeField(blank=True, editable=False, help_text='Time at the first stop. Derived — do not set it by hand.', null=True, verbose_name='Departure')), + ('ends_at', models.DateTimeField(blank=True, editable=False, help_text='Time at the last stop. Derived — do not set it by hand.', null=True, verbose_name='Arrival')), + ('period', models.GeneratedField(db_persist=True, expression=models.Func(models.F('starts_at'), models.F('ends_at'), models.Value('[)'), function='TSTZRANGE'), output_field=django.contrib.postgres.fields.ranges.DateTimeRangeField())), + ('bus', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shifts', to='fleet.bus')), + ('driver', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shifts', to='fleet.driver')), + ], + options={ + 'ordering': ['-starts_at'], + }, + ), + migrations.CreateModel( + name='BusStop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(verbose_name='Time of call')), + ('place', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stops', to='geography.place')), + ('shift', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stops', to='fleet.busshift')), + ], + options={ + 'ordering': ['time'], + }, + ), + migrations.AddConstraint( + model_name='busshift', + constraint=models.CheckConstraint(condition=models.Q(('starts_at__lt', models.F('ends_at'))), name='bus_shift_starts_before_it_ends', violation_error_message='A shift cannot end before it starts.'), + ), + migrations.AddConstraint( + model_name='busshift', + constraint=django.contrib.postgres.constraints.ExclusionConstraint(condition=models.Q(('ends_at__isnull', False), ('starts_at__isnull', False)), expressions=[('period', '&&'), ('bus', '=')], name='bus_shift_no_overlap_per_bus', violation_error_message='This bus is already assigned to a shift over that period.'), + ), + migrations.AddConstraint( + model_name='busshift', + constraint=django.contrib.postgres.constraints.ExclusionConstraint(condition=models.Q(('ends_at__isnull', False), ('starts_at__isnull', False)), expressions=[('period', '&&'), ('driver', '=')], name='bus_shift_no_overlap_per_driver', violation_error_message='This driver is already assigned to a shift over that period.'), + ), + migrations.AddConstraint( + model_name='busstop', + constraint=models.UniqueConstraint(fields=('shift', 'time'), name='bus_stop_one_call_per_instant', violation_error_message='This shift already calls somewhere at that time.'), + ), + ] diff --git a/padam_django/apps/fleet/models.py b/padam_django/apps/fleet/models.py index 4cd3f19d..131a5ce8 100644 --- a/padam_django/apps/fleet/models.py +++ b/padam_django/apps/fleet/models.py @@ -1,18 +1,179 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.contrib.postgres.constraints import ExclusionConstraint +from django.contrib.postgres.fields import DateTimeRangeField, RangeOperators from django.db import models +from django.db.models import F, Func, Max, Min, Q, Value + +if TYPE_CHECKING: + from datetime import timedelta class Driver(models.Model): - user = models.OneToOneField('users.User', on_delete=models.CASCADE, related_name='driver') + """A member of staff qualified to drive a bus. + + The driver identity is carried by the related user, not duplicated here: + a driver is a role a user holds, so deleting the user cascades to the + driver. The reverse accessor is named ``driver``, which is what + ``users.User.is_driver`` probes to decide whether a user holds the role. - def __str__(self): + A user holds the role at most once — the relation is one-to-one, so the + database refuses a second driver row for the same user. + """ + + user = models.OneToOneField("users.User", on_delete=models.CASCADE, related_name="driver") + + def __str__(self) -> str: return f"Driver: {self.user.username} (id: {self.pk})" class Bus(models.Model): + """A vehicle in the fleet, identified by its licence plate. + + The plate is **not** constrained to be unique at the database level, and no + format is validated: two rows may carry the same plate. Treat the primary + key, never the plate, as the vehicle's identity. + """ + licence_plate = models.CharField("Name of the bus", max_length=10) class Meta: verbose_name_plural = "Buses" - def __str__(self): + def __str__(self) -> str: return f"Bus: {self.licence_plate} (id: {self.pk})" + + +class BusShift(models.Model): + bus = models.ForeignKey(Bus, on_delete=models.PROTECT, related_name="shifts") + driver = models.ForeignKey(Driver, on_delete=models.PROTECT, related_name="shifts") + + starts_at = models.DateTimeField( + "Departure", + null=True, + blank=True, + editable=False, + help_text="Time at the first stop. Derived — do not set it by hand.", + ) + ends_at = models.DateTimeField( + "Arrival", + null=True, + blank=True, + editable=False, + help_text="Time at the last stop. Derived — do not set it by hand.", + ) + + # The half-open interval the shift occupies, computed BY THE DATABASE from + # the two bounds. A GeneratedField cannot drift from what it derives from, + # which removes one of the two desynchronisation risks the denormalised + # design carries — the other, bounds vs stops, stays the recompute's job. + # + # '[)' is the whole point: a shift ending at 12:00 and one starting at + # 12:00 do not overlap, so a driver's day chains without an artificial gap. + period = models.GeneratedField( + expression=Func( + F("starts_at"), + F("ends_at"), + Value("[)"), + function="TSTZRANGE", + ), + output_field=DateTimeRangeField(), + db_persist=True, + ) + + class Meta: + ordering = ["-starts_at"] + constraints = [ + models.CheckConstraint( + condition=Q(starts_at__lt=F("ends_at")), + name="bus_shift_starts_before_it_ends", + violation_error_message="A shift cannot end before it starts.", + ), + ExclusionConstraint( + name="bus_shift_no_overlap_per_bus", + expressions=[ + ("period", RangeOperators.OVERLAPS), + ("bus", RangeOperators.EQUAL), + ], + # WITHOUT THIS THE WHOLE TABLE LOCKS UP. A shift with no stops + # has NULL bounds, and TSTZRANGE(NULL, NULL) is not NULL — it is + # the UNBOUNDED range, which overlaps every other range. The + # first stopless shift would then forbid every shift for that + # bus. Partial index: only shifts that actually have a window + # take part. + condition=Q(starts_at__isnull=False, ends_at__isnull=False), + violation_error_message=( + "This bus is already assigned to a shift over that period." + ), + ), + ExclusionConstraint( + name="bus_shift_no_overlap_per_driver", + expressions=[ + ("period", RangeOperators.OVERLAPS), + ("driver", RangeOperators.EQUAL), + ], + condition=Q(starts_at__isnull=False, ends_at__isnull=False), + violation_error_message=( + "This driver is already assigned to a shift over that period." + ), + ), + ] + + def __str__(self) -> str: + return f"BusShift: {self.bus.licence_plate} / {self.driver.user.username} (id: {self.pk})" + + @property + def duration(self) -> timedelta | None: + """Return how long the shift takes, first stop to last. + + Returns: + The span between the derived bounds, or ``None`` while the shift + has no stops — an unknown duration, not a zero one. + """ + if self.starts_at is None or self.ends_at is None: + return None + return self.ends_at - self.starts_at + + def refresh_bounds(self) -> None: + """Recompute the derived bounds from this shift's stops. + + The single point of contact between the stops and the denormalised + columns. Deliberately NOT a signal: ``bulk_create``, ``bulk_update``, + ``queryset.update()`` and ``queryset.delete()`` all bypass signals, and + each of those is a path that would silently leave the bounds lying. + + Call it inside the same transaction as the change that made it + necessary — in the admin, from ``save_related()``, which runs after the + inline formset has written the stops. + + Raises: + IntegrityError: If the resulting window overlaps another shift for + the same bus or the same driver. The exclusion constraint is + evaluated on this UPDATE, not on the shift's INSERT — a shift is + created without a window and only acquires one here. + """ + bounds = self.stops.aggregate(first=Min("time"), last=Max("time")) + self.starts_at = bounds["first"] + self.ends_at = bounds["last"] + self.save(update_fields=["starts_at", "ends_at"]) + + +class BusStop(models.Model): + shift = models.ForeignKey(BusShift, on_delete=models.CASCADE, related_name="stops") + place = models.ForeignKey("geography.Place", on_delete=models.PROTECT, related_name="stops") + time = models.DateTimeField("Time of call") + + class Meta: + ordering = ["time"] + constraints = [ + models.UniqueConstraint( + fields=["shift", "time"], + name="bus_stop_one_call_per_instant", + violation_error_message=("This shift already calls somewhere at that time."), + ), + ] + + def __str__(self) -> str: + return f"BusStop: {self.place.name} at {self.time:%Y-%m-%d %H:%M} (id: {self.pk})" diff --git a/padam_django/apps/geography/admin.py b/padam_django/apps/geography/admin.py index e0334458..768f1b05 100644 --- a/padam_django/apps/geography/admin.py +++ b/padam_django/apps/geography/admin.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from django.contrib import admin from . import models diff --git a/padam_django/apps/geography/apps.py b/padam_django/apps/geography/apps.py index 1639d6e8..b8ccfa6b 100644 --- a/padam_django/apps/geography/apps.py +++ b/padam_django/apps/geography/apps.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from django.apps import AppConfig class GeographyConfig(AppConfig): - name = 'padam_django.apps.geography' + name = "padam_django.apps.geography" diff --git a/padam_django/apps/geography/factories.py b/padam_django/apps/geography/factories.py index b134a30c..3925175e 100644 --- a/padam_django/apps/geography/factories.py +++ b/padam_django/apps/geography/factories.py @@ -1,13 +1,26 @@ +from __future__ import annotations + import factory from faker import Faker from . import models - -fake = Faker(['fr']) +fake = Faker(["fr"]) class PlaceFactory(factory.django.DjangoModelFactory): + """Build ``Place`` rows at random coordinates with a French street name. + + Coordinates are drawn worldwide, not within France, so a generated place + bears a French name at an arbitrary point on the globe. Fine for fixtures, + misleading if read as realistic data. + + Warning: + Draws are independent, so a batch can collide on the + ``(longitude, latitude)`` uniqueness constraint and abort mid-way. The + probability is low; the failure is not reproducible when it happens. + """ + name = factory.LazyFunction(fake.street_name) longitude = factory.LazyFunction(fake.longitude) diff --git a/padam_django/apps/geography/management/commands/create_places.py b/padam_django/apps/geography/management/commands/create_places.py index beb41514..67db3880 100644 --- a/padam_django/apps/geography/management/commands/create_places.py +++ b/padam_django/apps/geography/management/commands/create_places.py @@ -1,13 +1,15 @@ -from padam_django.apps.common.management.base import CreateDataBaseCommand +from __future__ import annotations + +from typing import Any +from padam_django.apps.common.management.base import CreateDataBaseCommand from padam_django.apps.geography.factories import PlaceFactory class Command(CreateDataBaseCommand): + help = "Create few places" - help = 'Create few places' - - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: super().handle(*args, **options) - self.stdout.write(f'Creating {self.number} places ...') + self.stdout.write(f"Creating {self.number} places ...") PlaceFactory.create_batch(size=self.number) diff --git a/padam_django/apps/geography/models.py b/padam_django/apps/geography/models.py index e566ee2b..10cce33d 100644 --- a/padam_django/apps/geography/models.py +++ b/padam_django/apps/geography/models.py @@ -1,7 +1,21 @@ +from __future__ import annotations + from django.db import models class Place(models.Model): + """A geographic point a bus can call at. + + Coordinates are stored as fixed-point decimals rather than floats, so two + places compared by coordinates compare exactly — which is what the + uniqueness constraint below relies on. ``max_digits=9`` with + ``decimal_places=6`` gives three integer digits: enough for a longitude in + [-180, 180], and roughly 0.1 m of resolution. + + Note the asymmetry: the pair of coordinates is unique, the name is not. + Two places may share a name; none may share a location. + """ + name = models.CharField("Name of the place", max_length=50) longitude = models.DecimalField("Longitude", max_digits=9, decimal_places=6) @@ -9,7 +23,7 @@ class Place(models.Model): class Meta: # Two places cannot be located at the same coordinates. - unique_together = (("longitude", "latitude"), ) + unique_together = (("longitude", "latitude"),) - def __str__(self): + def __str__(self) -> str: return f"Place: {self.name} (id: {self.pk})" diff --git a/padam_django/apps/users/admin.py b/padam_django/apps/users/admin.py index 2bc531c6..e0aebea8 100644 --- a/padam_django/apps/users/admin.py +++ b/padam_django/apps/users/admin.py @@ -1,13 +1,37 @@ +from __future__ import annotations + from django.contrib import admin from . import models @admin.register(models.User) +@admin.display(boolean=True, description="Is driver") class UserAdmin(admin.ModelAdmin): - list_display = ('username', 'email', 'first_name', 'last_name', 'is_driver') + """Admin for the user model. + + This deliberately does **not** extend ``django.contrib.auth.admin.UserAdmin``, + so it offers no password change form, no permission fieldsets and no + staff-safe creation flow — it is a plain list-and-edit view. + """ + + list_display = ("username", "email", "first_name", "last_name", "is_driver") + + def is_driver(self, obj: models.User) -> bool: + """Render the driver role as a boolean icon in the changelist. - def is_driver(self, obj): + Args: + obj: The ``User`` row being rendered. + + Returns: + True when the user holds the driver role. + + Note: + Delegates to ``User.is_driver``, which resolves a reverse relation. + The changelist queryset is not annotated, so this costs one query + per displayed row. + """ return obj.is_driver + is_driver.boolean = True - is_driver.short_description = 'Is driver' + is_driver.short_description = "Is driver" diff --git a/padam_django/apps/users/apps.py b/padam_django/apps/users/apps.py index 67b3114c..9f542862 100644 --- a/padam_django/apps/users/apps.py +++ b/padam_django/apps/users/apps.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from django.apps import AppConfig class UsersConfig(AppConfig): - name = 'padam_django.apps.users' + name = "padam_django.apps.users" diff --git a/padam_django/apps/users/factories.py b/padam_django/apps/users/factories.py index 945eab16..e58ada0d 100644 --- a/padam_django/apps/users/factories.py +++ b/padam_django/apps/users/factories.py @@ -1,13 +1,27 @@ +from __future__ import annotations + import factory from faker import Faker from . import models - -fake = Faker(['fr']) +fake = Faker(["fr"]) class UserFactory(factory.django.DjangoModelFactory): + """Build ``User`` rows with French-locale fake identities. + + Warning: + ``fake.user_name`` gives **no uniqueness guarantee**, while + ``AbstractUser.username`` is ``unique=True``. Two identical draws inside + one batch raise ``IntegrityError`` and abort the command with part of + the batch already committed. A ``factory.Sequence`` or a unique-aware + Faker provider is the durable fix. + + The factory also leaves the password unusable: no ``set_password`` is + called, so instances cannot log in. They are display fixtures. + """ + username = factory.LazyFunction(fake.user_name) email = factory.LazyFunction(fake.ascii_email) first_name = factory.LazyFunction(fake.first_name) diff --git a/padam_django/apps/users/management/commands/create_users.py b/padam_django/apps/users/management/commands/create_users.py index 5b71b863..ffdd1f90 100644 --- a/padam_django/apps/users/management/commands/create_users.py +++ b/padam_django/apps/users/management/commands/create_users.py @@ -1,13 +1,15 @@ -from padam_django.apps.common.management.base import CreateDataBaseCommand +from __future__ import annotations + +from typing import Any +from padam_django.apps.common.management.base import CreateDataBaseCommand from padam_django.apps.users.factories import UserFactory class Command(CreateDataBaseCommand): + help = "Create few users" - help = 'Create few users' - - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: super().handle(*args, **options) - self.stdout.write(f'Creating {self.number} users ...') + self.stdout.write(f"Creating {self.number} users ...") UserFactory.create_batch(size=self.number) diff --git a/padam_django/apps/users/models.py b/padam_django/apps/users/models.py index 672f6a15..23ff84c3 100644 --- a/padam_django/apps/users/models.py +++ b/padam_django/apps/users/models.py @@ -1,9 +1,29 @@ +from __future__ import annotations + from django.contrib.auth.models import AbstractUser class User(AbstractUser): + """The project's user model, referenced by ``AUTH_USER_MODEL``. + + It adds nothing to ``AbstractUser`` but the ``is_driver`` role probe. + It is substituted for Django's default so the model can grow without a + data migration of the auth tables later. + """ @property def is_driver(self) -> bool: - """Define if the user is related to a driver.""" - return hasattr(self, 'driver') + """Tell whether this user also holds the driver role. + + Probes the ``driver`` reverse accessor of ``fleet.Driver``. + + Returns: + True when a ``Driver`` row references this user. + + Note: + Resolving the accessor issues **one query per call** unless the + relation was already loaded — so calling this once per row of a + list view costs one query per row. Use ``select_related('driver')`` + or an ``annotate(...)`` on the queryset when displaying many users. + """ + return hasattr(self, "driver") diff --git a/padam_django/asgi.py b/padam_django/asgi.py index 3b4826e3..d7c2daaa 100644 --- a/padam_django/asgi.py +++ b/padam_django/asgi.py @@ -7,10 +7,12 @@ https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ """ +from __future__ import annotations + import os from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'padam_django.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "padam_django.settings") application = get_asgi_application() diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..0a360927 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -10,100 +10,145 @@ https://docs.djangoproject.com/en/3.2/ref/settings/ """ +from __future__ import annotations + from pathlib import Path +import environ + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-&r2)+_fdqxe2dtc@1vizr6tsh6!1cesaptlfgj@ug*%3=fnq=i' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] +# Configuration +# Read once, into a typed object. No getenv scattered through the code: a value +# read in three places has three defaults and two bugs. +# +# Every variable below is declared in .env.example, which is the contract. +# Those with no default fail the boot with a message naming them, rather than +# falling back to something only correct in development. +env = environ.Env( + DJANGO_DEBUG=(bool, False), + DJANGO_ALLOWED_HOSTS=(list, []), + DJANGO_CSRF_TRUSTED_ORIGINS=(list, []), + DJANGO_STATIC_ROOT=(str, "/opt/static"), + TEST_DATABASE_NAME=(str, ""), +) + +# No default: a secret with a fallback is a wrong value reaching production +# unnoticed. Generate one with +# `python -c "from django.core.management.utils import get_random_secret_key as k; print(k())"`. +SECRET_KEY = env("DJANGO_SECRET_KEY") + +# Defaults to False. The unsafe value has to be asked for explicitly. +DEBUG = env("DJANGO_DEBUG") + +ALLOWED_HOSTS = env("DJANGO_ALLOWED_HOSTS") + +# Origins trusted for cross-origin POST — the admin login form, once the app is +# reached through a published port rather than the address it binds. +CSRF_TRUSTED_ORIGINS = env("DJANGO_CSRF_TRUSTED_ORIGINS") + +# Transport security. Defaults are the safe ones: a developer relaxes them in +# .env to work over plain http, and the value that reaches production is the +# secure one unless someone asks otherwise. These are values, not branches — +# there is no `if ENV == 'prod'` anywhere in this module. +SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True) +SESSION_COOKIE_SECURE = env.bool("DJANGO_SESSION_COOKIE_SECURE", default=True) +CSRF_COOKIE_SECURE = env.bool("DJANGO_CSRF_COOKIE_SECURE", default=True) +# One year, the value HSTS preloading requires. Enable only once every +# subdomain is served over TLS: the header is cached by browsers and cannot be +# withdrawn quickly. +SECURE_HSTS_SECONDS = env.int("DJANGO_SECURE_HSTS_SECONDS", default=31536000) +SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool("DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True) +SECURE_HSTS_PRELOAD = env.bool("DJANGO_SECURE_HSTS_PRELOAD", default=True) # Application definition INSTALLED_APPS = [ # Django apps - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", # Third party apps - 'django_extensions', + "django_extensions", # Internal apps - 'padam_django.apps.common', - 'padam_django.apps.fleet', - 'padam_django.apps.geography', - 'padam_django.apps.users', + "padam_django.apps.common", + "padam_django.apps.fleet", + "padam_django.apps.geography", + "padam_django.apps.users", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + # Directly after SecurityMiddleware and before everything else: a static + # file is served without touching sessions, auth or the database. + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'padam_django.urls' +ROOT_URLCONF = "padam_django.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'padam_django.wsgi.application' +WSGI_APPLICATION = "padam_django.wsgi.application" # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases +# Parsed from a single DATABASE_URL, which compose derives from the POSTGRES_* +# variables so the connection string has one definition rather than two that +# drift. No default: booting against an unintended database is worse than not +# booting at all. DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - } + "default": env.db_url("DATABASE_URL"), } +# A separate database for the suite, so a test run never truncates the data +# someone was looking at in the admin. +if env("TEST_DATABASE_NAME"): + DATABASES["default"]["TEST"] = {"NAME": env("TEST_DATABASE_NAME")} + # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -116,13 +161,13 @@ # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True -USE_L10N = True +# USE_L10N was removed in Django 5.0 — localised formatting is always on. USE_TZ = True @@ -130,9 +175,33 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" + +# Where collectstatic writes at build time, and where the image serves from. +# Outside BASE_DIR on purpose: compose bind-mounts the repository over /app for +# local development, and a STATIC_ROOT inside it would be hidden at start-up, +# leaving the admin without its CSS. Nothing generated belongs beside the code. +STATIC_ROOT = env("DJANGO_STATIC_ROOT") + +# Whitenoise serves the collected files from the application process, so the +# image is complete on its own: no second service and no reverse proxy is +# needed for the admin to render styled. +# +# The manifest backend fingerprints each file and rewrites the references, so +# assets can be cached indefinitely and a deploy invalidates them by changing +# the name. It reads the manifest written by collectstatic at build time — so +# an image that skipped collectstatic fails loudly on the first page rather +# than serving a broken one. +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} # Default primary key field type # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/padam_django/urls.py b/padam_django/urls.py index 7ecf590e..cd5149e0 100644 --- a/padam_django/urls.py +++ b/padam_django/urls.py @@ -13,9 +13,12 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + +from __future__ import annotations + from django.contrib import admin from django.urls import path urlpatterns = [ - path('admin/', admin.site.urls), + path("admin/", admin.site.urls), ] diff --git a/padam_django/wsgi.py b/padam_django/wsgi.py index 8cb819b2..a2510cfa 100644 --- a/padam_django/wsgi.py +++ b/padam_django/wsgi.py @@ -7,10 +7,12 @@ https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ """ +from __future__ import annotations + import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'padam_django.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "padam_django.settings") application = get_wsgi_application() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..76496d1a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,179 @@ +[project] +name = "padam-django" +version = "0.2.0" +description = "Bus shift modelling and management through the Django admin." +readme = "README.md" +# Matches the interpreter pinned in the Dockerfile. Never assume it — read it. +requires-python = ">=3.13,<3.14" + +dependencies = [ + # 5.2 is the only LTS currently supported; extended support ends April 2028. + "django>=5.2,<5.3", + # libpq ships inside the wheel, so no system Postgres package is needed + # in the production image. + "psycopg[binary]>=3.2,<4", + "gunicorn>=23,<24", + # Runtime, not dev: 'django_extensions' is listed in INSTALLED_APPS, so the + # production image imports it at startup. Its dev-only conveniences + # (runserver_plus and its Werkzeug dependency) stay in the dev group. + "django-extensions>=4.1,<5", + # Reads the environment into settings once, with types and no scattered + # getenv. Required by the settings rewrite the container chain assumes. + "django-environ>=0.12,<1", + # Serves the collected static files from the application itself. gunicorn + # does not serve them, and runserver's free static handling is a + # development convenience, not a runtime — without this the admin renders + # unstyled as soon as DEBUG is false. + "whitenoise>=6.8,<7", + # SCOPE EXCEPTION, deliberate — see docs/coverage.md. + # Fixture libraries do not belong in a production artefact. They are here + # because the create_* management commands live in the application package + # and are copied into the runtime image, so leaving them out would make + # `manage.py help` fail to import them. The real fix is architectural — the + # seeding tooling should not ship with the application — and it is out of + # scope for this exercise. Raised in the pull request as a next step. + "factory-boy>=3.3,<4", + "faker>=33", +] + +[dependency-groups] +# PEP 735 groups, not extras: extras are part of a published package's public +# surface, development tooling is not. +test = [ + "pytest>=8,<9", + "pytest-django>=4.9,<5", + "pytest-cov>=6,<7", + # factory-boy and faker are runtime dependencies here, not test ones — + # see the scope exception in [project].dependencies. +] +lint = [ + "ruff>=0.9,<1", + "mypy>=1.14,<2", + "django-stubs[compatible-mypy]>=5.1,<6", +] +docs = [ + "mkdocs>=1.6,<2", + "mkdocs-material>=9.5,<10", + "mkdocstrings[python]>=0.27,<1", +] +dev = [ + { include-group = "test" }, + { include-group = "lint" }, + { include-group = "docs" }, + # runserver_plus and the interactive shell. + "werkzeug>=3.1,<4", + "ipython>=8.29", +] + +[tool.uv] +# This is an application, not a distributable library: manage.py sits at the +# repository root and nothing imports padam_django as an installed package. +# Declaring it non-package is what lets `uv sync --no-install-project` in the +# Dockerfile be the whole story, with no build backend to satisfy. +package = false + +# --------------------------------------------------------------------------- +# Tooling. Every cache and every generated artefact is directed outside the +# source tree here, in the tool's own configuration, so that a bare invocation +# already does the right thing — not only the Makefile target. +# --------------------------------------------------------------------------- + +[tool.ruff] +target-version = "py313" +line-length = 100 +cache-dir = "/tmp/cache/ruff" +extend-exclude = [ + # Generated by Django, not hand-maintained. + "*/migrations/*", + # Template tooling shipped with the assistant configuration. It is not this + # project's code and holding it to this project's style would mean editing + # a vendored file on every template update. + ".claude/*", + # Session transcripts are a record, not source. ruff formats Python code + # blocks inside markdown, which would rewrite what was actually said. + "docs/sessions/*", +] + +[tool.ruff.lint] +select = [ + "E", "W", # pycodestyle + "F", # pyflakes + "I", # import sorting + "UP", # pyupgrade + "B", # bugbear + "DJ", # flake8-django + "S", # bandit + "C4", # comprehensions + "SIM", # simplify + "TC", # type-checking: imports used ONLY in annotations belong in the + # `if TYPE_CHECKING:` block, after the runtime imports. Keeps + # them out of the import graph at run time. + "RUF", +] + +[tool.ruff.lint.isort] +# `from __future__ import annotations` everywhere, so annotations are never +# evaluated at runtime and a type-only import can live in the TYPE_CHECKING +# block below the real ones. +required-imports = ["from __future__ import annotations"] + +[tool.ruff.lint.per-file-ignores] +# Tests assert; asserts are the point. +"tests/**" = ["S101"] +# Django's Meta options are class-level lists by design; RUF012 would have every +# model declare them as ClassVar for nothing. +"**/models.py" = ["RUF012"] +# Settings are module-level constants, not code. +"padam_django/settings.py" = ["E501"] + +[tool.mypy] +python_version = "3.13" +plugins = ["mypy_django_plugin.main"] +cache_dir = "/tmp/cache/mypy" +# NOT strict, deliberately. `python.md` asks for a mypy-clean codebase, and the +# inherited code carries almost no annotations: strict mode reports 52 errors in +# 15 files, none of them a defect anyone is going to fix in this exercise. A +# gate that is red on every run trains everyone to ignore the gates, which is +# worse than not having one. +# +# What is enabled below still catches the mistakes that matter, and new code is +# expected to be annotated. Tightening to `strict = true` is a next step, listed +# in docs/coverage.md. +warn_redundant_casts = true +warn_unused_ignores = true +warn_unreachable = true +check_untyped_defs = true +# Migrations are generated code; they are not hand-maintained and not typed. +exclude = "migrations/" + +[[tool.mypy.overrides]] +module = ["factory.*", "django_extensions.*", "environ.*"] +ignore_missing_imports = true + +# A REAL finding, silenced here rather than hidden: CreateDataBaseCommand sets +# `self.number = None` in __init__ and fills it in _init_options, so every +# `create_batch(size=self.number)` is `int | None` where `int` is required. The +# fix is one annotation in the base command, and it belongs to the developer — +# see docs/coverage.md. Remove this block once it is done. +[[tool.mypy.overrides]] +module = ["padam_django.apps.*.management.*"] +disable_error_code = ["arg-type", "assignment"] + +[tool.django-stubs] +django_settings_module = "padam_django.settings" + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "padam_django.settings" +testpaths = ["tests"] +addopts = "--strict-markers --strict-config" + +[tool.coverage.run] +source = ["padam_django"] +omit = ["*/migrations/*", "*/factories.py", "padam_django/wsgi.py", "padam_django/asgi.py"] +# Written outside the bind mount: a coverage database in the source tree is +# owned by the wrong uid and becomes the next person's permission error. +data_file = "/tmp/cache/coverage/.coverage" + +[tool.coverage.report] +show_missing = true +skip_covered = true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 863fd63d..00000000 --- a/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -Django==4.2.16 - -django-extensions==3.2.1 -Werkzeug==3.1.3 -ipython==8.29.0 - -factory-boy==3.2.0 -Faker==8.10.1 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..2ee3b8e5 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,42 @@ +# `tests/` — the suite + +## What this folder is for + +Behavioural tests of the application, run by `make test` inside the dev +container. Never on the host. + +## What belongs here + +One module per behaviour under test, named after the behaviour rather than +after the class it happens to exercise. Fixtures shared by more than one module +live in `conftest.py`. + +## What must never land here + +- **Tests of private methods.** A test that breaks on a behaviour-preserving + refactor is a bad test. Exercise the public interface — the model's contract, + the admin's form, the constraint's refusal. +- **Network, clock or randomness.** Inject them. A suite whose result depends on + the hour it runs at is a suite nobody trusts by Friday. +- Assertions on more than one concept per test. The name states the case and the + expectation; if the name needs an "and", it is two tests. + +## Conventions + +- `pytest`, with fixtures over setup methods. `pytest-django`'s `db` fixture, or + the `django_db` marker, for anything touching the database. +- A test asserting a **refusal** matters more than one asserting a success: the + business rules in this project are all about what must not be possible. +- Datetimes are timezone-aware — `USE_TZ` is on. A naive datetime raises a + warning that will eventually become an error. +- Constraint tests must reach the database. A constraint is not proven by a + `full_clean()` that never issued an `INSERT`. + +## State + +**The models under test do not exist yet.** These modules import `BusShift` and +`BusStop` from `padam_django.apps.fleet.models`, so the suite fails to collect +until they are written. That is intended: no red test, no implementation. + +The assumed interface is written at the top of `conftest.py`. If the +implementation names things differently, that file is the only place to adjust. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..b4c31801 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,118 @@ +"""Shared fixtures for the suite. + +**Every assumption the tests make about the implementation lives here.** If the +models name something differently, this is the only file to adjust — the test +modules speak in fixtures and helpers, never in field names. + +Assumed interface, as of writing: + +- ``BusShift(bus, driver)`` with ``starts_at`` / ``ends_at`` derived from its + stops and not set by hand. +- ``BusShift.duration`` returning ``ends_at - starts_at``, or ``None`` while + either bound is unset. +- ``BusStop(shift, place, time)``, reachable from the shift as ``.stops``. +- A single explicit recompute entry point on ``BusShift`` — see + ``recompute_bounds`` below. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from helpers import RECOMPUTE_METHOD_NAME, at # noqa: F401 (re-exported for fixtures) + +from padam_django.apps.fleet.models import Bus, BusShift, BusStop, Driver +from padam_django.apps.geography.models import Place +from padam_django.apps.users.models import User + +if TYPE_CHECKING: + from datetime import datetime + + +@pytest.fixture +def place_factory(db): + """Return a callable producing distinct places. + + Coordinates are derived from a counter rather than drawn at random: the + model refuses two places at identical coordinates, and a random draw makes + that collision a rare, unreproducible failure. + """ + counter = {"n": 0} + + def _make(name: str | None = None) -> Place: + counter["n"] += 1 + n = counter["n"] + return Place.objects.create( + name=name or f"Place {n}", + longitude=n / 1000, + latitude=n / 1000, + ) + + return _make + + +@pytest.fixture +def bus(db) -> Bus: + return Bus.objects.create(licence_plate="AA-000-AA") + + +@pytest.fixture +def other_bus(db) -> Bus: + return Bus.objects.create(licence_plate="BB-000-BB") + + +@pytest.fixture +def driver(db) -> Driver: + user = User.objects.create(username="driver-1") + return Driver.objects.create(user=user) + + +@pytest.fixture +def other_driver(db) -> Driver: + user = User.objects.create(username="driver-2") + return Driver.objects.create(user=user) + + +@pytest.fixture +def add_stop(place_factory): + """Return a callable adding one stop to a shift, at a given time.""" + + def _add(shift: BusShift, when: datetime) -> BusStop: + return BusStop.objects.create(shift=shift, place=place_factory(), time=when) + + return _add + + +@pytest.fixture +def recompute_bounds(): + """Return a callable refreshing a shift's derived bounds from its stops. + + Routed through one helper so the suite has a single point of contact with + the recompute entry point. + """ + + def _recompute(shift: BusShift) -> None: + getattr(shift, RECOMPUTE_METHOD_NAME)() + + return _recompute + + +@pytest.fixture +def shift_factory(db, bus, driver, add_stop, recompute_bounds): + """Return a callable creating a shift spanning a given window. + + The shift is built the way the application builds one: the row first, then + its stops, then the recompute — since a stop cannot reference a shift that + has no primary key yet. + """ + + def _make(start: datetime, end: datetime, *, on_bus=None, with_driver=None) -> BusShift: + shift = BusShift.objects.create(bus=on_bus or bus, driver=with_driver or driver) + add_stop(shift, start) + add_stop(shift, end) + recompute_bounds(shift) + shift.refresh_from_db() + return shift + + return _make diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..f5e7029e --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,65 @@ +"""Helpers the test modules import directly. + +Kept out of ``conftest.py`` because pytest injects fixtures, not functions: +a plain helper has to be imported, and ``tests/`` is not a package. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from django.utils import timezone + +if TYPE_CHECKING: + from padam_django.apps.fleet.models import BusShift + +# The related_name from BusStop back to BusShift. Adjust here if it differs. +STOPS_RELATED_NAME = "stops" + +# The method that recomputes a shift's bounds from its stops. The design puts +# the recompute in ONE explicit place rather than in a signal, because +# bulk_create, bulk_update, queryset.update() and queryset.delete() all bypass +# signals. Adjust the name here if the implementation calls it something else. +RECOMPUTE_METHOD_NAME = "refresh_bounds" + + +def at(hour: int, minute: int = 0, day: int = 1) -> datetime: + """Build a timezone-aware datetime in January 2026. + + The suite never reads the clock: a test whose result depends on the hour it + runs at is a test nobody trusts. + + Args: + hour: Hour of day, 0-23. + minute: Minute of the hour. + day: Day of January 2026, so several days can be exercised. + + Returns: + An aware datetime in the project's timezone. + """ + return timezone.make_aware(datetime(2026, 1, day, hour, minute)) + + +def assert_bounds_match_stops(shift: BusShift) -> None: + """Assert the denormalisation invariant holds for one shift. + + ``starts_at`` must equal the earliest stop's time and ``ends_at`` the + latest. This is the oracle the whole design rests on: the bounds are stored + rather than derived on read, so they can drift, and every path that changes + stops has to keep them true. + + Raises: + AssertionError: If either bound disagrees with the shift's stops. + """ + times = sorted(getattr(shift, STOPS_RELATED_NAME).values_list("time", flat=True)) + shift.refresh_from_db() + if not times: + assert shift.starts_at is None and shift.ends_at is None, ( + "A shift with no stop must carry no bounds" + ) + return + assert shift.starts_at == times[0], ( + f"starts_at {shift.starts_at} is not the first stop {times[0]}" + ) + assert shift.ends_at == times[-1], f"ends_at {shift.ends_at} is not the last stop {times[-1]}" diff --git a/tests/test_bus_shift_bounds.py b/tests/test_bus_shift_bounds.py new file mode 100644 index 00000000..d47a6a13 --- /dev/null +++ b/tests/test_bus_shift_bounds.py @@ -0,0 +1,102 @@ +"""The denormalisation invariant, and what the bounds are worth. + +``starts_at`` and ``ends_at`` are stored rather than derived on read, which is +what makes the overlap constraint possible — and what makes them able to lie. +These tests are the oracle: they assert the bounds still agree with the stops +after each kind of change. +""" + +from __future__ import annotations + +import pytest +from helpers import assert_bounds_match_stops, at + + +@pytest.mark.django_db +class TestDerivedFromStops: + def test_a_shift_without_stops_has_no_bounds(self, bus, driver): + """A shift necessarily exists before its stops do. + + A stop carries a foreign key to its shift, so the shift row must be + written first. There is therefore always an instant where a shift has + no stop and no bounds — which is why "at least two stops" cannot be a + table constraint. + """ + from padam_django.apps.fleet.models import BusShift + + shift = BusShift.objects.create(bus=bus, driver=driver) + assert shift.starts_at is None + assert shift.ends_at is None + + def test_bounds_are_the_first_and_last_stop(self, shift_factory): + shift = shift_factory(at(8), at(12)) + assert shift.starts_at == at(8) + assert shift.ends_at == at(12) + + def test_bounds_ignore_the_order_stops_were_added_in( + self, bus, driver, add_stop, recompute_bounds + ): + """Chronology comes from the times, not from insertion order.""" + from padam_django.apps.fleet.models import BusShift + + shift = BusShift.objects.create(bus=bus, driver=driver) + add_stop(shift, at(12)) + add_stop(shift, at(8)) + add_stop(shift, at(10)) + recompute_bounds(shift) + assert_bounds_match_stops(shift) + + def test_adding_an_earlier_stop_moves_the_departure( + self, shift_factory, add_stop, recompute_bounds + ): + shift = shift_factory(at(8), at(12)) + add_stop(shift, at(6)) + recompute_bounds(shift) + assert_bounds_match_stops(shift) + + def test_adding_a_later_stop_moves_the_arrival(self, shift_factory, add_stop, recompute_bounds): + shift = shift_factory(at(8), at(12)) + add_stop(shift, at(15)) + recompute_bounds(shift) + assert_bounds_match_stops(shift) + + def test_removing_the_first_stop_moves_the_departure( + self, shift_factory, add_stop, recompute_bounds + ): + shift = shift_factory(at(8), at(12)) + add_stop(shift, at(10)) + shift.stops.order_by("time").first().delete() + recompute_bounds(shift) + assert_bounds_match_stops(shift) + + def test_retiming_a_stop_moves_the_bounds(self, shift_factory, recompute_bounds): + shift = shift_factory(at(8), at(12)) + stop = shift.stops.order_by("time").first() + stop.time = at(7) + stop.save() + recompute_bounds(shift) + assert_bounds_match_stops(shift) + + +@pytest.mark.django_db +class TestDuration: + def test_duration_is_the_span_between_first_and_last_stop(self, shift_factory): + shift = shift_factory(at(8, 30), at(12, 0)) + assert shift.duration.total_seconds() == 3.5 * 3600 + + def test_duration_is_unknown_while_the_shift_has_no_stops(self, bus, driver): + from padam_django.apps.fleet.models import BusShift + + shift = BusShift.objects.create(bus=bus, driver=driver) + assert shift.duration is None + + def test_intermediate_stops_do_not_change_the_duration( + self, shift_factory, add_stop, recompute_bounds + ): + """Only the extremes count — an added stop in between is not extra time.""" + shift = shift_factory(at(8), at(12)) + before = shift.duration + add_stop(shift, at(10)) + recompute_bounds(shift) + shift.refresh_from_db() + assert shift.duration == before diff --git a/tests/test_bus_shift_overlap.py b/tests/test_bus_shift_overlap.py new file mode 100644 index 00000000..4930ae38 --- /dev/null +++ b/tests/test_bus_shift_overlap.py @@ -0,0 +1,82 @@ +"""The business rule the exercise is built around. + +A bus cannot be on two shifts at once, and neither can a driver. These two are +**independent** constraints, not one constraint on the pair: a shift may share +its bus with one shift and its driver with another. + +Every test here reaches the database. A refusal proven by ``full_clean()`` +alone proves nothing about two concurrent transactions, which is the case the +constraint exists for. +""" + +from __future__ import annotations + +import pytest +from django.db import IntegrityError, transaction +from helpers import at + + +@pytest.mark.django_db(transaction=True) +class TestSameBus: + def test_identical_window_is_refused(self, shift_factory): + shift_factory(at(8), at(12)) + with pytest.raises(IntegrityError), transaction.atomic(): + shift_factory(at(8), at(12)) + + def test_partial_overlap_is_refused(self, shift_factory): + shift_factory(at(8), at(12)) + with pytest.raises(IntegrityError), transaction.atomic(): + shift_factory(at(11), at(14)) + + def test_enclosed_window_is_refused(self, shift_factory): + shift_factory(at(8), at(18)) + with pytest.raises(IntegrityError), transaction.atomic(): + shift_factory(at(10), at(12)) + + def test_back_to_back_shifts_are_accepted(self, shift_factory): + """One ends exactly when the next begins. + + The interval is half-open — ``[starts_at, ends_at)`` — so 12:00 belongs + to the second shift and to no other. Without that, chaining a driver's + day would need an arbitrary one-minute gap between every shift. + """ + shift_factory(at(8), at(12)) + shift_factory(at(12), at(16)) + + def test_shifts_on_different_days_are_accepted(self, shift_factory): + shift_factory(at(8, day=1), at(12, day=1)) + shift_factory(at(8, day=2), at(12, day=2)) + + +@pytest.mark.django_db(transaction=True) +class TestSameDriver: + def test_overlapping_window_on_another_bus_is_refused(self, shift_factory, other_bus): + """The driver constraint holds independently of the bus.""" + shift_factory(at(8), at(12)) + with pytest.raises(IntegrityError), transaction.atomic(): + shift_factory(at(10), at(14), on_bus=other_bus) + + def test_back_to_back_shifts_are_accepted(self, shift_factory, other_bus): + shift_factory(at(8), at(12)) + shift_factory(at(12), at(16), on_bus=other_bus) + + +@pytest.mark.django_db(transaction=True) +class TestIndependence: + def test_overlap_is_allowed_when_bus_and_driver_both_differ( + self, shift_factory, other_bus, other_driver + ): + shift_factory(at(8), at(12)) + shift_factory(at(8), at(12), on_bus=other_bus, with_driver=other_driver) + + def test_a_shift_may_share_its_bus_with_one_shift_and_its_driver_with_another( + self, shift_factory, other_bus, other_driver + ): + """Sharing is only forbidden when it happens at the same time. + + This is the case a constraint written on the (bus, driver) pair would + get wrong: the pairs all differ, yet neither resource is double-booked. + """ + shift_factory(at(8), at(12)) + shift_factory(at(14), at(16), on_bus=other_bus, with_driver=other_driver) + shift_factory(at(18), at(20), with_driver=other_driver) diff --git a/tests/test_bus_stop.py b/tests/test_bus_stop.py new file mode 100644 index 00000000..ee278d51 --- /dev/null +++ b/tests/test_bus_stop.py @@ -0,0 +1,83 @@ +"""What a stop is, and what a shift's set of stops may not be. + +The subject gives no specification for ``BusStop`` beyond "between 2 and n". +The rules asserted here are the ones the model has to choose deliberately — +each one is a decision, and a decision nobody writes down is one nobody can +review. +""" + +from __future__ import annotations + +import pytest +from django.db import IntegrityError, transaction +from helpers import at + + +@pytest.mark.django_db +class TestStopIdentity: + def test_a_stop_belongs_to_exactly_one_shift(self, shift_factory): + shift = shift_factory(at(8), at(12)) + assert shift.stops.count() == 2 + assert all(stop.shift_id == shift.pk for stop in shift.stops.all()) + + def test_stops_are_listed_in_chronological_order(self, bus, driver, add_stop, recompute_bounds): + """Insertion order is not itinerary order. + + Without an explicit ordering the admin inline shows stops in the order + rows happened to be written, which is not the order the bus calls at + them. + """ + from padam_django.apps.fleet.models import BusShift + + shift = BusShift.objects.create(bus=bus, driver=driver) + add_stop(shift, at(12)) + add_stop(shift, at(8)) + add_stop(shift, at(10)) + times = [stop.time for stop in shift.stops.all()] + assert times == sorted(times) + + +@pytest.mark.django_db(transaction=True) +class TestCollisionWithinAShift: + def test_two_stops_at_the_same_time_are_refused(self, shift_factory, add_stop): + """A bus cannot call at two places at the same instant.""" + shift = shift_factory(at(8), at(12)) + with pytest.raises(IntegrityError), transaction.atomic(): + add_stop(shift, at(8)) + + def test_the_same_time_in_two_different_shifts_is_accepted( + self, shift_factory, add_stop, other_bus, other_driver + ): + """The uniqueness is scoped to the shift, not global.""" + first = shift_factory(at(8), at(12)) + second = shift_factory(at(8), at(12), on_bus=other_bus, with_driver=other_driver) + assert first.stops.count() == 2 + assert second.stops.count() == 2 + + +@pytest.mark.django_db +class TestCardinality: + def test_a_shift_needs_at_least_two_stops_to_be_valid(self, bus, driver, add_stop): + """Between 2 and n stops, per the subject. + + This cannot be a table constraint: it counts rows in another table, and + the shift necessarily exists with zero stops for an instant. It is + validated where the shift and its stops are seen together in one + transaction — the admin's inline formset. + + TODO: point this at the real validation entry point once the admin + formset is written. Until then it asserts only that a single-stop shift + is refused by whatever the model exposes for validation. + """ + from django.core.exceptions import ValidationError + + from padam_django.apps.fleet.models import BusShift + + shift = BusShift.objects.create(bus=bus, driver=driver) + add_stop(shift, at(8)) + with pytest.raises(ValidationError): + shift.full_clean() + + def test_two_stops_are_enough(self, shift_factory): + shift = shift_factory(at(8), at(12)) + shift.full_clean() diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..5f521cee --- /dev/null +++ b/uv.lock @@ -0,0 +1,1095 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80c52b0ac4076c261a8ffa1f1b343ff2f6e89ce0e1cef/backrefs-8.0.tar.gz", hash = "sha256:b556cd7d36c3a3a2f256b89590b176b8eddfb73bcfaee3a3ddd84ea66d21ce50", size = 7013081, upload-time = "2026-07-26T19:54:24.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c5/e62c87f4799d1e3647d5b2ae16ea1d12205d72fde1ea8529e13fe050f678/coverage-7.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1545c52ce756b8a97007f439a220297f1cd72a2cbbcdffccdf1c1f70e74f9a42", size = 223215, upload-time = "2026-08-28T21:51:55.628Z" }, + { url = "https://files.pythonhosted.org/packages/89/e9/5e62fda9397175fb206f75368b6e85da06d831c181b6d0f67ca073cd2f89/coverage-7.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0598aadae641f30a0796b75b45c0b9c5de8619bd5cfb251bb0cc254e86e6dd13", size = 223585, upload-time = "2026-08-28T21:51:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/b9/40/bede08621b1ba67e88c4d3336c22b52cb7911ff1fa4ef055344b6670e58a/coverage-7.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4080ad6bad9f14690e6b2104f5e8d137ccc65a4b5427a36662090637d4bd16d5", size = 254575, upload-time = "2026-08-28T21:51:59.233Z" }, + { url = "https://files.pythonhosted.org/packages/12/d8/ab0bdaa45dfd6b8cbf1a3ec548fdf827684b1997f9724375c5b3e89144fb/coverage-7.16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9883a2f8206ce3af59117dc278e5d043fea06912bca3f199816129e5e2de354", size = 257172, upload-time = "2026-08-28T21:52:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bb/135de81784bbd7dfedcab2b92b03d71d75b09b0815b42d6dabb052def5a6/coverage-7.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984e5430fc6f858385009e92549955157d79335b1f3e13e1031e0f89d1284261", size = 258410, upload-time = "2026-08-28T21:52:02.76Z" }, + { url = "https://files.pythonhosted.org/packages/ad/72/ce44ecc062fb2e43d9447bb76154d091c2139232f20c125297c4b58f4c6a/coverage-7.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b1374099dd1ad0d31fbb6c95d00a56a3c5e85fb3343dca14fc12f78323a2b42a", size = 260539, upload-time = "2026-08-28T21:52:04.821Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/9389c36a41e59406ca2bba493807c2294d2e5186a7e9ebcc2e63a0f2a711/coverage-7.16.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d8686bce035c8465b318a8c2890e69ba14a00801a27f4eb6bdc97c23944d87", size = 254756, upload-time = "2026-08-28T21:52:06.68Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0f/7762447b15e01fb84263608540123c4d9941f06303265ee74d801ccbec0e/coverage-7.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:857fceba6ff4b507ee0ad98798a33d544a8473df0c542bf04251ee4ed5ee6292", size = 256540, upload-time = "2026-08-28T21:52:08.529Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fa/c60dc75a8346c1dbebebc7279b19971c88f70dd575f0bc10bc0cb16f92d5/coverage-7.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bbf08d951abaa1ce89e28c998361d56b952413846b459cd017f116ad4c9adbfa", size = 254508, upload-time = "2026-08-28T21:52:10.323Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/4e0834f3a1fccaa8bf625a2a1d73bde0fa32577dc3249853c0dd0e7f2b20/coverage-7.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1a03e78f53e4d2ab13adac19958a89322d1829913e5623d642627bf60b35da21", size = 258659, upload-time = "2026-08-28T21:52:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ec/fe712d3a11fd6e874565a5fa5497c48b8ece561d9611da040b44cdcf8386/coverage-7.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dcd3dafcdd78305d27c59a1006b53a4990acb89e68d8fbe0992f4f83503c827f", size = 254326, upload-time = "2026-08-28T21:52:14.181Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/093e12072e01034c65ff380f76c74b79dd83e44fa92b689a2154389be734/coverage-7.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c1bcfe470a796fbea6234accd81d258a31574dc0b7bf569e16be757572c4de17", size = 256102, upload-time = "2026-08-28T21:52:16.003Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c0/265176117ca5d06e3f65575842884cdda96cf213350a31e9d41c80d65854/coverage-7.16.0-cp313-cp313-win32.whl", hash = "sha256:1420370276f1694b663207b8245c3628aafb9624fe3cebf313a13d860e55ee67", size = 225250, upload-time = "2026-08-28T21:52:17.82Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/8a87f2c04fde322430b45d16d8f543693e9894c5b2d2ca238a287c00beca/coverage-7.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:496277c8d7beed695e02c7be53516a0152e4caef8738a0feab6a638546cce449", size = 225790, upload-time = "2026-08-28T21:52:19.641Z" }, + { url = "https://files.pythonhosted.org/packages/23/40/c21feacd9edfe7063195bf9cc84d650e9938fc6a23063e4f027199b160e1/coverage-7.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:181c2906b9b3759955c1c33c51fbb91c754fbd0b82ea49e2c81061f5a052082c", size = 225180, upload-time = "2026-08-28T21:52:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, +] + +[[package]] +name = "django" +version = "5.2.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/d8/43e9d000519adceb189620b6869ff88031e046df91c2e9da72f8f6918399/django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f", size = 10889740, upload-time = "2026-08-04T15:04:03.173Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/f8/ce120525ca78f12b07daf65786679c5d0b54a75285a8958d3ae55e39da35/django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db", size = 8315563, upload-time = "2026-08-04T15:03:59.1Z" }, +] + +[[package]] +name = "django-environ" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/e1/4c4ddcf6e90f023e89edb1d92f9a7fffce61ad4b691df497f2f5c0be6e26/django_environ-0.14.0.tar.gz", hash = "sha256:b6c48d93b9d2ff8a3ea14099e90c35aa4f101c1b4d5f262dfee0d27b06742ed7", size = 60417, upload-time = "2026-06-18T22:49:55.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/fe/67b423fc30f16d10259e901320fbc121746bf168aaaad9043e6ebb0110c1/django_environ-0.14.0-py3-none-any.whl", hash = "sha256:8dbe8a57f0a540ab8abd6f54f230de5e99e3a2c9d797cb9caecb037bca3d47d8", size = 20934, upload-time = "2026-06-18T22:49:54.355Z" }, +] + +[[package]] +name = "django-extensions" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" }, +] + +[[package]] +name = "django-stubs" +version = "5.2.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-stubs-ext" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/01/86c921e0e19c9fa7e705bf795998dbf55eb183e7be0342a3027dc1bcbc9f/django_stubs-5.2.9.tar.gz", hash = "sha256:c192257120b08785cfe6f2f1c91f1797aceae8e9daa689c336e52c91e8f6a493", size = 257970, upload-time = "2026-01-20T23:59:27.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/05/4c9c419b7051eb4b350100b086be6df487f968ab672d3d370f8ccf7c3746/django_stubs-5.2.9-py3-none-any.whl", hash = "sha256:2317a7130afdaa76f6ff7f623650d7f3bf1b6c86a60f95840e14e6ec6de1a7cd", size = 508656, upload-time = "2026-01-20T23:59:25.12Z" }, +] + +[package.optional-dependencies] +compatible-mypy = [ + { name = "mypy" }, +] + +[[package]] +name = "django-stubs-ext" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/40/75dd6650c5fc345b9edaebe42085811096e31a502fdbe17a52ffb9640d03/django_stubs_ext-6.1.0.tar.gz", hash = "sha256:d5635a481f5bdf2e04d533a8b602d3b9a28f073deffc85b83048dd20032e4d20", size = 6848, upload-time = "2026-08-12T10:54:44.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/41/10b8d576651af4f1dc6bdfb0d8a203e8b3a55d60ae36ba305d0edcdb594f/django_stubs_ext-6.1.0-py3-none-any.whl", hash = "sha256:57273506823274700a707c8f404dfb52a12c4554daf1468dfb2bff857bb22074", size = 10404, upload-time = "2026-08-12T10:54:42.979Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "factory-boy" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, +] + +[[package]] +name = "faker" +version = "40.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/fb/35acc76128d5ee8983d940da871cc8eba876e7b0e9e6bd402ecd7104dcdc/faker-40.37.0.tar.gz", hash = "sha256:a92dff7f310e61fb544c61720e15edb2e7448bc33d15a321a99e9ab7b94abf54", size = 2025920, upload-time = "2026-08-21T16:33:16.261Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/22/bf589b6b2c7527047f55450358fbe5961aeaadf168d6b51a1a1c67da497f/faker-40.37.0-py3-none-any.whl", hash = "sha256:ddbafa55c94d5b69c08ced3a7f202614204a02e07ba6548c729b8d18acc0b490", size = 2062853, upload-time = "2026-08-21T16:33:14.516Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffelib" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/a767e91c606deefc447a96eaf59edd77397960b1d677dffd833ee8449831/griffelib-2.2.0.tar.gz", hash = "sha256:e1bc36fe9cd21d4b6b659b456346755e4cfdc5676c0a5214083126ee12612b3c", size = 227048, upload-time = "2026-08-16T14:04:58.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl", hash = "sha256:d71c3bc2bbed9f958488634fe788b843a9f705d6d2838ca32cd6c25eeb64dfc4", size = 166779, upload-time = "2026-08-16T14:04:54.365Z" }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipython" +version = "9.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/bc/e05ae123712ce4e1fde4408eedca1791fc1ff832684565132ea1dc646092/ipython-9.17.0.tar.gz", hash = "sha256:1dc69e6966b270fb259f676c71a21450e63607729b14a672b942914a54e8b730", size = 4538547, upload-time = "2026-08-28T09:00:58.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/5f/f992b57e8deb8fa6c2e614b422d80698adb5107902bbc89832e203665bd1/ipython-9.17.0-py3-none-any.whl", hash = "sha256:ce647713be8fef3fab2418c515a0def4d45d6705dd102be2c6d1f3015d7368b0", size = 638698, upload-time = "2026-08-28T09:00:56.174Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "0.30.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/33/2fa3243439f794e685d3e694590d28469a9b8ea733af4b48c250a3ffc9a0/mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f", size = 106350, upload-time = "2025-09-19T10:49:26.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/2c/f0dc4e1ee7f618f5bff7e05898d20bf8b6e7fa612038f768bfa295f136a4/mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82", size = 36704, upload-time = "2025-09-19T10:49:24.805Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/5d/1be1c7a49d8fa13dc80f66a85f53333d52cf5206911412006ffdff8fb9a0/mkdocstrings_python-2.0.7.tar.gz", hash = "sha256:8c49faf66d243072d7590a1b5dea028d9d7425fac191f54f096123a4a9c1a783", size = 201598, upload-time = "2026-08-17T16:56:18.239Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/6d/77546d8c26f038fce314a507106954f76270f6c182488bcf9ac9721175df/mkdocstrings_python-2.0.7-py3-none-any.whl", hash = "sha256:1fce5fbfe4ffa6e8136a35351cdc97c3bf55219c7efbd3f92a82260f93235d60", size = 105387, upload-time = "2026-08-17T16:56:16.813Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "padam-django" +version = "0.2.0" +source = { virtual = "." } +dependencies = [ + { name = "django" }, + { name = "django-environ" }, + { name = "django-extensions" }, + { name = "factory-boy" }, + { name = "faker" }, + { name = "gunicorn" }, + { name = "psycopg", extra = ["binary"] }, + { name = "whitenoise" }, +] + +[package.dev-dependencies] +dev = [ + { name = "django-stubs", extra = ["compatible-mypy"] }, + { name = "ipython" }, + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-django" }, + { name = "ruff" }, + { name = "werkzeug" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] +lint = [ + { name = "django-stubs", extra = ["compatible-mypy"] }, + { name = "mypy" }, + { name = "ruff" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-django" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=5.2,<5.3" }, + { name = "django-environ", specifier = ">=0.12,<1" }, + { name = "django-extensions", specifier = ">=4.1,<5" }, + { name = "factory-boy", specifier = ">=3.3,<4" }, + { name = "faker", specifier = ">=33" }, + { name = "gunicorn", specifier = ">=23,<24" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, + { name = "whitenoise", specifier = ">=6.8,<7" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "django-stubs", extras = ["compatible-mypy"], specifier = ">=5.1,<6" }, + { name = "ipython", specifier = ">=8.29" }, + { name = "mkdocs", specifier = ">=1.6,<2" }, + { name = "mkdocs-material", specifier = ">=9.5,<10" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.27,<1" }, + { name = "mypy", specifier = ">=1.14,<2" }, + { name = "pytest", specifier = ">=8,<9" }, + { name = "pytest-cov", specifier = ">=6,<7" }, + { name = "pytest-django", specifier = ">=4.9,<5" }, + { name = "ruff", specifier = ">=0.9,<1" }, + { name = "werkzeug", specifier = ">=3.1,<4" }, +] +docs = [ + { name = "mkdocs", specifier = ">=1.6,<2" }, + { name = "mkdocs-material", specifier = ">=9.5,<10" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.27,<1" }, +] +lint = [ + { name = "django-stubs", extras = ["compatible-mypy"], specifier = ">=5.1,<6" }, + { name = "mypy", specifier = ">=1.14,<2" }, + { name = "ruff", specifier = ">=0.9,<1" }, +] +test = [ + { name = "pytest", specifier = ">=8,<9" }, + { name = "pytest-cov", specifier = ">=6,<7" }, + { name = "pytest-django", specifier = ">=4.9,<5" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/06/cf1564dcc2e2261c8c8c6c05628dc8b418943bdae2a4e58640ceb2f770fa/platformdirs-4.11.5.tar.gz", hash = "sha256:e8b31f4f8bcbbedef91a6b57a706255e4f148d2a4e01648382a0a47342539173", size = 34823, upload-time = "2026-08-27T21:36:37.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/12/6f3fcd5067a9cbf4f8664b32957973498da8b083455203c8d9cab83a725c/platformdirs-4.11.5-py3-none-any.whl", hash = "sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b", size = 23900, upload-time = "2026-08-27T21:36:36.227Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "11.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/17/2db4b414de89659144488e0d9c6c0bf0c8395841dc12d81d0532cc6ef310/pymdown_extensions-11.0.2.tar.gz", hash = "sha256:9506fcbe66fa355a775b768084334238dd6805020ac4b92bea0c0dda6f8f223d", size = 855419, upload-time = "2026-08-22T19:28:47.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/43/9f45ec4d14e596efc32c925a78104934790438b0c0628b70d741016734ad/pymdown_extensions-11.0.2-py3-none-any.whl", hash = "sha256:259910762019732caa1dfd76f3faa62c59f191d46573e80bcb1d13c0f675bbe5", size = 269929, upload-time = "2026-08-22T19:28:45.389Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "traitlets" +version = "5.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/2e/a7fbfe268c8a3b32546930c0297c101d65a4a14c304ad5790a9f478f0e4e/traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1", size = 166137, upload-time = "2026-08-03T08:32:36.848Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260815" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/72/b56089aeee6c496d969bac42376bedb6e3eeab4682e1018fa3137122f94b/types_pyyaml-6.0.12.20260815.tar.gz", hash = "sha256:28764110c9cf35846e733da32d8d734df7473c5dde9ef67c3b7332ec0e819858", size = 18545, upload-time = "2026-08-15T02:41:51.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/52/eefeba09be4ef2a1eb989eb92934561e8e502a6ee3c32654996e4be7e399/types_pyyaml-6.0.12.20260815-py3-none-any.whl", hash = "sha256:6f332212b7e191f3afd5016a713c510b6340593b7ebec573c7d5d20aa5386d3b", size = 21148, upload-time = "2026-08-15T02:41:50.555Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, +]