Repository navigation
Conversation
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.
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
Refactor: dead code, single CRUD path, polymorphism, single-responsibility, no global state
pyproject.toml 0.1.29 to 0.2.0 (after merging master's two workflow bumps), uv.lock follows, the chart's appVersion goes to 0.2.0, and the CHANGELOG's Unreleased block becomes [0.2.0] - 2026-10-12. A minor version because behaviour changed since 0.1.29: JSON 405, 409 on constraint violations, strict YAML keys, PATCH validates field constraints, decimal_places enforced. LightApi.from_dict stays: its deprecation text promised removal in 0.2.0 and now says 0.3.0, in the docstring, the warning, the API reference and the test that pins the warning.
chore: release 0.2.0
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 37435064 | Triggered | Generic Password | 8642855 | tools/differential/scenarios_sync.py | View secret |
| 37435065 | Triggered | Base64 Basic Authentication | 8642855 | tools/differential/scenarios_sync.py | View secret |
| 38113857 | Triggered | Bearer Token | b5b2299 | docs/superpowers/plans/2026-10-11-e2e-curl-suite.md | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secrets safely. Learn here the best practices.
- Revoke and rotate these secrets.
- 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
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 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.
…collects on fresh installs The CI workflow installs the package with pip, unpinned, and resolved anyio 4.15.1 with starlette 0.52.1. starlette's TestClient still names anyio.abc.BlockingPortal, which anyio now deprecates at import, and with filterwarnings = ["error"] that one warning broke the collection of 28 test modules. The lock pins anyio 4.9.0, so nothing showed locally. The warning is third-party and narrow; it is ignored by its exact text. Reproduced and fixed with `uv run --with anyio==4.15.1 --with starlette==0.52.1`: 633 passed with and without the overlay.
test: ignore starlette's deprecated anyio alias warning on fresh installs
…n a live Redis The release PR's CI run installs the package with pip, unpinned, on a runner with no Redis, and 48 tests failed plus 10 fixtures errored. Both causes sat in the tests and examples: PyJWT 2.15 warns at every encode and decode when the HMAC key is shorter than 32 bytes (RFC 7518, 3.2), and the suite runs with warnings as errors. Every test, example and doc snippet used a short secret; all of them now use values of 32 bytes or more, and the authentication guide says why. The Meta.cache tests built their app against whatever Redis answered on localhost:6379. A local one always did, so the startup ping passed; on the runner the warning fired inside the fixture. The fixtures now patch the backend's ping while building the app, as the tests already patch its get, set and delete. Verified under the runner's conditions (LIGHTAPI_REDIS_URL at a closed port, pip's versions layered over the lock): 633 passed. Locked: 633.
test: 32-byte JWT secrets; cache fixtures stop depending on a live Redis
…emy 2.1 The reflected-annotation rule said "Numeric and its subclasses map to Decimal", and SQLAlchemy 2.1 took Float (and with it Double and REAL) out of the Numeric hierarchy, so a reflected float column validated as a JSON number on 2.1 and as a string on 2.0. The release PR's CI run caught it: pip gave Python 3.11 and 3.12 SQLAlchemy 2.1.4 and the five pinned cases of tests/test_reflected_annotations.py failed. Float is now named next to Numeric. Verified under 2.1.4 and the locked 2.0.40: 633 passed each.
fix: reflected Float, Double and REAL columns stay Decimal on SQLAlchemy 2.1
This was referenced Oct 12, 2026
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.
Release 0.2.0: everything merged into
devsince 0.1.29, in one PR.What ships
docs/audit.mdas the record, zero pytest warnings with warnings as errors.tools/e2e/run_all.sh, 17 scenarios, 400 steps on SQLite and Postgres, and the 20 library bugs it found and fixed.pyproject.toml, the chart and the CHANGELOG.Behaviour a 0.1.x user will notice (all under
[0.2.0]in the CHANGELOG with the old behaviour): JSON 405 withAllow,409 {"detail": "constraint violation"}instead of a 500, a YAML key the format does not know refuses to start, PATCH validates field constraints,Field(decimal_places=N)is enforced, async mode detected from the engine or the URL, the container HEALTHCHECK probes/healthz.Verified
On
devafter the merges and on this branch: 633 unit tests on 3.13, ruff, mypy,mkdocs build --strict; e2e suite21 ok, 0 failed, 1 skippedon 3.13 and 3.10; docker scenario 7/7;uv buildmakeslightapi-0.2.0-py3-none-any.whl.Before merging, read this
Merging to
masterrunspython-publish.yml, which has an "Update version" step that adds one to the last number ofpyproject.tomland commits it before building. With 0.2.0 on this branch the package published to PyPI will be 0.2.1, and the commit onmasterwill say "Bump version to 0.2.1". Two ways out, pick one before merging:.github/workflows/python-publish.ymlfirst, so the workflow publishes the version the branch declares. Then every release is a deliberate bump in a PR, like this one.docker-publish.ymlreads the version frompyproject.tomlat the commit it builds, so the image tag follows whatevermasterends up with.