Skip to content

Commit 48ca69b

Browse files
committed
Make trailing slash an app-wide setting
Adds `URLS_TRAILING_SLASH` (default `False`) and `force_trailing_slash` on `path()` so the canonical trailing-slash form is one project-wide choice instead of a per-route decision. The slash in a route string is stripped silently; root URLs and catchalls remain slash-neutral. - Drop `Route` dataclass; `URLPattern`/`URLResolver` hold segments directly and `URLPattern.converters` is a cached property - Methodize `_build_lookups`, `_collect_*`, `_register_namespace`, `_try_reverse` on `URLResolver` - `plain urls list` renders canonical slashes from segment tuples - Update plain-api OpenAPI generator and the internal admin/oauth/ observer/support/loginlink/pageviews/assets routes plus example app - Switch support embed iframe URL to the no-slash canonical form
1 parent d3d109e commit 48ca69b

51 files changed

Lines changed: 977 additions & 797 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

example/app/contacts/urls.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ class ContactsRouter(Router):
99
namespace = "contacts"
1010
urls = [
1111
path("", views.ContactView, name="form"),
12-
path("success/", views.ContactSuccessView, name="success"),
13-
path("archive/", views.ContactArchiveView, name="archive"),
12+
path("success", views.ContactSuccessView, name="success"),
13+
path("archive", views.ContactArchiveView, name="archive"),
1414
]

example/app/notes/urls.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ class NotesRouter(Router):
99
namespace = "notes"
1010
urls = [
1111
path("", views.NoteListView, name="list"),
12-
path("new/", views.NoteCreateView, name="create"),
13-
path("<int:id>/", views.NoteDetailView, name="detail"),
14-
path("<int:id>/edit/", views.NoteUpdateView, name="update"),
15-
path("<int:id>/delete/", views.NoteDeleteView, name="delete"),
12+
path("new", views.NoteCreateView, name="create"),
13+
path("<int:id>", views.NoteDetailView, name="detail"),
14+
path("<int:id>/edit", views.NoteUpdateView, name="update"),
15+
path("<int:id>/delete", views.NoteDeleteView, name="delete"),
1616
]

example/app/tasks/urls.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ class TasksRouter(Router):
99
namespace = "tasks"
1010
urls = [
1111
path("", views.TaskListView, name="list"),
12-
path("seed/", views.TaskSeedView, name="seed"),
13-
path("new/", views.TaskCreateView, name="create"),
14-
path("<int:id>/", views.TaskDetailView, name="detail"),
15-
path("<int:id>/edit/", views.TaskUpdateView, name="update"),
16-
path("<int:id>/delete/", views.TaskDeleteView, name="delete"),
12+
path("seed", views.TaskSeedView, name="seed"),
13+
path("new", views.TaskCreateView, name="create"),
14+
path("<int:id>", views.TaskDetailView, name="detail"),
15+
path("<int:id>/edit", views.TaskUpdateView, name="update"),
16+
path("<int:id>/delete", views.TaskDeleteView, name="delete"),
1717
]

example/app/urls.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,23 @@ def get(self) -> NoReturn:
4343
class AppRouter(Router):
4444
namespace = ""
4545
urls = [
46-
include("admin/", AdminRouter),
47-
include("assets/", AssetsRouter),
48-
include("observer/", ObserverRouter),
49-
include("pageviews/", PageviewsRouter),
50-
include("notes/", NotesRouter),
51-
include("contacts/", ContactsRouter),
52-
include("tasks/", TasksRouter),
53-
include("api/", APIRouter),
54-
include("tasks-api/", TasksAPIRouter),
55-
path("mcp/", NotesMCP, name="mcp"),
56-
path("login/", LoginView, name="login"),
57-
path("logout/", LogoutView, name="logout"),
58-
path("error/", ErrorView, name="error"),
59-
path("sse/", SSEDemoView, name="sse_demo"),
60-
path("sse/clock/", ClockView, name="sse_clock"),
61-
path("sse/ticker/", StockTickerView, name="sse_ticker"),
62-
path("jobs/run/", RunExampleJobView, name="run_example_job"),
46+
include("admin", AdminRouter),
47+
include("assets", AssetsRouter),
48+
include("observer", ObserverRouter),
49+
include("pageviews", PageviewsRouter),
50+
include("notes", NotesRouter),
51+
include("contacts", ContactsRouter),
52+
include("tasks", TasksRouter),
53+
include("api", APIRouter),
54+
include("tasks-api", TasksAPIRouter),
55+
path("mcp", NotesMCP, name="mcp"),
56+
path("login", LoginView, name="login"),
57+
path("logout", LogoutView, name="logout"),
58+
path("error", ErrorView, name="error"),
59+
path("sse", SSEDemoView, name="sse_demo"),
60+
path("sse/clock", ClockView, name="sse_clock"),
61+
path("sse/ticker", StockTickerView, name="sse_ticker"),
62+
path("jobs/run", RunExampleJobView, name="run_example_job"),
6363
path("", IndexView, name="index"),
6464
path("<path:_>", NotFoundView),
6565
]

example/tests/test_urls.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@ def test_admin_access(db):
77
client = Client()
88

99
# Login required
10-
assert client.get("/admin/").status_code == 302
10+
assert client.get("/admin").status_code == 302
1111

1212
user = User.query.create(email="admin@example.com", password="strongpass1")
1313
client.force_login(user)
1414

1515
# Not admin yet
16-
assert client.get("/admin/").status_code == 404
16+
assert client.get("/admin").status_code == 404
1717

1818
user.is_admin = True
1919
user.save()
2020

2121
# Now admin
22-
assert client.get("/admin/").status_code in {200, 302}
22+
assert client.get("/admin").status_code in {200, 302}

plain-admin/plain/admin/impersonate/urls.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
class ImpersonateRouter(Router):
77
namespace = "impersonate"
88
urls = [
9-
path("stop/", ImpersonateStopView, name="stop"),
10-
path("start/<id>/", ImpersonateStartView, name="start"),
9+
path("stop", ImpersonateStopView, name="stop"),
10+
path("start/<id>", ImpersonateStartView, name="start"),
1111
]

plain-admin/plain/admin/urls.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,16 @@
2121
class AdminRouter(Router):
2222
namespace = "admin"
2323
urls = [
24-
path("search/", AdminSearchView, name="search"),
25-
path("ui/", UIView, name="ui"),
26-
path("settings/", SettingsView, name="settings"),
27-
path("settings/<name>/", SettingDetailView, name="setting_detail"),
28-
path("preflight/", PreflightView, name="preflight"),
29-
path("logout/", LogoutView, name="logout"),
30-
path("_/pin/", PinNavView, name="pin"),
31-
path("_/unpin/", UnpinNavView, name="unpin"),
32-
path("_/reorder/", ReorderPinnedView, name="reorder"),
33-
include("impersonate/", ImpersonateRouter),
24+
path("search", AdminSearchView, name="search"),
25+
path("ui", UIView, name="ui"),
26+
path("settings", SettingsView, name="settings"),
27+
path("settings/<name>", SettingDetailView, name="setting_detail"),
28+
path("preflight", PreflightView, name="preflight"),
29+
path("logout", LogoutView, name="logout"),
30+
path("_/pin", PinNavView, name="pin"),
31+
path("_/unpin", UnpinNavView, name="unpin"),
32+
path("_/reorder", ReorderPinnedView, name="reorder"),
33+
include("impersonate", ImpersonateRouter),
3434
include("", registry.get_urls()),
3535
path("", AdminIndexView, name="index"),
3636
]

plain-admin/tests/app/urls.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ def get(self):
1717
class AppRouter(Router):
1818
namespace = ""
1919
urls = [
20-
include("admin/", AdminRouter),
21-
include("assets/", AssetsRouter),
22-
path("login/", LoginView, name="login"),
23-
path("logout/", LogoutView, name="logout"),
20+
include("admin", AdminRouter),
21+
include("assets", AssetsRouter),
22+
path("login", LoginView, name="login"),
23+
path("logout", LogoutView, name="logout"),
2424
]

plain-admin/tests/public/test_admin.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,21 @@ def test_admin_login_required(db):
99
client = Client()
1010

1111
# Login required
12-
assert client.get("/admin/").status_code == 302
12+
assert client.get("/admin").status_code == 302
1313

1414
user = User.query.create(username="test")
1515
client.force_login(user)
1616

1717
# Not admin yet
18-
assert client.get("/admin/").status_code == 404
18+
assert client.get("/admin").status_code == 404
1919

2020
user.is_admin = True
2121
user.save()
2222

2323
# Now admin (currently redirects to the first view)
24-
resp = client.get("/admin/")
24+
resp = client.get("/admin")
2525
assert resp.status_code == 302
26-
assert resp.url == "/admin/p/session/"
26+
assert resp.url == "/admin/p/session"
2727

2828

2929
def test_has_permission_on_view(db):
@@ -80,7 +80,7 @@ def test_ui_view_renders(db):
8080
client = Client()
8181
client.force_login(user)
8282

83-
resp = client.get("/admin/ui/")
83+
resp = client.get("/admin/ui")
8484
assert resp.status_code == 200
8585
body = resp.content.decode()
8686
# Sanity-check a few markers from each major section

plain-api/plain/api/openapi/generator.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,10 @@ def get_paths(
124124

125125
def path_from_url_pattern(self, url_pattern: URLPattern, root_path: str) -> str:
126126
path = root_path + url_pattern.raw_route
127+
if url_pattern.trailing_slash and url_pattern.raw_route:
128+
path += "/"
127129

128-
for name, converter in url_pattern.route.converters.items():
130+
for name, converter in url_pattern.converters.items():
129131
# Handle both `<type:name>` and the `<name>` shorthand for the default `str` converter.
130132
path = path.replace(f"<{converter.keyword}:{name}>", f"{{{name}}}")
131133
path = path.replace(f"<{name}>", f"{{{name}}}")
@@ -266,7 +268,7 @@ def parameters_from_url_patterns(
266268
parameters = []
267269

268270
for url_pattern in url_patterns:
269-
for name, converter in url_pattern.route.converters.items():
271+
for name, converter in url_pattern.converters.items():
270272
parameters.append(
271273
{
272274
"name": name,

0 commit comments

Comments
 (0)