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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"enabledPlugins": {
"pr-review-toolkit@claude-plugins-official": true
},
"permissions": {
"allow": [
"Bash(uv:*)",
"Bash(uvx:*)",
"Bash(ruff:*)",
"Bash(basedpyright:*)",
"Bash(pytest:*)",
"Bash(make:*)",
"Bash(nix develop:*)",
"Bash(nix flake:*)"
]
}
}
18 changes: 18 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
root = true

[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 4

[*.{toml,yaml,yml,json,jsonc}]
indent_size = 2

[*.md]
trim_trailing_whitespace = false

[Makefile]
indent_style = tab
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

# uv path — runs for everyone
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

- name: Set up Python
run: uv python install

- name: Install deps
run: uv sync --frozen

- name: Inner loop (uv)
run: make check

# nix path — only if the user kept flake.nix
- name: Install Nix
if: ${{ hashFiles('flake.nix') != '' }}
uses: cachix/install-nix-action@v30
with:
extra_nix_config: |
experimental-features = nix-command flakes

- name: Inner loop (nix develop)
if: ${{ hashFiles('flake.nix') != '' }}
run: nix develop -c make check
33 changes: 33 additions & 0 deletions .github/workflows/gitleaks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: gitleaks

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

concurrency:
group: gitleaks-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
scan:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gitleaks needs full history to scan commit-by-commit

- name: Install gitleaks
run: |
set -euo pipefail
version=8.21.2
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${version}/gitleaks_${version}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks

- name: Scan
run: gitleaks detect --source . --redact --no-banner -v
9 changes: 5 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ dist/
venv/
env/

# Pytest / mypy / etc.
# Pytest / basedpyright / hypothesis / coverage caches
.pytest_cache/
.mypy_cache/
.ruff_cache/
.basedpyright/
.hypothesis/
.coverage
htmlcov/

Expand All @@ -32,7 +34,6 @@ htmlcov/
# Tracked test fixtures live in tests/fixtures/ instead.
research/

# Claude Code per-session agent state — settings only. Project-shared
# skills under .claude/skills/ ARE checked in.
.claude/settings.json
# Claude Code per-session agent state. Project-shared `.claude/settings.json`
# and `.claude/skills/` ARE checked in; per-machine override stays local.
.claude/settings.local.json
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
174 changes: 174 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Agent instructions

Contract for agents working in this repo. Read first; overrides defaults.

This project follows the [`ak2k/python-starter`](https://github.com/ak2k/python-starter)
template (Profile A — distributable CLI). See also `CLAUDE.md` for
flight-cli-specific load-bearing quirks (Matrix wire-format gotchas, SPA
capture workflow, two-phase calendar flow) — read both before non-trivial
work.

## Stack (one per concern; substitutes are bans)

| Concern | Use | flight-cli uses | Not |
|---|---|---|---|
| Package manager | `uv` | ✓ | pip, poetry, pipenv, pyenv |
| Lint / format | `ruff` + `ruff format` | ✓ | black, isort, flake8, pylint |
| Type checker | `basedpyright` strict | ✓ (was `pyright`) | mypy, pyright |
| Boundary validation | Pydantic v2 | ✓ (see `wire.py`, `models.py`, `domain.py`) | dataclasses, attrs, TypedDict |
| HTTP | `httpx` | ✓ | requests, aiohttp, urllib |
| Async runtime | `anyio` | ✓ | raw asyncio |
| Logging | `structlog` | ✓ (see `log.py`) | `print()` |
| Paths | `pathlib.Path` | ✓ | `os.path` |
| Tests | `pytest` + `hypothesis` | `pytest` (no hypothesis yet) | unittest |
| Errors | subclass project-local error hierarchy | `MatrixApiError`, `ApiKeyResolutionError` | bare `Exception`, string errors |

## When you need it, use

Not every project hits these concerns. When yours does, this is the
choice that fits the rest of the stack — don't substitute.

| Concern | Use | flight-cli uses |
|---|---|---|
| Retry / backoff | `stamina` | ✓ (see `_http.py`) |
| CLI framework | `typer` (paired with `rich` for human-facing output) | ✓ (see `cli.py`) |
| Time injection in tests | `time-machine` | — |
| HTTP rate limiting (outbound) | `aiolimiter` | ✓ (see `_http.py`) |
| HTTP fingerprint evasion | `httpx-curl-cffi` transport (keep httpx API + `MockTransport` tests) | ✓ (see `_http.py`) |
| SQL | `sqlalchemy 2.0` Core | — |
| Async file I/O | `anyio.Path` | — |

## Inner loop

```
make fix # autofix + full check
make check # full check (CI runs this)
```

Both must be green. `filterwarnings = ["error"]` and `xfail_strict = true`
are load-bearing — deprecation warnings and unexpected passes are real
failures, not noise.

Smoke test against the live backend:

```
flight fare JFK LHR --dep 2026-08-15 --return 2026-08-22 -n 2
```

If `make check` is green but the smoke test fails, suspect the API key
cache (`~/.cache/flight-cli/.matrix-key`) or a Matrix brownout (see
`CLAUDE.md` quirk #7).

## Principles

1. **Boundaries fail loudly.** Pydantic at every external edge. Wire
models in `wire.py` AND response models in `models.py` both use
`extra="ignore"` — Matrix is reverse-engineered and adds fields
unannounced in directions that aren't load-bearing for us
(Profile-B-edge divergence; see "Appropriate divergence" below).
Domain models in `domain.py` use `extra="forbid"` — that's where
*we* control the contract. Domain errors (`MatrixApiError`,
`ApiKeyResolutionError`) wrap third-party exceptions —
`httpx.HTTPStatusError` never leaks to a caller.

2. **Suppress with cost.** Every `# pyright: ignore[code]` and `# noqa: code`
names the specific rule and a one-line reason. Suppression is annotated
debt — visible, greppable, justified — not a workaround.

3. **Copy the canonical example.** Inventing a new shape for any of these
is a deliberate choice. The defaults are:
- **Wire boundary (request)**: `src/flight_cli/wire.py` — pydantic
models with `populate_by_name`, discriminated-union dispatch via
`match` + `assert_never`.
- **Wire boundary (response)**: `src/flight_cli/models.py` — `_Loose`
base with `extra="ignore"`, `from_api()` classmethod constructors.
- **Domain types (intent)**: `src/flight_cli/domain.py` — frozen
pydantic models, discriminated by `kind` Literal, validators with
domain-meaningful error messages.
- **HTTP client**: `src/flight_cli/client.py` — single `execute()`
entry point; wraps `httpx.HTTPStatusError` in `MatrixApiError` at
the boundary.
- **HTTP transport**: `src/flight_cli/_http.py` — `stamina.retry`
decorator, `aiolimiter.AsyncLimiter`, `httpx-curl-cffi` for
fingerprinting, on-disk JSON cache. structlog `BoundLogger` for
cache-hit / retry / rate-limit diagnostics.
- **Logging config**: `src/flight_cli/log.py` — idempotent
`configure(level)` with ConsoleRenderer; stderr-bound for CLI use.
- **Env + disk-cache config**: `src/flight_cli/_api_key.py` — env-var
override → disk cache (TTL) → live resolution → cache write.
- **Test pattern**: `tests/test_wire_round_trip.py` — captured SPA
bodies as golden-file fixtures; reconstruct via `to_wire()` and
compare.

4. **Diverge with a comment.** When tuning a default below (or deviating
from the stack table), leave `# DIVERGE: <reason>` so future readers
don't "fix" it back. Existing divergences from the template default:
- Coverage gate at 0% — golden-file regression tests; fixture parity
is the signal, not line coverage.
- See "Appropriate divergence" table for the Profile-A→A/B-edge
tunings (reportAny, Pydantic `extra` on boundary models).

5. **Ask when guessing.** Unknown Matrix wire shape, new SPA capture
needed, irresolvable type error → ask. Don't invent the shape; capture
a real body via `research/record_user_session.py` first.

6. **Tests assert behavior, not implementation.** Golden-file fixtures
for wire-format round-trips (`tests/fixtures/`). `httpx.MockTransport`
is the substitution point for hermetic HTTP tests. `monkeypatch.setenv`
for env-driven settings. Assert on domain errors (`MatrixApiError`),
not on HTTP status codes leaking through.

7. **Imports declare intent.** `from __future__ import annotations` at
the top of every module; runtime-only third-party types inside
`if TYPE_CHECKING:`.

8. **Async runtime is anyio.** Not raw asyncio. Compose with sync at the
edges via `anyio.from_thread` / `anyio.to_thread`. CLI entrypoints use
`anyio.run(coro_fn)` (not `asyncio.run(coro_fn())`). Concurrency
primitives: `anyio.Semaphore`, `anyio.create_task_group()`.

## Appropriate divergence

This project is **Profile A — distributable CLI** with a Profile-B edge
(reverse-engineering / scraping) — the CLI surface is typed strictly,
but the integration boundaries with `fli`, `fast_flights`, Matrix's
undocumented JSON, and pydantic internals are inherently `Any`-typed.
Tunings applied versus the strict-service default:

| Knob | Default | flight-cli |
|---|---|---|
| `requires-python` | `>=3.13` | `>=3.12` |
| Ruff `D*` (docstrings) | enabled | omitted |
| `pythonVersion` (basedpyright) | `"3.13"` | `"3.12"` |
| Coverage `fail_under` | 80 | 0 (golden-file suite) |
| `PLR0913` (too many args) | strict | ignored (CLI verbs are wide) |
| `N815` (camelCase) | strict | per-file-ignored in `wire.py` only — direct camelCase attrs mirror Matrix's JSON without per-field `alias=`. `models.py` uses snake_case + `Field(alias="…")` so N815 doesn't fire there; `domain.py` is snake_case throughout. |
| `reportAny` | `"warning"` | `"none"` — Profile-B edge: fli/fast_flights ship no stubs; Matrix response is `dict[str, Any]` by design; `ValidationInfo.data` and `Field` overloads are Any internally. `reportUnknown*` stays on (higher-signal "can't type this"). |
| Pydantic `extra` on boundary models | `"forbid"` | `"ignore"` on wire + response (`_Wire`, `_Loose`) — Profile-B edge: Matrix is reverse-engineered and adds fields unannounced in directions that aren't load-bearing. Domain models stay `extra="forbid"` (we control that contract). |

## flight-cli load-bearing quirks (read before touching wire.py)

Full detail in [`CLAUDE.md`](./CLAUDE.md) and
[`docs/memories/MEMORY.md`](./docs/memories/MEMORY.md). One-line summaries:

- `routeLanguage` ≠ `commandLine` (two distinct wire fields).
- Per-mode field rules: specific-date emits `dateModifier` + `isArrivalDate`; calendar omits.
- `maxLegsRelativeToMin` defaults to 1, not 10.
- Calendar brownouts are real; not our bug.
- Two-phase calendar flow: `calendar` returns grid; `calendarFollowup` returns itineraries.
- Multiple AIza keys in the SPA bundle — only the bare `matrix` label is the prod key.

When in doubt: capture a real SPA body via `research/record_user_session.py`
and write a reconstruction test in `tests/test_wire_round_trip.py`.

## Known gotchas (non-obvious from the toolchain)

- `extra="forbid"` Pydantic models raise on any unknown upstream field.
Wire models use `extra="ignore"` instead — Matrix adds fields without
notice and we don't want every shape addition to be a P0.
- `http.HTTPStatus.NOT_FOUND` — stdlib, well-typed. Not
`httpx.codes.NOT_FOUND` (mis-typed as tuple) and not bare `404`
(PLR2004).
- Domain `InputValidationError`-style errors should never shadow
`pydantic.ValidationError` — keep our errors named distinctly
(`MatrixApiError`, `ApiKeyResolutionError`).
31 changes: 31 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.PHONY: help install check lint format typecheck test fix clean

help: ## show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-12s\033[0m %s\n", $$1, $$2}'

install: ## uv sync (install all deps including dev)
uv sync

check: lint typecheck test ## full inner loop (CI runs this)

fix: ## autofix lint + format, then run full check
uv run ruff check --fix src tests
uv run ruff format src tests
$(MAKE) check

lint: ## ruff check (no fix) + format --check
uv run ruff check src tests
uv run ruff format --check src tests

format: ## ruff format (writes)
uv run ruff format src tests

typecheck: ## basedpyright strict
uv run basedpyright src tests

test: ## pytest
uv run pytest

clean: ## remove caches
rm -rf .pytest_cache .ruff_cache .basedpyright .hypothesis .coverage htmlcov dist build
find . -type d -name __pycache__ -exec rm -rf {} +
Loading
Loading