cadwyn 4.3.1__tar.gz → 4.4.1__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.

Potentially problematic release.


This version of cadwyn might be problematic. Click here for more details.

Files changed (133) hide show
  1. {cadwyn-4.3.1 → cadwyn-4.4.1}/.github/actions/setup-python-uv/action.yaml +4 -4
  2. {cadwyn-4.3.1 → cadwyn-4.4.1}/.github/workflows/ci.yaml +37 -10
  3. cadwyn-4.4.1/.github/workflows/daily_tests.yaml +42 -0
  4. {cadwyn-4.3.1 → cadwyn-4.4.1}/.github/workflows/validate_links.yaml +9 -4
  5. {cadwyn-4.3.1 → cadwyn-4.4.1}/CHANGELOG.md +7 -0
  6. {cadwyn-4.3.1 → cadwyn-4.4.1}/Makefile +1 -1
  7. {cadwyn-4.3.1 → cadwyn-4.4.1}/PKG-INFO +2 -2
  8. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/applications.py +30 -6
  9. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/route_generation.py +36 -12
  10. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/schema_generation.py +1 -1
  11. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/structure/versions.py +2 -0
  12. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/quickstart/setup.md +1 -1
  13. cadwyn-4.4.1/docs_src/quickstart/setup/block001.sh +1 -0
  14. {cadwyn-4.3.1 → cadwyn-4.4.1}/pyproject.toml +2 -1
  15. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/versioned_app/app.py +3 -5
  16. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/versioned_app/webhooks.py +1 -1
  17. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/conftest.py +2 -1
  18. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_applications.py +67 -7
  19. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_render.py +10 -5
  20. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_router_generation.py +17 -17
  21. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_schema_generation/test_schema_validator.py +11 -5
  22. {cadwyn-4.3.1 → cadwyn-4.4.1}/uv.lock +259 -211
  23. {cadwyn-4.3.1 → cadwyn-4.4.1}/.github/CODE_OF_CONDUCT.md +0 -0
  24. {cadwyn-4.3.1 → cadwyn-4.4.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  25. {cadwyn-4.3.1 → cadwyn-4.4.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  26. {cadwyn-4.3.1 → cadwyn-4.4.1}/.github/workflows/publish_docs.yaml +0 -0
  27. {cadwyn-4.3.1 → cadwyn-4.4.1}/.github/workflows/release.yaml +0 -0
  28. {cadwyn-4.3.1 → cadwyn-4.4.1}/.gitignore +0 -0
  29. {cadwyn-4.3.1 → cadwyn-4.4.1}/.pre-commit-config.yaml +0 -0
  30. {cadwyn-4.3.1 → cadwyn-4.4.1}/LICENSE +0 -0
  31. {cadwyn-4.3.1 → cadwyn-4.4.1}/README.md +0 -0
  32. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/__init__.py +0 -0
  33. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/__main__.py +0 -0
  34. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/_asts.py +0 -0
  35. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/_importer.py +0 -0
  36. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/_render.py +0 -0
  37. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/_utils.py +0 -0
  38. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/changelogs.py +0 -0
  39. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/exceptions.py +0 -0
  40. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/middleware.py +0 -0
  41. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/py.typed +0 -0
  42. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/routing.py +0 -0
  43. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/static/__init__.py +0 -0
  44. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/static/docs.html +0 -0
  45. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/structure/__init__.py +0 -0
  46. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/structure/common.py +0 -0
  47. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/structure/data.py +0 -0
  48. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/structure/endpoints.py +0 -0
  49. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/structure/enums.py +0 -0
  50. {cadwyn-4.3.1 → cadwyn-4.4.1}/cadwyn/structure/schemas.py +0 -0
  51. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/CNAME +0 -0
  52. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/__init__.py +0 -0
  53. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/api_version_header_and_context_variables.md +0 -0
  54. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/beware_of_data_versioning.md +0 -0
  55. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/changelogs.md +0 -0
  56. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/cli.md +0 -0
  57. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/endpoint_migrations.md +0 -0
  58. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/enum_migrations.md +0 -0
  59. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/index.md +0 -0
  60. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/main_app.md +0 -0
  61. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/methodology.md +0 -0
  62. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/schema_generation.md +0 -0
  63. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/schema_migrations.md +0 -0
  64. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/testing.md +0 -0
  65. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/concepts/version_changes.md +0 -0
  66. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/home/CONTRIBUTING.md +0 -0
  67. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/how_to/change_business_logic/index.md +0 -0
  68. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/how_to/change_endpoints/index.md +0 -0
  69. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/how_to/change_openapi_schemas/add_field.md +0 -0
  70. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/how_to/change_openapi_schemas/change_field_type.md +0 -0
  71. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/how_to/change_openapi_schemas/changing_constraints.md +0 -0
  72. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/how_to/change_openapi_schemas/remove_field.md +0 -0
  73. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/how_to/change_openapi_schemas/rename_a_field_in_schema.md +0 -0
  74. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/how_to/index.md +0 -0
  75. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/img/dashboard_with_one_version.png +0 -0
  76. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/img/dashboard_with_two_versions.png +0 -0
  77. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/img/get_users_endpoint_from_prior_version.png +0 -0
  78. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/img/simplified_migration_model.png +0 -0
  79. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/img/sponsor_logos/monite.png +0 -0
  80. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/img/unversioned_dashboard.png +0 -0
  81. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/index.md +0 -0
  82. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/plugin.py +0 -0
  83. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/quickstart/tutorial.md +0 -0
  84. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/theory/how_to_build_versioning_framework.md +0 -0
  85. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/theory/how_we_got_here.md +0 -0
  86. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs/theory/literature.md +0 -0
  87. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/__init__.py +0 -0
  88. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/__init__.py +0 -0
  89. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/setup/__init__.py +0 -0
  90. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/setup/block002.py +0 -0
  91. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/setup/tests/__init__.py +0 -0
  92. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/setup/tests/test_block002.py +0 -0
  93. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/tutorial/__init__.py +0 -0
  94. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/tutorial/block001.py +0 -0
  95. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/tutorial/block002.py +0 -0
  96. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/tutorial/block003.py +0 -0
  97. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/tutorial/tests/__init__.py +0 -0
  98. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/tutorial/tests/test_block001.py +0 -0
  99. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/tutorial/tests/test_block002.py +0 -0
  100. {cadwyn-4.3.1 → cadwyn-4.4.1}/docs_src/quickstart/tutorial/tests/test_block003.py +0 -0
  101. {cadwyn-4.3.1 → cadwyn-4.4.1}/mkdocs.yml +0 -0
  102. {cadwyn-4.3.1 → cadwyn-4.4.1}/ruff.toml +0 -0
  103. {cadwyn-4.3.1 → cadwyn-4.4.1}/scripts/fix_links.py +0 -0
  104. {cadwyn-4.3.1 → cadwyn-4.4.1}/scripts/split_md.py +0 -0
  105. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/__init__.py +0 -0
  106. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_data/__init__.py +0 -0
  107. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_data/unversioned_schema_dir/__init__.py +0 -0
  108. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_data/unversioned_schema_dir/unversioned_schemas.py +0 -0
  109. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_data/unversioned_schemas.py +0 -0
  110. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/__init__.py +0 -0
  111. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/app_for_testing_routing.py +0 -0
  112. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/render/__init__.py +0 -0
  113. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/render/classes.py +0 -0
  114. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/render/complex/__init__.py +0 -0
  115. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/render/complex/classes.py +0 -0
  116. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/render/complex/versions.py +0 -0
  117. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/render/versions.py +0 -0
  118. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/utils.py +0 -0
  119. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/versioned_app/__init__.py +0 -0
  120. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/versioned_app/v2021_01_01.py +0 -0
  121. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/_resources/versioned_app/v2022_01_02.py +0 -0
  122. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_changelog.py +0 -0
  123. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_cli.py +0 -0
  124. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_data_migrations.py +0 -0
  125. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_routing.py +0 -0
  126. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_schema_generation/__init__.py +0 -0
  127. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_schema_generation/test_enum.py +0 -0
  128. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_schema_generation/test_schema.py +0 -0
  129. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_schema_generation/test_schema_field.py +0 -0
  130. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/test_structure.py +0 -0
  131. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/tutorial/__init__.py +0 -0
  132. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/tutorial/main.py +0 -0
  133. {cadwyn-4.3.1 → cadwyn-4.4.1}/tests/tutorial/test_example.py +0 -0
@@ -13,15 +13,15 @@ inputs:
13
13
  runs:
14
14
  using: "composite"
15
15
  steps:
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ inputs.python-version }}
16
19
  - uses: astral-sh/setup-uv@v3
17
20
  with:
18
21
  version: ${{ inputs.uv-version }}
19
22
  enable-cache: true
20
23
  cache-dependency-glob: "uv.lock"
21
- - uses: actions/setup-python@v5
22
- with:
23
- python-version: ${{ inputs.python-version }}
24
24
  - run: |
25
- uv sync --inexact --frozen --all-extras --dev
25
+ uv sync --frozen --all-extras --dev
26
26
  echo "$(pwd)/.venv/bin" >> $GITHUB_PATH
27
27
  shell: bash
@@ -27,6 +27,8 @@ jobs:
27
27
  python-version: "3.11"
28
28
  - os: ubuntu-latest
29
29
  python-version: "3.12"
30
+ - os: ubuntu-latest
31
+ python-version: "3.13"
30
32
  - os: windows-latest
31
33
  python-version: "3.10"
32
34
  - os: macos-latest
@@ -38,32 +40,57 @@ jobs:
38
40
  - uses: ./.github/actions/setup-python-uv
39
41
  with:
40
42
  python-version: ${{ matrix.python-version }}
41
- - run: uv run coverage run --source=. -m pytest .
43
+ - run: uv run coverage run --source=. --parallel-mode -m pytest tests
42
44
  - name: Upload coverage results
43
45
  uses: actions/upload-artifact@v3
44
46
  if: matrix.os == 'ubuntu-latest' # Cross-platform coverage combination doesn't work
45
47
  with:
46
- name: coverage-results
48
+ name: main-tests-coverage-results
49
+ path: coverage/
50
+ Tutorial-tests:
51
+ runs-on: ubuntu-latest
52
+ name: Tutorial tests
53
+ steps:
54
+ - uses: actions/checkout@v4
55
+ - uses: actions/setup-python@v5
56
+ with:
57
+ python-version: "3.10"
58
+ - name: Install cadwyn with instructions from docs
59
+ run: sh docs_src/quickstart/setup/block001.sh
60
+ - run: pip install pytest coverage dirty-equals
61
+ - run: coverage run --source=. --parallel-mode -m pytest docs_src
62
+ - name: Upload coverage results
63
+ uses: actions/upload-artifact@v3
64
+ with:
65
+ name: docs-tests-coverage-results
47
66
  path: coverage/
48
67
  Coverage:
49
- needs: Tests
68
+ needs: [Tests, Tutorial-tests]
50
69
  runs-on: ubuntu-latest
51
70
  steps:
52
71
  - uses: actions/checkout@v4
53
- - uses: actions/download-artifact@v3
72
+ - name: Download main tests coverage info
73
+ uses: actions/download-artifact@v3
54
74
  with:
55
- name: coverage-results
75
+ name: main-tests-coverage-results
56
76
  path: coverage/
57
- - uses: ./.github/actions/setup-python-uv
58
- - run: ls -all
59
- - run: uv run coverage combine
60
- - run: uv run coverage xml
77
+ - name: Download docs tests coverage info
78
+ uses: actions/download-artifact@v3
79
+ with:
80
+ name: docs-tests-coverage-results
81
+ path: coverage/
82
+ - uses: actions/setup-python@v5
83
+ with:
84
+ python-version: "3.10"
85
+ - run: pip install 'coverage[toml]'
86
+ - run: coverage combine
87
+ - run: coverage xml
61
88
  - name: Upload to Codecov
62
89
  uses: codecov/codecov-action@v4
63
90
  env:
64
91
  fail_ci_if_error: true
65
92
  CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
66
- - run: uv run coverage report --fail-under=100
93
+ - run: coverage report --fail-under=100
67
94
 
68
95
  Lint:
69
96
  runs-on: ubuntu-latest
@@ -0,0 +1,42 @@
1
+ name: Twice Daily Package Update Testing
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "0 0 * * *"
6
+ - cron: "0 12 * * *"
7
+ workflow_dispatch: # Allows manual triggering of the workflow
8
+
9
+ jobs:
10
+ update-dependencies-and-test:
11
+ name: Update dependencies and run tests
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ fail-fast: true
15
+ matrix:
16
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: ./.github/actions/setup-python-uv
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+ - run: uv sync --refresh --all-extras --dev --upgrade
24
+ - run: pytest .
25
+ - uses: jakebailey/pyright-action@v1
26
+ with:
27
+ pylance-version: latest-release
28
+
29
+ notify-on-failure:
30
+ name: Notify on failure
31
+ runs-on: ubuntu-latest
32
+ needs: update-dependencies-and-test
33
+ if: failure()
34
+ steps:
35
+ - name: Send Telegram Notification on Failure
36
+ uses: appleboy/telegram-action@master
37
+ with:
38
+ to: 438153389
39
+ token: ${{ secrets.TELEGRAM_TOKEN }}
40
+ message: |
41
+ New version of something broke Cadwyn!
42
+ ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
@@ -21,10 +21,15 @@ jobs:
21
21
  runs-on: ubuntu-latest
22
22
  steps:
23
23
  - uses: actions/checkout@v4
24
- - uses:
25
- ./.github/actions/setup-python-uv
26
- # TODO: Validate that the command exits with code 0 and has no ERROR in logs (both)
27
- - run: uv run mkdocs build
24
+ - uses: ./.github/actions/setup-python-uv
25
+ - run: |
26
+ OUTPUT=$(uv run mkdocs build 2>&1)
27
+ echo "$OUTPUT"
28
+
29
+ if echo "$OUTPUT" | grep -q "ERROR"; then
30
+ exit 1
31
+ fi
32
+
28
33
  markdown-link-check:
29
34
  runs-on: ubuntu-latest
30
35
  steps:
@@ -5,6 +5,13 @@ Please follow [the Keep a Changelog standard](https://keepachangelog.com/en/1.0.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [4.4.0]
9
+
10
+ ### Added
11
+
12
+ * Support for [webhooks](https://fastapi.tiangolo.com/advanced/openapi-webhooks/) in swagger
13
+ * Automatic generation of versioned routes and webhooks upon the first request to Cadwyn. Notice that if you were using some of cadwyn's internal interfaces, this might break your code. If it did, make an issue and let's discuss your use case
14
+
8
15
  ## [4.3.1]
9
16
 
10
17
  ### Fixed
@@ -2,7 +2,7 @@ SHELL := /bin/bash
2
2
  py_warn = PYTHONDEVMODE=1
3
3
 
4
4
  install:
5
- uv sync --all-extras
5
+ uv sync --all-extras --dev
6
6
 
7
7
  lint:
8
8
  pre-commit run --all-files
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: cadwyn
3
- Version: 4.3.1
3
+ Version: 4.4.1
4
4
  Summary: Production-ready community-driven modern Stripe-like API versioning in FastAPI
5
5
  Project-URL: Source code, https://github.com/zmievsa/cadwyn
6
6
  Project-URL: Documentation, https://docs.cadwyn.dev
7
7
  Author-email: Stanislav Zmiev <zmievsa@gmail.com>
8
8
  License-Expression: MIT
9
9
  License-File: LICENSE
10
- Keywords: api,api-versioning,code-generation,fastapi,hints,json-schema,pydantic,python,python310,python311,python312,stripe,versioning
10
+ Keywords: api,api-versioning,code-generation,fastapi,hints,json-schema,pydantic,python,python310,python311,python312,python313,stripe,versioning
11
11
  Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Environment :: Web Environment
13
13
  Classifier: Framework :: AsyncIO
@@ -25,6 +25,7 @@ from starlette.routing import BaseRoute, Route
25
25
  from starlette.types import Lifespan
26
26
  from typing_extensions import Self
27
27
 
28
+ from cadwyn._utils import same_definition_as_in
28
29
  from cadwyn.changelogs import CadwynChangelogResource, _generate_changelog
29
30
  from cadwyn.middleware import HeaderVersioningMiddleware, _get_api_version_dependency
30
31
  from cadwyn.route_generation import generate_versioned_routers
@@ -96,8 +97,8 @@ class Cadwyn(FastAPI):
96
97
  **extra: Any,
97
98
  ) -> None:
98
99
  self.versions = versions
99
- # TODO: Remove argument entirely in any major version.
100
100
  self._dependency_overrides_provider = FakeDependencyOverridesProvider({})
101
+ self._cadwyn_initialized = False
101
102
 
102
103
  super().__init__(
103
104
  debug=debug,
@@ -156,6 +157,8 @@ class Cadwyn(FastAPI):
156
157
  api_version_header_name=api_version_header_name,
157
158
  api_version_var=self.versions.api_version_var,
158
159
  )
160
+ self._versioned_webhook_routers: dict[date, APIRouter] = {}
161
+ self._latest_version_router = APIRouter(dependency_overrides_provider=self._dependency_overrides_provider)
159
162
 
160
163
  self.changelog_url = changelog_url
161
164
  self.include_changelog_url_in_schema = include_changelog_url_in_schema
@@ -176,6 +179,26 @@ class Cadwyn(FastAPI):
176
179
  default_response_class=default_response_class,
177
180
  )
178
181
 
182
+ @same_definition_as_in(FastAPI.__call__)
183
+ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
184
+ if not self._cadwyn_initialized:
185
+ self._cadwyn_initialize()
186
+ self.__call__ = super().__call__
187
+ await self.__call__(scope, receive, send)
188
+
189
+ def _cadwyn_initialize(self) -> None:
190
+ generated_routers = generate_versioned_routers(
191
+ self._latest_version_router,
192
+ webhooks=self.webhooks,
193
+ versions=self.versions,
194
+ )
195
+ for version, router in generated_routers.endpoints.items():
196
+ self.add_header_versioned_routers(router, header_value=version.isoformat())
197
+
198
+ for version, router in generated_routers.webhooks.items():
199
+ self._versioned_webhook_routers[version] = router
200
+ self._cadwyn_initialized = True
201
+
179
202
  def _add_default_versioned_routers(self) -> None:
180
203
  for version in self.versions:
181
204
  self.router.versioned_routers[version.value] = APIRouter(**self._kwargs_to_router)
@@ -240,12 +263,8 @@ class Cadwyn(FastAPI):
240
263
  )
241
264
 
242
265
  def generate_and_include_versioned_routers(self, *routers: APIRouter) -> None:
243
- root_router = APIRouter(dependency_overrides_provider=self._dependency_overrides_provider)
244
266
  for router in routers:
245
- root_router.include_router(router)
246
- router_versions = generate_versioned_routers(root_router, versions=self.versions)
247
- for version, router in router_versions.items():
248
- self.add_header_versioned_routers(router, header_value=version.isoformat())
267
+ self._latest_version_router.include_router(router)
249
268
 
250
269
  async def openapi_jsons(self, req: Request) -> JSONResponse:
251
270
  raw_version = req.query_params.get("version") or req.headers.get(self.router.api_version_header_name)
@@ -276,6 +295,10 @@ class Cadwyn(FastAPI):
276
295
  if root_path and root_path not in server_urls and self.root_path_in_servers:
277
296
  self.servers.insert(0, {"url": root_path})
278
297
 
298
+ webhook_routes = None
299
+ if version in self._versioned_webhook_routers:
300
+ webhook_routes = self._versioned_webhook_routers[version].routes
301
+
279
302
  return JSONResponse(
280
303
  get_openapi(
281
304
  title=self.title,
@@ -287,6 +310,7 @@ class Cadwyn(FastAPI):
287
310
  contact=self.contact,
288
311
  license_info=self.license_info,
289
312
  routes=routes,
313
+ webhooks=webhook_routes,
290
314
  tags=self.openapi_tags,
291
315
  servers=self.servers,
292
316
  )
@@ -7,7 +7,6 @@ from typing import (
7
7
  TYPE_CHECKING,
8
8
  Any,
9
9
  Generic,
10
- TypeVar,
11
10
  cast,
12
11
  )
13
12
 
@@ -20,7 +19,7 @@ from fastapi.routing import APIRoute
20
19
  from issubclass import issubclass as lenient_issubclass
21
20
  from pydantic import BaseModel
22
21
  from starlette.routing import BaseRoute
23
- from typing_extensions import assert_never
22
+ from typing_extensions import TypeVar, assert_never
24
23
 
25
24
  from cadwyn._utils import Sentinel
26
25
  from cadwyn.exceptions import (
@@ -48,7 +47,8 @@ if TYPE_CHECKING:
48
47
  from fastapi.dependencies.models import Dependant
49
48
 
50
49
  _Call = TypeVar("_Call", bound=Callable[..., Any])
51
- _R = TypeVar("_R", bound=fastapi.routing.APIRouter)
50
+ _R = TypeVar("_R", bound=APIRouter)
51
+ _WR = TypeVar("_WR", bound=APIRouter, default=APIRouter)
52
52
  # This is a hack we do because we can't guarantee how the user will use the router.
53
53
  _DELETED_ROUTE_TAG = "_CADWYN_DELETED_ROUTE"
54
54
 
@@ -59,8 +59,21 @@ class _EndpointInfo:
59
59
  endpoint_methods: frozenset[str]
60
60
 
61
61
 
62
- def generate_versioned_routers(router: _R, versions: VersionBundle) -> dict[VersionDate, _R]:
63
- return _EndpointTransformer(router, versions).transform()
62
+ @dataclass(slots=True, frozen=True)
63
+ class GeneratedRouters(Generic[_R, _WR]):
64
+ endpoints: dict[VersionDate, _R]
65
+ webhooks: dict[VersionDate, _WR]
66
+
67
+
68
+ def generate_versioned_routers(
69
+ router: _R,
70
+ versions: VersionBundle,
71
+ *,
72
+ webhooks: _WR | None = None,
73
+ ) -> GeneratedRouters[_R, _WR]:
74
+ if webhooks is None:
75
+ webhooks = cast(_WR, APIRouter())
76
+ return _EndpointTransformer(router, versions, webhooks).transform()
64
77
 
65
78
 
66
79
  class VersionedAPIRouter(fastapi.routing.APIRouter):
@@ -77,30 +90,36 @@ class VersionedAPIRouter(fastapi.routing.APIRouter):
77
90
  return endpoint
78
91
 
79
92
 
80
- class _EndpointTransformer(Generic[_R]):
81
- def __init__(self, parent_router: _R, versions: VersionBundle) -> None:
93
+ class _EndpointTransformer(Generic[_R, _WR]):
94
+ def __init__(self, parent_router: _R, versions: VersionBundle, webhooks: _WR) -> None:
82
95
  super().__init__()
83
96
  self.parent_router = parent_router
84
97
  self.versions = versions
98
+ self.parent_webhooks_router = webhooks
85
99
  self.schema_generators = generate_versioned_models(versions)
86
100
 
87
101
  self.routes_that_never_existed = [
88
102
  route for route in parent_router.routes if isinstance(route, APIRoute) and _DELETED_ROUTE_TAG in route.tags
89
103
  ]
90
104
 
91
- def transform(self) -> dict[VersionDate, _R]:
105
+ def transform(self) -> GeneratedRouters[_R, _WR]:
92
106
  router = deepcopy(self.parent_router)
107
+ webhook_router = deepcopy(self.parent_webhooks_router)
93
108
  routers: dict[VersionDate, _R] = {}
109
+ webhook_routers: dict[VersionDate, _WR] = {}
94
110
 
95
111
  for version in self.versions:
96
112
  self.schema_generators[str(version.value)].annotation_transformer.migrate_router_to_version(router)
113
+ self.schema_generators[str(version.value)].annotation_transformer.migrate_router_to_version(webhook_router)
97
114
 
98
115
  self._validate_all_data_converters_are_applied(router, version)
99
116
 
100
117
  routers[version.value] = router
118
+ webhook_routers[version.value] = webhook_router
101
119
  # Applying changes for the next version
102
120
  router = deepcopy(router)
103
- self._apply_endpoint_changes_to_router(router, version)
121
+ webhook_router = deepcopy(webhook_router)
122
+ self._apply_endpoint_changes_to_router(router.routes + webhook_router.routes, version)
104
123
 
105
124
  if self.routes_that_never_existed:
106
125
  raise RouterGenerationError(
@@ -146,7 +165,13 @@ class _EndpointTransformer(Generic[_R]):
146
165
  for route in router.routes
147
166
  if not (isinstance(route, fastapi.routing.APIRoute) and _DELETED_ROUTE_TAG in route.tags)
148
167
  ]
149
- return routers
168
+ for _, webhook_router in webhook_routers.items():
169
+ webhook_router.routes = [
170
+ route
171
+ for route in webhook_router.routes
172
+ if not (isinstance(route, fastapi.routing.APIRoute) and _DELETED_ROUTE_TAG in route.tags)
173
+ ]
174
+ return GeneratedRouters(routers, webhook_routers)
150
175
 
151
176
  def _validate_all_data_converters_are_applied(self, router: APIRouter, version: Version):
152
177
  path_to_route_methods_mapping, head_response_models, head_request_bodies = self._extract_all_routes_identifiers(
@@ -223,10 +248,9 @@ class _EndpointTransformer(Generic[_R]):
223
248
  # TODO (https://github.com/zmievsa/cadwyn/issues/28): Simplify
224
249
  def _apply_endpoint_changes_to_router( # noqa: C901
225
250
  self,
226
- router: fastapi.routing.APIRouter,
251
+ routes: list[BaseRoute] | list[APIRoute],
227
252
  version: Version,
228
253
  ):
229
- routes = router.routes
230
254
  for version_change in version.changes:
231
255
  for instruction in version_change.alter_endpoint_instructions:
232
256
  original_routes = _get_routes(
@@ -607,7 +607,7 @@ class SchemaGenerator:
607
607
 
608
608
  def __getitem__(self, model: type[_T_ANY_MODEL], /) -> type[_T_ANY_MODEL]:
609
609
  if not isinstance(model, type) or not issubclass(model, BaseModel | Enum) or model in (BaseModel, RootModel):
610
- return model # pyright: ignore[reportReturnType]
610
+ return model
611
611
  model = _unwrap_model(model)
612
612
 
613
613
  if model in self.concrete_models:
@@ -156,6 +156,8 @@ class VersionChange:
156
156
  "instructions_to_migrate_to_previous_version",
157
157
  "__module__",
158
158
  "__doc__",
159
+ "__firstlineno__",
160
+ "__static_attributes__",
159
161
  }:
160
162
  raise CadwynStructureError(
161
163
  f"Found: '{attr_name}' attribute of type '{type(attr_value)}' in '{cls.__name__}'."
@@ -6,7 +6,7 @@ Cadwyn is built around FastAPI and supports all of its functionality out of the
6
6
  ## Installation
7
7
 
8
8
  ```bash
9
- pip install 'cadwyn[standard]'
9
+ {!> ../docs_src/quickstart/setup/block001.sh !}
10
10
  ```
11
11
 
12
12
  ## The basics
@@ -0,0 +1 @@
1
+ pip install 'cadwyn[standard]'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cadwyn"
3
- version = "4.3.1"
3
+ version = "4.4.1"
4
4
  description = "Production-ready community-driven modern Stripe-like API versioning in FastAPI"
5
5
  authors = [{ name = "Stanislav Zmiev", email = "zmievsa@gmail.com" }]
6
6
  license = "MIT"
@@ -20,6 +20,7 @@ keywords = [
20
20
  "python310",
21
21
  "python311",
22
22
  "python312",
23
+ "python313",
23
24
  ]
24
25
  classifiers = [
25
26
  "Intended Audience :: Information Technology",
@@ -8,10 +8,9 @@ from fastapi.testclient import TestClient
8
8
 
9
9
  from cadwyn import Cadwyn
10
10
  from cadwyn.structure.versions import Version, VersionBundle
11
- from tests._resources.utils import BASIC_HEADERS
12
11
  from tests._resources.versioned_app.v2021_01_01 import router as v2021_01_01_router
13
12
  from tests._resources.versioned_app.v2022_01_02 import router as v2022_01_02_router
14
- from tests._resources.versioned_app.webhooks import router as webhooks_router
13
+ from tests._resources.versioned_app.webhooks import router as unversioned_router
15
14
 
16
15
 
17
16
  # TODO: Add better tests for covering lifespan
@@ -23,17 +22,16 @@ async def lifespan(app: FastAPI):
23
22
  versioned_app = Cadwyn(versions=VersionBundle(Version(date(2021, 1, 1))), lifespan=lifespan)
24
23
  versioned_app.add_header_versioned_routers(v2021_01_01_router, header_value="2021-01-01")
25
24
  versioned_app.add_header_versioned_routers(v2022_01_02_router, header_value="2022-02-02")
26
- versioned_app.include_router(webhooks_router)
25
+ versioned_app.include_router(unversioned_router)
27
26
 
28
27
  versioned_app_with_custom_api_version_var = Cadwyn(
29
28
  versions=VersionBundle(Version(date(2021, 1, 1))), lifespan=lifespan, api_version_var=ContextVar("My api version")
30
29
  )
31
30
  versioned_app_with_custom_api_version_var.add_header_versioned_routers(v2021_01_01_router, header_value="2021-01-01")
32
31
  versioned_app_with_custom_api_version_var.add_header_versioned_routers(v2022_01_02_router, header_value="2022-02-02")
33
- versioned_app_with_custom_api_version_var.include_router(webhooks_router)
32
+ versioned_app_with_custom_api_version_var.include_router(unversioned_router)
34
33
 
35
34
  # TODO: We should not have any clients that are run like this. Instead, all of them must run using "with"
36
- client = TestClient(versioned_app, raise_server_exceptions=False, headers=BASIC_HEADERS)
37
35
  client_without_headers = TestClient(versioned_app)
38
36
  client_without_headers_and_with_custom_api_version_var = TestClient(versioned_app_with_custom_api_version_var)
39
37
 
@@ -3,6 +3,6 @@ from fastapi.routing import APIRouter
3
3
  router = APIRouter(prefix="/v1")
4
4
 
5
5
 
6
- @router.post("/webhooks", response_model=dict)
6
+ @router.post("/unversioned", response_model=dict)
7
7
  def read_root():
8
8
  return {"saved": True}
@@ -105,6 +105,7 @@ class CreateVersionedApp:
105
105
  )
106
106
  )
107
107
  app.generate_and_include_versioned_routers(router)
108
+ app._cadwyn_initialize()
108
109
  return app
109
110
 
110
111
 
@@ -141,7 +142,7 @@ def version_change(
141
142
  **body_items: Any,
142
143
  ):
143
144
  return type(VersionChange)(
144
- "MyVersionChange",
145
+ "MyVersionChange", # pyright: ignore[reportCallIssue]
145
146
  (VersionChange,),
146
147
  {
147
148
  "description": "",
@@ -6,10 +6,13 @@ import pytest
6
6
  from fastapi import APIRouter, BackgroundTasks, Depends, FastAPI
7
7
  from fastapi.routing import APIRoute
8
8
  from fastapi.testclient import TestClient
9
+ from pydantic import BaseModel
9
10
 
10
11
  from cadwyn import Cadwyn
11
12
  from cadwyn.route_generation import VersionedAPIRouter
12
- from cadwyn.structure.versions import HeadVersion, Version, VersionBundle
13
+ from cadwyn.structure.endpoints import endpoint
14
+ from cadwyn.structure.schemas import schema
15
+ from cadwyn.structure.versions import HeadVersion, Version, VersionBundle, VersionChange
13
16
  from tests._resources.utils import BASIC_HEADERS, DEFAULT_API_VERSION
14
17
  from tests._resources.versioned_app.app import (
15
18
  client_without_headers,
@@ -162,8 +165,8 @@ def test__header_based_versioning__invalid_version_header_format__should_raise_4
162
165
  assert resp.json()[0]["loc"] == ["header", "x-api-version"]
163
166
 
164
167
 
165
- def test__get_webhooks_router():
166
- resp = client_without_headers.post("/v1/webhooks")
168
+ def test__get_unversioned_router():
169
+ resp = client_without_headers.post("/v1/unversioned")
167
170
  assert resp.status_code == 200
168
171
  assert resp.json() == {"saved": True}
169
172
 
@@ -254,14 +257,14 @@ def test__get_docs__specific_version():
254
257
  assert resp.status_code == 200
255
258
 
256
259
 
257
- def test__get_webhooks_with_redirect():
258
- resp = client_without_headers.post("/v1/webhooks/")
260
+ def test__get_unversioned_with_redirect():
261
+ resp = client_without_headers.post("/v1/unversioned/")
259
262
  assert resp.status_code == 200
260
263
  assert resp.json() == {"saved": True}
261
264
 
262
265
 
263
- def test__get_webhooks_as_partial_because_of_method():
264
- resp = client_without_headers.patch("/v1/webhooks")
266
+ def test__get_unversioned_as_partial_because_of_method():
267
+ resp = client_without_headers.patch("/v1/unversioned")
265
268
  assert resp.status_code == 405
266
269
 
267
270
 
@@ -291,3 +294,60 @@ def test__background_tasks():
291
294
  resp = client.post("/send-notification/test@example.com", headers=BASIC_HEADERS)
292
295
  assert resp.status_code == 200, resp.json()
293
296
  assert background_task_data == ("test@example.com", "some notification")
297
+
298
+
299
+ def test__webhooks():
300
+ webhooks = VersionedAPIRouter()
301
+
302
+ class Subscription(BaseModel):
303
+ username: str
304
+ monthly_fee: float
305
+ start_date: str
306
+
307
+ @webhooks.post("new-subscription")
308
+ def new_subscription(body: Subscription): # pragma: no cover
309
+ """
310
+ When a new user subscribes to your service we'll send you a POST request with this
311
+ data to the URL that you register for the event `new-subscription` in the dashboard.
312
+ """
313
+
314
+ class MyVersionChange(VersionChange):
315
+ description = "Mess with webhooks"
316
+ instructions_to_migrate_to_previous_version = [
317
+ endpoint("new-subscription", ["POST"]).didnt_exist,
318
+ schema(Subscription).field("monthly_fee").didnt_exist,
319
+ ]
320
+
321
+ app = Cadwyn(
322
+ versions=VersionBundle(HeadVersion(), Version("2023-04-12", MyVersionChange), Version("2022-11-16")),
323
+ webhooks=webhooks,
324
+ )
325
+
326
+ @app.webhooks.post("post-subscription") # pragma: no cover
327
+ def post_subscription(body: Subscription): # pragma: no cover
328
+ """This should also be there"""
329
+
330
+ with TestClient(app) as client:
331
+ resp = client.get("/openapi.json?version=2023-04-12")
332
+ openapi_dict = resp.json()
333
+
334
+ assert "webhooks" in openapi_dict, "'webhooks' section is missing"
335
+ assert "new-subscription" in openapi_dict["webhooks"], "'new-subscription' webhook is missing"
336
+ assert "post-subscription" in openapi_dict["webhooks"], "'post-subscription' webhook is missing"
337
+ assert "post" in openapi_dict["webhooks"]["post-subscription"], "POST method for 'post-subscription' is missing"
338
+ assert "Subscription" in openapi_dict["components"]["schemas"], "'Subscription' component is missing"
339
+ assert (
340
+ "monthly_fee" in openapi_dict["components"]["schemas"]["Subscription"]["properties"]
341
+ ), "monthly_fee field is missing"
342
+
343
+ resp = client.get("/openapi.json?version=2022-11-16")
344
+ openapi_dict = resp.json()
345
+
346
+ assert "webhooks" in openapi_dict, "'webhooks' section is missing"
347
+ assert "new-subscription" not in openapi_dict["webhooks"], "'new-subscription' webhook is missing"
348
+ assert "post-subscription" in openapi_dict["webhooks"], "'post-subscription' webhook is present"
349
+ assert "post" in openapi_dict["webhooks"]["post-subscription"], "POST method for 'post-subscription' is missing"
350
+ assert "Subscription" in openapi_dict["components"]["schemas"], "'Subscription' component is missing"
351
+ assert (
352
+ "monthly_fee" not in openapi_dict["components"]["schemas"]["Subscription"]["properties"]
353
+ ), "monthly_fee field is present yet it must be deleted"
@@ -19,11 +19,16 @@ def test__render_model__with_weird_types():
19
19
  )
20
20
  # TODO: sobolevn has created a tool for doing such nocovers in a better manner.
21
21
  # hopefully someday we will switch to it.
22
- if sys.version_info >= (3, 11): # pragma: no cover # We cover this in CI
22
+ if sys.version_info >= (3, 11):
23
23
  rendered_lambda = "lambda: 83"
24
- else: # pragma: no cover # We cover this in CI
24
+ else:
25
25
  rendered_lambda = "lambda : 83"
26
26
 
27
+ if sys.version_info >= (3, 13):
28
+ rend_ann = "typing.Annotated"
29
+ else:
30
+ rend_ann = "Annotated"
31
+
27
32
  # TODO: As you see, we do not rename bases correctly in render. We gotta fix it some day...
28
33
  assert code(result) == code(
29
34
  f'''
@@ -32,11 +37,11 @@ class ModelWithWeirdFields(A):
32
37
  foo: dict = Field(default={{'a': 'b'}})
33
38
  bar: list[int] = Field(default_factory=my_default_factory)
34
39
  baz: typing.Literal[MyEnum.foo] = Field()
35
- saz: Annotated[str, StringConstraints(to_upper=True)] = Field()
36
- laz: Annotated[int, None, Interval(gt=12, ge=None, lt=None, le=None), None] = Field()
40
+ saz: {rend_ann}[str, StringConstraints(to_upper=True)] = Field()
41
+ laz: {rend_ann}[int, None, Interval(gt=12, ge=None, lt=None, le=None), None] = Field()
37
42
  taz: typing.Union[int, str, None] = Field(default_factory={rendered_lambda})
38
43
  naz: list[int] = Field(default=[1, 2, 3])
39
- gaz: Annotated[bytes, Strict(strict=True), Len(min_length=0, max_length=None)] = Field(min_length=3, title='Hewwo')
44
+ gaz: {rend_ann}[bytes, Strict(strict=True), Len(min_length=0, max_length=None)] = Field(min_length=3, title='Hewwo')
40
45
  '''
41
46
  )
42
47