fastapi-viewsets 1.5.1__tar.gz → 1.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {fastapi_viewsets-1.5.1/fastapi_viewsets.egg-info → fastapi_viewsets-1.5.2}/PKG-INFO +61 -14
  2. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/README.md +57 -10
  3. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/getting-started.md +4 -3
  4. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/pagination-filtering.md +2 -2
  5. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/__init__.py +11 -5
  6. fastapi_viewsets-1.5.2/fastapi_viewsets/_register.py +245 -0
  7. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/async_base.py +12 -6
  8. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/peewee_adapter.py +12 -11
  9. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/sqlalchemy_adapter.py +46 -30
  10. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/tortoise_adapter.py +13 -11
  11. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2/fastapi_viewsets.egg-info}/PKG-INFO +61 -14
  12. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets.egg-info/SOURCES.txt +1 -0
  13. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets.egg-info/requires.txt +3 -3
  14. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/pyproject.toml +4 -4
  15. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_async_utils.py +10 -10
  16. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_edge_cases.py +11 -13
  17. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_error_handling.py +1 -1
  18. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_exception_handling.py +3 -3
  19. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_integration.py +1 -1
  20. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_utils.py +9 -9
  21. fastapi_viewsets-1.5.2/tests/test_write_path_regressions.py +447 -0
  22. fastapi_viewsets-1.5.1/fastapi_viewsets/_register.py +0 -133
  23. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/LICENSE +0 -0
  24. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/MANIFEST.in +0 -0
  25. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/api-reference.md +0 -0
  26. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/assets/custom.css +0 -0
  27. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/authentication.md +0 -0
  28. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/changelog.md +0 -0
  29. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/comparison.md +0 -0
  30. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/custom-routes.md +0 -0
  31. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/eager-loading.md +0 -0
  32. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/index.md +0 -0
  33. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/orm-adapters.md +0 -0
  34. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/overrides.md +0 -0
  35. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/quickstart-async.md +0 -0
  36. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/quickstart-sync.md +0 -0
  37. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/_compat.py +0 -0
  38. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/async_utils.py +0 -0
  39. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/constants.py +0 -0
  40. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/db_conf.py +0 -0
  41. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/filtering.py +0 -0
  42. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/__init__.py +0 -0
  43. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/base.py +0 -0
  44. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/factory.py +0 -0
  45. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/serializer_utils.py +0 -0
  46. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/utils.py +0 -0
  47. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets.egg-info/dependency_links.txt +0 -0
  48. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets.egg-info/top_level.txt +0 -0
  49. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/mkdocs.yml +0 -0
  50. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/pytest.ini +0 -0
  51. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/setup.cfg +0 -0
  52. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/setup.py +0 -0
  53. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/__init__.py +0 -0
  54. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/conftest.py +0 -0
  55. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_adapter_methods_coverage.py +0 -0
  56. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_async_base_viewset.py +0 -0
  57. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_async_driver_missing.py +0 -0
  58. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_backward_compatibility.py +0 -0
  59. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_base_viewset.py +0 -0
  60. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_coverage_gaps.py +0 -0
  61. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_db_conf.py +0 -0
  62. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_db_conf_extended.py +0 -0
  63. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_missing_coverage.py +0 -0
  64. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_orm_adapters.py +0 -0
  65. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_orm_adapters_extended.py +0 -0
  66. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_quickstarts.py +0 -0
  67. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_search_ordering.py +0 -0
  68. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_select_prefetch_related.py +0 -0
  69. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_tortoise_lifecycle.py +0 -0
  70. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_viewsets_with_adapters.py +0 -0
  71. {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/tortoise_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi_viewsets
3
- Version: 1.5.1
3
+ Version: 1.5.2
4
4
  Summary: DRF-style viewsets for FastAPI with SQLAlchemy/Tortoise/Peewee adapters and Pydantic v2 support.
5
5
  Author: Alexander Valenchits
6
6
  License: MIT
@@ -25,12 +25,10 @@ Requires-Python: >=3.9
25
25
  Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
27
  Requires-Dist: fastapi>=0.110.0
28
- Requires-Dist: uvicorn>=0.17.6
29
- Requires-Dist: SQLAlchemy>=2.0.0
30
28
  Requires-Dist: pydantic<3,>=2.5
31
29
  Requires-Dist: python-dotenv>=0.19.0
32
30
  Provides-Extra: sqlalchemy
33
- Requires-Dist: SQLAlchemy>=2.0.0; extra == "sqlalchemy"
31
+ Requires-Dist: SQLAlchemy[asyncio]>=2.0.0; extra == "sqlalchemy"
34
32
  Provides-Extra: tortoise
35
33
  Requires-Dist: tortoise-orm<1.0,>=0.20.0; extra == "tortoise"
36
34
  Requires-Dist: asyncpg>=0.28.0; extra == "tortoise"
@@ -43,6 +41,8 @@ Requires-Dist: pytest-cov>=4.0.0; extra == "test"
43
41
  Requires-Dist: httpx>=0.24.0; extra == "test"
44
42
  Requires-Dist: faker>=18.0.0; extra == "test"
45
43
  Requires-Dist: aiosqlite>=0.19.0; extra == "test"
44
+ Requires-Dist: uvicorn>=0.17.6; extra == "test"
45
+ Requires-Dist: SQLAlchemy[asyncio]>=2.0.0; extra == "test"
46
46
  Provides-Extra: lint
47
47
  Requires-Dist: ruff>=0.5; extra == "lint"
48
48
  Requires-Dist: black>=24; extra == "lint"
@@ -95,18 +95,21 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
95
95
  pip install fastapi-viewsets
96
96
  ```
97
97
 
98
- SQLAlchemy 2.0 or newer is installed automatically. For a local SQLite app,
99
- start with the [sync quickstart](#quickstart-sqlalchemy-sync); no separate database driver is needed.
100
-
101
- Optional extras (see `pyproject.toml`):
98
+ The base install is ORM-agnostic: it pulls in only FastAPI, Pydantic and
99
+ python-dotenv. Pick your ORM via an extra (see `pyproject.toml`):
102
100
 
103
101
  ```bash
104
- pip install "fastapi-viewsets[sqlalchemy]"
102
+ pip install "fastapi-viewsets[sqlalchemy]" # SQLAlchemy 2.x, incl. asyncio support (greenlet)
105
103
  pip install "fastapi-viewsets[tortoise]"
106
104
  pip install "fastapi-viewsets[peewee]"
107
105
  pip install "fastapi-viewsets[test]" # pytest, httpx, coverage, etc.
108
106
  ```
109
107
 
108
+ For a local SQLite app, start with the
109
+ [sync quickstart](#quickstart-sqlalchemy-sync); no separate database driver is needed.
110
+ To execute the quickstart's `python main.py` (or serve any app) you also
111
+ need an ASGI server, e.g. `pip install uvicorn`.
112
+
110
113
  For async SQLAlchemy you still need a driver such as `aiosqlite`, `asyncpg`, or `aiomysql` alongside your database URL.
111
114
 
112
115
  ## Database connection examples
@@ -498,10 +501,23 @@ Notes:
498
501
  `model_config = ConfigDict(from_attributes=True)` instead of the v1
499
502
  `class Config: orm_mode = True`.
500
503
  - `PATCH` uses `model_dump(exclude_unset=True)` internally, so unset
501
- fields are no longer overwritten with defaults.
504
+ fields are no longer overwritten with defaults. Since v1.5.2 the PATCH
505
+ body is validated against an auto-generated all-optional variant of
506
+ your `response_model`, so partial bodies pass validation even when the
507
+ schema has required fields, and an explicit JSON `null` clears a
508
+ nullable column.
509
+ - `PUT` replaces exactly the fields present in the payload; model
510
+ columns that are not part of the `response_model` are left untouched.
511
+ - `POST` never sends an explicit `NULL` primary key to the database —
512
+ `id: Optional[int] = None` in the schema is safe on every adapter.
513
+ - Integrity violations (e.g. duplicate unique values) return
514
+ `409 Conflict` with a sanitized message; raw SQL and driver internals
515
+ are never exposed in the response body.
502
516
  - If the async driver (`aiosqlite` / `asyncpg` / `aiomysql`) is not
503
517
  installed, sync usage still works — only `get_async_session()` raises
504
- a helpful `RuntimeError`.
518
+ a helpful `RuntimeError`. The `sqlalchemy` extra installs
519
+ `SQLAlchemy[asyncio]`, which already includes `greenlet` for async
520
+ sessions.
505
521
 
506
522
  ## Overriding `list` and `create_element` (custom LIST and POST)
507
523
 
@@ -509,8 +525,11 @@ Every CRUD handler is a regular method, so subclassing the viewset is
509
525
  the canonical way to add filtering, ordering, validation, conflict
510
526
  handling, and so on. The example below subclasses `AsyncBaseViewset`
511
527
  and overrides both `list` (case-insensitive search + simple ordering)
512
- and `create_element` (input normalization + map `IntegrityError` to
513
- 409).
528
+ and `create_element` (input normalization + custom conflict message).
529
+
530
+ > Since v1.5.2 the adapters themselves map `IntegrityError` to
531
+ > `409 Conflict`; override `create_element` only when you need a custom
532
+ > error payload or extra normalization.
514
533
 
515
534
  ```python
516
535
  from typing import List, Optional
@@ -676,7 +695,7 @@ app.include_router(router)
676
695
 
677
696
  ## Pagination, filtering, ordering
678
697
 
679
- **Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route.
698
+ **Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route. Defaults are `limit=10`, `offset=0`; negative values are rejected with `422`, and `limit` is capped at `10000`.
680
699
 
681
700
  ```python
682
701
  from fastapi_viewsets import BaseViewset
@@ -737,6 +756,34 @@ class ItemsWithStats(BaseViewset):
737
756
 
738
757
  ## What is new
739
758
 
759
+ ### v1.5.2
760
+
761
+ Bugfix release for the CRUD write paths (see
762
+ [RELEASE_1.5.2.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.5.2.md)):
763
+
764
+ - **Multi-viewset apps fixed**: `register()` no longer leaks one viewset's
765
+ body schema into other viewsets — every `POST`/`PUT`/`PATCH` endpoint
766
+ now validates against its own `response_model`.
767
+ - **PATCH is truly partial**: the PATCH body is validated against an
768
+ auto-generated all-optional variant of the response schema, and an
769
+ explicit JSON `null` clears a nullable column.
770
+ - **PUT no longer nulls columns** that are absent from the Pydantic
771
+ schema.
772
+ - **Integrity violations return `409 Conflict`** with a sanitized message
773
+ instead of `400` with raw SQL/driver internals.
774
+ - **Create never passes an explicit `NULL` primary key** — fixes Tortoise
775
+ `POST` and PostgreSQL inserts with `id: Optional[int] = None` schemas.
776
+ - **Pagination validated**: negative `limit`/`offset` rejected with `422`.
777
+ - **Filters advertised in OpenAPI**: whitelisted `ListConfig.filters`
778
+ fields and their `__op` variants show up in Swagger UI.
779
+ - **Leaner dependencies**: the base install no longer requires SQLAlchemy
780
+ or uvicorn; the `sqlalchemy` extra installs `SQLAlchemy[asyncio]`
781
+ (includes `greenlet`).
782
+
783
+ Earlier releases: v1.5.0 (server-side `search`, declarative ordering and
784
+ filters — [RELEASE_1.5.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.5.0.md)),
785
+ v1.4.0 ([RELEASE_1.4.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.4.0.md)).
786
+
740
787
  ### v1.3.0
741
788
 
742
789
  - **Declarative eager loading** via `RelatedConfig` inside Pydantic schemas.
@@ -40,18 +40,21 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
40
40
  pip install fastapi-viewsets
41
41
  ```
42
42
 
43
- SQLAlchemy 2.0 or newer is installed automatically. For a local SQLite app,
44
- start with the [sync quickstart](#quickstart-sqlalchemy-sync); no separate database driver is needed.
45
-
46
- Optional extras (see `pyproject.toml`):
43
+ The base install is ORM-agnostic: it pulls in only FastAPI, Pydantic and
44
+ python-dotenv. Pick your ORM via an extra (see `pyproject.toml`):
47
45
 
48
46
  ```bash
49
- pip install "fastapi-viewsets[sqlalchemy]"
47
+ pip install "fastapi-viewsets[sqlalchemy]" # SQLAlchemy 2.x, incl. asyncio support (greenlet)
50
48
  pip install "fastapi-viewsets[tortoise]"
51
49
  pip install "fastapi-viewsets[peewee]"
52
50
  pip install "fastapi-viewsets[test]" # pytest, httpx, coverage, etc.
53
51
  ```
54
52
 
53
+ For a local SQLite app, start with the
54
+ [sync quickstart](#quickstart-sqlalchemy-sync); no separate database driver is needed.
55
+ To execute the quickstart's `python main.py` (or serve any app) you also
56
+ need an ASGI server, e.g. `pip install uvicorn`.
57
+
55
58
  For async SQLAlchemy you still need a driver such as `aiosqlite`, `asyncpg`, or `aiomysql` alongside your database URL.
56
59
 
57
60
  ## Database connection examples
@@ -443,10 +446,23 @@ Notes:
443
446
  `model_config = ConfigDict(from_attributes=True)` instead of the v1
444
447
  `class Config: orm_mode = True`.
445
448
  - `PATCH` uses `model_dump(exclude_unset=True)` internally, so unset
446
- fields are no longer overwritten with defaults.
449
+ fields are no longer overwritten with defaults. Since v1.5.2 the PATCH
450
+ body is validated against an auto-generated all-optional variant of
451
+ your `response_model`, so partial bodies pass validation even when the
452
+ schema has required fields, and an explicit JSON `null` clears a
453
+ nullable column.
454
+ - `PUT` replaces exactly the fields present in the payload; model
455
+ columns that are not part of the `response_model` are left untouched.
456
+ - `POST` never sends an explicit `NULL` primary key to the database —
457
+ `id: Optional[int] = None` in the schema is safe on every adapter.
458
+ - Integrity violations (e.g. duplicate unique values) return
459
+ `409 Conflict` with a sanitized message; raw SQL and driver internals
460
+ are never exposed in the response body.
447
461
  - If the async driver (`aiosqlite` / `asyncpg` / `aiomysql`) is not
448
462
  installed, sync usage still works — only `get_async_session()` raises
449
- a helpful `RuntimeError`.
463
+ a helpful `RuntimeError`. The `sqlalchemy` extra installs
464
+ `SQLAlchemy[asyncio]`, which already includes `greenlet` for async
465
+ sessions.
450
466
 
451
467
  ## Overriding `list` and `create_element` (custom LIST and POST)
452
468
 
@@ -454,8 +470,11 @@ Every CRUD handler is a regular method, so subclassing the viewset is
454
470
  the canonical way to add filtering, ordering, validation, conflict
455
471
  handling, and so on. The example below subclasses `AsyncBaseViewset`
456
472
  and overrides both `list` (case-insensitive search + simple ordering)
457
- and `create_element` (input normalization + map `IntegrityError` to
458
- 409).
473
+ and `create_element` (input normalization + custom conflict message).
474
+
475
+ > Since v1.5.2 the adapters themselves map `IntegrityError` to
476
+ > `409 Conflict`; override `create_element` only when you need a custom
477
+ > error payload or extra normalization.
459
478
 
460
479
  ```python
461
480
  from typing import List, Optional
@@ -621,7 +640,7 @@ app.include_router(router)
621
640
 
622
641
  ## Pagination, filtering, ordering
623
642
 
624
- **Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route.
643
+ **Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route. Defaults are `limit=10`, `offset=0`; negative values are rejected with `422`, and `limit` is capped at `10000`.
625
644
 
626
645
  ```python
627
646
  from fastapi_viewsets import BaseViewset
@@ -682,6 +701,34 @@ class ItemsWithStats(BaseViewset):
682
701
 
683
702
  ## What is new
684
703
 
704
+ ### v1.5.2
705
+
706
+ Bugfix release for the CRUD write paths (see
707
+ [RELEASE_1.5.2.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.5.2.md)):
708
+
709
+ - **Multi-viewset apps fixed**: `register()` no longer leaks one viewset's
710
+ body schema into other viewsets — every `POST`/`PUT`/`PATCH` endpoint
711
+ now validates against its own `response_model`.
712
+ - **PATCH is truly partial**: the PATCH body is validated against an
713
+ auto-generated all-optional variant of the response schema, and an
714
+ explicit JSON `null` clears a nullable column.
715
+ - **PUT no longer nulls columns** that are absent from the Pydantic
716
+ schema.
717
+ - **Integrity violations return `409 Conflict`** with a sanitized message
718
+ instead of `400` with raw SQL/driver internals.
719
+ - **Create never passes an explicit `NULL` primary key** — fixes Tortoise
720
+ `POST` and PostgreSQL inserts with `id: Optional[int] = None` schemas.
721
+ - **Pagination validated**: negative `limit`/`offset` rejected with `422`.
722
+ - **Filters advertised in OpenAPI**: whitelisted `ListConfig.filters`
723
+ fields and their `__op` variants show up in Swagger UI.
724
+ - **Leaner dependencies**: the base install no longer requires SQLAlchemy
725
+ or uvicorn; the `sqlalchemy` extra installs `SQLAlchemy[asyncio]`
726
+ (includes `greenlet`).
727
+
728
+ Earlier releases: v1.5.0 (server-side `search`, declarative ordering and
729
+ filters — [RELEASE_1.5.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.5.0.md)),
730
+ v1.4.0 ([RELEASE_1.4.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.4.0.md)).
731
+
685
732
  ### v1.3.0
686
733
 
687
734
  - **Declarative eager loading** via `RelatedConfig` inside Pydantic schemas.
@@ -7,8 +7,8 @@
7
7
  | Python | >= 3.9 |
8
8
  | FastAPI | >= 0.110 |
9
9
  | Pydantic | >= 2.5, < 3 |
10
- | SQLAlchemy | >= 2.0.0 |
11
10
  | python-dotenv | >= 0.19 |
11
+ | SQLAlchemy | >= 2.0.0 (only for the `sqlalchemy` extra / default `ORM_TYPE`) |
12
12
 
13
13
  ## Install from PyPI
14
14
 
@@ -18,8 +18,9 @@ pip install fastapi-viewsets
18
18
 
19
19
  ## Optional extras
20
20
 
21
- SQLAlchemy is a core dependency (installed automatically). The extras below
22
- add ORM-specific packages when you need Tortoise or Peewee:
21
+ The base install is ORM-agnostic (FastAPI + Pydantic + python-dotenv only).
22
+ Add the extra for your ORM — and an ASGI server such as `uvicorn` to run
23
+ your app:
23
24
 
24
25
  === "SQLAlchemy"
25
26
 
@@ -10,8 +10,8 @@ GET /items?limit=10&offset=20
10
10
 
11
11
  | Parameter | Type | Default | Description |
12
12
  | --- | --- | --- | --- |
13
- | `limit` | `Optional[int]` | `10` | Maximum number of items to return |
14
- | `offset` | `Optional[int]` | `0` | Number of items to skip |
13
+ | `limit` | `int` | `10` | Maximum number of items to return (must be `0..10000`, negative values are rejected with `422`) |
14
+ | `offset` | `int` | `0` | Number of items to skip (negative values are rejected with `422`) |
15
15
 
16
16
  No additional configuration needed — pagination is built into the default `list()` handler.
17
17
 
@@ -9,9 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union
11
11
 
12
- from fastapi import APIRouter, Body, Depends, Request
12
+ from fastapi import APIRouter, Body, Depends, Query, Request
13
13
  from pydantic import BaseModel
14
- from sqlalchemy.orm import Session
15
14
 
16
15
  from fastapi_viewsets._compat import model_to_dict
17
16
  from fastapi_viewsets._register import _RegisterMixin
@@ -82,7 +81,7 @@ class BaseViewset(_RegisterMixin, APIRouter):
82
81
  self.endpoint: Optional[str] = endpoint
83
82
  self.response_model: Optional[Type[ResponseModelType]] = response_model
84
83
  self.model: Optional[Type[ModelType]] = model
85
- self.db_session: Optional[Callable[[], Session]] = db_session
84
+ self.db_session: Optional[Callable[[], Any]] = db_session
86
85
  self.orm_adapter: Optional[BaseORMAdapter] = orm_adapter or get_orm_adapter()
87
86
 
88
87
  # ------------------------------------------------------------------
@@ -91,8 +90,8 @@ class BaseViewset(_RegisterMixin, APIRouter):
91
90
 
92
91
  def list(
93
92
  self,
94
- limit: Optional[int] = 10,
95
- offset: Optional[int] = 0,
93
+ limit: int = Query(10, ge=0, le=10000),
94
+ offset: int = Query(0, ge=0),
96
95
  search: Optional[str] = None,
97
96
  ordering: Optional[str] = None,
98
97
  request: Request = None,
@@ -117,6 +116,13 @@ class BaseViewset(_RegisterMixin, APIRouter):
117
116
  parse_ordering_param,
118
117
  )
119
118
 
119
+ # FastAPI resolves ``Query`` defaults for HTTP calls; direct
120
+ # programmatic calls receive the unresolved ``Query`` object.
121
+ if not isinstance(limit, int):
122
+ limit = 10
123
+ if not isinstance(offset, int):
124
+ offset = 0
125
+
120
126
  config = get_list_config(self.response_model)
121
127
  return get_list_queryset(
122
128
  self.model,
@@ -0,0 +1,245 @@
1
+ """Shared route registration logic for sync and async viewsets.
2
+
3
+ Both ``BaseViewset`` and ``AsyncBaseViewset`` need to wire CRUD methods
4
+ into FastAPI ``APIRouter`` routes in exactly the same way. The logic is
5
+ collected here as a mixin to avoid two divergent copies.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import types
12
+ from collections.abc import Iterable
13
+ from typing import Any, Dict, List, Optional, Type
14
+
15
+ from fastapi import Depends
16
+ from fastapi.security import OAuth2PasswordBearer
17
+ from pydantic import BaseModel, create_model
18
+
19
+ from fastapi_viewsets.constants import MAP_METHODS
20
+
21
+ # Routing order matters: routes without path parameters must be added
22
+ # before routes that use them, otherwise FastAPI may match LIST against
23
+ # the ``/{id}`` route.
24
+ _METHOD_ORDER = ("LIST", "POST", "GET", "PUT", "PATCH", "DELETE")
25
+
26
+ # Cache of generated partial (all-optional) PATCH body models, keyed by
27
+ # the source schema, so repeated ``register()`` calls reuse one model.
28
+ _PARTIAL_MODEL_CACHE: Dict[type, type] = {}
29
+
30
+
31
+ def _build_partial_model(model: Type[BaseModel]) -> Type[BaseModel]:
32
+ """Build an all-optional variant of a Pydantic schema for PATCH bodies.
33
+
34
+ PATCH semantics require that clients may send any subset of fields,
35
+ so required fields in the response schema must become optional in
36
+ the request body. Unset fields are excluded later via
37
+ ``model_dump(exclude_unset=True)``; explicitly sent ``null`` values
38
+ are preserved so nullable columns can be cleared.
39
+ """
40
+ cached = _PARTIAL_MODEL_CACHE.get(model)
41
+ if cached is not None:
42
+ return cached
43
+
44
+ if not hasattr(model, "model_fields"): # pragma: no cover - pydantic v1 fallback
45
+ return model
46
+
47
+ fields: Dict[str, Any] = {}
48
+ for name, field_info in model.model_fields.items():
49
+ fields[name] = (Optional[field_info.annotation], None)
50
+
51
+ partial = create_model(f"{model.__name__}Patch", **fields)
52
+ _PARTIAL_MODEL_CACHE[model] = partial
53
+ return partial
54
+
55
+
56
+ def _clone_bound_method(bound: Any, annotation_overrides: Dict[str, Any]) -> Any:
57
+ """Return a per-instance copy of a bound method with patched annotations.
58
+
59
+ ``register()`` is called once per viewset instance, but CRUD handlers
60
+ are functions defined once on the class. Mutating
61
+ ``handler.__annotations__`` directly would leak one viewset's body
62
+ schema into every other viewset in the process, so the function is
63
+ cloned first and the clone's annotations are patched instead.
64
+ """
65
+ func = getattr(bound, "__func__", None)
66
+ if func is None:
67
+ # Not a plain bound method (already a partial/closure): patch a
68
+ # fresh annotation dict on the object itself, best-effort.
69
+ try:
70
+ merged = {**getattr(bound, "__annotations__", {}), **annotation_overrides}
71
+ bound.__annotations__ = merged
72
+ except (AttributeError, TypeError):
73
+ pass
74
+ return bound
75
+
76
+ new_func = types.FunctionType(
77
+ func.__code__,
78
+ func.__globals__,
79
+ func.__name__,
80
+ func.__defaults__,
81
+ func.__closure__,
82
+ )
83
+ new_func.__dict__.update(func.__dict__)
84
+ new_func.__kwdefaults__ = func.__kwdefaults__
85
+ new_func.__annotations__ = {**func.__annotations__, **annotation_overrides}
86
+ new_func.__doc__ = func.__doc__
87
+ new_func.__module__ = func.__module__
88
+ new_func.__qualname__ = func.__qualname__
89
+ return types.MethodType(new_func, bound.__self__)
90
+
91
+
92
+ class _RegisterMixin:
93
+ """Mixin providing :meth:`register` for CRUD endpoint wiring.
94
+
95
+ Subclasses are expected to expose ``self.endpoint``, ``self.tags``,
96
+ ``self.response_model`` and the CRUD handlers
97
+ (``list``, ``get_element``, ``create_element``, ``update_element``,
98
+ ``delete_element``) used by :data:`MAP_METHODS`.
99
+ """
100
+
101
+ ALLOWED_METHODS: List[str]
102
+
103
+ def register(
104
+ self,
105
+ methods: Optional[List[str]] = None,
106
+ oauth_protect: Optional[OAuth2PasswordBearer] = None,
107
+ protected_methods: Optional[List[str]] = None,
108
+ ) -> None:
109
+ """Register CRUD endpoints on this router.
110
+
111
+ Args:
112
+ methods: Logical methods to register. Defaults to all
113
+ allowed methods. Allowed values:
114
+ ``LIST``, ``GET``, ``POST``, ``PUT``, ``PATCH``,
115
+ ``DELETE``.
116
+ oauth_protect: ``OAuth2PasswordBearer`` instance used as a
117
+ FastAPI dependency on protected operations.
118
+ protected_methods: Subset of ``methods`` that should require
119
+ the bearer token. Ignored if ``oauth_protect`` is None.
120
+ """
121
+ protected_methods = list(protected_methods or [])
122
+
123
+ if methods is None:
124
+ methods = list(self.ALLOWED_METHODS)
125
+ if not isinstance(methods, Iterable):
126
+ raise ValueError(
127
+ 'methods must be List of methods (e.g. ["GET"])'
128
+ )
129
+
130
+ # Sort so LIST is registered before GET/{id}.
131
+ sorted_methods = sorted(
132
+ methods,
133
+ key=lambda m: _METHOD_ORDER.index(m) if m in _METHOD_ORDER else len(_METHOD_ORDER),
134
+ )
135
+
136
+ for method in sorted_methods:
137
+ spec = MAP_METHODS.get(method)
138
+ if not spec:
139
+ raise ValueError(
140
+ f"Unknown method: {method}. "
141
+ f"Allowed methods: {list(MAP_METHODS.keys())}"
142
+ )
143
+
144
+ handler = getattr(self, spec["method"])
145
+
146
+ # Hint FastAPI/Pydantic about the body schema if the handler
147
+ # carries an ``item`` parameter. The handler is cloned first
148
+ # so patching never mutates the shared class-level function
149
+ # (which would leak this viewset's schema into other
150
+ # viewsets). PATCH gets an all-optional partial model so
151
+ # clients may send any subset of fields.
152
+ if self.response_model is not None and "item" in getattr(
153
+ handler, "__annotations__", {}
154
+ ):
155
+ body_model = self.response_model
156
+ if spec["http_method"] == "PATCH":
157
+ body_model = _build_partial_model(self.response_model)
158
+ handler = _clone_bound_method(handler, {"item": body_model})
159
+
160
+ original_doc = handler.__doc__
161
+
162
+ # PUT vs PATCH share an underlying handler but differ in the
163
+ # ``partial`` flag.
164
+ if spec["method"] == "update_element":
165
+ handler = functools.partial(
166
+ handler, partial=method == "PATCH"
167
+ )
168
+ handler.__doc__ = original_doc
169
+
170
+ if method in protected_methods and oauth_protect is not None:
171
+ handler = functools.partial(handler, token=Depends(oauth_protect))
172
+ handler.__doc__ = original_doc
173
+
174
+ endpoint = (self.endpoint or "") + spec.get("path", "")
175
+ response_model = self._build_response_model(spec)
176
+ route_name = self._build_route_name(method)
177
+ openapi_extra = self._build_openapi_extra(spec)
178
+
179
+ self.add_api_route(
180
+ endpoint,
181
+ handler,
182
+ response_model=response_model,
183
+ tags=self.tags,
184
+ methods=[spec["http_method"]],
185
+ name=route_name,
186
+ openapi_extra=openapi_extra,
187
+ )
188
+
189
+ # --- helpers ---------------------------------------------------
190
+
191
+ def _build_openapi_extra(self, spec) -> Optional[Dict[str, Any]]:
192
+ """Document dynamic LIST filter parameters in OpenAPI.
193
+
194
+ Filters are parsed from ``request.query_params`` at runtime, so
195
+ FastAPI cannot see them in the handler signature. Advertise the
196
+ whitelisted ``ListConfig.filters`` fields (and their ``__op``
197
+ variants) explicitly via ``openapi_extra``.
198
+ """
199
+ if not spec.get("is_list") or self.response_model is None:
200
+ return None
201
+
202
+ from fastapi_viewsets.filtering import FILTER_OPS, get_list_config
203
+
204
+ config = get_list_config(self.response_model)
205
+ if not config.filters:
206
+ return None
207
+
208
+ parameters: List[Dict[str, Any]] = []
209
+ for field_name in config.filters:
210
+ parameters.append(
211
+ {
212
+ "name": field_name,
213
+ "in": "query",
214
+ "required": False,
215
+ "schema": {"type": "string"},
216
+ "description": f"Exact-match filter on `{field_name}`.",
217
+ }
218
+ )
219
+ for op in FILTER_OPS:
220
+ parameters.append(
221
+ {
222
+ "name": f"{field_name}__{op}",
223
+ "in": "query",
224
+ "required": False,
225
+ "schema": {"type": "string"},
226
+ "description": f"Comparison filter `{op}` on `{field_name}`.",
227
+ }
228
+ )
229
+ return {"parameters": parameters}
230
+
231
+ def _build_response_model(self, spec):
232
+ """Compute the FastAPI ``response_model`` for a given method spec."""
233
+ if spec.get("http_method") == "DELETE":
234
+ return None
235
+ if self.response_model is None:
236
+ return None
237
+ if spec.get("is_list"):
238
+ return List[self.response_model]
239
+ return self.response_model
240
+
241
+ def _build_route_name(self, method: str) -> str:
242
+ """Build a stable route name for OpenAPI/url_path_for."""
243
+ endpoint = self.endpoint or ""
244
+ slug = endpoint.replace("/", "_").strip("_") or "root"
245
+ return f"{method.lower()}_{slug}"
@@ -4,9 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union
6
6
 
7
- from fastapi import APIRouter, Body, Depends, Request
7
+ from fastapi import APIRouter, Body, Depends, Query, Request
8
8
  from pydantic import BaseModel
9
- from sqlalchemy.ext.asyncio import AsyncSession
10
9
 
11
10
  from fastapi_viewsets._compat import model_to_dict
12
11
  from fastapi_viewsets._register import _RegisterMixin
@@ -51,7 +50,7 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
51
50
  allowed_methods: Optional[List[str]] = None,
52
51
  endpoint: Optional[str] = None,
53
52
  model: Optional[Type[ModelType]] = None,
54
- db_session: Optional[Callable[[], AsyncSession]] = None,
53
+ db_session: Optional[Callable[[], Any]] = None,
55
54
  response_model: Optional[Type[ResponseModelType]] = None,
56
55
  orm_adapter: Optional[BaseORMAdapter] = None,
57
56
  **kwargs,
@@ -73,7 +72,7 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
73
72
  self.endpoint: Optional[str] = endpoint
74
73
  self.response_model: Optional[Type[ResponseModelType]] = response_model
75
74
  self.model: Optional[Type[ModelType]] = model
76
- self.db_session: Optional[Callable[[], AsyncSession]] = db_session
75
+ self.db_session: Optional[Callable[[], Any]] = db_session
77
76
  self.orm_adapter: Optional[BaseORMAdapter] = orm_adapter or get_orm_adapter()
78
77
 
79
78
  # ------------------------------------------------------------------
@@ -82,8 +81,8 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
82
81
 
83
82
  async def list(
84
83
  self,
85
- limit: Optional[int] = 10,
86
- offset: Optional[int] = 0,
84
+ limit: int = Query(10, ge=0, le=10000),
85
+ offset: int = Query(0, ge=0),
87
86
  search: Optional[str] = None,
88
87
  ordering: Optional[str] = None,
89
88
  request: Request = None,
@@ -108,6 +107,13 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
108
107
  parse_ordering_param,
109
108
  )
110
109
 
110
+ # FastAPI resolves ``Query`` defaults for HTTP calls; direct
111
+ # programmatic calls receive the unresolved ``Query`` object.
112
+ if not isinstance(limit, int):
113
+ limit = 10
114
+ if not isinstance(offset, int):
115
+ offset = 0
116
+
111
117
  config = get_list_config(self.response_model)
112
118
  return await get_list_queryset(
113
119
  self.model,