implement DJ014

This commit is contained in:
Jonas Vacek 2025-10-29 19:55:24 +01:00
parent bb40c34361
commit a2fcf0eb3f
No known key found for this signature in database
8 changed files with 229 additions and 0 deletions

View File

@ -0,0 +1,40 @@
from django.urls import path
from . import views
# Errors - missing trailing slash
urlpatterns = [
path("help", views.help_view), # DJ014
path("about", views.about_view), # DJ014
path("contact", views.contact_view), # DJ014
path("api/users", views.users_view), # DJ014
path("blog/posts", views.posts_view), # DJ014
]
# OK - has trailing slash
urlpatterns_ok = [
path("help/", views.help_view),
path("about/", views.about_view),
path("contact/", views.contact_view),
path("api/users/", views.users_view),
path("blog/posts/", views.posts_view),
]
# OK - just root path
urlpatterns_root = [
path("/", views.index_view),
path("", views.home_view),
]
# OK - with path parameters
urlpatterns_params = [
path("users/<int:id>/", views.user_detail),
path("posts/<slug:slug>/", views.post_detail),
]
# Mixed cases
urlpatterns_mixed = [
path("good/", views.good_view),
path("bad", views.bad_view), # DJ014
path("also-good/", views.also_good_view),
path("also-bad", views.also_bad_view), # DJ014
]

View File

@ -1181,6 +1181,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
if checker.is_rule_enabled(Rule::DjangoLocalsInRenderFunction) {
flake8_django::rules::locals_in_render_function(checker, call);
}
if checker.is_rule_enabled(Rule::DjangoURLPathWithoutTrailingSlash) {
flake8_django::rules::url_path_without_trailing_slash(checker, call);
}
if checker.is_rule_enabled(Rule::UnsupportedMethodCallOnAll) {
flake8_pyi::rules::unsupported_method_call_on_all(checker, func);
}

View File

@ -1100,6 +1100,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Flake8Django, "008") => rules::flake8_django::rules::DjangoModelWithoutDunderStr,
(Flake8Django, "012") => rules::flake8_django::rules::DjangoUnorderedBodyContentInModel,
(Flake8Django, "013") => rules::flake8_django::rules::DjangoNonLeadingReceiverDecorator,
(Flake8Django, "014") => rules::flake8_django::rules::DjangoURLPathWithoutTrailingSlash,
// flynt
// Reserved: (Flynt, "001") => Rule: :StringConcatenationToFString,

View File

@ -18,6 +18,7 @@ mod tests {
#[test_case(Rule::DjangoExcludeWithModelForm, Path::new("DJ006.py"))]
#[test_case(Rule::DjangoAllWithModelForm, Path::new("DJ007.py"))]
#[test_case(Rule::DjangoModelWithoutDunderStr, Path::new("DJ008.py"))]
#[test_case(Rule::DjangoURLPathWithoutTrailingSlash, Path::new("DJ014.py"))]
#[test_case(Rule::DjangoUnorderedBodyContentInModel, Path::new("DJ012.py"))]
#[test_case(Rule::DjangoNonLeadingReceiverDecorator, Path::new("DJ013.py"))]
fn rules(rule_code: Rule, path: &Path) -> Result<()> {

View File

@ -5,6 +5,7 @@ pub(crate) use model_without_dunder_str::*;
pub(crate) use non_leading_receiver_decorator::*;
pub(crate) use nullable_model_string_field::*;
pub(crate) use unordered_body_content_in_model::*;
pub(crate) use url_path_without_trailing_slash::*;
mod all_with_model_form;
mod exclude_with_model_form;
@ -13,3 +14,4 @@ mod model_without_dunder_str;
mod non_leading_receiver_decorator;
mod nullable_model_string_field;
mod unordered_body_content_in_model;
mod url_path_without_trailing_slash;

View File

@ -0,0 +1,104 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{self as ast, Expr};
use ruff_python_semantic::Modules;
use ruff_text_size::Ranged;
use crate::Violation;
use crate::checkers::ast::Checker;
/// ## What it does
/// Checks that all Django URL route definitions using `django.urls.path()`
/// end with a trailing slash.
///
/// ## Why is this bad?
/// Django's convention is to use trailing slashes in URL patterns. This is
/// enforced by the `APPEND_SLASH` setting (enabled by default), which
/// redirects requests without trailing slashes to URLs with them. Omitting
/// the trailing slash can lead to unnecessary redirects and inconsistent URL
/// patterns throughout your application.
///
/// ## Example
/// ```python
/// from django.urls import path
/// from . import views
///
/// urlpatterns = [
/// path("help", views.help_view), # Missing trailing slash
/// path("about", views.about_view), # Missing trailing slash
/// ]
/// ```
///
/// Use instead:
/// ```python
/// from django.urls import path
/// from . import views
///
/// urlpatterns = [
/// path("help/", views.help_view),
/// path("about/", views.about_view),
/// ]
/// ```
///
/// ## References
/// - [Django documentation: URL dispatcher](https://docs.djangoproject.com/en/stable/topics/http/urls/)
#[derive(ViolationMetadata)]
#[violation_metadata(preview_since = "v0.14.1")]
pub(crate) struct DjangoURLPathWithoutTrailingSlash {
url_pattern: String,
}
impl Violation for DjangoURLPathWithoutTrailingSlash {
#[derive_message_formats]
fn message(&self) -> String {
let DjangoURLPathWithoutTrailingSlash { url_pattern } = self;
format!("URL route `{url_pattern}` is missing a trailing slash")
}
}
/// DJ014
pub(crate) fn url_path_without_trailing_slash(checker: &Checker, call: &ast::ExprCall) {
if !checker.semantic().seen_module(Modules::DJANGO) {
return;
}
// Check if this is a call to django.urls.path
if !checker
.semantic()
.resolve_qualified_name(&call.func)
.is_some_and(|qualified_name| {
matches!(qualified_name.segments(), ["django", "urls", "path"])
})
{
return;
}
// Get the first argument (the route pattern)
let Some(route_arg) = call.arguments.args.first() else {
return;
};
// Check if it's a string literal
if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = route_arg {
let route = value.to_str();
// Skip empty strings
if route.is_empty() {
return;
}
// Skip route parameters (routes with angle brackets like "<int:id>")
// These are often at the end and shouldn't require trailing slashes
// Also skip routes that are just "/" or already end with "/"
if route == "/" || route.ends_with('/') {
return;
}
// Report diagnostic for routes without trailing slash
checker.report_diagnostic(
DjangoURLPathWithoutTrailingSlash {
url_pattern: route.to_string(),
},
route_arg.range(),
);
}
}

View File

@ -0,0 +1,77 @@
---
source: crates/ruff_linter/src/rules/flake8_django/mod.rs
---
DJ014 URL route `help` is missing a trailing slash
--> DJ014.py:6:10
|
4 | # Errors - missing trailing slash
5 | urlpatterns = [
6 | path("help", views.help_view), # DJ014
| ^^^^^^
7 | path("about", views.about_view), # DJ014
8 | path("contact", views.contact_view), # DJ014
|
DJ014 URL route `about` is missing a trailing slash
--> DJ014.py:7:10
|
5 | urlpatterns = [
6 | path("help", views.help_view), # DJ014
7 | path("about", views.about_view), # DJ014
| ^^^^^^^
8 | path("contact", views.contact_view), # DJ014
9 | path("api/users", views.users_view), # DJ014
|
DJ014 URL route `contact` is missing a trailing slash
--> DJ014.py:8:10
|
6 | path("help", views.help_view), # DJ014
7 | path("about", views.about_view), # DJ014
8 | path("contact", views.contact_view), # DJ014
| ^^^^^^^^^
9 | path("api/users", views.users_view), # DJ014
10 | path("blog/posts", views.posts_view), # DJ014
|
DJ014 URL route `api/users` is missing a trailing slash
--> DJ014.py:9:10
|
7 | path("about", views.about_view), # DJ014
8 | path("contact", views.contact_view), # DJ014
9 | path("api/users", views.users_view), # DJ014
| ^^^^^^^^^^^
10 | path("blog/posts", views.posts_view), # DJ014
11 | ]
|
DJ014 URL route `blog/posts` is missing a trailing slash
--> DJ014.py:10:10
|
8 | path("contact", views.contact_view), # DJ014
9 | path("api/users", views.users_view), # DJ014
10 | path("blog/posts", views.posts_view), # DJ014
| ^^^^^^^^^^^^
11 | ]
|
DJ014 URL route `bad` is missing a trailing slash
--> DJ014.py:37:10
|
35 | urlpatterns_mixed = [
36 | path("good/", views.good_view),
37 | path("bad", views.bad_view), # DJ014
| ^^^^^
38 | path("also-good/", views.also_good_view),
39 | path("also-bad", views.also_bad_view), # DJ014
|
DJ014 URL route `also-bad` is missing a trailing slash
--> DJ014.py:39:10
|
37 | path("bad", views.bad_view), # DJ014
38 | path("also-good/", views.also_good_view),
39 | path("also-bad", views.also_bad_view), # DJ014
| ^^^^^^^^^^
40 | ]
|

1
ruff.schema.json generated
View File

@ -3157,6 +3157,7 @@
"DJ01",
"DJ012",
"DJ013",
"DJ014",
"DOC",
"DOC1",
"DOC10",