kyro-routing 0.1.0__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 (42) hide show
  1. kyro_routing-0.1.0/.github/workflows/ci.yml +60 -0
  2. kyro_routing-0.1.0/.github/workflows/release.yml +68 -0
  3. kyro_routing-0.1.0/.gitignore +17 -0
  4. kyro_routing-0.1.0/.python-version +1 -0
  5. kyro_routing-0.1.0/CHANGELOG.md +37 -0
  6. kyro_routing-0.1.0/LICENSE +21 -0
  7. kyro_routing-0.1.0/PKG-INFO +222 -0
  8. kyro_routing-0.1.0/README.md +190 -0
  9. kyro_routing-0.1.0/pyproject.toml +80 -0
  10. kyro_routing-0.1.0/src/kyro/__init__.py +11 -0
  11. kyro_routing-0.1.0/src/kyro/apps.py +9 -0
  12. kyro_routing-0.1.0/src/kyro/checks.py +132 -0
  13. kyro_routing-0.1.0/src/kyro/dispatch.py +72 -0
  14. kyro_routing-0.1.0/src/kyro/inspection.py +349 -0
  15. kyro_routing-0.1.0/src/kyro/management/__init__.py +0 -0
  16. kyro_routing-0.1.0/src/kyro/management/commands/__init__.py +0 -0
  17. kyro_routing-0.1.0/src/kyro/management/commands/debug_router.py +52 -0
  18. kyro_routing-0.1.0/src/kyro/management/commands/router_match.py +53 -0
  19. kyro_routing-0.1.0/src/kyro/management/output.py +53 -0
  20. kyro_routing-0.1.0/src/kyro/params.py +261 -0
  21. kyro_routing-0.1.0/src/kyro/py.typed +0 -0
  22. kyro_routing-0.1.0/src/kyro/router.py +392 -0
  23. kyro_routing-0.1.0/src/kyro/syntax.py +240 -0
  24. kyro_routing-0.1.0/tests/__init__.py +0 -0
  25. kyro_routing-0.1.0/tests/conftest.py +34 -0
  26. kyro_routing-0.1.0/tests/otherapp/__init__.py +3 -0
  27. kyro_routing-0.1.0/tests/otherapp/views.py +8 -0
  28. kyro_routing-0.1.0/tests/settings.py +15 -0
  29. kyro_routing-0.1.0/tests/test_checks.py +180 -0
  30. kyro_routing-0.1.0/tests/test_debug_router.py +108 -0
  31. kyro_routing-0.1.0/tests/test_discovery.py +53 -0
  32. kyro_routing-0.1.0/tests/test_params.py +207 -0
  33. kyro_routing-0.1.0/tests/test_router_match.py +160 -0
  34. kyro_routing-0.1.0/tests/test_routing.py +305 -0
  35. kyro_routing-0.1.0/tests/test_smoke.py +9 -0
  36. kyro_routing-0.1.0/tests/test_syntax.py +100 -0
  37. kyro_routing-0.1.0/tests/testapp/__init__.py +0 -0
  38. kyro_routing-0.1.0/tests/testapp/models.py +9 -0
  39. kyro_routing-0.1.0/tests/testapp/views.py +62 -0
  40. kyro_routing-0.1.0/tests/urls.py +3 -0
  41. kyro_routing-0.1.0/tests/urls_debug.py +15 -0
  42. kyro_routing-0.1.0/uv.lock +679 -0
@@ -0,0 +1,60 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ lint:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v5
20
+ - uses: astral-sh/setup-uv@v6
21
+ - run: uv sync --locked
22
+ - run: uv run ruff check .
23
+ - run: uv run ruff format --check .
24
+ - run: uv run python -m mypy
25
+
26
+ test:
27
+ name: test (Python ${{ matrix.python }}, Django ${{ matrix.django }})
28
+ runs-on: ubuntu-latest
29
+ strategy:
30
+ fail-fast: false
31
+ matrix:
32
+ # Lowest and highest Python supported by each Django version.
33
+ include:
34
+ - { python: "3.10", django: "4.2" }
35
+ - { python: "3.12", django: "4.2" }
36
+ - { python: "3.10", django: "5.2" }
37
+ - { python: "3.13", django: "5.2" }
38
+ - { python: "3.12", django: "6.0" }
39
+ - { python: "3.14", django: "6.0" }
40
+ - { python: "3.12", django: "6.1" }
41
+ - { python: "3.14", django: "6.1" }
42
+ steps:
43
+ - uses: actions/checkout@v5
44
+ - uses: astral-sh/setup-uv@v6
45
+ with:
46
+ python-version: ${{ matrix.python }}
47
+ - run: uv sync --locked
48
+ - run: uv pip install "django~=${{ matrix.django }}.0"
49
+ - run: uv run --no-sync pytest
50
+
51
+ build:
52
+ runs-on: ubuntu-latest
53
+ steps:
54
+ - uses: actions/checkout@v5
55
+ - uses: astral-sh/setup-uv@v6
56
+ - run: uv build
57
+ - uses: actions/upload-artifact@v4
58
+ with:
59
+ name: dist
60
+ path: dist/
@@ -0,0 +1,68 @@
1
+ name: Release
2
+
3
+ # A version tag (v0.1.0) publishes to TestPyPI, then PyPI.
4
+ # A manual run publishes to TestPyPI only, to try a release out.
5
+ on:
6
+ push:
7
+ tags: ["v[0-9]*"]
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ build:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v5
18
+ - uses: astral-sh/setup-uv@v6
19
+ - run: uv build
20
+ - name: Check that the tag matches the package version
21
+ if: github.ref_type == 'tag'
22
+ run: |
23
+ wheel="dist/kyro_routing-${GITHUB_REF_NAME#v}-py3-none-any.whl"
24
+ if [ ! -f "$wheel" ]; then
25
+ echo "::error::Tag $GITHUB_REF_NAME does not match the built package: $(ls dist)"
26
+ exit 1
27
+ fi
28
+ - name: Check that the wheel installs and imports
29
+ run: uv run --isolated --no-project --with dist/*.whl python -c "import kyro.checks, kyro; print(kyro.__version__)"
30
+ - uses: actions/upload-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+
35
+ testpypi:
36
+ needs: build
37
+ runs-on: ubuntu-latest
38
+ environment:
39
+ name: testpypi
40
+ url: https://test.pypi.org/p/kyro-routing
41
+ permissions:
42
+ id-token: write
43
+ steps:
44
+ - uses: actions/download-artifact@v4
45
+ with:
46
+ name: dist
47
+ path: dist/
48
+ - uses: pypa/gh-action-pypi-publish@release/v1
49
+ with:
50
+ repository-url: https://test.pypi.org/legacy/
51
+ # A version already tried out with a manual run is kept as is.
52
+ skip-existing: true
53
+
54
+ pypi:
55
+ needs: testpypi
56
+ if: github.ref_type == 'tag'
57
+ runs-on: ubuntu-latest
58
+ environment:
59
+ name: pypi
60
+ url: https://pypi.org/p/kyro-routing
61
+ permissions:
62
+ id-token: write
63
+ steps:
64
+ - uses: actions/download-artifact@v4
65
+ with:
66
+ name: dist
67
+ path: dist/
68
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,17 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Tooling
13
+ .pytest_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ htmlcov/
17
+ *.sqlite3
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
6
+ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-11-01
11
+
12
+ First release.
13
+
14
+ ### Added
15
+
16
+ - `@route` decorator for functions (sync and async), methods of any class, classes with
17
+ `__call__` and Django class-based views. On a class with routed methods, the class route
18
+ is a prefix for path, name, requirements, defaults and methods.
19
+ - Paths in Django syntax (`<int:id>`) and Symfony syntax (`{id}`, `{id<\d+>}`, `{page?1}`,
20
+ `{page?}`), mixed freely. Trailing parameters with a default are optional.
21
+ - Route options: `name` (generated when omitted), `methods`, `requirements`, `defaults` and
22
+ `priority`. Several routes can share a path with different methods; other methods get a
23
+ 405 with an `Allow` header.
24
+ - Parameter conversion from annotations: `int`, `float`, `Decimal`, `bool`, `UUID`, `date`,
25
+ `datetime` and `Enum`, plus Django models looked up by primary key, by field or with
26
+ `MapModel`. Invalid values and missing objects are a 404.
27
+ - `kyro.discover()` to build the URL patterns from the `views` modules of the installed
28
+ apps, or from given modules; `Router` for separate sets of routes.
29
+ - `debug_router` management command, listing the routes of the project or showing one in
30
+ detail.
31
+ - `router_match` management command, telling which route serves a request.
32
+ - System checks: shadowed routes (`kyro.W001`), shared route names (`kyro.W002`), routes
33
+ missing from the URLconf (`kyro.W003`) and ignored defaults (`kyro.W004`).
34
+ - Support for Python 3.10 to 3.14 and Django 4.2, 5.2, 6.0 and 6.1.
35
+
36
+ [Unreleased]: https://github.com/mistermerlin/kyro-routing/compare/v0.1.0...HEAD
37
+ [0.1.0]: https://github.com/mistermerlin/kyro-routing/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aziz Ouattara
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,222 @@
1
+ Metadata-Version: 2.5
2
+ Name: kyro-routing
3
+ Version: 0.1.0
4
+ Summary: Symfony-style attribute routing for Django: declare routes with decorators on views and controllers.
5
+ Project-URL: Homepage, https://github.com/mistermerlin/kyro-routing
6
+ Project-URL: Repository, https://github.com/mistermerlin/kyro-routing.git
7
+ Project-URL: Issues, https://github.com/mistermerlin/kyro-routing/issues
8
+ Project-URL: Changelog, https://github.com/mistermerlin/kyro-routing/blob/main/CHANGELOG.md
9
+ Author-email: Aziz Ouattara <datoulobaben@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: decorator,django,router,routing,symfony,urls
13
+ Classifier: Development Status :: 2 - Pre-Alpha
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 4.2
16
+ Classifier: Framework :: Django :: 5.2
17
+ Classifier: Framework :: Django :: 6.0
18
+ Classifier: Framework :: Django :: 6.1
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Programming Language :: Python :: 3.14
27
+ Classifier: Topic :: Internet :: WWW/HTTP
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.10
30
+ Requires-Dist: django>=4.2
31
+ Description-Content-Type: text/markdown
32
+
33
+ # kyro
34
+
35
+ [![CI](https://github.com/mistermerlin/kyro-routing/actions/workflows/ci.yml/badge.svg)](https://github.com/mistermerlin/kyro-routing/actions/workflows/ci.yml)
36
+
37
+ Symfony-style attribute routing for Django: declare routes with decorators directly on
38
+ your views and controllers instead of maintaining a long `urls.py`.
39
+
40
+ > Status: pre-alpha, under active development.
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ pip install kyro-routing # or: uv add kyro-routing
46
+ ```
47
+
48
+ The package is imported as `kyro`. Add `"kyro"` to `INSTALLED_APPS` for the management
49
+ commands and the system checks.
50
+
51
+ ## Quick start
52
+
53
+ ```python
54
+ # blog/views.py
55
+ from django.http import HttpResponse
56
+ from kyro import route
57
+
58
+ from blog.models import Post
59
+
60
+
61
+ @route("/hello/{name}", name="hello")
62
+ def hello(request, name):
63
+ return HttpResponse(f"Hello {name}")
64
+
65
+
66
+ @route("/blog", name="blog_")
67
+ class BlogController:
68
+ @route("", name="index", methods="GET")
69
+ def index(self, request): ...
70
+
71
+ @route("", name="create", methods="POST")
72
+ def create(self, request): ...
73
+
74
+ @route(r"/page/{page<\d+>?1}", name="list") # before /{slug}, which would catch /page
75
+ def list(self, request, page: int): ...
76
+
77
+ @route("/{slug}", name="show")
78
+ def show(self, request, post: Post): # fetched by slug, 404 if missing
79
+ ...
80
+ ```
81
+
82
+ ```python
83
+ # project/urls.py
84
+ import kyro
85
+
86
+ urlpatterns = kyro.discover() # imports the `views` module of every installed app
87
+ ```
88
+
89
+ Routes are plain Django URL patterns: `reverse("blog_show", kwargs={"slug": "hello"})` and
90
+ `{% url "blog_index" %}` work as usual, and so do `include()` and namespaces.
91
+
92
+ ## Paths
93
+
94
+ Both syntaxes are accepted, even in the same path:
95
+
96
+ | Syntax | Meaning |
97
+ |---|---|
98
+ | `<name>`, `<int:id>` | Django parameter and converter |
99
+ | `{name}` | parameter (anything but `/`) |
100
+ | `{id<\d+>}` | parameter with a regex requirement |
101
+ | `{page?1}` | optional parameter with default `"1"` |
102
+ | `{page?}` | optional parameter with default `None` |
103
+
104
+ Trailing parameters with a default (inline or in `defaults=`) are optional:
105
+ `/blog/{page?1}` matches `/blog` and `/blog/2`.
106
+
107
+ ## `@route` arguments
108
+
109
+ | Argument | |
110
+ |---|---|
111
+ | `path` | the path, relative to the class prefix if any |
112
+ | `name` | route name, appended to the class `name` prefix; generated when omitted |
113
+ | `methods` | `"GET"` or `["GET", "POST"]`; others get a 405. `GET` also allows `HEAD` |
114
+ | `requirements` | `{"id": r"\d+"}` |
115
+ | `defaults` | defaults of optional parameters and extra kwargs passed to the view |
116
+ | `priority` | higher priority routes are matched first; otherwise declaration order |
117
+
118
+ Several routes may share a path with different `methods`: kyro dispatches each request to
119
+ the right view. `@route` can be stacked to give a view several routes.
120
+
121
+ ## What can be routed
122
+
123
+ - Functions, sync or async. Put `@route` above or below other decorators.
124
+ - Methods of any class: the class is instantiated for each request. A `@route` on the class
125
+ is a prefix (path, name, requirements, defaults, methods) for its methods. Classes defined
126
+ inside a function must be decorated (`@route()` for no prefix).
127
+ - A class with `__call__` and no routed method, or a Django `View`: the class route is the
128
+ route of the class itself.
129
+
130
+ ## Parameter conversion
131
+
132
+ Annotated view arguments are converted from the URL:
133
+
134
+ - `int`, `float`, `Decimal`, `bool`, `UUID`, `date`, `datetime`, `Enum`: an invalid value
135
+ is a 404.
136
+ - Django models, fetched like Symfony's entity resolver:
137
+ - `{post}` for `post: Post`: lookup by primary key;
138
+ - `{slug}` for `post: Post`: lookup by fields named like URL parameters (the parameter is
139
+ only passed to the view if its signature accepts it);
140
+ - `post: Annotated[Post, MapModel(slug="post_slug")]`: explicit mapping.
141
+
142
+ A missing object is a 404, or `None` when the annotation is optional (`Post | None`).
143
+
144
+ ## Debugging routes
145
+
146
+ With `"kyro"` in `INSTALLED_APPS`, `manage.py debug_router` lists every route of the
147
+ project, kyro or not, in matching order:
148
+
149
+ ```text
150
+ $ python manage.py debug_router
151
+ -------------- ---------- ------------------
152
+ Name Method Path
153
+ -------------- ---------- ------------------
154
+ blog_index GET|HEAD /blog
155
+ blog_create POST /blog
156
+ blog_show ANY /blog/{slug}
157
+ -------------- ---------- ------------------
158
+ ```
159
+
160
+ - `debug_router blog_show` shows one route in detail: Django routes, requirements,
161
+ defaults, priority, view and the file and line where it is declared. A partial name shows
162
+ the detail when it matches one route, the list of matches otherwise.
163
+ - `--show-views` adds the view of each route to the list, `--format json` outputs JSON and
164
+ `--urlconf` inspects another URLconf than `ROOT_URLCONF`.
165
+
166
+ `manage.py router_match` tells which route serves a request, method dispatch included:
167
+
168
+ ```text
169
+ $ python manage.py router_match /blog --method POST
170
+ ------------- ---------- ------- --------------------
171
+ Name Method Path Result
172
+ ------------- ---------- ------- --------------------
173
+ blog_index GET|HEAD /blog method not allowed
174
+ blog_create POST /blog match
175
+ ------------- ---------- ------- --------------------
176
+
177
+ Route 'blog_create' matches.
178
+ ```
179
+
180
+ It then shows the route in detail with the kwargs its view receives. Routes matching the
181
+ path but not the method, or hidden by an earlier route (`shadowed`), are listed; `-v 2`
182
+ lists every route. The command fails when no route serves the request (404 or 405).
183
+ `--format json` and `--urlconf` work as for `debug_router`.
184
+
185
+ ## System checks
186
+
187
+ With `"kyro"` in `INSTALLED_APPS`, `manage.py check` (and `runserver`) warns about:
188
+
189
+ | Id | Problem |
190
+ |---|---|
191
+ | `kyro.W001` | a route without parameters is served by an earlier route, for all or some of its methods (`/blog/{slug}` declared before `/blog/new`) |
192
+ | `kyro.W002` | a route name is shared with other routes, so `reverse()` only returns one of them |
193
+ | `kyro.W003` | routes declared with `@route` are not in the URLconf (`kyro.discover()` forgotten?) |
194
+ | `kyro.W004` | a parameter has a default but is not trailing, so it cannot be omitted |
195
+
196
+ Silence one with `SILENCED_SYSTEM_CHECKS = ["kyro.W003"]`.
197
+
198
+ ## Settings
199
+
200
+ - `KYRO_DISCOVER` (default `["views"]`): app submodules imported by `kyro.discover()`.
201
+
202
+ `kyro.discover(["blog"])` imports the given modules (for a package, its `KYRO_DISCOVER`
203
+ submodules too) and returns only their routes, to mount them under a prefix:
204
+ `path("blog/", include(kyro.discover(["blog"])))`.
205
+
206
+ ## Development
207
+
208
+ ```bash
209
+ uv sync # create .venv and install dependencies
210
+ uv run pytest # run the test suite
211
+ uv run ruff check . # lint
212
+ uv run ruff format . # format
213
+ uv run python -m mypy # type check (strict)
214
+ ```
215
+
216
+ ## Releasing
217
+
218
+ 1. Set the version in `src/kyro/__init__.py` (`__version__`), and in `CHANGELOG.md` rename
219
+ `[Unreleased]` to `[x.y.z] - YYYY-MM-DD` and update the links at the bottom.
220
+ 2. Optionally, try the release out on TestPyPI: *Actions → Release → Run workflow*.
221
+ 3. Commit, tag and push: `git tag vx.y.z && git push origin main vx.y.z`. The `Release`
222
+ workflow publishes to TestPyPI, then PyPI.
@@ -0,0 +1,190 @@
1
+ # kyro
2
+
3
+ [![CI](https://github.com/mistermerlin/kyro-routing/actions/workflows/ci.yml/badge.svg)](https://github.com/mistermerlin/kyro-routing/actions/workflows/ci.yml)
4
+
5
+ Symfony-style attribute routing for Django: declare routes with decorators directly on
6
+ your views and controllers instead of maintaining a long `urls.py`.
7
+
8
+ > Status: pre-alpha, under active development.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install kyro-routing # or: uv add kyro-routing
14
+ ```
15
+
16
+ The package is imported as `kyro`. Add `"kyro"` to `INSTALLED_APPS` for the management
17
+ commands and the system checks.
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ # blog/views.py
23
+ from django.http import HttpResponse
24
+ from kyro import route
25
+
26
+ from blog.models import Post
27
+
28
+
29
+ @route("/hello/{name}", name="hello")
30
+ def hello(request, name):
31
+ return HttpResponse(f"Hello {name}")
32
+
33
+
34
+ @route("/blog", name="blog_")
35
+ class BlogController:
36
+ @route("", name="index", methods="GET")
37
+ def index(self, request): ...
38
+
39
+ @route("", name="create", methods="POST")
40
+ def create(self, request): ...
41
+
42
+ @route(r"/page/{page<\d+>?1}", name="list") # before /{slug}, which would catch /page
43
+ def list(self, request, page: int): ...
44
+
45
+ @route("/{slug}", name="show")
46
+ def show(self, request, post: Post): # fetched by slug, 404 if missing
47
+ ...
48
+ ```
49
+
50
+ ```python
51
+ # project/urls.py
52
+ import kyro
53
+
54
+ urlpatterns = kyro.discover() # imports the `views` module of every installed app
55
+ ```
56
+
57
+ Routes are plain Django URL patterns: `reverse("blog_show", kwargs={"slug": "hello"})` and
58
+ `{% url "blog_index" %}` work as usual, and so do `include()` and namespaces.
59
+
60
+ ## Paths
61
+
62
+ Both syntaxes are accepted, even in the same path:
63
+
64
+ | Syntax | Meaning |
65
+ |---|---|
66
+ | `<name>`, `<int:id>` | Django parameter and converter |
67
+ | `{name}` | parameter (anything but `/`) |
68
+ | `{id<\d+>}` | parameter with a regex requirement |
69
+ | `{page?1}` | optional parameter with default `"1"` |
70
+ | `{page?}` | optional parameter with default `None` |
71
+
72
+ Trailing parameters with a default (inline or in `defaults=`) are optional:
73
+ `/blog/{page?1}` matches `/blog` and `/blog/2`.
74
+
75
+ ## `@route` arguments
76
+
77
+ | Argument | |
78
+ |---|---|
79
+ | `path` | the path, relative to the class prefix if any |
80
+ | `name` | route name, appended to the class `name` prefix; generated when omitted |
81
+ | `methods` | `"GET"` or `["GET", "POST"]`; others get a 405. `GET` also allows `HEAD` |
82
+ | `requirements` | `{"id": r"\d+"}` |
83
+ | `defaults` | defaults of optional parameters and extra kwargs passed to the view |
84
+ | `priority` | higher priority routes are matched first; otherwise declaration order |
85
+
86
+ Several routes may share a path with different `methods`: kyro dispatches each request to
87
+ the right view. `@route` can be stacked to give a view several routes.
88
+
89
+ ## What can be routed
90
+
91
+ - Functions, sync or async. Put `@route` above or below other decorators.
92
+ - Methods of any class: the class is instantiated for each request. A `@route` on the class
93
+ is a prefix (path, name, requirements, defaults, methods) for its methods. Classes defined
94
+ inside a function must be decorated (`@route()` for no prefix).
95
+ - A class with `__call__` and no routed method, or a Django `View`: the class route is the
96
+ route of the class itself.
97
+
98
+ ## Parameter conversion
99
+
100
+ Annotated view arguments are converted from the URL:
101
+
102
+ - `int`, `float`, `Decimal`, `bool`, `UUID`, `date`, `datetime`, `Enum`: an invalid value
103
+ is a 404.
104
+ - Django models, fetched like Symfony's entity resolver:
105
+ - `{post}` for `post: Post`: lookup by primary key;
106
+ - `{slug}` for `post: Post`: lookup by fields named like URL parameters (the parameter is
107
+ only passed to the view if its signature accepts it);
108
+ - `post: Annotated[Post, MapModel(slug="post_slug")]`: explicit mapping.
109
+
110
+ A missing object is a 404, or `None` when the annotation is optional (`Post | None`).
111
+
112
+ ## Debugging routes
113
+
114
+ With `"kyro"` in `INSTALLED_APPS`, `manage.py debug_router` lists every route of the
115
+ project, kyro or not, in matching order:
116
+
117
+ ```text
118
+ $ python manage.py debug_router
119
+ -------------- ---------- ------------------
120
+ Name Method Path
121
+ -------------- ---------- ------------------
122
+ blog_index GET|HEAD /blog
123
+ blog_create POST /blog
124
+ blog_show ANY /blog/{slug}
125
+ -------------- ---------- ------------------
126
+ ```
127
+
128
+ - `debug_router blog_show` shows one route in detail: Django routes, requirements,
129
+ defaults, priority, view and the file and line where it is declared. A partial name shows
130
+ the detail when it matches one route, the list of matches otherwise.
131
+ - `--show-views` adds the view of each route to the list, `--format json` outputs JSON and
132
+ `--urlconf` inspects another URLconf than `ROOT_URLCONF`.
133
+
134
+ `manage.py router_match` tells which route serves a request, method dispatch included:
135
+
136
+ ```text
137
+ $ python manage.py router_match /blog --method POST
138
+ ------------- ---------- ------- --------------------
139
+ Name Method Path Result
140
+ ------------- ---------- ------- --------------------
141
+ blog_index GET|HEAD /blog method not allowed
142
+ blog_create POST /blog match
143
+ ------------- ---------- ------- --------------------
144
+
145
+ Route 'blog_create' matches.
146
+ ```
147
+
148
+ It then shows the route in detail with the kwargs its view receives. Routes matching the
149
+ path but not the method, or hidden by an earlier route (`shadowed`), are listed; `-v 2`
150
+ lists every route. The command fails when no route serves the request (404 or 405).
151
+ `--format json` and `--urlconf` work as for `debug_router`.
152
+
153
+ ## System checks
154
+
155
+ With `"kyro"` in `INSTALLED_APPS`, `manage.py check` (and `runserver`) warns about:
156
+
157
+ | Id | Problem |
158
+ |---|---|
159
+ | `kyro.W001` | a route without parameters is served by an earlier route, for all or some of its methods (`/blog/{slug}` declared before `/blog/new`) |
160
+ | `kyro.W002` | a route name is shared with other routes, so `reverse()` only returns one of them |
161
+ | `kyro.W003` | routes declared with `@route` are not in the URLconf (`kyro.discover()` forgotten?) |
162
+ | `kyro.W004` | a parameter has a default but is not trailing, so it cannot be omitted |
163
+
164
+ Silence one with `SILENCED_SYSTEM_CHECKS = ["kyro.W003"]`.
165
+
166
+ ## Settings
167
+
168
+ - `KYRO_DISCOVER` (default `["views"]`): app submodules imported by `kyro.discover()`.
169
+
170
+ `kyro.discover(["blog"])` imports the given modules (for a package, its `KYRO_DISCOVER`
171
+ submodules too) and returns only their routes, to mount them under a prefix:
172
+ `path("blog/", include(kyro.discover(["blog"])))`.
173
+
174
+ ## Development
175
+
176
+ ```bash
177
+ uv sync # create .venv and install dependencies
178
+ uv run pytest # run the test suite
179
+ uv run ruff check . # lint
180
+ uv run ruff format . # format
181
+ uv run python -m mypy # type check (strict)
182
+ ```
183
+
184
+ ## Releasing
185
+
186
+ 1. Set the version in `src/kyro/__init__.py` (`__version__`), and in `CHANGELOG.md` rename
187
+ `[Unreleased]` to `[x.y.z] - YYYY-MM-DD` and update the links at the bottom.
188
+ 2. Optionally, try the release out on TestPyPI: *Actions → Release → Run workflow*.
189
+ 3. Commit, tag and push: `git tag vx.y.z && git push origin main vx.y.z`. The `Release`
190
+ workflow publishes to TestPyPI, then PyPI.