Repository navigation
Conversation
added 6 commits
September 18, 2026 10:39
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.
This was referenced Sep 18, 2026
Closed
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.
This was referenced Sep 18, 2026
Owner
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #39, which is stacked on #38. Merge in that order; GitHub retargets each PR as the one under it lands.
What
Places where the code asked "what type is this?" and branched now call a method on the object instead. Six commits, plus one fix:
EndpointHandlerreplacesmake_collection_handlerandmake_detail_handler, two 80-line closures that were the same except for anif/elifonrequest.method. Each HTTP method is a row in a table: which user method overrides it, which CRUD method serves it, whether it reads a body, whether GET caching applies.body_reader.pyandresponse_wrapper.py(one function each, used only here) move in as private helpers.async def get/post/...was used; a plaindef get(self, request)was skipped without a warning, although three doc pages teach that form. A column that happens to be calledpostis still a column.if backend.__name__ == "JWTAuthentication"(twice) becomesBaseAuthentication.from_config.Authenticationgetspermission_for,requires_loginandbuild_backend.EndpointGuardreplacescheck_auth, andLoginEndpointreplaceslogin_handlerplus a closure that patchedvalidate_credentialsat runtime. This also removes theobject.__setattr__on the frozenAuthenticationdataclass.render(), andPagination.build_paginator()picks one from a table.NoPaginationis the third implementation, so_listhas no branch on style.register()read them back withgetattr. It now stores one object,DeclaredTableorReflectedTable, withmap().table_mapping.pygoes from five classes and a singleton to two classes, 239 lines to 133.column.type.python_typeinstead of a 70-line chain of type checks. A seventh commit (67b8d78) keeps everyNumeric, which includesFloat,DoubleandREAL, asDecimal, and everyUuidasUUID, because that is wherepython_typeand the old chain disagree. See the correction under Checks.Behaviour changes
/auth/logincallsvalidate_credentials()of the backend the endpoint configured, sync orasync def. Before, it always used a bareJWTAuthentication(), so the "custom authentication" example in the README returned 401. An app-levellogin_validatorstill wins.jwt_expiration,jwt_algorithmandjwt_extra_claimsnow reach subclasses ofJWTAuthentication. They used to reach only a class named exactlyJWTAuthentication. A subclass that defines its own__init__is still built with no arguments.All three are in
CHANGELOG.md.What does not change
Public names and signatures:
RestEndpointand its CRUD methods,_*_asynchelpers,paginate,paginate_async,wrap,JWTAuthentication(algorithm, expiration, rate_limiter),BasicAuthentication(rate_limiter, login_validator),generate_token,SchemaFactory.build_from_reflected_table, route names,_current_requestand_background.I left
normalise_serializer,resolve_fields,_row_to_dictand_apply_fieldsalone. They have about 30 assertions of their own and.cursorrulesnames them as a project invariant.Checks
All run on my machine. The test workflow does not run for PRs into
dev.pytest: 370 passed, 1 xfailed, 2 xpassed. That is 335 from refactor: one CRUD path for sync and async engines #39, 10 new ones, and 25 that came with the fix.test_sync_verb_override.py, 3 of 6 intest_login_backend_subclass.py. The others pin neighbours that must keep working.test_login_auth.py(1,002 lines) and both reflection suites.lightapi/: 84% before, 85% now (2,123 statements, 319 missed).ruff check,ruff format --check,mypy: clean.python_typeagrees with the old chain for the 13 column types it knew. That was wrong: I had compared it with what I expected the chain to return, not with what it returned. The differential run in test: pin the public API and compare every response with the published 0.1.29 #43 caught it. A reflected float column came back as the JSON number0.5where 0.1.29 returns the string"0.5". Running the real old function next to the new one over 43 column types showed 8 differences: every float type, andUuid(as_uuid=False). Commit 67b8d78 restores the old answers, the same comparison now shows 0 differences, andtests/test_reflected_annotations.pypins 25 column types, 8 of which fail without the fix.Not checked: Postgres. Reflection and auth were only run on SQLite.