Skip to content

test: pin the public API and compare every response with the published 0.1.29 - #43

Closed
iklobato wants to merge 9 commits into
refactor/no-global-statefrom
test/release-validation
Closed

iklobato wants to merge 9 commits into
refactor/no-global-statefrom
test/release-validation

Conversation

@iklobato

Copy link
Copy Markdown
Owner

Stacked on #42. Last in the series: it adds no feature, it checks the other five PRs.

What

Two test tools, and what they found.

  1. tests/test_public_api.py: the API that the README, docs and examples teach must keep working. tools/differential/snapshot_public_api.py collects every from lightapi... import ... in README.md, docs/ and examples/, keeps the ones that really import, and records the signatures. tests/data/public_api.json is that snapshot taken from lightapi 0.1.29 installed from PyPI: 52 imports, 71 signatures. A name may gain an optional parameter; it may not disappear, lose a parameter, reorder one, or start requiring one. It fails when I make build_app's debug required or drop prev_cursor from CursorPaginator.wrap.
  2. tools/differential/: one fixed script of 412 steps run against whichever lightapi is installed, saving status, a few headers and the raw response text of every step, plus the CREATE TABLE text of all 45 tables for SQLite and PostgreSQL. compare.py diffs two recordings and exits non-zero on any difference the CHANGELOG does not declare. It covers CRUD and optimistic locking (in-memory and pooled engine), validation errors, 404/405/409, the four Serializer forms, filters, page and cursor pagination, JWT and Basic auth with every permission shape, the login routes, sync and async middleware, both CORS paths, full and partial reflection, a YAML app and twelve invalid YAML documents, the same CRUD on an async engine, async querysets and verb overrides, background tasks, and the response cache through a fake Redis.

What it found

A regression in #40 that no test had caught. Reflected float columns came back as a JSON number (0.5) where 0.1.29 returns a JSON string ("0.5"). In SQLAlchemy Float, Double and REAL are subclasses of Numeric, and the old type chain tested Numeric first, so a reflected float has always validated as Decimal. column.type.python_type says float. I had written in #40 that the two agreed; I had compared python_type with what I expected the old chain to return, not with what it returned.

Running the real old function next to the new one over 43 column types showed 8 differences: every float type, and Uuid(as_uuid=False). The fix is commit 67b8d78 on the #40 branch, merged up through #41 and #42 into this one, with tests/test_reflected_annotations.py pinning 25 column types (8 fail without the fix). The same comparison now shows 0 differences.

Whether a reflected float should be a JSON number is a fair question. It changes the wire format, so it needs a decision and a changelog entry, not a refactor.

A trap in the tooling. Two comparisons in a row reported the old behaviour after the fix. uv run --with <local wheel> had reused the environment built for the previous wheel, because a rebuild keeps the file name and version. record.py now says to install each version into its own venv with --no-cache --reinstall, and stores a fingerprint of the installed source that compare.py prints.

Result

Installed and verified by fingerprint:

  • 0.1.29 against itself: 412 identical steps. This is what makes a difference between versions mean something.
  • 0.1.29 against the wheel of this branch: 406 identical, 6 different and all declared in the CHANGELOG, 0 undeclared. The 6: sync def get/post overrides are served (3), login uses the subclass backend (1), Meta.cache works on an async engine (2).
  • All 90 DDL steps are identical, so this version opens a database created by 0.1.29 with no migration.

Also run for this PR, all on my machine:

  • The suite on Python 3.10, 3.11, 3.12 and 3.13: green on all four. Until today it had only been run on 3.13, which CI does not use.
  • Dependencies at the declared floor (SQLAlchemy 2.0.30, pydantic 2.0, starlette 0.37.0, uvicorn 0.30.0, redis 5.0.0, PyJWT 2.8.0, PyYAML 6.0) on Python 3.10: green. This needs httpx<0.28, because starlette 0.37's TestClient does not work with newer httpx; that is a test dependency, not a package one.
  • Dependencies at the newest allowed versions (starlette 0.52.1, pydantic 2.13.5, SQLAlchemy 2.0.54): green.
  • Each branch of the stack alone on Python 3.10: green.
  • The wheel: none of the 14 deleted modules, all 4 new ones, entry point intact, twine check passes, and it installs and serves a sync app on Python 3.10 with no async driver present.

Not green, and not from these PRs

  • Random test order fails on 2 of 3 seeds (tests/test_filtering.py, tests/test_http_methods.py). Plain dev fails on the same seeds with the same counts, so the refactor added no order dependence, but the suite has one.
  • An AsyncEngine alone does not switch the app to async. The README calls it a one-line swap, but without mode="async" or an async def method the app stays sync, table creation fails with a swallowed warning, and every request is a 500. Same in 0.1.29; the harness records it so it stays visible.

Checks

  • pytest: 502 passed, 1 xfailed, 2 xpassed (379 from fix: lazy Redis client; deprecate LightApi.from_dict #42 after the fix was merged up, plus 123 here). Green on Python 3.10 and 3.12 too.
  • Coverage on lightapi/: 88% (2,099 statements, 244 missed).
  • ruff check, ruff format --check, mypy: clean.

Not done: Postgres, a real Redis, a real uvicorn server, Docker, Helm, the docs snippets. Those are levels 3 to 8 of the test plan.

iklo added 4 commits September 18, 2026 12:47
Three steps of the refactor plan had to be dropped half way because a
name that looked private was in fact taught by the README or the docs
(_list_async and friends, paginate_async, _meta as a dict). Nothing in
the suite would have caught a break there.

tools/differential/snapshot_public_api.py collects every
`from lightapi... import ...` found in README.md, docs/ and examples/,
keeps the ones that really import in the running version, and records
the signatures of those classes and functions, plus the underscore
helpers the docs tell users to call. tests/data/public_api.json is that
snapshot taken from lightapi 0.1.29 installed from PyPI: 52 imports and
71 signatures.

tests/test_public_api.py checks the working tree against it. A name may
gain an optional parameter. It may not disappear, lose a parameter,
reorder one, or start requiring one. The four additions made by the
refactor (build_app(debug=), JWTAuthentication(extra_claims=), and
page_size= on both paginators) pass because they are optional.

Checked that it fails when it should: making build_app's debug required
and dropping prev_cursor from CursorPaginator.wrap turns two tests red.

Tests: 477 passed (354 + 123), 1 xfailed, 2 xpassed.
A refactor is supposed to change nothing a client can see, and the unit
tests only check what someone thought of checking. tools/differential
runs one fixed script of requests against whichever lightapi is
installed and saves, for every step, the status, a few headers and the
raw response text. compare.py then diffs two recordings. Raw text, not
parsed JSON, because the key order of a response is part of what clients
see.

The script covers CRUD and optimistic locking on an in-memory and on a
pooled engine, validation errors, 404/405/409, HEAD and OPTIONS, the four
Serializer forms, field/search/ordering filters, page and cursor
pagination, JWT and Basic auth with every permission shape, the login
routes (JSON, Basic header, malformed, rate limited, custom auth_path),
sync and async middleware, both CORS paths, full and partial reflection,
a YAML app plus twelve invalid YAML documents, the same CRUD on an async
engine with async querysets and verb overrides, background tasks, the
response cache through a fake Redis, and the CREATE TABLE text of every
table for SQLite and PostgreSQL.

Only timestamps, JWTs, the rate-limit reset time and the order of the
Allow header are normalised; the last two change with the process, not
with the code. Recording the same version twice gives 412 identical
steps, which is what makes a difference between two versions mean
something. compare.py lists the differences the CHANGELOG declares and
exits non-zero on any other.

record.py refuses to run against the working tree, so it always tests an
installed wheel or release, the thing users get.
While checking a fix, two comparisons in a row reported the old
behaviour although the rebuilt wheel on disk had the new code.
`uv run --with <local wheel>` had reused the environment it built for
the previous wheel: a rebuild keeps the file name and the version, and
--refresh-package did not change that.

record.py now tells people to install each version into a venv of its
own (with --no-cache --reinstall for a local wheel), and stores a
fingerprint of the installed source in the recording. compare.py prints
both fingerprints, so a B that did not change after a rebuild is easy to
spot.

Verified run: 0.1.29 against itself, 412 identical steps; 0.1.29 against
the wheel of this branch, 406 identical, 6 different and all declared in
the CHANGELOG, 0 undeclared.
@gitguardian

gitguardian Bot commented Sep 18, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 3 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
37435065 Triggered Base64 Basic Authentication 8642855 tools/differential/scenarios_sync.py View secret
37435064 Triggered Generic Password 8642855 tools/differential/scenarios_sync.py View secret
37435064 Triggered Generic Password 8642855 tools/differential/diff_validators.py View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

iklo added 5 commits September 18, 2026 13:53
The plan's Level 3 (T3-01 to T3-04, T3-08, T3-09) had never been run: all
354+ existing tests use SQLite and a mocked Redis. tests/integration/
adds 19 tests, skipped unless the plan's environment variables are set
(LIGHTAPI_TEST_PG_SYNC, LIGHTAPI_TEST_PG_ASYNC, LIGHTAPI_REDIS_URL), so
the normal suite is unaffected: 502 passed, 19 skipped, 1 xfailed, 2
xpassed, same as before this commit plus the skips.

- test_postgres_crud.py: sync CRUD, optimistic locking, 404/409/422
  against a real Postgres engine with a real connection pool (T3-01).
- test_postgres_async.py: the same through asyncpg and
  AsyncSession.run_sync, plus Decimal/UUID/datetime/bool round-trips
  (T3-02).
- test_postgres_reflection.py: full and partial reflection of a table
  with BIGINT, NUMERIC, DOUBLE PRECISION, UUID, JSONB, INET, ARRAY and
  more; confirms every reflected column, nullable or not, is a required
  key in the create schema, JSONB/INET/ARRAY fall back to Any with a
  warning and can be sent as null (T3-03).
- test_postgres_concurrency.py: 20 real concurrent writers against one
  row, sync and async; exactly 1 succeeds and 19 get 409 (T3-04).
- test_redis_cache.py: Meta.cache against a real Redis, sync and async,
  hit/miss/invalidation, and the RuntimeWarning-but-still-serves path
  when Redis is unreachable (T3-08, T3-09).

Found and worked around a pre-existing bug (not a regression, identical
on 0.1.29): LightApi._create_tables() detects a running event loop and
creates tables via asyncio.run() in a worker thread; for a loop-bound
async driver (asyncpg, and aiosqlite's shared in-memory connection) the
next request can be handed a connection tied to that now-dead loop and
fail. Documented in test_postgres_async.py's module docstring and pinned
by test_the_pool_poisoning_bug_is_reproducible; every other test here
builds the app before any event loop is running, or creates its tables
directly on the real loop first.

Also fixed along the way, all self-inflicted test bugs rather than
framework issues: reusing one RestEndpoint class across an isolated and
a non-isolated app (or two isolated apps) leaves _model_class set from
the first mapping, so the second app silently keeps operating on the
first app's table -- each engine/isolation combination now gets its own
class. Postgres tables persist across local runs of the same disposable
container, so CRUD/concurrency fixtures truncate before each test.

pytest.ini registers the "integration" marker (this file, not
pyproject.toml, is the one pytest actually reads here).

Tests: 502 passed, 19 skipped (without the env vars), 1 xfailed, 2
xpassed. All 19 pass against real Postgres 16 and Redis 7 containers,
repeatably across multiple runs against the same persistent container.
- test_cli_serve.py: `lightapi serve` and `python -m lightapi` as real
  subprocesses against docker/lightapi.example.yaml, plus the three
  error paths (no LIGHTAPI_CONFIG, bad LIGHTAPI_PORT, unknown
  subcommand) all exiting non-zero with a message naming the problem
  (T4-01, T4-02, T4-03).
- test_server_lifecycle.py: build_app(debug=True) shows a traceback,
  debug=False does not (T4-05); cors_origins wraps the whole app and
  handles preflight, lightapi.core.CORSMiddleware only reaches requests
  Starlette already routes to the endpoint (OPTIONS is not one, since
  the Route's methods never include it -- confirmed identical on 0.1.29,
  not a regression) (T4-06); the login rate limiter returns 429 with
  Retry-After after the configured limit, and the 1000/min default
  leaves normal use alone (T4-07).

Also ran, not as an automated test because it needs two full Python
environments and a timing comparison rather than a pass/fail assertion
(T4-04, the strongest claim of the whole series): a real uvicorn server,
Postgres pg_sleep(2) behind a sync GET, and /healthz timed 200ms later
on a second connection. Two runs each: 0.1.29 answered healthz in 1.87s
and 1.88s (blocked, nearly the full 2s); this branch in 0.378s and
0.007s (free). The scripts are in the session scratchpad, not committed
here.

Tests: 513 passed, 19 skipped (integration, needs Postgres/Redis), 1
xfailed, 2 xpassed.
Three cases from the test plan were marked "Parcial" because the
existing test only covered part of what the case asks for.

- T7-03: test_sync_verb_override.py only covered a sync def get. Added
  post/put/patch/delete overrides (each stamps a header so the test can
  tell the override ran, not the built-in method) and a case for the
  detail route, since a sync def get also has to intercept GET /x/{id}.
- T7-04: test_login_backend_subclass.py only covered JWTAuthentication
  subclasses. Added a BasicAuthentication subclass: /auth/login uses its
  validate_credentials, wrong credentials still 401, and the subclass
  still guards the endpoint with a real Basic header.
- T7-07: test_crud.py's PATCH tests never included a nullable field, so
  the three null-handling rules in _update_values had no test: null
  clears a nullable column, null on a non-nullable column is ignored
  (not an error), and an omitted field leaves the column as it was.

Tests: 524 passed, 19 skipped, 1 xfailed, 2 xpassed.
Extracts the Quick Start python block from README.md and executes it
verbatim (minus swapping the on-disk sqlite path for a tmp one and
disabling the __main__ app.run() call) so a doc edit that breaks the
example fails this test instead of going unnoticed.

The example must use a real sqlite FILE, matching the README's own
sqlite:///books.db: create_engine("sqlite://") (in-memory) defaults to
SingletonThreadPool, a separate empty database per thread, and
TestClient may run the request on a different thread than the one
that just built the app and created the table.

Claude-Session: https://claude.ai/code/session_01Kmta8V3goGySJyWVEkgweD
…ctor (T6-03)

Parses every ```python fence in the ten changed doc pages (ast.parse), then
executes the handful that are complete, runnable scripts as real
subprocesses: the async/middleware pytest-asyncio fixture pattern, and the
authentication "Complete Example".

The doc's own async testing pattern (building the app inside an already
running loop, exactly as pytest_asyncio.fixture does) deterministically
reproduces the pre-existing table-creation bug already tracked in
tests/integration/test_postgres_async.py (B19):
LightApi._create_tables() creates tables via asyncio.run() in a worker
thread when a loop is already running, which for aiosqlite/asyncpg poisons
the engine's connection pool with a connection bound to that dead thread's
loop. Every later async ORM call then fails with MissingGreenlet, and the
create_all exception itself is swallowed to a warning log. Pinned as a
reproduction rather than worked around, since this is the first place it's
shown to hit the docs' own recommended pattern, not just Postgres.

Claude-Session: https://claude.ai/code/session_01Kmta8V3goGySJyWVEkgweD
@iklobato

Copy link
Copy Markdown
Owner Author

Consolidated into #44 for a single review pass (one PR covering all five refactors plus the validation work, instead of six stacked ones). Closing this one, no commits were rewritten, they're all still in #44's history.

@iklobato iklobato closed this Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant