Skip to content

Refactor: dead code, single CRUD path, polymorphism, single-responsibility, no global state - #44

Merged
iklobato merged 150 commits into
devfrom
test/release-validation
Oct 12, 2026
Merged

iklobato merged 150 commits into
devfrom
test/release-validation

Conversation

@iklobato

Copy link
Copy Markdown
Owner

Consolidates the five stacked refactor PRs plus the validation work into a single review. No commit history was rewritten: all 40 commits from the original branches are still here, in order, for blame and partial revert.

Closes #38, #39, #40, #41, #42, #43 — folded into this one.

What changed

  1. chore: remove dead code (unimported modules, stale copies, unused internals) #38 chore: remove dead code (11 unimported modules, stale handler copies, unused internals, the unwired swagger module)
  2. refactor: one CRUD path for sync and async engines #39 refactor: one CRUD path for sync and async engines (sync DB access moved off the event loop onto a thread pool; async helpers use AsyncSession.run_sync)
  3. refactor: replace type checks with polymorphism (handler, auth, pagination, table mapping) #40 refactor: replace type checks with polymorphism (EndpointHandler, auth from_config, LoginEndpoint, paginator render(), TableSource, reflected types from SQLAlchemy's python_type)
  4. refactor: give the big functions one job each (metaclass, register, Repository, YAML loader) #41 refactor: split the big functions into one job each (metaclass, register/run/build_app, Repository, the YAML loader, _schema_pair)
  5. fix: lazy Redis client; deprecate LightApi.from_dict #42 fix: build the Redis client on first use instead of at import; deprecate LightApi.from_dict ahead of 0.2.0

User-visible behavior changes

Validated

59 test cases across 9 levels: static gates, the existing suite across Python 3.10-3.13 and dependency floor/ceiling, a byte-for-byte differential harness against the published 0.1.29, real Postgres/Redis integration, a real server and CLI, Docker/Helm, docs and examples, each declared behavior change, and performance. Full case-by-case table and live results: https://claude.ai/artifact/C1chg9jpoWwaJtUequp77N

Result: 50 passed, 3 failed, 5 not run, 1 skipped.

Confirmed correct, with real evidence

  • Differential vs the published 0.1.29 wheel: 406/412 steps byte-for-byte identical; the other 6 are exactly the behavior changes listed above, nothing undeclared.
  • CREATE TABLE for the same 45 tables is identical in SQLite and Postgres: a database created by 0.1.29 opens with this version with no migration (also proven live: booted this version against a volume seeded by 0.1.29, full CRUD round-trip, no missing-column/table errors).
  • The event loop stays free during a slow sync query: on 0.1.29 /healthz blocks ~2s behind a pg_sleep(2) request; on this branch it answers in under 100ms. This is the core claim of refactor: one CRUD path for sync and async engines #39, now measured, not just inferred from which thread runs.
  • Real Postgres (asyncpg + psycopg2) and real Redis integration, real Docker image and Helm chart on a kind cluster, memory stable over 10k requests.

Needs a decision before merging to master

  • Performance trade-off (T8-01): the sync SQLite-file path is measured ~13-20% slower on this branch than on 0.1.29 (228-241 rps vs 262-300 rps, p50 ~4.2ms vs ~3.4ms), reproduced over 4 runs. The async path is unaffected. Likely cost of refactor: one CRUD path for sync and async engines #39 moving sync DB access onto a thread pool for every request. Worth a call: is the event-loop-freedom win (T4-04) worth this cost on the sync path.
  • Pre-existing, not caused by this work: the suite fails under randomized test order on 2 of 3 seeds (T1-08); a plain dev checkout fails the same seeds with the same counts, so it's an old ordering dependency, not a regression here.
  • Pre-existing, not caused by this work: the Dockerfile HEALTHCHECK checks / instead of /healthz; identical on the published image. The Helm chart's own probes are correct and unaffected.
  • Version number: the automatic bump stays in 0.1.x, but this changes behavior a user can observe (the four items above). Needs your call on whether that's a minor bump or held for 0.2.0 alongside the from_dict removal.

Full bug list (including the two pre-existing ones above, B19/B20) and the original refactor rationale: https://claude.ai/artifact/8bvar8iWNwtNNrQodtSsXp

iklo added 30 commits September 18, 2026 09:35
auth_service, auth_strategy, cache_handler, cache_service, crud_sync,
crud_async, queryset, http_dispatcher, middleware_pipeline and
route_builder are leftovers of an earlier attempt to split lightapi.py
into classes. The split was never wired: register() still builds its
handlers through handler_factory, and RestEndpoint still owns the CRUD.

No file in the package, tests, examples or docs imports any of them,
none is exported from lightapi/__init__.py, and no open PR touches
them. queryset.py was only used by the two crud_* modules. They had
already drifted from the live code, so reading them gave the wrong
picture of how a request is handled.

pyproject.toml loses only the mypy override entries that named the
deleted files.

swagger.py is also unimported but stays: .cursorrules lists it as
frozen.

Behaviour is unchanged: 327 passed, 1 xfailed, 2 xpassed, same as
before the change. ruff, ruff format and mypy are clean.
swagger.py (SwaggerGenerator plus two route functions, 323 lines) is not
imported by the package, tests, examples or docs, and no route ever
serves it. docs/api-reference/swagger.md already tells users that v2
ships no built-in Swagger UI or OpenAPI endpoint and points them to
Starlette tooling, so the module contradicted the documented behaviour.

It was kept out of the previous commit because .cursorrules lists it as
frozen. That list dates from 2026-03-05 and is dropped in the next
commit.

Tests are unchanged: 327 passed, 1 xfailed, 2 xpassed.
The list was written on 2026-03-05 for the 1-v2-full-refactor branch and
never edited again. The files it froze kept changing anyway: 14 commits
to lightapi.py since then, 4 to auth.py, 3 to cache.py. An agent that
reads the file is told not to touch code the project itself edits every
month, so the rule only blocks work and protects nothing.

Removes the section and the three inline "FROZEN" notes that repeated
it. The rest of the file is untouched.
When the request handlers moved to handler_factory.py the originals
stayed behind: LightApi._make_collection_handler, _make_detail_handler
and nine module functions (_read_body, _check_auth, the two middleware
runners, the cache helpers, _wrap_dict_response). register() has used
handler_factory.make_* since then, so the only callers of the old
functions were each other.

The copies had drifted. The dead _check_auth treated AllowAny differently
from the live auth_checker.check_auth, so anyone reading the main file
read the wrong rule.

_validate_async_dependencies stays: run() uses it and a test imports it.
Nine imports that only served the dead code go with it. The handlers doc
now names the functions that really build the routes.

Tests are unchanged: 327 passed, 1 xfailed, 2 xpassed.
Each of these is referenced only by itself or by another item in this
list. Nothing in the package, tests, examples or docs uses them, and
none is exported from lightapi/__init__.py:

- cache.py: CacheBackend, CacheManager, _cache_manager, _get_redis.
  The live cache path is RedisCacheBackend behind get_cached,
  set_cached and invalidate_cache_prefix. RedisCache and BaseCache stay:
  they are public.
- schema.py: SchemaHelper (a class that only forwarded to the four
  module functions), register_column_type with its empty registry, and
  _strip_lightapi_kwargs.
- session_manager.py: EngineProtocol, AsyncEngineProtocol, EngineType,
  LoginValidator, clear_test_registries, and the SessionManager members
  is_async, get_registry_and_metadata, session, async_session, dispose.
  CRUD code opens its own sessions, so these were never reached. The
  docstrings now say what the class really does.
- pagination.py: PaginatorProtocol and PaginatorFactory. The protocol
  described a wrap() signature that neither paginator has.
- exceptions.py: MissingHandlerImplementationError.

Tests are unchanged: 327 passed, 1 xfailed, 2 xpassed.
- constants.py: the HttpMethod enum (only the deleted http_dispatcher
  used it; the live HttpMethod is the mixin namespace in methods.py) and
  ten constants nothing reads: RESPONSE_KEY_ERROR,
  RESPONSE_KEY_ALLOWED_METHODS, RESERVED_JWT_CLAIMS, the three
  DEFAULT_RATE_LIMIT_*, CLEANUP_INTERVAL, DEFAULT_MODE, VALID_MODES and
  DEFAULT_AUTH_PATH. Checked against lightapi/, tests/, examples/ and
  docs/.
- config.py: Config.update, Authentication.permission_value, and an
  Authentication.__post_init__ whose body was `pass`. No caller, and not
  shown in any doc or example.
- rest.py: _test_isolation_table_name was written on every endpoint
  class and never read. The try/except around `from sqlalchemy import
  Uuid` could never fail: the dependency floor is SQLAlchemy 2.0.30 and
  Uuid is present there (checked on 2.0.30 and 2.0.40), so the PG_UUID
  fallback was unreachable.

Tests are unchanged: 327 passed, 1 xfailed, 2 xpassed.
pyproject.toml still carried a ruff per-file-ignore and a mypy
ignore_errors entry for base_endpoint, database, handlers and models.
Those four files were deleted in e2faf25 ("Remove legacy features and
fix documentation"), so the entries matched nothing.

Only the [tool.ruff] and [tool.mypy] tables change. ruff and mypy give
the same result as before.
RestEndpoint carried every CRUD method twice: list, retrieve, create,
update and destroy, and again as _list_async ... _destroy_async, copied
line by line with `await` added. update alone was 107 lines, twice. Any
fix had to be made in both copies.

The logic now lives once in _list, _retrieve, _create, _update and
_destroy, which take an open sync Session. The public methods are thin:

- list/retrieve/create/update/destroy open a sync session
  (get_sync_session) and call the core.
- the _*_async helpers call the same core through
  AsyncSession.run_sync, which is SQLAlchemy's supported way to run
  sync ORM code on an async driver.

Names and signatures do not change. The _*_async helpers and
paginate_async stay because the README, docs/advanced/async.md and
three examples tell users to call them. paginate_async now delegates to
paginate the same way; it had no test, so it gets one.

Checked on aiosqlite: the five test_async_* files and
test_background_tasks pass through the new path (insert with refresh,
update with rowcount, delete with RETURNING, both paginators, async
queryset, reflection). Not checked on asyncpg: no Postgres here.

Tests: 329 passed (327 + 2 new), 1 xfailed, 2 xpassed.
With a sync Engine the route handlers are `async def` but called
endpoint.list(), create(), update() and the rest directly, so every
database round trip ran on the event loop thread. One slow query stalled
all other requests on that worker. The Redis cache calls had the same
problem.

The sync branches now go through session.run_blocking, which uses
Starlette's run_in_threadpool. Cache invalidation goes the same way and
is skipped for GET, which never invalidates.

Engines on StaticPool or SingletonThreadPool keep running in place.
Those are the in-memory SQLite pools: SingletonThreadPool gives every
thread its own empty database, and StaticPool shares one connection that
is not safe across threads. The docs install snippet and most examples
use them, and they would have broken under a plain thread pool.

tests/test_sync_offload.py pins both cases by comparing the thread that
runs the queryset with the event loop thread. It fails without this
change.

Tests: 331 passed, 1 xfailed, 2 xpassed.
maybe_cached and the cache invalidation lived only in the sync branches
of the route handlers. An app on an AsyncEngine with `cache = Cache(...)`
never read, wrote or invalidated anything, with no warning.

cache_helper now splits the work into _cached_response and
_store_response. maybe_cached (sync) and the new maybe_cached_async
share them, so the rules exist once. The Redis client is blocking, so the
async path sends those calls to a worker thread and skips the hop when
the endpoint has no cache configured.

invalidate_cache_after_write replaces maybe_invalidate_cache and is
called for both engine kinds, which removes one `if not is_async` from
each handler.

tests/test_cache_async.py covers hit, miss with the stored key, body and
ttl, invalidation on POST, and no invalidation on GET. Three of the four
fail without this change.

Tests: 335 passed, 1 xfailed, 2 xpassed.
make_collection_handler and make_detail_handler were two 80-line
closures with the same prologue and epilogue and an if/elif on
request.method in the middle. Each branch repeated the same three-way
choice: async override, async engine, sync engine.

EndpointHandler keeps that choice in one place (_serve) and describes
each HTTP method as data: which user method overrides it, which CRUD
method serves it on each engine kind, whether it reads a body, whether
GET caching applies. Collection and detail routes are the same class
with a different verb table.

body_reader.py and response_wrapper.py each held one small function used
only here, so they move in as private helpers. The per-request lookups
and the order of steps are unchanged: pre middlewares, auth, verb,
dict-to-JSON, cache invalidation, background tasks, post middlewares.
Route names stay `<Class>_collection` and `<Class>_detail`.

Tests: 335 passed, 1 xfailed, 2 xpassed.
The handler only used a user's get/post/put/patch/delete when it was a
coroutine function. A plain `def get(self, request)` was skipped without
any warning and the built-in list() ran instead, although the tutorial,
the endpoints guide and the REST reference all teach the sync form.

Sync overrides now run through run_blocking on a sync engine. On an
async engine they run inside AsyncSession.run_sync, so an override that
calls self.list() or self.create() reaches the async driver.

Only real functions count as overrides: an endpoint with a column named
`post` must keep working, and a test pins that.

tests/test_sync_verb_override.py: three of the four tests fail without
this change.

Tests: 339 passed, 1 xfailed, 2 xpassed.
The framework built backends by comparing class names:
`if backend.__name__ == "JWTAuthentication"` in register() and again in
check_auth(). A subclass with another name fell into the `else` branch
and was built with no settings. The login route was worse: it validated
through a bare JWTAuthentication() with validate_credentials patched at
runtime, so the subclass override the README tells users to write was
never called and login answered 401.

Backends now build themselves: BaseAuthentication.from_config, overridden
by JWTAuthentication and BasicAuthentication. A subclass that defines its
own __init__ is still built without arguments, as before.

Authentication answers the questions that were asked about it from
outside: permission_for(method), requires_login(method), build_backend().
EndpointGuard (was check_auth) uses them and receives the app's
login_validator through its constructor. That removes the
object.__setattr__ on the frozen Authentication dataclass that smuggled
it in.

LoginEndpoint (was login_handler plus a closure) gets the endpoint's real
backend. What a successful login returns is the backend's call:
login_response() gives token and user for JWT, user for Basic, which
removes the has_jwt flag and four jwt_* parameters. validate_credentials
may be sync or async.

The login rate limits (1000/10000/100000) were written twice in
lightapi.py; they are one constant now.

tests/test_login_backend_subclass.py: three of six fail without the fix.
The 1002-line test_login_auth.py passes unchanged.

Tests: 345 passed, 1 xfailed, 2 xpassed.
_list picked the paginator with `if pagination_cfg.style == "cursor"` and
then had to know that the two paginators return different tuples and
take different wrap() arguments. No pagination was a third code path in
the same method.

Each paginator now has render(request, qs, session, serialize) -> dict,
which is its own paginate() followed by its own wrap(). NoPagination is
the third implementation. Pagination.build_paginator() picks the class
from a table, and _list is five lines with no branch on style.

paginate(), paginate_async() and wrap() keep their signatures.
PageNumberPaginator() and CursorPaginator() still work with no
arguments; page_size is an optional constructor argument now, which is
also what docs/api-reference/pagination.md already shows.

Tests: 345 passed, 1 xfailed, 2 xpassed.
The metaclass left five loose attributes on every endpoint class
(_reflect_deferred, _schema_deferred, _all_columns,
_reflect_partial_columns and, until recently,
_test_isolation_table_name) and register() read them back with
getattr(cls, "_flag", False) to decide how to map the table. The mapping
itself went through TableMapper, ImperativeTableBuilder,
ReflectedTableBuilder, TableNameResolver and FieldInfoStripper plus a
module singleton, for two operations. The test-isolation naming rule was
written twice, in register() and in TableNameResolver.

The metaclass now stores one object, cls._table_source: DeclaredTable
(columns from the annotated fields) or ReflectedTable (Meta.reflect).
register() calls cls._table_source.map(cls, session_manager) and knows
nothing else. ReflectedTable also builds the schemas, since they depend
on the reflected columns.

SessionManager answers what was being read from its private attributes:
is_async, table_name_for(base_name) and create_tables_now(). The
isolation naming rule lives only there.

table_mapping.py: 239 lines to 133. register() loses 70 lines.

Tests: 345 passed, 1 xfailed, 2 xpassed, including the 460-line
test_reflection.py and test_async_reflection.py unchanged.
build_from_reflected_table mapped column types to Python types with a
70-line chain: isinstance checks, then __mro__ checks, then a second pass
over lowercase type names, plus two try/except ImportError blocks for
Uuid types that the dependency floor already guarantees.

SQLAlchemy keeps that mapping itself as column.type.python_type. The
function now asks for it and accepts the answer when it is one of the
nine scalar types the chain knew (int, str, float, bool, Decimal, UUID,
datetime, date, time). Anything else, or a type with no python_type,
stays Any with the same warning, so JSON and binary columns validate as
before.

Checked that python_type agrees with the old chain for Integer,
BigInteger, SmallInteger, String, Text, Numeric, Float, Boolean,
DateTime, Date, Time, Uuid and the PostgreSQL UUID.

schema.py: 302 lines to 241. The reflection suites pass unchanged.

Tests: 345 passed, 1 xfailed, 2 xpassed.
RestEndpointMeta._process did seven numbered steps in one function:
collect annotations, build columns, inject the auto columns, build
schemas, parse Meta, scan the MRO for HTTP method mixins, pick the table
source. Reading how one field becomes one column meant scrolling past
the other six.

Each step is a function now: _field_annotations, _declared_columns,
_column_for, _auto_columns, _parsed_meta, _allowed_methods and
_reject_redeclared_auto_fields. _process is the 20 lines that put them
in order, and a reflected endpoint returns early instead of threading
`if reflect` through every step.

Three things that did nothing are gone:

- cls._fields_info was stored on every endpoint and never read.
- The block that removed "fields inherited from RestEndpoint itself"
  could not match anything: RestEndpoint only has underscore
  annotations, which the line above already skips.
- ReflectedTable's partial_columns was always an empty list, because
  columns are only built when Meta.reflect is falsy. Partial reflection
  maps columns that already exist in the database; it never added any.

Tests: 345 passed, 1 xfailed, 2 xpassed.
register() was 270 lines when this work started. After the table sources
and the auth change it still validated the mapping, detected async mode,
checked the engine, mapped tables, built routes and rebuilt the login
routes, all inline.

Now it reads as the four steps it performs. The checks are functions
(_require_rest_endpoint, _has_async_handlers, _require_async_engine) and
one endpoint's routes are built in _register_endpoint. The constructor
delegates to _engine_from_url, _checked_mode and _as_rate_limiter.

run() built its own Starlette app, a copy of build_app() with debug
added. It calls build_app(debug=debug) now; debug is a new keyword-only
argument with the old default.

The login routes were torn out of self._routes and re-inserted on every
register() call. They are composed in _asgi_routes() when the app is
built: login routes, endpoint routes, /healthz, in the same order as
before.

The "queryset is async but engine is sync" warning is removed. It could
not fire: an async queryset switches the app to async mode in the first
pass, before the second pass that checked for it.

lightapi.py: 910 lines on dev, 395 now.

Tests: 345 passed, 1 xfailed, 2 xpassed.
_update was 100 lines that validated input, built a pydantic model,
inspected the mapper, ran three SQL statements and chose between four
HTTP responses. _retrieve, _create and _destroy mixed SQL and HTTP the
same way.

Repository(model, session) now owns storage: get, add, update, delete,
the created_at/updated_at timestamps and the optimistic lock on
`version`. It knows nothing about HTTP and reports problems as
RowNotFound and VersionConflict. RestEndpoint validates, calls it, and
turns those two errors into 404 and 409 through one table, so the status
and wording of each error exist once.

The PATCH schema (every field optional) was rebuilt with
pydantic.create_model on every PATCH request. patch_schema() builds it
once per create schema and caches it.

Public CRUD methods and the _*_async helpers are untouched; they still
call _retrieve, _create, _update and _destroy.

tests/test_repository.py covers the lock directly: a stale version
raises and leaves the row alone, a current one bumps the version. It
fails if the version check or the cache is removed.

rest.py: 819 lines on dev, 505 now.

Tests: 350 passed, 1 xfailed, 2 xpassed.
yaml_loader.py was 705 lines doing three jobs: resolving class names,
describing the YAML document as pydantic models, and turning a validated
document into endpoint classes.

- yaml_names.py: the built-in name registry and dotted-path resolution.
- yaml_schema.py: the pydantic models and the field type map.
- yaml_loader.py: the translation and load_config (358 lines).

The rule "endpoint settings win, defaults fill the gaps" was written out
field by field twice, once in _make_authentication and again inside the
per-method branch of _build_meta_class. AuthConfig.merged_over(defaults)
states it once, on the object that owns the fields.

_build_meta_class was 123 lines; its per-method permission logic is now
_per_method_authentication and _permission_name. The branch that handled
a default permission that was neither str nor dict is gone: the pydantic
model only allows those two.

The one test change is an import: _resolve_name is resolve_name in
yaml_names. The 491-line test_yaml_config.py is otherwise untouched.

Tests: 350 passed, 1 xfailed, 2 xpassed.
SchemaFactory.build and build_from_reflected_table ended with the same
twenty lines: add the four auto-managed read fields, create the two
pydantic models, rebuild them. build also imported Optional, datetime
and ConfigDict a second time inside the function, and set read_fields
identically in both arms of an if/else.

_schema_pair does that tail once. Field order is unchanged (the
endpoint's fields first, then any auto field not already present), and
that order is the key order of every JSON response.

build: 73 lines to 40. Tests unchanged: 350 passed, 1 xfailed, 2 xpassed.
cache.py created its process-wide RedisCacheBackend as a module global.
The constructor reads LIGHTAPI_REDIS_URL, so the URL was fixed to
whatever the environment held before `import lightapi`. Setting it later
in code, or in a test, had no effect.

_default_backend() builds the client on the first cache call and keeps
it. get_cached, set_cached, invalidate_cache_prefix and _ping_redis keep
their names and behaviour; invalidate_cache_prefix is documented for
users and the tests patch the others.

tests/test_cache_backend_lazy.py mocks redis.from_url and checks the late
URL is used and that three cache calls share one client. Both tests fail
with the eager global.

Tests: 352 passed, 1 xfailed, 2 xpassed.
from_dict is a second way to build endpoint classes from data, next to
the YAML loader. It has its own 137-line builder, its own rule for class
names (/api/v1/items becomes ApiV1ItemsEndpoint here and ItemsEndpoint in
YAML) and, until now, no test at all. One page of the docs mentions it;
no example uses it.

It keeps working and now emits a DeprecationWarning that names the two
replacements. The docs page and the changelog say the same.

tests/test_from_dict_deprecation.py checks the warning and that the app
it builds still serves a POST.

Tests: 354 passed, 1 xfailed, 2 xpassed.
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.
The previous commit replaced the 70-line type chain with
column.type.python_type and said the two agreed. They did not. I had
compared python_type with what I expected the chain to return, not with
what it returned.

In SQLAlchemy Float, Double and REAL are subclasses of Numeric, and the
old chain tested Numeric first, so every reflected float column has
always been annotated as Decimal. python_type says float. Clients see
the difference: a Decimal is serialized as a JSON string ("0.5"), a float
as a JSON number (0.5). The old chain also returned UUID for a Uuid
column with as_uuid=False, where python_type says str.

Running the old function from dev next to the new one over 43 column
types (generic, PostgreSQL, MySQL, SQLite) showed 8 differences, all in
those two families. _reflected_annotation now answers Decimal for any
Numeric and UUID for any Uuid before asking python_type, and the same
comparison shows 0 differences.

Whether a reflected float should be a JSON number is a fair question,
but it is a change to the wire format and belongs in the changelog with
a decision behind it, not in a refactor.

tests/test_reflected_annotations.py pins 25 column types. 8 of them fail
without this fix. Found by tools/differential, which compares every
response with the published 0.1.29.

Tests: 370 passed, 1 xfailed, 2 xpassed.
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.
iklo and others added 28 commits October 11, 2026 21:54
…ed 500

A duplicate on a unique=True column raised IntegrityError out of
Repository.add()/update() and the request died with a 500 and a
traceback, leaving the session in the failed state. The Repository rolls
back and raises ConstraintViolation; the endpoint maps it to
409 {"detail": "constraint violation"} on POST, PUT and PATCH, sync and
async.

Planned by e2e scenario 17 (Field column options), which proves it end
to end against a real unique index on SQLite and Postgres.
The wrapper stripped decimal_places before pydantic saw it, and the
column it fed, Numeric(scale=N) with no precision, renders as plain
NUMERIC on SQLite and Postgres. The option did nothing: "1.999" was
stored as sent on a decimal_places=2 column, while five doc tables said
it set the scale. The wrapper now forwards decimal_places to pydantic,
so more places than declared is a 422 at the edge. The column type is
unchanged and the docs say so.

Found by e2e scenario 17 (Field column options) on both databases.
…value is 409

Scenario 17 reads the schema back (sqlite3 or psql) to prove unique,
index, foreign_key and exclude landed, then drives the constraint path:
a duplicate sku on POST and PUT is 409, the row is untouched and writes
keep working. decimal_places is proven at the edge: three places is 422,
two are kept. Ids come from the responses because Postgres consumes a
sequence value on the refused INSERT. 16 steps on SQLite and Postgres.
/ is a 404 on every LightAPI app, so docker reported the image as
unhealthy no matter what. /healthz is the route the app registers for
exactly this. e2e scenario 16 waits for `docker inspect` to say healthy.
…uest

With the path fixed, e2e scenario 16 still read "unhealthy": the probe
exceeded its timeout. Measured inside the image on Docker Desktop
(3 runs each): import urllib.request alone takes 1.8 to 2.9 s, the
request 0.18 to 0.38 s; a socket-only probe takes 0.42 to 0.53 s to
import plus 0.24 to 0.26 s to answer. The 3 s timeout now keeps 3.8x
headroom on this host and far more on a native Linux one.
…althy

Scenario 16 builds the wheel from this checkout (uv build), builds the
repository image from it through tools/e2e/Dockerfile.local (same probe
and timeout, shorter interval), mounts a YAML document, drives CRUD and
validation through the published port, and waits for docker inspect to
report healthy. It also checks the process runs as uid 1001. Tagged
docker: run_all skips it unless E2E_DOCKER=1. 7 steps.
…stgres drivers

The 3.10 run failed every [pg] scenario with ModuleNotFoundError: the
fresh .venv310 built from the lock with --extra dev had neither asyncpg
nor psycopg2, while the 3.13 .venv had both only because they had been
installed by hand outside the lock. The scripts now run with
--extra async (asyncpg from the lock) and run_all refuses to start the
[pg] runs until both drivers import, naming the install command for
psycopg2-binary, which is in no manifest. That first uv run also builds
a fresh environment before any server has to answer within its start
window, which is what made 01_crud_sync time out on the first 3.10 run.
…the JSON 405

The first fix (6ad5247) put every CRUD verb on every route so the
handler could answer the documented JSON 405. The whole-branch review
showed what that cost: two classes registered on one path (a GET-only
reader and a POST-only writer) stopped working because the first route
now matched every method, and a disallowed verb reached the
authentication guard (401 instead of 405 on a protected endpoint), the
middlewares and the login rate limiter (a GET /auth/login used a slot).

The routes list only their own methods again, as at the base, and the
Starlette app registers a 405 exception handler that renders the JSON
body and the sorted Allow header (HEAD left out: Starlette serves it on
every GET route). /auth/login is POST-only again and answers the same
body as the CRUD routes; LoginEndpoint's own 405 branch and
ApiError.LOGIN_METHOD_NOT_ALLOWED are gone with it.
…refusal is logged

The whole-branch review found two gaps in the constraint fix: delete()
was not wrapped, so a DELETE of a row another table references died
with a 500 although README and the REST reference already listed
"Constraint violation (unique, foreign key) 409"; and the IntegrityError
was turned into the 409 with nothing logged, which hides a NOT NULL or
CHECK failure (usually a schema/table mismatch) from the operator.

Repository._refused() now does the rollback, logs the table and the
driver's text at INFO and builds the ConstraintViolation for add(),
update() and delete(); _destroy maps it to the 409.
patch_schema() rebuilt every field as (Optional[annotation], None) and
dropped its FieldInfo, so min_length, ge, decimal_places and every other
constraint applied to POST and PUT but never to PATCH: {"name": ""}
against min_length=3 was a 200. Older than this branch; the whole-branch
review caught it because it made the new decimal_places promise untrue
on PATCH. The optional copy now keeps the FieldInfo and only replaces
the default, so a PATCH answers 422 like a PUT.
…s definition

B6-006 forwarded decimal_places to pydantic so a Decimal with more
places is a 422. The whole-branch review showed the other side: on a
float or int field, where the option used to be silently ignored,
pydantic raises TypeError ("Unable to apply constraint") at validation,
which is not a ValidationError, so every write became a 500. Same
through YAML, because decimal_places is a FIELD_CONSTRAINT_KEY.

DeclaredTable, which already knows whether the annotation is Decimal
when it picks the column type, now refuses the option on any other type
with a ConfigurationError that names the field.
Two messages this branch added printed the raw URL: the async-driver
ConfigurationError (B6-001) formatted the URL as given, and the YAML
load error (B2-012) printed pydantic's own text, whose input_value=
holds the rejected block, so a typo like databse: dumped the database
URL, password included, into whatever log collects the startup output.
Found by the whole-branch review.

The URL is rendered with the password masked, and the YAML error lists
each problem as location and message only, which still names the key.
FieldSpec keeps extra="allow" so constraint keys can pass through, but
field_info() forwards only FIELD_CONSTRAINT_KEYS: min_lenght: 3 loaded
fine and the constraint silently vanished, the exact failure B2-012
fixed for every other block, in the most common place to make a typo.
Found by the whole-branch review.

A model validator on FieldSpec refuses any extra key outside the
constraint keys and `default`, naming it, as the configuration guide
promises.
… repository dist/

Scenario 03 registers a GET-only and a POST-only class on one path and
proves Starlette splits them by method. Scenario 17 enables SQLite's
foreign keys on every connection (Postgres always enforces them) and
drives the two review gaps: a PATCH that breaks decimal_places or
min_length is 422, and the DELETE of a referenced category is 409 with
the row still there. Scenario 16 builds the wheel into its own
directory instead of wiping the repository dist/, which may hold a
release build.
README says the mode is auto-detected when omitted. It was not: an
AsyncEngine, or a database URL with an async driver, left the app in
sync mode, so the tables were never created and every request was a 500
(with a "Table creation skipped" warning). The docs' own async testing
fixture hit it verbatim, and two tests pinned that as a known bug.

An AsyncEngine, or a URL whose dialect is async (sqlite+aiosqlite,
postgresql+asyncpg, also through YAML), now selects async mode. An
explicit mode="sync" with either is a ConfigurationError. The two doc
snippet tests now assert the examples pass.
…sed at register()

The request guard calls BasicAuthentication.validate_credentials without
await. An `async def` there returns a coroutine, which is truthy, so
every Basic credential passed and request.state.user held a coroutine.
The login route awaits the same method, which is why the README's async
example (on a JWT subclass) works there.

register() now refuses a BasicAuthentication backend whose
validate_credentials is a coroutine function, with a ConfigurationError
naming the backend. JWT backends keep theirs.
?pages=99999999999999999999 passed int() and reached the driver, which
raised instead of comparing (OverflowError on SQLite): a 500 for a value
no column can hold, the same class as B1-008. The 64-bit bounds the
repository already used for primary keys move to lightapi.constants as
INT64_MIN/INT64_MAX and the filter coercion applies them, answering the
same 422 as any other value the column cannot parse.
Meta.reflect on an AsyncEngine reflects with asyncio.run() at register(),
before the serving loop exists, and the connection went back to the pool
bound to that loop. asyncpg refuses such a connection on the first
request that draws it, the B7-003 mechanism, this time at reflection.
The engine is disposed after the reflection unless its pool is a
StaticPool (the one in-memory SQLite connection, which holds the
database). Pinned with a file-based aiosqlite engine: the pool is empty
after register() and the app serves.
…the missing field

Review minors 12 and 13: Review Focus 1 was exercised through PATCH only,
so scenario 01 now sends a PUT with "version": "4" too; scenario 11's
"reflected patch without version" step checks that the first error names
`version` instead of only being an array.
… rows for the minor fixes

The README and REST reference error tables document the two 401 bodies
separately (JWT answers `detail`, Basic answers `error`, each with its
challenge header); three tables stop calling decimal_places a
"precision"; the class diagram lists ApiError's current members. The
audit records the four minor review findings fixed after the fix pass
and the final run numbers.
The branch was rebased once, commit by commit, to take two
credential-shaped literals out of every patch (the loopback Postgres
container password in tools/e2e/run_all.sh and the dummy URL of the
secrets test), so every hash the audit rows cite changed. Same subjects,
same order, same trees apart from those two files.
refactor: group loose constants/functions into classes and an enum
refactor: second OO pass (enums, no loose functions, one backend per route)
test: end-to-end curl suite and the 20 library fixes it forced
@iklobato
iklobato merged commit b08f062 into dev Oct 12, 2026
1 check failed
@iklobato iklobato mentioned this pull request Oct 12, 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