Add DJ015 for leading slashes as well

This commit is contained in:
Jonas Vacek 2025-10-29 19:57:02 +01:00
parent aa4228632a
commit dd7037a042
No known key found for this signature in database
8 changed files with 393 additions and 0 deletions

View File

@ -0,0 +1,48 @@
from django.urls import path
from . import views
# Errors - leading slash
urlpatterns = [
path("/help/", views.help_view), # DJ015
path("/about/", views.about_view), # DJ015
path("/contact/", views.contact_view), # DJ015
path("/api/users/", views.users_view), # DJ015
path("/blog/posts/", views.posts_view), # DJ015
]
# OK - no leading 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), # DJ015
path("also-good/", views.also_good_view),
path("/also-bad/", views.also_bad_view), # DJ015
]
# Edge cases with different quote styles
urlpatterns_quotes = [
path('/single-quote/', views.single_quote_view), # DJ015
path("/double-quote/", views.double_quote_view), # DJ015
path('''/triple-single/''', views.triple_single_view), # DJ015
path("""/triple-double/""", views.triple_double_view), # DJ015
]

View File

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

View File

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

View File

@ -19,6 +19,7 @@ mod tests {
#[test_case(Rule::DjangoAllWithModelForm, Path::new("DJ007.py"))] #[test_case(Rule::DjangoAllWithModelForm, Path::new("DJ007.py"))]
#[test_case(Rule::DjangoModelWithoutDunderStr, Path::new("DJ008.py"))] #[test_case(Rule::DjangoModelWithoutDunderStr, Path::new("DJ008.py"))]
#[test_case(Rule::DjangoURLPathWithoutTrailingSlash, Path::new("DJ014.py"))] #[test_case(Rule::DjangoURLPathWithoutTrailingSlash, Path::new("DJ014.py"))]
#[test_case(Rule::DjangoURLPathWithLeadingSlash, Path::new("DJ015.py"))]
#[test_case(Rule::DjangoUnorderedBodyContentInModel, Path::new("DJ012.py"))] #[test_case(Rule::DjangoUnorderedBodyContentInModel, Path::new("DJ012.py"))]
#[test_case(Rule::DjangoNonLeadingReceiverDecorator, Path::new("DJ013.py"))] #[test_case(Rule::DjangoNonLeadingReceiverDecorator, Path::new("DJ013.py"))]
fn rules(rule_code: Rule, path: &Path) -> Result<()> { 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 non_leading_receiver_decorator::*;
pub(crate) use nullable_model_string_field::*; pub(crate) use nullable_model_string_field::*;
pub(crate) use unordered_body_content_in_model::*; pub(crate) use unordered_body_content_in_model::*;
pub(crate) use url_path_with_leading_slash::*;
pub(crate) use url_path_without_trailing_slash::*; pub(crate) use url_path_without_trailing_slash::*;
mod all_with_model_form; mod all_with_model_form;
@ -14,4 +15,5 @@ mod model_without_dunder_str;
mod non_leading_receiver_decorator; mod non_leading_receiver_decorator;
mod nullable_model_string_field; mod nullable_model_string_field;
mod unordered_body_content_in_model; mod unordered_body_content_in_model;
mod url_path_with_leading_slash;
mod url_path_without_trailing_slash; mod url_path_without_trailing_slash;

View File

@ -0,0 +1,121 @@
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, TextSize};
use crate::checkers::ast::Checker;
use crate::{AlwaysFixableViolation, Edit, Fix};
/// ## What it does
/// Checks that all Django URL route definitions using `django.urls.path()`
/// do not start with a leading slash.
///
/// ## Why is this bad?
/// Django's URL patterns should not start with a leading slash. When using
/// `include()` or when patterns are combined, leading slashes can cause
/// issues with URL resolution. The Django documentation recommends that
/// URL patterns should not have leading slashes, as they are not necessary
/// and can lead to unexpected behavior.
///
/// ## Example
/// ```python
/// from django.urls import path
/// from . import views
///
/// urlpatterns = [
/// path("/help/", views.help_view), # Leading slash
/// path("/about/", views.about_view), # Leading 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 DjangoURLPathWithLeadingSlash {
url_pattern: String,
}
impl AlwaysFixableViolation for DjangoURLPathWithLeadingSlash {
#[derive_message_formats]
fn message(&self) -> String {
let DjangoURLPathWithLeadingSlash { url_pattern } = self;
format!("URL route `{url_pattern}` has an unnecessary leading slash")
}
fn fix_title(&self) -> String {
"Remove leading slash".to_string()
}
}
/// DJ015
pub(crate) fn url_path_with_leading_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 and root path "/"
if route.is_empty() || route == "/" {
return;
}
// Check if route starts with a leading slash
if route.starts_with('/') {
// Report diagnostic for routes with leading slash
let mut diagnostic = checker.report_diagnostic(
DjangoURLPathWithLeadingSlash {
url_pattern: route.to_string(),
},
route_arg.range(),
);
// Determine the quote style to find the insertion point for removal
let string_content = checker.locator().slice(route_arg.range());
let quote_len =
if string_content.starts_with("'''") || string_content.starts_with("\"\"\"") {
3
} else if string_content.starts_with('\'') || string_content.starts_with('"') {
1
} else {
return; // Invalid string format
};
// Remove the leading slash (after the opening quote(s))
let removal_start = route_arg.range().start() + TextSize::new(quote_len);
let removal_end = removal_start + TextSize::new(1); // Remove one character (the slash)
diagnostic.set_fix(Fix::safe_edit(Edit::deletion(removal_start, removal_end)));
}
}
}

View File

@ -0,0 +1,216 @@
---
source: crates/ruff_linter/src/rules/flake8_django/mod.rs
---
DJ015 [*] URL route `/help/` has an unnecessary leading slash
--> DJ015.py:6:10
|
4 | # Errors - leading slash
5 | urlpatterns = [
6 | path("/help/", views.help_view), # DJ015
| ^^^^^^^^
7 | path("/about/", views.about_view), # DJ015
8 | path("/contact/", views.contact_view), # DJ015
|
help: Remove leading slash
3 |
4 | # Errors - leading slash
5 | urlpatterns = [
- path("/help/", views.help_view), # DJ015
6 + path("help/", views.help_view), # DJ015
7 | path("/about/", views.about_view), # DJ015
8 | path("/contact/", views.contact_view), # DJ015
9 | path("/api/users/", views.users_view), # DJ015
DJ015 [*] URL route `/about/` has an unnecessary leading slash
--> DJ015.py:7:10
|
5 | urlpatterns = [
6 | path("/help/", views.help_view), # DJ015
7 | path("/about/", views.about_view), # DJ015
| ^^^^^^^^^
8 | path("/contact/", views.contact_view), # DJ015
9 | path("/api/users/", views.users_view), # DJ015
|
help: Remove leading slash
4 | # Errors - leading slash
5 | urlpatterns = [
6 | path("/help/", views.help_view), # DJ015
- path("/about/", views.about_view), # DJ015
7 + path("about/", views.about_view), # DJ015
8 | path("/contact/", views.contact_view), # DJ015
9 | path("/api/users/", views.users_view), # DJ015
10 | path("/blog/posts/", views.posts_view), # DJ015
DJ015 [*] URL route `/contact/` has an unnecessary leading slash
--> DJ015.py:8:10
|
6 | path("/help/", views.help_view), # DJ015
7 | path("/about/", views.about_view), # DJ015
8 | path("/contact/", views.contact_view), # DJ015
| ^^^^^^^^^^^
9 | path("/api/users/", views.users_view), # DJ015
10 | path("/blog/posts/", views.posts_view), # DJ015
|
help: Remove leading slash
5 | urlpatterns = [
6 | path("/help/", views.help_view), # DJ015
7 | path("/about/", views.about_view), # DJ015
- path("/contact/", views.contact_view), # DJ015
8 + path("contact/", views.contact_view), # DJ015
9 | path("/api/users/", views.users_view), # DJ015
10 | path("/blog/posts/", views.posts_view), # DJ015
11 | ]
DJ015 [*] URL route `/api/users/` has an unnecessary leading slash
--> DJ015.py:9:10
|
7 | path("/about/", views.about_view), # DJ015
8 | path("/contact/", views.contact_view), # DJ015
9 | path("/api/users/", views.users_view), # DJ015
| ^^^^^^^^^^^^^
10 | path("/blog/posts/", views.posts_view), # DJ015
11 | ]
|
help: Remove leading slash
6 | path("/help/", views.help_view), # DJ015
7 | path("/about/", views.about_view), # DJ015
8 | path("/contact/", views.contact_view), # DJ015
- path("/api/users/", views.users_view), # DJ015
9 + path("api/users/", views.users_view), # DJ015
10 | path("/blog/posts/", views.posts_view), # DJ015
11 | ]
12 |
DJ015 [*] URL route `/blog/posts/` has an unnecessary leading slash
--> DJ015.py:10:10
|
8 | path("/contact/", views.contact_view), # DJ015
9 | path("/api/users/", views.users_view), # DJ015
10 | path("/blog/posts/", views.posts_view), # DJ015
| ^^^^^^^^^^^^^^
11 | ]
|
help: Remove leading slash
7 | path("/about/", views.about_view), # DJ015
8 | path("/contact/", views.contact_view), # DJ015
9 | path("/api/users/", views.users_view), # DJ015
- path("/blog/posts/", views.posts_view), # DJ015
10 + path("blog/posts/", views.posts_view), # DJ015
11 | ]
12 |
13 | # OK - no leading slash
DJ015 [*] URL route `/bad/` has an unnecessary leading slash
--> DJ015.py:37:10
|
35 | urlpatterns_mixed = [
36 | path("good/", views.good_view),
37 | path("/bad/", views.bad_view), # DJ015
| ^^^^^^^
38 | path("also-good/", views.also_good_view),
39 | path("/also-bad/", views.also_bad_view), # DJ015
|
help: Remove leading slash
34 | # Mixed cases
35 | urlpatterns_mixed = [
36 | path("good/", views.good_view),
- path("/bad/", views.bad_view), # DJ015
37 + path("bad/", views.bad_view), # DJ015
38 | path("also-good/", views.also_good_view),
39 | path("/also-bad/", views.also_bad_view), # DJ015
40 | ]
DJ015 [*] URL route `/also-bad/` has an unnecessary leading slash
--> DJ015.py:39:10
|
37 | path("/bad/", views.bad_view), # DJ015
38 | path("also-good/", views.also_good_view),
39 | path("/also-bad/", views.also_bad_view), # DJ015
| ^^^^^^^^^^^^
40 | ]
|
help: Remove leading slash
36 | path("good/", views.good_view),
37 | path("/bad/", views.bad_view), # DJ015
38 | path("also-good/", views.also_good_view),
- path("/also-bad/", views.also_bad_view), # DJ015
39 + path("also-bad/", views.also_bad_view), # DJ015
40 | ]
41 |
42 | # Edge cases with different quote styles
DJ015 [*] URL route `/single-quote/` has an unnecessary leading slash
--> DJ015.py:44:10
|
42 | # Edge cases with different quote styles
43 | urlpatterns_quotes = [
44 | path('/single-quote/', views.single_quote_view), # DJ015
| ^^^^^^^^^^^^^^^^
45 | path("/double-quote/", views.double_quote_view), # DJ015
46 | path('''/triple-single/''', views.triple_single_view), # DJ015
|
help: Remove leading slash
41 |
42 | # Edge cases with different quote styles
43 | urlpatterns_quotes = [
- path('/single-quote/', views.single_quote_view), # DJ015
44 + path('single-quote/', views.single_quote_view), # DJ015
45 | path("/double-quote/", views.double_quote_view), # DJ015
46 | path('''/triple-single/''', views.triple_single_view), # DJ015
47 | path("""/triple-double/""", views.triple_double_view), # DJ015
DJ015 [*] URL route `/double-quote/` has an unnecessary leading slash
--> DJ015.py:45:10
|
43 | urlpatterns_quotes = [
44 | path('/single-quote/', views.single_quote_view), # DJ015
45 | path("/double-quote/", views.double_quote_view), # DJ015
| ^^^^^^^^^^^^^^^^
46 | path('''/triple-single/''', views.triple_single_view), # DJ015
47 | path("""/triple-double/""", views.triple_double_view), # DJ015
|
help: Remove leading slash
42 | # Edge cases with different quote styles
43 | urlpatterns_quotes = [
44 | path('/single-quote/', views.single_quote_view), # DJ015
- path("/double-quote/", views.double_quote_view), # DJ015
45 + path("double-quote/", views.double_quote_view), # DJ015
46 | path('''/triple-single/''', views.triple_single_view), # DJ015
47 | path("""/triple-double/""", views.triple_double_view), # DJ015
48 | ]
DJ015 [*] URL route `/triple-single/` has an unnecessary leading slash
--> DJ015.py:46:10
|
44 | path('/single-quote/', views.single_quote_view), # DJ015
45 | path("/double-quote/", views.double_quote_view), # DJ015
46 | path('''/triple-single/''', views.triple_single_view), # DJ015
| ^^^^^^^^^^^^^^^^^^^^^
47 | path("""/triple-double/""", views.triple_double_view), # DJ015
48 | ]
|
help: Remove leading slash
43 | urlpatterns_quotes = [
44 | path('/single-quote/', views.single_quote_view), # DJ015
45 | path("/double-quote/", views.double_quote_view), # DJ015
- path('''/triple-single/''', views.triple_single_view), # DJ015
46 + path('''triple-single/''', views.triple_single_view), # DJ015
47 | path("""/triple-double/""", views.triple_double_view), # DJ015
48 | ]
DJ015 [*] URL route `/triple-double/` has an unnecessary leading slash
--> DJ015.py:47:10
|
45 | path("/double-quote/", views.double_quote_view), # DJ015
46 | path('''/triple-single/''', views.triple_single_view), # DJ015
47 | path("""/triple-double/""", views.triple_double_view), # DJ015
| ^^^^^^^^^^^^^^^^^^^^^
48 | ]
|
help: Remove leading slash
44 | path('/single-quote/', views.single_quote_view), # DJ015
45 | path("/double-quote/", views.double_quote_view), # DJ015
46 | path('''/triple-single/''', views.triple_single_view), # DJ015
- path("""/triple-double/""", views.triple_double_view), # DJ015
47 + path("""triple-double/""", views.triple_double_view), # DJ015
48 | ]

1
ruff.schema.json generated
View File

@ -3158,6 +3158,7 @@
"DJ012", "DJ012",
"DJ013", "DJ013",
"DJ014", "DJ014",
"DJ015",
"DOC", "DOC",
"DOC1", "DOC1",
"DOC10", "DOC10",