From 546a52e1078ac272983b18129f5773844f23bd89 Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Wed, 25 Feb 2026 16:27:53 +0100
Subject: [PATCH 001/322] SONARPY-3837 Reorder package root resolution and add
src as default package when poetry backend is used (#889)
GitOrigin-RevId: c88192e0365cf2d16653419f7d9aa45a3a8a7997
---
.../python/indexer/PackageRootResolver.java | 10 +--
.../indexer/PyProjectTomlSourceRoots.java | 45 ++++++++---
.../indexer/PyProjectTomlSourceRootsTest.java | 76 +++++++++++++++++++
3 files changed, 116 insertions(+), 15 deletions(-)
diff --git a/python-commons/src/main/java/org/sonar/plugins/python/indexer/PackageRootResolver.java b/python-commons/src/main/java/org/sonar/plugins/python/indexer/PackageRootResolver.java
index 93cdb45c0..d5d6a2bce 100644
--- a/python-commons/src/main/java/org/sonar/plugins/python/indexer/PackageRootResolver.java
+++ b/python-commons/src/main/java/org/sonar/plugins/python/indexer/PackageRootResolver.java
@@ -70,16 +70,16 @@ public static List resolve(List extractedRoots, Configuration co
* @return list of fallback package root absolute paths
*/
static List resolveFallback(Configuration config, File baseDir) {
- String[] sonarSources = config.getStringArray(SONAR_SOURCES_KEY);
- if (sonarSources.length > 0) {
- return toAbsolutePaths(Arrays.asList(sonarSources), baseDir);
- }
-
List conventionalFolders = findConventionalFolders(baseDir);
if (!conventionalFolders.isEmpty()) {
return toAbsolutePaths(conventionalFolders, baseDir);
}
+ String[] sonarSources = config.getStringArray(SONAR_SOURCES_KEY);
+ if (sonarSources.length > 0) {
+ return toAbsolutePaths(Arrays.asList(sonarSources), baseDir);
+ }
+
return List.of(baseDir.getAbsolutePath());
}
diff --git a/python-commons/src/main/java/org/sonar/plugins/python/indexer/PyProjectTomlSourceRoots.java b/python-commons/src/main/java/org/sonar/plugins/python/indexer/PyProjectTomlSourceRoots.java
index d69298cf2..469b5e8b9 100644
--- a/python-commons/src/main/java/org/sonar/plugins/python/indexer/PyProjectTomlSourceRoots.java
+++ b/python-commons/src/main/java/org/sonar/plugins/python/indexer/PyProjectTomlSourceRoots.java
@@ -39,7 +39,8 @@
* Supports the following build systems:
*
* - setuptools: {@code [tool.setuptools.packages.find] where = ["src"]}
- * - Poetry: {@code [tool.poetry] packages = [{from = "src", include = "pkg"}]}
+ * - Poetry: {@code [tool.poetry] packages = [{from = "src", include = "pkg"}]}
+ * or auto-detects src/ layout when build-backend is poetry.core.masonry.api
* - Hatchling: {@code [tool.hatch.build.targets.wheel] sources = ["src"]}
* - uv_build: {@code [build-system] build-backend = "uv_build"} or {@code [tool.uv.build-backend] module-root = "src"} - auto-detects src/ layout by convention
* - PDM: {@code [tool.pdm] package-dir = "src"}
@@ -130,7 +131,7 @@ private static PyProjectExtractionResult extractFromConfig(PyProjectConfig confi
detectedBuildSystems.add(PackageResolutionResult.BuildSystem.SETUPTOOLS);
}
- List poetryRoots = extractFromPoetry(configTool.poetry());
+ List poetryRoots = extractFromPoetry(configTool.poetry(), config.buildSystem());
if (!poetryRoots.isEmpty()) {
sourceRoots.addAll(poetryRoots);
detectedBuildSystems.add(PackageResolutionResult.BuildSystem.POETRY);
@@ -199,6 +200,16 @@ private static List extractFromUVBuildSystem(@Nullable BuildSystem build
return List.of();
}
+ /**
+ * Checks if the build backend is Poetry (e.g., poetry.core.masonry.api).
+ */
+ private static boolean isPoetryBuildBackend(@Nullable BuildSystem buildSystem) {
+ if (buildSystem == null || buildSystem.buildBackend() == null) {
+ return false;
+ }
+ return buildSystem.buildBackend().contains("poetry");
+ }
+
// === Setuptools ===
// [tool.setuptools.packages.find]
// where = ["src"]
@@ -213,16 +224,30 @@ private static List extractFromSetuptools(@Nullable Setuptools setuptool
// === Poetry ===
// [tool.poetry]
// packages = [{ include = "mypackage", from = "src" }]
+ // Or auto-detects src/ layout when build-backend is poetry.core.masonry.api
+
+ private static List extractFromPoetry(@Nullable Poetry poetry, @Nullable BuildSystem buildSystem) {
+ // First try to get explicit "from" paths from packages
+ if (poetry != null) {
+ List explicitRoots = poetry.packages().stream()
+ .map(PoetryPackage::from)
+ .filter(from -> from != null && !from.isEmpty())
+ .distinct()
+ .toList();
- private static List extractFromPoetry(@Nullable Poetry poetry) {
- if (poetry == null) {
- return List.of();
+ if (!explicitRoots.isEmpty()) {
+ return explicitRoots;
+ }
}
- return poetry.packages().stream()
- .map(PoetryPackage::from)
- .filter(from -> from != null && !from.isEmpty())
- .distinct()
- .toList();
+
+ // If Poetry is the build backend but no explicit paths, use src-layout default
+ // Poetry auto-detects packages in src/ or project root; we default to src/
+ // and rely on legacy fallback for flat-layout projects
+ if (isPoetryBuildBackend(buildSystem)) {
+ return List.of("src");
+ }
+
+ return List.of();
}
// === Hatchling ===
diff --git a/python-commons/src/test/java/org/sonar/plugins/python/indexer/PyProjectTomlSourceRootsTest.java b/python-commons/src/test/java/org/sonar/plugins/python/indexer/PyProjectTomlSourceRootsTest.java
index 8386366dc..ceaeb85d0 100644
--- a/python-commons/src/test/java/org/sonar/plugins/python/indexer/PyProjectTomlSourceRootsTest.java
+++ b/python-commons/src/test/java/org/sonar/plugins/python/indexer/PyProjectTomlSourceRootsTest.java
@@ -193,6 +193,66 @@ void extract_poetry_onlyDependencies() {
""")).isEmpty();
}
+ // === Poetry Auto-Detection ===
+
+ @Test
+ void extract_poetry_autoDetect_withBuildBackend_returnsSrc() {
+ assertThat(extract("""
+ [build-system]
+ build-backend = "poetry.core.masonry.api"
+ requires = ["poetry-core"]
+
+ [tool.poetry]
+ name = "mypackage"
+ version = "0.1.0"
+ """)).containsExactly("src");
+ }
+
+ @Test
+ void extract_poetry_autoDetect_emptyPackages_returnsSrc() {
+ assertThat(extract("""
+ [build-system]
+ build-backend = "poetry.core.masonry.api"
+ requires = ["poetry-core"]
+
+ [tool.poetry]
+ packages = []
+ """)).containsExactly("src");
+ }
+
+ @Test
+ void extract_poetry_autoDetect_packagesWithoutFrom_returnsSrc() {
+ assertThat(extract("""
+ [build-system]
+ build-backend = "poetry.core.masonry.api"
+ requires = ["poetry-core"]
+
+ [tool.poetry]
+ packages = [{ include = "mypackage" }]
+ """)).containsExactly("src");
+ }
+
+ @Test
+ void extract_poetry_explicitFrom_overridesAutoDetect() {
+ assertThat(extract("""
+ [build-system]
+ build-backend = "poetry.core.masonry.api"
+ requires = ["poetry-core"]
+
+ [tool.poetry]
+ packages = [{ include = "mypackage", from = "lib" }]
+ """)).containsExactly("lib");
+ }
+
+ @Test
+ void extract_poetry_noBuildBackend_noPackages_returnsEmpty() {
+ assertThat(extract("""
+ [tool.poetry]
+ name = "mypackage"
+ version = "0.1.0"
+ """)).isEmpty();
+ }
+
// === Hatchling ===
@Test
@@ -550,6 +610,22 @@ void extractWithBuildSystem_poetry_detectsBuildSystem() {
assertThat(result.buildSystem()).isEqualTo(PackageResolutionResult.BuildSystem.POETRY);
}
+ @Test
+ void extractWithBuildSystem_poetry_autoDetect_detectsBuildSystem() {
+ var result = extractWithBuildSystem("""
+ [build-system]
+ build-backend = "poetry.core.masonry.api"
+ requires = ["poetry-core"]
+
+ [tool.poetry]
+ name = "mypackage"
+ version = "0.1.0"
+ """);
+
+ assertThat(result.relativeRoots()).containsExactly("src");
+ assertThat(result.buildSystem()).isEqualTo(PackageResolutionResult.BuildSystem.POETRY);
+ }
+
@Test
void extractWithBuildSystem_hatchling_detectsBuildSystem() {
var result = extractWithBuildSystem("""
From 465bbeedc9f8dab0cb7effcbf20d9622bfe6aa7f Mon Sep 17 00:00:00 2001
From: Julien HENRY
Date: Thu, 19 Feb 2026 16:24:17 +0100
Subject: [PATCH 002/322] SONARPY-3821 Save ncloc metric on test files
Enable saving the NCLOC metric for test files in MeasuresRepository,
skipped in SonarLint context, consistent with main file behavior.
Co-Authored-By: Claude Sonnet 4.6
GitOrigin-RevId: 844dc0115370b295c77bba31b24aabc835a3e378
---
.../sonar/python/it/plugin/MetricsTest.java | 12 ++++++++++--
.../plugins/python/MeasuresRepository.java | 8 ++++++++
.../sonar/plugins/python/PythonScanner.java | 10 +++++++---
.../plugins/python/PythonSensorTest.java | 19 +++++++++++++++++++
4 files changed, 44 insertions(+), 5 deletions(-)
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/MetricsTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/MetricsTest.java
index 6efd0d53d..d89e90c4e 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/MetricsTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/MetricsTest.java
@@ -75,7 +75,11 @@ static void startServer() {
@Test
void project_level() {
// Size
- assertThat(getProjectMeasureAsInt(NCLOC)).isEqualTo(6);
+ assertThat(getProjectMeasureAsInt(NCLOC)).satisfiesAnyOf(
+ nclocValue -> assertThat(nclocValue).isEqualTo(6),
+ // FIXME SONAR-27110 Can be removed when ITs will be run with SQS 2026.2+
+ nclocValue -> assertThat(nclocValue).isEqualTo(20)
+ );
assertThat(getProjectMeasureAsInt(LINES)).isEqualTo(13);
assertThat(getProjectMeasureAsInt(FILES)).isEqualTo(2);
assertThat(getProjectMeasureAsInt(STATEMENTS)).isEqualTo(6);
@@ -83,7 +87,11 @@ void project_level() {
assertThat(getProjectMeasureAsInt(CLASSES)).isZero();
// Documentation
assertThat(getProjectMeasureAsInt(COMMENT_LINES)).isOne();
- assertThat(getProjectMeasureAsDouble(COMMENT_LINES_DENSITY)).isEqualTo(14.3, OFFSET);
+ assertThat(getProjectMeasureAsDouble(COMMENT_LINES_DENSITY))
+ .satisfiesAnyOf(
+ density -> assertThat(density).isEqualTo(14.3, OFFSET),
+ // FIXME SONAR-27110 Can be removed when ITs will be run with SQS 2026.2+
+ density -> assertThat(density).isEqualTo(4.8, OFFSET));
// Complexity
assertThat(getProjectMeasureAsDouble(COMPLEXITY)).isEqualTo(3.0, OFFSET);
assertThat(getProjectMeasureAsDouble(COGNITIVE_COMPLEXITY)).isEqualTo(3.0, OFFSET);
diff --git a/python-commons/src/main/java/org/sonar/plugins/python/MeasuresRepository.java b/python-commons/src/main/java/org/sonar/plugins/python/MeasuresRepository.java
index c12adfeca..0b2b337aa 100644
--- a/python-commons/src/main/java/org/sonar/plugins/python/MeasuresRepository.java
+++ b/python-commons/src/main/java/org/sonar/plugins/python/MeasuresRepository.java
@@ -61,6 +61,14 @@ public void save(PythonInputFile inputFile, PythonVisitorContext visitorContext)
}
}
+ public void saveNclocForTestFile(PythonInputFile inputFile, PythonVisitorContext visitorContext) {
+ if (!isInSonarLint) {
+ FileMetrics fileMetrics = new FileMetrics(visitorContext, isNotebook(inputFile));
+ int linesOfCode = fileMetrics.fileLinesVisitor().getLinesOfCode().size();
+ saveMetricOnFile(inputFile, CoreMetrics.NCLOC, linesOfCode);
+ }
+ }
+
private void saveInternal(PythonInputFile inputFile, PythonVisitorContext visitorContext) {
FileMetrics fileMetrics = new FileMetrics(visitorContext, isNotebook(inputFile));
FileLinesVisitor fileLinesVisitor = fileMetrics.fileLinesVisitor();
diff --git a/python-commons/src/main/java/org/sonar/plugins/python/PythonScanner.java b/python-commons/src/main/java/org/sonar/plugins/python/PythonScanner.java
index 579b3c687..ac7ff4a16 100644
--- a/python-commons/src/main/java/org/sonar/plugins/python/PythonScanner.java
+++ b/python-commons/src/main/java/org/sonar/plugins/python/PythonScanner.java
@@ -135,9 +135,13 @@ protected void scanFile(PythonInputFile inputFile) throws IOException {
noSonarLineInfoCollector.collect(pythonFile.key(), visitorContext.rootTree());
- if (fileType == InputFile.Type.MAIN && visitorContext.rootTree() != null) {
- pushTokens(inputFile, visitorContext);
- measuresRepository.save(inputFile, visitorContext);
+ if (visitorContext.rootTree() != null) {
+ if (fileType == InputFile.Type.MAIN) {
+ pushTokens(inputFile, visitorContext);
+ measuresRepository.save(inputFile, visitorContext);
+ } else if (fileType == InputFile.Type.TEST) {
+ measuresRepository.saveNclocForTestFile(inputFile, visitorContext);
+ }
}
var issues = visitorContext.getIssues();
diff --git a/python-commons/src/test/java/org/sonar/plugins/python/PythonSensorTest.java b/python-commons/src/test/java/org/sonar/plugins/python/PythonSensorTest.java
index 771f0d26e..cc4f91ff1 100644
--- a/python-commons/src/test/java/org/sonar/plugins/python/PythonSensorTest.java
+++ b/python-commons/src/test/java/org/sonar/plugins/python/PythonSensorTest.java
@@ -610,6 +610,25 @@ void test_issues_on_test_files() {
assertThat(issue.ruleKey().rule()).isEqualTo("S5905");
}
+ @Test
+ void test_ncloc_metric_on_test_file() {
+ activeRules = new ActiveRulesBuilder().build();
+ PythonInputFile inputFile = inputFile(FILE_TEST_FILE, Type.TEST);
+ sensor().execute(context);
+
+ assertThat(context.measure(inputFile.wrappedFile().key(), CoreMetrics.NCLOC).value()).isEqualTo(3);
+ }
+
+ @Test
+ void test_ncloc_metric_not_saved_on_test_file_in_sonarlint() {
+ context.setRuntime(SONARLINT_RUNTIME);
+ activeRules = new ActiveRulesBuilder().build();
+ PythonInputFile inputFile = inputFile(FILE_TEST_FILE, Type.TEST);
+ sensor().execute(context);
+
+ assertThat(context.measure(inputFile.wrappedFile().key(), CoreMetrics.NCLOC)).isNull();
+ }
+
@Test
void test_failFast_triggered_on_main_files() {
activeRules = new ActiveRulesBuilder()
From 515619266ba3fc0be4b332f6bbf035442e5a26f5 Mon Sep 17 00:00:00 2001
From: "ss-vibe-bot[bot]" <247337653+ss-vibe-bot[bot]@users.noreply.github.com>
Date: Thu, 26 Feb 2026 13:42:46 +0000
Subject: [PATCH 003/322] SONARPY-3834 Fix S8396 FP: do not raise on X | None
without default value (#891)
Co-authored-by: Vibe Bot
Co-authored-by: Claude Sonnet 4.5
GitOrigin-RevId: 2bcd9301c3bbe254a2e20176a68f05c356b83c16
---
.../PydanticOptionalFieldDefaultCheck.java | 44 +++----
.../checks/pydanticOptionalFieldDefault.py | 113 +++++++++---------
2 files changed, 73 insertions(+), 84 deletions(-)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/PydanticOptionalFieldDefaultCheck.java b/python-checks/src/main/java/org/sonar/python/checks/PydanticOptionalFieldDefaultCheck.java
index 72da396e6..3c219be34 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/PydanticOptionalFieldDefaultCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/PydanticOptionalFieldDefaultCheck.java
@@ -67,30 +67,36 @@ private static void checkField(SubscriptionContext ctx, AnnotatedAssignment anno
TypeAnnotation annotation = annotatedAssignment.annotation();
Expression annotationExpr = annotation.expression();
- if (!isOptionalType(annotationExpr, ctx)) {
- return;
- }
-
Expression assignedValue = annotatedAssignment.assignedValue();
-
- boolean hasNoDefault = assignedValue == null;
boolean hasFieldWithEllipsis = assignedValue != null && isFieldCallWithEllipsis(ctx, assignedValue);
- if (hasNoDefault || hasFieldWithEllipsis) {
+ if (isTypingOptional(annotationExpr, ctx)) {
+ // Optional[X]: raise when no default, or when Field(...) with ellipsis
+ if (assignedValue == null || hasFieldWithEllipsis) {
+ ctx.addIssue(annotationExpr, MESSAGE);
+ }
+ } else if (isNullableNonOptional(annotationExpr, ctx) && hasFieldWithEllipsis) {
+ // X | None or Union[X, None]: only raise when Field(...) with ellipsis
ctx.addIssue(annotationExpr, MESSAGE);
}
}
- private static boolean isOptionalType(Expression annotationExpr, SubscriptionContext ctx) {
+ private static boolean isTypingOptional(Expression annotationExpr, SubscriptionContext ctx) {
+ if (annotationExpr instanceof SubscriptionExpression subscriptionExpr) {
+ return IS_TYPING_OPTIONAL.isTrueFor(subscriptionExpr.object(), ctx);
+ }
+ return false;
+ }
+
+ private static boolean isNullableNonOptional(Expression annotationExpr, SubscriptionContext ctx) {
// Case 1: T | None (BinaryExpression with BITWISE_OR)
if (annotationExpr.is(Tree.Kind.BITWISE_OR)) {
- BinaryExpression binaryExpr = (BinaryExpression) annotationExpr;
- return containsNone(binaryExpr, ctx);
+ return containsNone((BinaryExpression) annotationExpr, ctx);
}
- // Case 2: Optional[T] or Union[T, None] (SubscriptionExpression)
+ // Case 2: Union[T, None] (SubscriptionExpression)
if (annotationExpr instanceof SubscriptionExpression subscriptionExpr) {
- return isOptionalOrUnionWithNone(subscriptionExpr, ctx);
+ return IS_TYPING_UNION.isTrueFor(subscriptionExpr.object(), ctx) && subscriptsContainNone(subscriptionExpr, ctx);
}
return false;
@@ -107,20 +113,6 @@ private static boolean isNoneExpression(Expression expr, SubscriptionContext ctx
return IS_NONE_TYPE.isTrueFor(expr, ctx);
}
- private static boolean isOptionalOrUnionWithNone(SubscriptionExpression subscriptionExpr, SubscriptionContext ctx) {
- Expression subscriptedObj = subscriptionExpr.object();
-
- if (IS_TYPING_OPTIONAL.isTrueFor(subscriptedObj, ctx)) {
- return true;
- }
-
- if (IS_TYPING_UNION.isTrueFor(subscriptedObj, ctx)) {
- return subscriptsContainNone(subscriptionExpr, ctx);
- }
-
- return false;
- }
-
private static boolean subscriptsContainNone(SubscriptionExpression subscriptionExpr, SubscriptionContext ctx) {
return subscriptionExpr.subscripts().expressions().stream()
.anyMatch(expr -> isNoneExpression(expr, ctx));
diff --git a/python-checks/src/test/resources/checks/pydanticOptionalFieldDefault.py b/python-checks/src/test/resources/checks/pydanticOptionalFieldDefault.py
index 37fb8af28..0b886a610 100644
--- a/python-checks/src/test/resources/checks/pydanticOptionalFieldDefault.py
+++ b/python-checks/src/test/resources/checks/pydanticOptionalFieldDefault.py
@@ -10,24 +10,30 @@ class UserModel(BaseModel):
email: Optional[str] # Noncompliant {{Add an explicit default value to this optional field.}}
# ^^^^^^^^^^^^^
-class ProfileModel(BaseModel):
- bio: str | None # Noncompliant
-# ^^^^^^^^^^
-
class SettingsModel(BaseModel):
theme: Optional[str] = Field(...) # Noncompliant
# ^^^^^^^^^^^^^
-class ArticleModel(BaseModel):
- title: str
- subtitle: Optional[str] # Noncompliant
- tags: list[str] | None # Noncompliant
+class ModelWithMethods(BaseModel):
+ optional_field: Optional[str] # Noncompliant
+ required_field: str
+
+ def some_method(self):
+ pass
+
+# Field(...) with ellipsis always raises, even for X | None and Union[X, None]
-class DataModel(BaseModel):
- value: Union[str, None] # Noncompliant
+class BitwiseOrWithFieldEllipsis(BaseModel):
+ value: str | None = Field(...) # Noncompliant
+# ^^^^^^^^^^
-class ComplexModel(BaseModel):
- data: Union[str, int, None] # Noncompliant
+class NoneLeftWithFieldEllipsis(BaseModel):
+ value: None | str = Field(...) # Noncompliant
+# ^^^^^^^^^^
+
+class UnionWithFieldEllipsis(BaseModel):
+ value: Union[str, None] = Field(...) # Noncompliant
+# ^^^^^^^^^^^^^^^^
# =====================
# COMPLIANT CASES
@@ -35,90 +41,81 @@ class ComplexModel(BaseModel):
class UserModelCompliant(BaseModel):
name: str
- email: Optional[str] = None # Compliant
+ email: Optional[str] = None
class ProfileModelCompliant(BaseModel):
- bio: str | None = None # Compliant
+ bio: str | None = None
class SettingsModelCompliant(BaseModel):
- theme: Optional[str] = Field(default=None) # Compliant
- priority: Optional[int] = Field(default=0) # Compliant
+ theme: Optional[str] = Field(default=None)
+ priority: Optional[int] = Field(default=0)
class RequiredModel(BaseModel):
- required_field: str # Compliant - not Optional
- another_field: int = Field(...) # Compliant - not Optional
+ required_field: str
+ another_field: int = Field(...) # not Optional, no issue
class ConfigModel(BaseModel):
- timeout: Optional[int] = 30 # Compliant - has default
+ timeout: Optional[int] = 30
class FactoryModel(BaseModel):
- items: Optional[list] = Field(default_factory=list) # Compliant
+ items: Optional[list] = Field(default_factory=list)
class RegularClass:
- value: Optional[str] # Compliant - not a BaseModel
+ value: Optional[str] # not a BaseModel
+
+# X | None and Union[X, None] without default: not Optional[X], so no issue
+
+class BitwiseOrNoneCompliant(BaseModel):
+ reason: int | None
+
+class BitwiseOrNoneWithFieldDefaultCompliant(BaseModel):
+ title: str | None = Field(default=None)
+
+class NoneLeftCompliant(BaseModel):
+ description: None | str
+
+class UnionWithNoneCompliant(BaseModel):
+ data: Union[str, None]
# =====================
# EDGE CASES
# =====================
class ComplexModelCompliant(BaseModel):
- data: Union[str, int, None] = None # Compliant
+ data: Union[str, int, None] = None
class EmptyModel(BaseModel):
- pass # Compliant - no fields
+ pass
class OnlyRequiredModel(BaseModel):
id: int
- name: str
-
-# =====================
-# ADDITIONAL EDGE CASES FOR COVERAGE
-# =====================
-
-class NoneLeftModel(BaseModel):
- value: None | str # Noncompliant
-
-class NestedUnionLeftModel(BaseModel):
- value: None | str | int # Noncompliant
+ name: str
class EmptyFieldModel(BaseModel):
- value: Optional[str] = Field() # Compliant - Field() with no ellipsis
+ value: Optional[str] = Field()
class FieldWithValueModel(BaseModel):
- value: Optional[str] = Field(42) # Compliant - first arg is not ellipsis
+ value: Optional[str] = Field(42)
class FieldWithKeywordFirstModel(BaseModel):
- value: Optional[str] = Field(default=None) # Compliant - default is specified
+ value: Optional[str] = Field(default=None)
class FieldEllipsisWithDefaultModel(BaseModel):
- value: Optional[str] = Field(..., default=None) # Compliant
+ value: Optional[str] = Field(..., default=None)
class FieldEllipsisWithFactoryModel(BaseModel):
- value: Optional[list] = Field(..., default_factory=list) # Compliant
-
-class ModelWithMethods(BaseModel):
- optional_field: Optional[str] # Noncompliant
- required_field: str
-
- def some_method(self):
- pass
-
- @classmethod
- def class_method(cls):
- pass
+ value: Optional[list] = Field(..., default_factory=list)
-class MultiNestedModel(BaseModel):
- left_none: None | str # Noncompliant
- right_none: str | None # Noncompliant
- deep_left: None | int | str # Noncompliant
- deep_right1: int | str | None # Noncompliant
- deep_right2: int | str | str | str # Compliant
+class MultiNoneFieldCompliant(BaseModel):
+ deep_left: None | int | str
+ deep_right: int | str | None
+ no_none: int | str | str
def custom_field():
return None
class CustomFieldModel(BaseModel):
- value: Optional[str] = custom_field() # Compliant - not pydantic.Field
+ value: Optional[str] = custom_field()
class OtherSubscriptionModel(BaseModel):
- value: list[str] # Compliant
+ value: list[str]
From c3547069caa53e8a5f77d308dfe3ae40f992f4e6 Mon Sep 17 00:00:00 2001
From: "ss-vibe-bot[bot]" <247337653+ss-vibe-bot[bot]@users.noreply.github.com>
Date: Thu, 26 Feb 2026 16:23:18 +0000
Subject: [PATCH 004/322] SONARPY-3847 Fix S8396 FP: do not raise on `X | None
= Field(...)` patterns (#898)
Co-authored-by: Vibe Bot
GitOrigin-RevId: 10a57f35a44eb8dab9eb8106fe1253f064583a64
---
.../PydanticOptionalFieldDefaultCheck.java | 47 ++-----------------
.../checks/pydanticOptionalFieldDefault.py | 29 +++++-------
2 files changed, 16 insertions(+), 60 deletions(-)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/PydanticOptionalFieldDefaultCheck.java b/python-checks/src/main/java/org/sonar/python/checks/PydanticOptionalFieldDefaultCheck.java
index 3c219be34..b52399ada 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/PydanticOptionalFieldDefaultCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/PydanticOptionalFieldDefaultCheck.java
@@ -22,7 +22,6 @@
import org.sonar.plugins.python.api.SubscriptionContext;
import org.sonar.plugins.python.api.tree.AnnotatedAssignment;
import org.sonar.plugins.python.api.tree.Argument;
-import org.sonar.plugins.python.api.tree.BinaryExpression;
import org.sonar.plugins.python.api.tree.CallExpression;
import org.sonar.plugins.python.api.tree.ClassDef;
import org.sonar.plugins.python.api.tree.Expression;
@@ -43,8 +42,6 @@ public class PydanticOptionalFieldDefaultCheck extends PythonSubscriptionCheck {
private static final TypeMatcher IS_PYDANTIC_FIELD = TypeMatchers.isType("pydantic.Field");
private static final TypeMatcher IS_TYPING_OPTIONAL = TypeMatchers.isType("typing.Optional");
- private static final TypeMatcher IS_TYPING_UNION = TypeMatchers.isType("typing.Union");
- private static final TypeMatcher IS_NONE_TYPE = TypeMatchers.isObjectOfType("NoneType");
@Override
public void initialize(Context context) {
@@ -67,16 +64,12 @@ private static void checkField(SubscriptionContext ctx, AnnotatedAssignment anno
TypeAnnotation annotation = annotatedAssignment.annotation();
Expression annotationExpr = annotation.expression();
+ if (!isTypingOptional(annotationExpr, ctx)) {
+ return;
+ }
+
Expression assignedValue = annotatedAssignment.assignedValue();
- boolean hasFieldWithEllipsis = assignedValue != null && isFieldCallWithEllipsis(ctx, assignedValue);
-
- if (isTypingOptional(annotationExpr, ctx)) {
- // Optional[X]: raise when no default, or when Field(...) with ellipsis
- if (assignedValue == null || hasFieldWithEllipsis) {
- ctx.addIssue(annotationExpr, MESSAGE);
- }
- } else if (isNullableNonOptional(annotationExpr, ctx) && hasFieldWithEllipsis) {
- // X | None or Union[X, None]: only raise when Field(...) with ellipsis
+ if (assignedValue == null || isFieldCallWithEllipsis(ctx, assignedValue)) {
ctx.addIssue(annotationExpr, MESSAGE);
}
}
@@ -88,36 +81,6 @@ private static boolean isTypingOptional(Expression annotationExpr, SubscriptionC
return false;
}
- private static boolean isNullableNonOptional(Expression annotationExpr, SubscriptionContext ctx) {
- // Case 1: T | None (BinaryExpression with BITWISE_OR)
- if (annotationExpr.is(Tree.Kind.BITWISE_OR)) {
- return containsNone((BinaryExpression) annotationExpr, ctx);
- }
-
- // Case 2: Union[T, None] (SubscriptionExpression)
- if (annotationExpr instanceof SubscriptionExpression subscriptionExpr) {
- return IS_TYPING_UNION.isTrueFor(subscriptionExpr.object(), ctx) && subscriptsContainNone(subscriptionExpr, ctx);
- }
-
- return false;
- }
-
- private static boolean containsNone(BinaryExpression binaryExpr, SubscriptionContext ctx) {
- return isNoneExpression(binaryExpr.leftOperand(), ctx) ||
- isNoneExpression(binaryExpr.rightOperand(), ctx) ||
- (binaryExpr.leftOperand().is(Tree.Kind.BITWISE_OR) && containsNone((BinaryExpression) binaryExpr.leftOperand(), ctx)) ||
- (binaryExpr.rightOperand().is(Tree.Kind.BITWISE_OR) && containsNone((BinaryExpression) binaryExpr.rightOperand(), ctx));
- }
-
- private static boolean isNoneExpression(Expression expr, SubscriptionContext ctx) {
- return IS_NONE_TYPE.isTrueFor(expr, ctx);
- }
-
- private static boolean subscriptsContainNone(SubscriptionExpression subscriptionExpr, SubscriptionContext ctx) {
- return subscriptionExpr.subscripts().expressions().stream()
- .anyMatch(expr -> isNoneExpression(expr, ctx));
- }
-
private static boolean isFieldCallWithEllipsis(SubscriptionContext ctx, Expression assignedValue) {
if (!(assignedValue instanceof CallExpression callExpr)) {
return false;
diff --git a/python-checks/src/test/resources/checks/pydanticOptionalFieldDefault.py b/python-checks/src/test/resources/checks/pydanticOptionalFieldDefault.py
index 0b886a610..49088f225 100644
--- a/python-checks/src/test/resources/checks/pydanticOptionalFieldDefault.py
+++ b/python-checks/src/test/resources/checks/pydanticOptionalFieldDefault.py
@@ -21,20 +21,6 @@ class ModelWithMethods(BaseModel):
def some_method(self):
pass
-# Field(...) with ellipsis always raises, even for X | None and Union[X, None]
-
-class BitwiseOrWithFieldEllipsis(BaseModel):
- value: str | None = Field(...) # Noncompliant
-# ^^^^^^^^^^
-
-class NoneLeftWithFieldEllipsis(BaseModel):
- value: None | str = Field(...) # Noncompliant
-# ^^^^^^^^^^
-
-class UnionWithFieldEllipsis(BaseModel):
- value: Union[str, None] = Field(...) # Noncompliant
-# ^^^^^^^^^^^^^^^^
-
# =====================
# COMPLIANT CASES
# =====================
@@ -52,7 +38,7 @@ class SettingsModelCompliant(BaseModel):
class RequiredModel(BaseModel):
required_field: str
- another_field: int = Field(...) # not Optional, no issue
+ another_field: int = Field(...)
class ConfigModel(BaseModel):
timeout: Optional[int] = 30
@@ -61,22 +47,29 @@ class FactoryModel(BaseModel):
items: Optional[list] = Field(default_factory=list)
class RegularClass:
- value: Optional[str] # not a BaseModel
-
-# X | None and Union[X, None] without default: not Optional[X], so no issue
+ value: Optional[str]
class BitwiseOrNoneCompliant(BaseModel):
reason: int | None
+class BitwiseOrNoneWithFieldEllipsisCompliant(BaseModel):
+ bio: str | None = Field(...)
+
class BitwiseOrNoneWithFieldDefaultCompliant(BaseModel):
title: str | None = Field(default=None)
class NoneLeftCompliant(BaseModel):
description: None | str
+class NoneLeftWithFieldEllipsisCompliant(BaseModel):
+ value: None | str = Field(...)
+
class UnionWithNoneCompliant(BaseModel):
data: Union[str, None]
+class UnionWithFieldEllipsisCompliant(BaseModel):
+ value: Union[str, None] = Field(...)
+
# =====================
# EDGE CASES
# =====================
From 22ca1189824b83ea3b01ee16281c759e29114560 Mon Sep 17 00:00:00 2001
From: Marc Jasper
Date: Thu, 26 Feb 2026 17:52:57 +0100
Subject: [PATCH 005/322] SONARPY-3757 Create rule S8437: Class-Based Views
should override get_context_data correctly (#866)
Co-authored-by: Claude Sonnet 4.5
GitOrigin-RevId: e59e83f595293c11c9552a7ba7a602fc337f1716
---
.../python/checks/FlaskRouteMethodsCheck.java | 6 +--
.../python/indexer/SetupPySourceRoots.java | 2 +-
.../java/org/sonar/python/tree/TreeUtils.java | 18 +++++++-
.../types/custom_protobuf/django.protobuf | 3 +-
.../django.views.generic.base.protobuf | 24 +++++++++++
.../django.views.generic.detail.protobuf | 14 ++++++
.../django.views.generic.list.protobuf | 13 ++++++
.../django.views.generic.protobuf | 43 +++++++++++++++++++
.../custom_protobuf/django.views.protobuf | 10 +++++
.../org/sonar/python/tree/TreeUtilsTest.java | 39 +++++++++++++++++
.../resources/custom/django/__init__.pyi | 1 +
.../custom/django/views/__init__.pyi | 1 +
.../custom/django/views/generic/__init__.pyi | 5 +++
.../custom/django/views/generic/base.pyi | 7 +++
.../custom/django/views/generic/detail.pyi | 5 +++
.../custom/django/views/generic/list.pyi | 5 +++
.../tests/test_serializers.py | 2 +-
17 files changed, 189 insertions(+), 9 deletions(-)
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.base.protobuf
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.detail.protobuf
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.list.protobuf
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.protobuf
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.protobuf
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/views/__init__.pyi
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/views/generic/__init__.pyi
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/views/generic/base.pyi
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/views/generic/detail.pyi
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/views/generic/list.pyi
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FlaskRouteMethodsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FlaskRouteMethodsCheck.java
index 39d35088f..5c58c00ad 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FlaskRouteMethodsCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FlaskRouteMethodsCheck.java
@@ -24,7 +24,6 @@
import org.sonar.plugins.python.api.tree.Expression;
import org.sonar.plugins.python.api.tree.FunctionDef;
import org.sonar.plugins.python.api.tree.Tree;
-import org.sonar.plugins.python.api.tree.UnpackingExpression;
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
import org.sonar.python.tree.TreeUtils;
@@ -74,9 +73,6 @@ private static boolean hasMethodsParameter(CallExpression callExpr) {
return true;
}
- return callExpr.arguments().stream()
- .filter(UnpackingExpression.class::isInstance)
- .map(UnpackingExpression.class::cast)
- .anyMatch(unpacking -> "**".equals(unpacking.starToken().value()));
+ return callExpr.arguments().stream().anyMatch(TreeUtils::isDoubleStarExpression);
}
}
diff --git a/python-commons/src/main/java/org/sonar/plugins/python/indexer/SetupPySourceRoots.java b/python-commons/src/main/java/org/sonar/plugins/python/indexer/SetupPySourceRoots.java
index 7d152fc10..5090c7ba3 100644
--- a/python-commons/src/main/java/org/sonar/plugins/python/indexer/SetupPySourceRoots.java
+++ b/python-commons/src/main/java/org/sonar/plugins/python/indexer/SetupPySourceRoots.java
@@ -149,7 +149,7 @@ public void visitCallExpression(CallExpression callExpression) {
*/
private void extractFromUnpackingArguments(CallExpression callExpression) {
for (Argument argument : callExpression.arguments()) {
- if (argument instanceof UnpackingExpression unpacking && "**".equals(unpacking.starToken().value())) {
+ if (argument instanceof UnpackingExpression unpacking && TreeUtils.isDoubleStarExpression(unpacking)) {
Expression unpackedExpr = resolveExpression(unpacking.expression());
if (unpackedExpr instanceof DictionaryLiteral dictLiteral) {
extractFromSetupConfigDict(dictLiteral);
diff --git a/python-frontend/src/main/java/org/sonar/python/tree/TreeUtils.java b/python-frontend/src/main/java/org/sonar/python/tree/TreeUtils.java
index baaba5bc7..90a829d11 100644
--- a/python-frontend/src/main/java/org/sonar/python/tree/TreeUtils.java
+++ b/python-frontend/src/main/java/org/sonar/python/tree/TreeUtils.java
@@ -62,6 +62,7 @@
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.Tree.Kind;
import org.sonar.plugins.python.api.tree.Tuple;
+import org.sonar.plugins.python.api.tree.UnpackingExpression;
import org.sonar.plugins.python.api.types.v2.PythonType;
import org.sonar.python.api.PythonTokenType;
@@ -171,7 +172,7 @@ public static ClassSymbol getClassSymbolFromDef(@Nullable ClassDef classDef) {
@CheckForNull
public static ClassDef getEnclosingClassDef(Tree tree) {
- Tree enclosingClass = firstAncestorOfKind(tree, Tree.Kind.CLASSDEF, Tree.Kind.FUNCDEF);
+ Tree enclosingClass = firstAncestorOfKind(tree, Tree.Kind.CLASSDEF, Tree.Kind.FUNCDEF, Tree.Kind.LAMBDA);
if (enclosingClass instanceof ClassDef classDef) {
return classDef;
}
@@ -634,6 +635,21 @@ public static Set getLocalVariableSymbols(FunctionDef functionDef) {
.collect(Collectors.toSet());
}
+ /**
+ * Checks if a tree node represents a double-star ({@code **}) expression.
+ * Supports both {@link Parameter} (e.g. {@code **kwargs} in function definitions)
+ * and {@link UnpackingExpression} (e.g. {@code **kwargs} in call arguments).
+ */
+ public static boolean isDoubleStarExpression(Tree tree) {
+ Token starToken = null;
+ if (tree instanceof Parameter parameter) {
+ starToken = parameter.starToken();
+ } else if (tree instanceof UnpackingExpression unpackingExpression) {
+ starToken = unpackingExpression.starToken();
+ }
+ return starToken != null && "**".equals(starToken.value());
+ }
+
private static final Pattern CONSTANT_NAME_PATTERN = Pattern.compile("^[_A-Z][A-Z0-9_]*$");
public static boolean isConstantName(String name) {
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.protobuf
index 8f5f3eec8..f93c94608 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.protobuf
@@ -12,4 +12,5 @@
utilsdjango.utils *
urlsdjango.urls *
confdjango.conf *
-appsdjango.apps
\ No newline at end of file
+appsdjango.apps *
+viewsdjango.views
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.base.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.base.protobuf
new file mode 100644
index 000000000..3a0d513a3
--- /dev/null
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.base.protobuf
@@ -0,0 +1,24 @@
+
+django.views.generic.base
+Viewdjango.views.generic.base.View"builtins.object*`
+dispatch'django.views.generic.base.View.dispatch*
+self*
+request*
+args*
+
+kwargs*Y
+get_context_data/django.views.generic.base.View.get_context_data*
+self*
+
+kwargs
+TemplateView&django.views.generic.base.TemplateView"django.views.generic.base.View*a
+get_context_data7django.views.generic.base.TemplateView.get_context_data*
+self*
+
+kwargsrc
+
template_name4django.views.generic.base.TemplateView.template_name
+builtins.str"builtins.str*
+__annotations__)django.views.generic.base.__annotations__W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.detail.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.detail.protobuf
new file mode 100644
index 000000000..2a3ae63a7
--- /dev/null
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.detail.protobuf
@@ -0,0 +1,14 @@
+
+django.views.generic.detail
+
+DetailView&django.views.generic.detail.DetailView"django.views.generic.base.View*a
+get_context_data7django.views.generic.detail.DetailView.get_context_data*
+self*
+
+kwargsrU
+model,django.views.generic.detail.DetailView.model
+
builtins.type"
builtins.type*
+__annotations__+django.views.generic.detail.__annotations__W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.list.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.list.protobuf
new file mode 100644
index 000000000..e3e66452c
--- /dev/null
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.list.protobuf
@@ -0,0 +1,13 @@
+
+django.views.generic.list
+ListView"django.views.generic.list.ListView"django.views.generic.base.View*]
+get_context_data3django.views.generic.list.ListView.get_context_data*
+self*
+
+kwargsrQ
+model(django.views.generic.list.ListView.model
+
builtins.type"
builtins.type*
+__annotations__)django.views.generic.list.__annotations__W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.protobuf
new file mode 100644
index 000000000..e7563255b
--- /dev/null
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.protobuf
@@ -0,0 +1,43 @@
+
+django.views.generic
+Viewdjango.views.generic.base.View"builtins.object*`
+dispatch'django.views.generic.base.View.dispatch*
+self*
+request*
+args*
+
+kwargs*Y
+get_context_data/django.views.generic.base.View.get_context_data*
+self*
+
+kwargs
+TemplateView&django.views.generic.base.TemplateView"django.views.generic.base.View*a
+get_context_data7django.views.generic.base.TemplateView.get_context_data*
+self*
+
+kwargsrc
+
template_name4django.views.generic.base.TemplateView.template_name
+builtins.str"builtins.str
+ListView"django.views.generic.list.ListView"django.views.generic.base.View*]
+get_context_data3django.views.generic.list.ListView.get_context_data*
+self*
+
+kwargsrQ
+model(django.views.generic.list.ListView.model
+
builtins.type"
builtins.type
+
+DetailView&django.views.generic.detail.DetailView"django.views.generic.base.View*a
+get_context_data7django.views.generic.detail.DetailView.get_context_data*
+self*
+
+kwargsrU
+model,django.views.generic.detail.DetailView.model
+
builtins.type"
builtins.type*u
+__path__django.views.generic.__path__J
+builtins.list[builtins.str]
+builtins.str"builtins.str"
builtins.list*
+__annotations__$django.views.generic.__annotations__W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*#
+basedjango.views.generic.base
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.protobuf
new file mode 100644
index 000000000..f2d0faccc
--- /dev/null
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.protobuf
@@ -0,0 +1,10 @@
+
+django.views*m
+__path__django.views.__path__J
+builtins.list[builtins.str]
+builtins.str"builtins.str"
builtins.list*
+__annotations__django.views.__annotations__W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*!
+genericdjango.views.generic
\ No newline at end of file
diff --git a/python-frontend/src/test/java/org/sonar/python/tree/TreeUtilsTest.java b/python-frontend/src/test/java/org/sonar/python/tree/TreeUtilsTest.java
index b68c40b0f..63d2be240 100644
--- a/python-frontend/src/test/java/org/sonar/python/tree/TreeUtilsTest.java
+++ b/python-frontend/src/test/java/org/sonar/python/tree/TreeUtilsTest.java
@@ -40,9 +40,11 @@
import org.sonar.plugins.python.api.tree.IfStatement;
import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.NumericLiteral;
+import org.sonar.plugins.python.api.tree.Parameter;
import org.sonar.plugins.python.api.tree.PassStatement;
import org.sonar.plugins.python.api.tree.QualifiedExpression;
import org.sonar.plugins.python.api.tree.RegularArgument;
+import org.sonar.plugins.python.api.tree.UnpackingExpression;
import org.sonar.plugins.python.api.tree.Statement;
import org.sonar.plugins.python.api.tree.Token;
import org.sonar.plugins.python.api.tree.Tree;
@@ -923,6 +925,43 @@ def inner(): pass
assertThat(TreeUtils.getEnclosingClassDef(funcDefInner)).isNull();
}
+ @Test
+ void test_isDoubleStarExpression() {
+ FileInput fileInput = PythonTestUtils.parse("""
+ def foo(*args, **kwargs): pass
+ bar(**x, *y)
+ """);
+
+ FunctionDef funcDef = PythonTestUtils.getFirstChild(fileInput, t -> t.is(Kind.FUNCDEF));
+ Parameter doubleStarParam = TreeUtils.nonTupleParameters(funcDef).stream()
+ .filter(p -> p.name() != null && "kwargs".equals(p.name().name()))
+ .findFirst().get();
+ Parameter singleStarParam = TreeUtils.nonTupleParameters(funcDef).stream()
+ .filter(p -> p.name() != null && "args".equals(p.name().name()))
+ .findFirst().get();
+
+ assertThat(TreeUtils.isDoubleStarExpression(doubleStarParam)).isTrue();
+ assertThat(TreeUtils.isDoubleStarExpression(singleStarParam)).isFalse();
+
+ CallExpression callExpr = PythonTestUtils.getFirstChild(fileInput, t -> t.is(Kind.CALL_EXPR));
+ UnpackingExpression doubleStarUnpacking = callExpr.arguments().stream()
+ .filter(UnpackingExpression.class::isInstance)
+ .map(UnpackingExpression.class::cast)
+ .filter(u -> u.expression() instanceof Name name && "x".equals(name.name()))
+ .findFirst().get();
+ UnpackingExpression singleStarUnpacking = callExpr.arguments().stream()
+ .filter(UnpackingExpression.class::isInstance)
+ .map(UnpackingExpression.class::cast)
+ .filter(u -> u.expression() instanceof Name name && "y".equals(name.name()))
+ .findFirst().get();
+
+ assertThat(TreeUtils.isDoubleStarExpression(doubleStarUnpacking)).isTrue();
+ assertThat(TreeUtils.isDoubleStarExpression(singleStarUnpacking)).isFalse();
+
+ // A tree that is neither Parameter nor UnpackingExpression
+ assertThat(TreeUtils.isDoubleStarExpression(funcDef)).isFalse();
+ }
+
@Test
void testIsConstantName() {
assertThat(TreeUtils.isConstantName("_FOO")).isTrue();
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/__init__.pyi b/python-frontend/typeshed_serializer/resources/custom/django/__init__.pyi
index 45fdc7bf6..0904438d1 100644
--- a/python-frontend/typeshed_serializer/resources/custom/django/__init__.pyi
+++ b/python-frontend/typeshed_serializer/resources/custom/django/__init__.pyi
@@ -4,3 +4,4 @@ import django.utils as utils
import django.urls as urls
import django.conf as conf
import django.apps as apps
+import django.views as views
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/views/__init__.pyi b/python-frontend/typeshed_serializer/resources/custom/django/views/__init__.pyi
new file mode 100644
index 000000000..e194e8119
--- /dev/null
+++ b/python-frontend/typeshed_serializer/resources/custom/django/views/__init__.pyi
@@ -0,0 +1 @@
+import django.views.generic as generic
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/__init__.pyi b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/__init__.pyi
new file mode 100644
index 000000000..bec54e706
--- /dev/null
+++ b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/__init__.pyi
@@ -0,0 +1,5 @@
+import django.views.generic.base as base
+from django.views.generic.base import View as View
+from django.views.generic.base import TemplateView as TemplateView
+from django.views.generic.list import ListView as ListView
+from django.views.generic.detail import DetailView as DetailView
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/base.pyi b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/base.pyi
new file mode 100644
index 000000000..61b9730c7
--- /dev/null
+++ b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/base.pyi
@@ -0,0 +1,7 @@
+class View:
+ def dispatch(self, request, *args, **kwargs): ...
+ def get_context_data(self, **kwargs): ...
+
+class TemplateView(View):
+ template_name: str
+ def get_context_data(self, **kwargs): ...
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/detail.pyi b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/detail.pyi
new file mode 100644
index 000000000..e82fc016f
--- /dev/null
+++ b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/detail.pyi
@@ -0,0 +1,5 @@
+from django.views.generic.base import View
+
+class DetailView(View):
+ model: type
+ def get_context_data(self, **kwargs): ...
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/list.pyi b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/list.pyi
new file mode 100644
index 000000000..73ed1996f
--- /dev/null
+++ b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/list.pyi
@@ -0,0 +1,5 @@
+from django.views.generic.base import View
+
+class ListView(View):
+ model: type
+ def get_context_data(self, **kwargs): ...
diff --git a/python-frontend/typeshed_serializer/tests/test_serializers.py b/python-frontend/typeshed_serializer/tests/test_serializers.py
index 42831654a..0ca5bd184 100644
--- a/python-frontend/typeshed_serializer/tests/test_serializers.py
+++ b/python-frontend/typeshed_serializer/tests/test_serializers.py
@@ -74,7 +74,7 @@ def test_custom_stubs_serializer(typeshed_custom_stubs):
custom_stubs_serializer.serialize()
assert custom_stubs_serializer.get_build_result.call_count == 1
# Not every files from "typeshed_custom_stubs" build are serialized, as some are builtins
- assert symbols.save_module.call_count == 328
+ assert symbols.save_module.call_count == 333
def test_importer_serializer():
From ed683b0f3ae87041a43ea1cbd9ff07a4876dcd4f Mon Sep 17 00:00:00 2001
From: Marc Jasper
Date: Fri, 27 Feb 2026 11:08:16 +0100
Subject: [PATCH 006/322] SONARPY-3756 Create rule S8435: Sensitive data should
not be passed in URL query parameters (#863)
Co-authored-by: Claude Sonnet 4.5
GitOrigin-RevId: d8b745caa0930aa6ed179ae84659f06ebd436915
---
.../types/custom_protobuf/django.http.protobuf | 15 ++++++++++++++-
.../resources/custom/django/http/__init__.pyi | 4 ++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.http.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.http.protobuf
index be638c101..0492a5da8 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.http.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.http.protobuf
@@ -1,5 +1,18 @@
-django.httpX
+django.http
+HttpRequestdjango.http.request.HttpRequest"*SonarPythonAnalyzerFakeStub.CustomStubBaserj
+GET#django.http.request.HttpRequest.GET>
+django.http.request.QueryDict"django.http.request.QueryDictrl
+POST$django.http.request.HttpRequest.POST>
+django.http.request.QueryDict"django.http.request.QueryDictrr
+COOKIES'django.http.request.HttpRequest.COOKIES>
+django.http.request.QueryDict"django.http.request.QueryDictrl
+META$django.http.request.HttpRequest.META>
+django.http.request.QueryDict"django.http.request.QueryDictrn
+FILES%django.http.request.HttpRequest.FILES>
+django.http.request.QueryDict"django.http.request.QueryDictrr
+headers'django.http.request.HttpRequest.headers>
+django.http.request.QueryDict"django.http.request.QueryDictX
HttpResponse!django.http.response.HttpResponse"%django.http.response.HttpResponseBasel
HttpResponseBadRequest+django.http.response.HttpResponseBadRequest"%django.http.response.HttpResponseBasej
HttpResponseForbidden*django.http.response.HttpResponseForbidden"%django.http.response.HttpResponseBase`
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/http/__init__.pyi b/python-frontend/typeshed_serializer/resources/custom/django/http/__init__.pyi
index a14a012a6..4276278cd 100644
--- a/python-frontend/typeshed_serializer/resources/custom/django/http/__init__.pyi
+++ b/python-frontend/typeshed_serializer/resources/custom/django/http/__init__.pyi
@@ -1,6 +1,10 @@
import django.http.request as request
import django.http.response as response
+from .request import (
+ HttpRequest as HttpRequest,
+)
+
from .response import (
HttpResponse as HttpResponse,
HttpResponseBadRequest as HttpResponseBadRequest,
From 672cdd9cb6264bac319767a7908d11380c795abd Mon Sep 17 00:00:00 2001
From: Thomas Serre
<118730793+thomas-serre-sonarsource@users.noreply.github.com>
Date: Fri, 27 Feb 2026 13:52:44 +0100
Subject: [PATCH 007/322] SONARPY-3423 S112: Do not raise when exception is not
instantiated in the function (#897)
GitOrigin-RevId: 501565ebf7df5fa9f9a83d924f48a023576f863f
---
.../checks/GenericExceptionRaisedCheck.java | 46 ++++++++++++++++---
.../genericExceptionRaised.py | 7 +++
2 files changed, 47 insertions(+), 6 deletions(-)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java b/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java
index f206a835e..c707d504e 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java
@@ -19,11 +19,17 @@
import java.util.List;
import org.sonar.check.Rule;
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
-import org.sonar.plugins.python.api.TriBool;
+import org.sonar.plugins.python.api.symbols.v2.SymbolV2;
+import org.sonar.plugins.python.api.symbols.v2.UsageV2;
import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.RaiseStatement;
+import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.Tree.Kind;
import org.sonar.plugins.python.api.types.v2.PythonType;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
+import org.sonar.python.tree.TreeUtils;
import static org.sonar.plugins.python.api.types.BuiltinTypes.BASE_EXCEPTION;
import static org.sonar.plugins.python.api.types.BuiltinTypes.EXCEPTION;
@@ -31,6 +37,13 @@
@Rule(key = "S112")
public class GenericExceptionRaisedCheck extends PythonSubscriptionCheck {
+ private final TypeMatcher isExceptionOrBaseExceptionMatcher = TypeMatchers.any(
+ TypeMatchers.isObjectOfType(EXCEPTION),
+ TypeMatchers.isObjectOfType(BASE_EXCEPTION),
+ TypeMatchers.isType(EXCEPTION),
+ TypeMatchers.isType(BASE_EXCEPTION)
+ );
+
@Override
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Kind.RAISE_STMT, ctx -> {
@@ -39,13 +52,34 @@ public void initialize(Context context) {
if (expressions.isEmpty()) {
return;
}
+
Expression expression = expressions.get(0);
- PythonType pythonType = expression.typeV2();
- TriBool isException = ctx.typeChecker().typeCheckBuilder().isBuiltinWithName(EXCEPTION).check(pythonType);
- TriBool isBaseException = ctx.typeChecker().typeCheckBuilder().isBuiltinWithName(BASE_EXCEPTION).check(pythonType);
- if (isException == TriBool.TRUE || isBaseException == TriBool.TRUE) {
- ctx.addIssue(expression, "Replace this generic exception class with a more specific one.");
+ if (!isExceptionOrBaseExceptionMatcher.isTrueFor(expression, ctx)) {
+ return;
}
+ if (!isExceptionFunctionLocal(expression, raise)) {
+ return;
+ }
+
+ ctx.addIssue(expression, "Replace this generic exception class with a more specific one.");
});
}
+
+ private static boolean isExceptionFunctionLocal(Expression expression, RaiseStatement raise) {
+ if (!(expression instanceof Name name)) return true;
+ SymbolV2 symbolV2 = name.symbolV2();
+ return symbolV2 == null || isLocalVariable(symbolV2, raise);
+ }
+
+ private static boolean isLocalVariable(SymbolV2 symbol, Tree raiseStatement) {
+ Tree function = TreeUtils.firstAncestorOfKind(raiseStatement, Kind.FUNCDEF);
+ if (function == null) {
+ return false;
+ }
+
+ return symbol.getSingleBindingUsage()
+ .filter(u -> !u.kind().equals(UsageV2.Kind.PARAMETER))
+ .map(usage -> TreeUtils.firstAncestor(usage.tree(), t -> t == function) != null)
+ .orElse(false);
+ }
}
diff --git a/python-checks/src/test/resources/checks/genericException/genericExceptionRaised.py b/python-checks/src/test/resources/checks/genericException/genericExceptionRaised.py
index fe78defa4..1cf03e093 100644
--- a/python-checks/src/test/resources/checks/genericException/genericExceptionRaised.py
+++ b/python-checks/src/test/resources/checks/genericException/genericExceptionRaised.py
@@ -48,3 +48,10 @@ def python2_multiple_expressions(cond):
def no_issue_with_self_return_type():
raise MyException().with_traceback("foo")
+
+def raised_exception_is_the_parameter(exception: BaseException):
+ raise exception
+
+global_exception = BaseException()
+def raise_global_exception():
+ raise global_exception
From 8f3ce60fe3e24fe9c1200b4dfa55038c855b6e9e Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Fri, 27 Feb 2026 16:04:41 +0100
Subject: [PATCH 008/322] SONARPY-3856 Update Django generic view stubs (#904)
GitOrigin-RevId: 881ff26218cb9d180f192292af4cfd539ab0a216
---
.../unusedFunctionParameter.py | 7 +
.../django.views.generic.base.protobuf | 230 ++++++++++++-
.../django.views.generic.dates.protobuf | 309 ++++++++++++++++++
.../django.views.generic.detail.protobuf | 89 ++++-
.../django.views.generic.edit.protobuf | 249 ++++++++++++++
.../django.views.generic.list.protobuf | 137 +++++++-
.../django.views.generic.protobuf | 229 +++++++++++--
.../checksums/custom.checksum | 4 +-
.../custom/django/views/generic/__init__.pyi | 18 +-
.../custom/django/views/generic/base.pyi | 62 +++-
.../custom/django/views/generic/dates.pyi | 115 +++++++
.../custom/django/views/generic/detail.pyi | 35 +-
.../custom/django/views/generic/edit.pyi | 74 +++++
.../custom/django/views/generic/list.pyi | 52 ++-
.../tests/test_serializers.py | 2 +-
15 files changed, 1532 insertions(+), 80 deletions(-)
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.dates.protobuf
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.edit.protobuf
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/views/generic/dates.pyi
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/views/generic/edit.pyi
diff --git a/python-checks/src/test/resources/checks/unusedFunctionParameter/unusedFunctionParameter.py b/python-checks/src/test/resources/checks/unusedFunctionParameter/unusedFunctionParameter.py
index 9a24014f5..c8de14191 100644
--- a/python-checks/src/test/resources/checks/unusedFunctionParameter/unusedFunctionParameter.py
+++ b/python-checks/src/test/resources/checks/unusedFunctionParameter/unusedFunctionParameter.py
@@ -228,3 +228,10 @@ class LocalClassWithAnnotatedMember:
class LocalClassChild(LocalClassWithAnnotatedMember):
def my_member(self, param, other_param): # OK, respecting contract defined in parent
print("Execute")
+
+
+from django.views import generic
+
+class TestDjango(generic.DetailView):
+ def get_object(self, queryset=None):
+ print("Should not raise")
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.base.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.base.protobuf
index 3a0d513a3..ce7ce7f9d 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.base.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.base.protobuf
@@ -1,23 +1,217 @@
-django.views.generic.base
-Viewdjango.views.generic.base.View"builtins.object*`
-dispatch'django.views.generic.base.View.dispatch*
-self*
-request*
-args*
-
-kwargs*Y
-get_context_data/django.views.generic.base.View.get_context_data*
-self*
-
-kwargs
-TemplateView&django.views.generic.base.TemplateView"django.views.generic.base.View*a
-get_context_data7django.views.generic.base.TemplateView.get_context_data*
-self*
+django.views.generic.base
+ContextMixin&django.views.generic.base.ContextMixin"builtins.object*
+get_context_data7django.views.generic.base.ContextMixin.get_context_data"W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*Z
+selfP
+&django.views.generic.base.ContextMixin"&django.views.generic.base.ContextMixin*
+kwargs
+Anyr
+
extra_context4django.views.generic.base.ContextMixin.extra_context
++Union[builtins.dict[builtins.str,Any],None]W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict
+None
+Viewdjango.views.generic.base.View"builtins.object*
+setup$django.views.generic.base.View.setup"
+None*J
+self@
+django.views.generic.base.View"django.views.generic.base.View*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+dispatch'django.views.generic.base.View.dispatch"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*J
+self@
+django.views.generic.base.View"django.views.generic.base.View*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+http_method_not_allowed6django.views.generic.base.View.http_method_not_allowed"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*J
+self@
+django.views.generic.base.View"django.views.generic.base.View*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+options&django.views.generic.base.View.options"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*J
+self@
+django.views.generic.base.View"django.views.generic.base.View*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+as_view&django.views.generic.base.View.as_view"K
+CallableType[builtins.function]&
+builtins.function"builtins.function*y
+clsp
+$Type[django.views.generic.base.View]@
+django.views.generic.base.View"django.views.generic.base.View"type*
-kwargsrc
-
template_name4django.views.generic.base.TemplateView.template_name
-builtins.str"builtins.str*
+initkwargs
+Any0:builtins.classmethodpr
+http_method_names0django.views.generic.base.View.http_method_namesJ
+builtins.list[builtins.str]
+builtins.str"builtins.str"
builtins.listru
+request&django.views.generic.base.View.requestB
+django.http.request.HttpRequest"django.http.request.HttpRequestr[
+args#django.views.generic.base.View.args.
+builtins.tuple[Any]
+Any"builtins.tuplerj
+kwargs%django.views.generic.base.View.kwargs9
+builtins.dict[Any,Any]
+Any
+Any"
builtins.dict
+TemplateResponseMixin/django.views.generic.base.TemplateResponseMixin"builtins.object*
+render_to_responseBdjango.views.generic.base.TemplateResponseMixin.render_to_response"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*l
+selfb
+/django.views.generic.base.TemplateResponseMixin"/django.views.generic.base.TemplateResponseMixin*d
+contextW
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*
+response_kwargs
+Any*
+get_template_namesBdjango.views.generic.base.TemplateResponseMixin.get_template_names"J
+builtins.list[builtins.str]
+builtins.str"builtins.str"
builtins.list*l
+selfb
+/django.views.generic.base.TemplateResponseMixin"/django.views.generic.base.TemplateResponseMixinr
+
template_name=django.views.generic.base.TemplateResponseMixin.template_nameD
+Union[builtins.str,None]
+builtins.str"builtins.str
+Noner
+template_engine?django.views.generic.base.TemplateResponseMixin.template_engineD
+Union[builtins.str,None]
+builtins.str"builtins.str
+Noner
+response_class>django.views.generic.base.TemplateResponseMixin.response_classy
+'Type[django.http.response.HttpResponse]F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse"typer
+content_typedjango.views.generic.dates.BaseDateListView.get_dated_queryset"
+Any*d
+selfZ
++django.views.generic.dates.BaseDateListView"+django.views.generic.dates.BaseDateListView*
+lookup
+Any*
+get_date_list_period@django.views.generic.dates.BaseDateListView.get_date_list_period"
+builtins.str"builtins.str*d
+selfZ
++django.views.generic.dates.BaseDateListView"+django.views.generic.dates.BaseDateListView*
+
get_date_list9django.views.generic.dates.BaseDateListView.get_date_list"
+Any*d
+selfZ
++django.views.generic.dates.BaseDateListView"+django.views.generic.dates.BaseDateListView*
+queryset
+Any*U
+ date_typeD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None *,
+ordering
+builtins.str"builtins.str rf
+allow_empty7django.views.generic.dates.BaseDateListView.allow_empty
+
builtins.bool"
builtins.boolrn
+date_list_perioddjango.views.generic.dates.BaseYearArchiveView.get_dated_items"
+.Tuple[Any,Any,builtins.dict[builtins.str,Any]]
+Any
+AnyW
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*j
+self`
+.django.views.generic.dates.BaseYearArchiveView".django.views.generic.dates.BaseYearArchiveView*
+get_make_object_listCdjango.views.generic.dates.BaseYearArchiveView.get_make_object_list"
+
builtins.bool"
builtins.bool*j
+self`
+.django.views.generic.dates.BaseYearArchiveView".django.views.generic.dates.BaseYearArchiveViewrq
+date_list_period?django.views.generic.dates.BaseYearArchiveView.date_list_period
+builtins.str"builtins.strrs
+make_object_list?django.views.generic.dates.BaseYearArchiveView.make_object_list
+
builtins.bool"
builtins.bool
+YearArchiveView*django.views.generic.dates.YearArchiveView"=django.views.generic.list.MultipleObjectTemplateResponseMixin".django.views.generic.dates.BaseYearArchiveViewru
+template_name_suffix?django.views.generic.dates.YearArchiveView.template_name_suffix
+builtins.str"builtins.str
+BaseMonthArchiveView/django.views.generic.dates.BaseMonthArchiveView"$django.views.generic.dates.YearMixin"%django.views.generic.dates.MonthMixin"+django.views.generic.dates.BaseDateListView*
+get_dated_items?django.views.generic.dates.BaseMonthArchiveView.get_dated_items"
+.Tuple[Any,Any,builtins.dict[builtins.str,Any]]
+Any
+AnyW
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*l
+selfb
+/django.views.generic.dates.BaseMonthArchiveView"/django.views.generic.dates.BaseMonthArchiveViewrr
+date_list_period@django.views.generic.dates.BaseMonthArchiveView.date_list_period
+builtins.str"builtins.str
+MonthArchiveView+django.views.generic.dates.MonthArchiveView"=django.views.generic.list.MultipleObjectTemplateResponseMixin"/django.views.generic.dates.BaseMonthArchiveViewrv
+template_name_suffix@django.views.generic.dates.MonthArchiveView.template_name_suffix
+builtins.str"builtins.str
+BaseWeekArchiveView.django.views.generic.dates.BaseWeekArchiveView"$django.views.generic.dates.YearMixin"$django.views.generic.dates.WeekMixin"+django.views.generic.dates.BaseDateListView*
+get_dated_items>django.views.generic.dates.BaseWeekArchiveView.get_dated_items"
+.Tuple[Any,Any,builtins.dict[builtins.str,Any]]
+Any
+AnyW
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*j
+self`
+.django.views.generic.dates.BaseWeekArchiveView".django.views.generic.dates.BaseWeekArchiveView
+WeekArchiveView*django.views.generic.dates.WeekArchiveView"=django.views.generic.list.MultipleObjectTemplateResponseMixin".django.views.generic.dates.BaseWeekArchiveViewru
+template_name_suffix?django.views.generic.dates.WeekArchiveView.template_name_suffix
+builtins.str"builtins.str
+BaseDayArchiveView-django.views.generic.dates.BaseDayArchiveView"$django.views.generic.dates.YearMixin"%django.views.generic.dates.MonthMixin"#django.views.generic.dates.DayMixin"+django.views.generic.dates.BaseDateListView*
+get_dated_items=django.views.generic.dates.BaseDayArchiveView.get_dated_items"
+.Tuple[Any,Any,builtins.dict[builtins.str,Any]]
+Any
+AnyW
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*h
+self^
+-django.views.generic.dates.BaseDayArchiveView"-django.views.generic.dates.BaseDayArchiveView
+DayArchiveView)django.views.generic.dates.DayArchiveView"=django.views.generic.list.MultipleObjectTemplateResponseMixin"-django.views.generic.dates.BaseDayArchiveViewrt
+template_name_suffix>django.views.generic.dates.DayArchiveView.template_name_suffix
+builtins.str"builtins.str
+BaseTodayArchiveView/django.views.generic.dates.BaseTodayArchiveView"-django.views.generic.dates.BaseDayArchiveView*
+get_dated_items?django.views.generic.dates.BaseTodayArchiveView.get_dated_items"
+.Tuple[Any,Any,builtins.dict[builtins.str,Any]]
+Any
+AnyW
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*l
+selfb
+/django.views.generic.dates.BaseTodayArchiveView"/django.views.generic.dates.BaseTodayArchiveView
+TodayArchiveView+django.views.generic.dates.TodayArchiveView"=django.views.generic.list.MultipleObjectTemplateResponseMixin"/django.views.generic.dates.BaseTodayArchiveViewrv
+template_name_suffix@django.views.generic.dates.TodayArchiveView.template_name_suffix
+builtins.str"builtins.str
+BaseDateDetailView-django.views.generic.dates.BaseDateDetailView"$django.views.generic.dates.YearMixin"%django.views.generic.dates.MonthMixin"#django.views.generic.dates.DayMixin"$django.views.generic.dates.DateMixin"*django.views.generic.detail.BaseDetailView*
+
+get_object8django.views.generic.dates.BaseDateDetailView.get_object"
+Any*h
+self^
+-django.views.generic.dates.BaseDateDetailView"-django.views.generic.dates.BaseDateDetailView*6
+queryset&
+Union[Any,None]
+Any
+None
+DateDetailView)django.views.generic.dates.DateDetailView"=django.views.generic.detail.SingleObjectTemplateResponseMixin"-django.views.generic.dates.BaseDateDetailViewrt
+template_name_suffix>django.views.generic.dates.DateDetailView.template_name_suffix
+builtins.str"builtins.str*
+__annotations__*django.views.generic.dates.__annotations__W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*:
+ Paginator$django.views.generic.dates.Paginator
+Any
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.detail.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.detail.protobuf
index 2a3ae63a7..f413ed5b8 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.detail.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.detail.protobuf
@@ -1,13 +1,88 @@
-django.views.generic.detail
+django.views.generic.detail
+SingleObjectMixin-django.views.generic.detail.SingleObjectMixin"&django.views.generic.base.ContextMixin*
-DetailView&django.views.generic.detail.DetailView"django.views.generic.base.View*a
-get_context_data7django.views.generic.detail.DetailView.get_context_data*
-self*
+get_object8django.views.generic.detail.SingleObjectMixin.get_object"
+Any*h
+self^
+-django.views.generic.detail.SingleObjectMixin"-django.views.generic.detail.SingleObjectMixin*6
+queryset&
+Union[Any,None]
+Any
+None *
+get_queryset:django.views.generic.detail.SingleObjectMixin.get_queryset"
+Any*h
+self^
+-django.views.generic.detail.SingleObjectMixin"-django.views.generic.detail.SingleObjectMixin*
+get_slug_fielddjango.views.generic.detail.SingleObjectMixin.get_context_data"W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*h
+self^
+-django.views.generic.detail.SingleObjectMixin"-django.views.generic.detail.SingleObjectMixin*
+kwargs
+Anyr
+model3django.views.generic.detail.SingleObjectMixin.modelA
+Union[Type[Any],None]
+ Type[Any]
+Any"type
+Nonerj
+queryset6django.views.generic.detail.SingleObjectMixin.queryset&
+Union[Any,None]
+Any
+Nonerd
+
+slug_field8django.views.generic.detail.SingleObjectMixin.slug_field
+builtins.str"builtins.strr
+context_object_nameAdjango.views.generic.detail.SingleObjectMixin.context_object_nameD
+Union[builtins.str,None]
+builtins.str"builtins.str
+Nonerl
+slug_url_kwargdjango.views.generic.list.MultipleObjectMixin.get_context_data"W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict*h
+self^
+-django.views.generic.list.MultipleObjectMixin"-django.views.generic.list.MultipleObjectMixin*9
+object_list&
+Union[Any,None]
+Any
+None *
+kwargs
+Anyrh
+allow_empty9django.views.generic.list.MultipleObjectMixin.allow_empty
+
builtins.bool"
builtins.boolrj
+queryset6django.views.generic.list.MultipleObjectMixin.queryset&
+Union[Any,None]
+Any
+Noner
+model3django.views.generic.list.MultipleObjectMixin.modelA
+Union[Type[Any],None]
+ Type[Any]
+Any"type
+Noner
+paginate_by9django.views.generic.list.MultipleObjectMixin.paginate_byD
+Union[builtins.int,None]
+builtins.int"builtins.int
+Nonerp
+paginate_orphans>django.views.generic.list.MultipleObjectMixin.paginate_orphans
+builtins.int"builtins.intr
+context_object_nameAdjango.views.generic.list.MultipleObjectMixin.context_object_nameD
+Union[builtins.str,None]
+builtins.str"builtins.str
+Nonern
+paginator_class=django.views.generic.list.MultipleObjectMixin.paginator_class
+ Type[Any]
+Any"typerd
-kwargsrQ
-model(django.views.generic.list.ListView.model
-
builtins.type"
builtins.type*
+page_kwarg8django.views.generic.list.MultipleObjectMixin.page_kwarg
+builtins.str"builtins.strrj
+ordering6django.views.generic.list.MultipleObjectMixin.ordering&
+Union[Any,None]
+Any
+NonerQ
+object_list9django.views.generic.list.MultipleObjectMixin.object_list
+Any
+BaseListView&django.views.generic.list.BaseListView"-django.views.generic.list.MultipleObjectMixin"django.views.generic.base.View*
+get*django.views.generic.list.BaseListView.get"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*Z
+selfP
+&django.views.generic.list.BaseListView"&django.views.generic.list.BaseListView*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any
+#MultipleObjectTemplateResponseMixin=django.views.generic.list.MultipleObjectTemplateResponseMixin"/django.views.generic.base.TemplateResponseMixin*
+get_template_namesPdjango.views.generic.list.MultipleObjectTemplateResponseMixin.get_template_names"J
+builtins.list[builtins.str]
+builtins.str"builtins.str"
builtins.list*
+self~
+=django.views.generic.list.MultipleObjectTemplateResponseMixin"=django.views.generic.list.MultipleObjectTemplateResponseMixinr
+template_name_suffixRdjango.views.generic.list.MultipleObjectTemplateResponseMixin.template_name_suffix
+builtins.str"builtins.str
+ListView"django.views.generic.list.ListView"=django.views.generic.list.MultipleObjectTemplateResponseMixin"&django.views.generic.list.BaseListView*
__annotations__)django.views.generic.list.__annotations__W
builtins.dict[builtins.str,Any]
builtins.str"builtins.str
-Any"
builtins.dict
\ No newline at end of file
+Any"
builtins.dict*9
+ Paginator#django.views.generic.list.Paginator
+Any
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.protobuf
index e7563255b..f2067d188 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.views.generic.protobuf
@@ -1,38 +1,209 @@
-django.views.generic
-Viewdjango.views.generic.base.View"builtins.object*`
-dispatch'django.views.generic.base.View.dispatch*
-self*
-request*
-args*
+django.views.generic
+RedirectView&django.views.generic.base.RedirectView"django.views.generic.base.View*
+get_redirect_url7django.views.generic.base.RedirectView.get_redirect_url"D
+Union[builtins.str,None]
+builtins.str"builtins.str
+None*Z
+selfP
+&django.views.generic.base.RedirectView"&django.views.generic.base.RedirectView*
+args
+Any*
+kwargs
+Any*
+get*django.views.generic.base.RedirectView.get"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*Z
+selfP
+&django.views.generic.base.RedirectView"&django.views.generic.base.RedirectView*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+head+django.views.generic.base.RedirectView.head"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*Z
+selfP
+&django.views.generic.base.RedirectView"&django.views.generic.base.RedirectView*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+post+django.views.generic.base.RedirectView.post"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*Z
+selfP
+&django.views.generic.base.RedirectView"&django.views.generic.base.RedirectView*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+options.django.views.generic.base.RedirectView.options"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*Z
+selfP
+&django.views.generic.base.RedirectView"&django.views.generic.base.RedirectView*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+delete-django.views.generic.base.RedirectView.delete"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*Z
+selfP
+&django.views.generic.base.RedirectView"&django.views.generic.base.RedirectView*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+put*django.views.generic.base.RedirectView.put"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*Z
+selfP
+&django.views.generic.base.RedirectView"&django.views.generic.base.RedirectView*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+patch,django.views.generic.base.RedirectView.patch"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*Z
+selfP
+&django.views.generic.base.RedirectView"&django.views.generic.base.RedirectView*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Anyr]
+ permanent0django.views.generic.base.RedirectView.permanent
+
builtins.bool"
builtins.boolrw
+url*django.views.generic.base.RedirectView.urlD
+Union[builtins.str,None]
+builtins.str"builtins.str
+Noner
+pattern_name3django.views.generic.base.RedirectView.pattern_nameD
+Union[builtins.str,None]
+builtins.str"builtins.str
+Nonerc
+query_string3django.views.generic.base.RedirectView.query_string
+
builtins.bool"
builtins.bool
+TemplateView&django.views.generic.base.TemplateView"/django.views.generic.base.TemplateResponseMixin"&django.views.generic.base.ContextMixin"django.views.generic.base.View*
+get*django.views.generic.base.TemplateView.get"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*Z
+selfP
+&django.views.generic.base.TemplateView"&django.views.generic.base.TemplateView*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any
+Viewdjango.views.generic.base.View"builtins.object*
+setup$django.views.generic.base.View.setup"
+None*J
+self@
+django.views.generic.base.View"django.views.generic.base.View*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+dispatch'django.views.generic.base.View.dispatch"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*J
+self@
+django.views.generic.base.View"django.views.generic.base.View*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+http_method_not_allowed6django.views.generic.base.View.http_method_not_allowed"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*J
+self@
+django.views.generic.base.View"django.views.generic.base.View*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+options&django.views.generic.base.View.options"F
+!django.http.response.HttpResponse"!django.http.response.HttpResponse*J
+self@
+django.views.generic.base.View"django.views.generic.base.View*O
+requestB
+django.http.request.HttpRequest"django.http.request.HttpRequest*
+args
+Any*
+kwargs
+Any*
+as_view&django.views.generic.base.View.as_view"K
+CallableType[builtins.function]&
+builtins.function"builtins.function*y
+clsp
+$Type[django.views.generic.base.View]@
+django.views.generic.base.View"django.views.generic.base.View"type*
-kwargs*Y
-get_context_data/django.views.generic.base.View.get_context_data*
-self*
-
-kwargs
-TemplateView&django.views.generic.base.TemplateView"django.views.generic.base.View*a
-get_context_data7django.views.generic.base.TemplateView.get_context_data*
-self*
+initkwargs
+Any0:builtins.classmethodpr
+http_method_names0django.views.generic.base.View.http_method_namesJ
+builtins.list[builtins.str]
+builtins.str"builtins.str"
builtins.listru
+request&django.views.generic.base.View.requestB
+django.http.request.HttpRequest"django.http.request.HttpRequestr[
+args#django.views.generic.base.View.args.
+builtins.tuple[Any]
+Any"builtins.tuplerj
+kwargs%django.views.generic.base.View.kwargs9
+builtins.dict[Any,Any]
+Any
+Any"
builtins.dict
+ArchiveIndexView+django.views.generic.dates.ArchiveIndexView"=django.views.generic.list.MultipleObjectTemplateResponseMixin"/django.views.generic.dates.BaseArchiveIndexViewrv
+template_name_suffix@django.views.generic.dates.ArchiveIndexView.template_name_suffix
+builtins.str"builtins.str
+DateDetailView)django.views.generic.dates.DateDetailView"=django.views.generic.detail.SingleObjectTemplateResponseMixin"-django.views.generic.dates.BaseDateDetailViewrt
+template_name_suffix>django.views.generic.dates.DateDetailView.template_name_suffix
+builtins.str"builtins.str
+DayArchiveView)django.views.generic.dates.DayArchiveView"=django.views.generic.list.MultipleObjectTemplateResponseMixin"-django.views.generic.dates.BaseDayArchiveViewrt
+template_name_suffix>django.views.generic.dates.DayArchiveView.template_name_suffix
+builtins.str"builtins.str
+MonthArchiveView+django.views.generic.dates.MonthArchiveView"=django.views.generic.list.MultipleObjectTemplateResponseMixin"/django.views.generic.dates.BaseMonthArchiveViewrv
+template_name_suffix@django.views.generic.dates.MonthArchiveView.template_name_suffix
+builtins.str"builtins.str
+TodayArchiveView+django.views.generic.dates.TodayArchiveView"=django.views.generic.list.MultipleObjectTemplateResponseMixin"/django.views.generic.dates.BaseTodayArchiveViewrv
+template_name_suffix@django.views.generic.dates.TodayArchiveView.template_name_suffix
+builtins.str"builtins.str
+WeekArchiveView*django.views.generic.dates.WeekArchiveView"=django.views.generic.list.MultipleObjectTemplateResponseMixin".django.views.generic.dates.BaseWeekArchiveViewru
+template_name_suffix?django.views.generic.dates.WeekArchiveView.template_name_suffix
+builtins.str"builtins.str
+YearArchiveView*django.views.generic.dates.YearArchiveView"=django.views.generic.list.MultipleObjectTemplateResponseMixin".django.views.generic.dates.BaseYearArchiveViewru
+template_name_suffix?django.views.generic.dates.YearArchiveView.template_name_suffix
+builtins.str"builtins.str
-kwargsrc
-
template_name4django.views.generic.base.TemplateView.template_name
-builtins.str"builtins.str
-ListView"django.views.generic.list.ListView"django.views.generic.base.View*]
-get_context_data3django.views.generic.list.ListView.get_context_data*
-self*
+DetailView&django.views.generic.detail.DetailView"=django.views.generic.detail.SingleObjectTemplateResponseMixin"*django.views.generic.detail.BaseDetailView
-kwargsrQ
-model(django.views.generic.list.ListView.model
-
builtins.type"
builtins.type
+CreateView$django.views.generic.edit.CreateView"=django.views.generic.detail.SingleObjectTemplateResponseMixin"(django.views.generic.edit.BaseCreateViewro
+template_name_suffix9django.views.generic.edit.CreateView.template_name_suffix
+builtins.str"builtins.str
-DetailView&django.views.generic.detail.DetailView"django.views.generic.base.View*a
-get_context_data7django.views.generic.detail.DetailView.get_context_data*
-self*
+DeleteView$django.views.generic.edit.DeleteView"=django.views.generic.detail.SingleObjectTemplateResponseMixin"(django.views.generic.edit.BaseDeleteViewro
+template_name_suffix9django.views.generic.edit.DeleteView.template_name_suffix
+builtins.str"builtins.str
+FormView"django.views.generic.edit.FormView"/django.views.generic.base.TemplateResponseMixin"&django.views.generic.edit.BaseFormView
-kwargsrU
-model,django.views.generic.detail.DetailView.model
-
builtins.type"
builtins.type*u
+UpdateView$django.views.generic.edit.UpdateView"=django.views.generic.detail.SingleObjectTemplateResponseMixin"(django.views.generic.edit.BaseUpdateViewro
+template_name_suffix9django.views.generic.edit.UpdateView.template_name_suffix
+builtins.str"builtins.str
+ListView"django.views.generic.list.ListView"=django.views.generic.list.MultipleObjectTemplateResponseMixin"&django.views.generic.list.BaseListViewM
+GenericViewError%django.views.generic.GenericViewError"builtins.Exception*u
__path__django.views.generic.__path__J
builtins.list[builtins.str]
builtins.str"builtins.str"
builtins.list*
diff --git a/python-frontend/typeshed_serializer/checksums/custom.checksum b/python-frontend/typeshed_serializer/checksums/custom.checksum
index 736af910e..fe1f5520e 100644
--- a/python-frontend/typeshed_serializer/checksums/custom.checksum
+++ b/python-frontend/typeshed_serializer/checksums/custom.checksum
@@ -1,2 +1,2 @@
-5264643196941521a2f81ff6d51c750007ab196d892c0c113697b51b17985c82
-1ce0cddf4561cb6431cb0d5e7e4dbd30c96cfcf6397241185edf2d3ba452aaef
\ No newline at end of file
+ff99a5ab4ee8349e8b21eeb3668e2e4990aa198e31cc27ca31aee9299e0bed67
+5ed498acd62426c597e0c2ab3ff0ab186b5cc72f27538e9d7627a996c36f4568
\ No newline at end of file
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/__init__.pyi b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/__init__.pyi
index bec54e706..4f3d6ed07 100644
--- a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/__init__.pyi
+++ b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/__init__.pyi
@@ -1,5 +1,19 @@
import django.views.generic.base as base
-from django.views.generic.base import View as View
+from django.views.generic.base import RedirectView as RedirectView
from django.views.generic.base import TemplateView as TemplateView
-from django.views.generic.list import ListView as ListView
+from django.views.generic.base import View as View
+from django.views.generic.dates import ArchiveIndexView as ArchiveIndexView
+from django.views.generic.dates import DateDetailView as DateDetailView
+from django.views.generic.dates import DayArchiveView as DayArchiveView
+from django.views.generic.dates import MonthArchiveView as MonthArchiveView
+from django.views.generic.dates import TodayArchiveView as TodayArchiveView
+from django.views.generic.dates import WeekArchiveView as WeekArchiveView
+from django.views.generic.dates import YearArchiveView as YearArchiveView
from django.views.generic.detail import DetailView as DetailView
+from django.views.generic.edit import CreateView as CreateView
+from django.views.generic.edit import DeleteView as DeleteView
+from django.views.generic.edit import FormView as FormView
+from django.views.generic.edit import UpdateView as UpdateView
+from django.views.generic.list import ListView as ListView
+
+class GenericViewError(Exception): ...
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/base.pyi b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/base.pyi
index 61b9730c7..319e9169b 100644
--- a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/base.pyi
+++ b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/base.pyi
@@ -1,7 +1,59 @@
+from typing import Any, Callable, Dict, List, Optional, Type
+
+from django.http.request import HttpRequest
+from django.http.response import HttpResponse
+
+class ContextMixin:
+ extra_context: Optional[Dict[str, Any]]
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: ...
+
class View:
- def dispatch(self, request, *args, **kwargs): ...
- def get_context_data(self, **kwargs): ...
+ http_method_names: List[str]
+ request: HttpRequest
+ args: tuple
+ kwargs: dict
+ def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: ...
+ def dispatch(
+ self, request: HttpRequest, *args: Any, **kwargs: Any
+ ) -> HttpResponse: ...
+ def http_method_not_allowed(
+ self, request: HttpRequest, *args: Any, **kwargs: Any
+ ) -> HttpResponse: ...
+ def options(
+ self, request: HttpRequest, *args: Any, **kwargs: Any
+ ) -> HttpResponse: ...
+ @classmethod
+ def as_view(cls, **initkwargs: Any) -> Callable[..., HttpResponse]: ...
+
+class TemplateResponseMixin:
+ template_name: Optional[str]
+ template_engine: Optional[str]
+ response_class: Type[HttpResponse]
+ content_type: Optional[str]
+ def render_to_response(
+ self, context: Dict[str, Any], **response_kwargs: Any
+ ) -> HttpResponse: ...
+ def get_template_names(self) -> List[str]: ...
+
+class TemplateView(TemplateResponseMixin, ContextMixin, View):
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
-class TemplateView(View):
- template_name: str
- def get_context_data(self, **kwargs): ...
+class RedirectView(View):
+ permanent: bool
+ url: Optional[str]
+ pattern_name: Optional[str]
+ query_string: bool
+ def get_redirect_url(self, *args: Any, **kwargs: Any) -> Optional[str]: ...
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+ def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+ def options(
+ self, request: HttpRequest, *args: Any, **kwargs: Any
+ ) -> HttpResponse: ...
+ def delete(
+ self, request: HttpRequest, *args: Any, **kwargs: Any
+ ) -> HttpResponse: ...
+ def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+ def patch(
+ self, request: HttpRequest, *args: Any, **kwargs: Any
+ ) -> HttpResponse: ...
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/dates.pyi b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/dates.pyi
new file mode 100644
index 000000000..af3a61d06
--- /dev/null
+++ b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/dates.pyi
@@ -0,0 +1,115 @@
+import datetime
+from typing import Any, Dict, List, Optional, Tuple, Type
+
+from django.core.paginator import Paginator
+from django.db.models import Model
+from django.http.request import HttpRequest
+from django.http.response import HttpResponse
+from django.views.generic.base import View
+from django.views.generic.detail import (
+ BaseDetailView,
+ SingleObjectTemplateResponseMixin,
+)
+from django.views.generic.list import (
+ MultipleObjectMixin,
+ MultipleObjectTemplateResponseMixin,
+)
+
+class YearMixin:
+ year_format: str
+ year: Optional[str]
+ def get_year_format(self) -> str: ...
+ def get_year(self) -> str: ...
+ def get_next_year(self, date: datetime.date) -> Optional[datetime.date]: ...
+ def get_previous_year(self, date: datetime.date) -> Optional[datetime.date]: ...
+
+class MonthMixin:
+ month_format: str
+ month: Optional[str]
+ def get_month_format(self) -> str: ...
+ def get_month(self) -> str: ...
+ def get_next_month(self, date: datetime.date) -> Optional[datetime.date]: ...
+ def get_previous_month(self, date: datetime.date) -> Optional[datetime.date]: ...
+
+class DayMixin:
+ day_format: str
+ day: Optional[str]
+ def get_day_format(self) -> str: ...
+ def get_day(self) -> str: ...
+ def get_next_day(self, date: datetime.date) -> Optional[datetime.date]: ...
+ def get_previous_day(self, date: datetime.date) -> Optional[datetime.date]: ...
+
+class WeekMixin:
+ week_format: str
+ week: Optional[str]
+ def get_week_format(self) -> str: ...
+ def get_week(self) -> str: ...
+ def get_next_week(self, date: datetime.date) -> Optional[datetime.date]: ...
+ def get_previous_week(self, date: datetime.date) -> Optional[datetime.date]: ...
+
+class DateMixin:
+ date_field: Optional[str]
+ allow_future: bool
+ uses_datetime_field: bool
+ def get_date_field(self) -> str: ...
+ def get_allow_future(self) -> bool: ...
+
+class BaseDateListView(MultipleObjectMixin, DateMixin, View):
+ allow_empty: bool
+ date_list_period: str
+ date_list: Any
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+ def get_dated_items(self) -> Tuple[Any, Any, Dict[str, Any]]: ...
+ def get_ordering(self) -> Optional[Any]: ...
+ def get_dated_queryset(self, **lookup: Any) -> Any: ...
+ def get_date_list_period(self) -> str: ...
+ def get_date_list(
+ self, queryset: Any, date_type: Optional[str] = ..., ordering: str = ...
+ ) -> Any: ...
+
+class BaseArchiveIndexView(BaseDateListView):
+ context_object_name: str
+ def get_dated_items(self) -> Tuple[Any, Any, Dict[str, Any]]: ...
+
+class ArchiveIndexView(MultipleObjectTemplateResponseMixin, BaseArchiveIndexView):
+ template_name_suffix: str
+
+class BaseYearArchiveView(YearMixin, BaseDateListView):
+ date_list_period: str
+ make_object_list: bool
+ def get_dated_items(self) -> Tuple[Any, Any, Dict[str, Any]]: ...
+ def get_make_object_list(self) -> bool: ...
+
+class YearArchiveView(MultipleObjectTemplateResponseMixin, BaseYearArchiveView):
+ template_name_suffix: str
+
+class BaseMonthArchiveView(YearMixin, MonthMixin, BaseDateListView):
+ date_list_period: str
+ def get_dated_items(self) -> Tuple[Any, Any, Dict[str, Any]]: ...
+
+class MonthArchiveView(MultipleObjectTemplateResponseMixin, BaseMonthArchiveView):
+ template_name_suffix: str
+
+class BaseWeekArchiveView(YearMixin, WeekMixin, BaseDateListView):
+ def get_dated_items(self) -> Tuple[Any, Any, Dict[str, Any]]: ...
+
+class WeekArchiveView(MultipleObjectTemplateResponseMixin, BaseWeekArchiveView):
+ template_name_suffix: str
+
+class BaseDayArchiveView(YearMixin, MonthMixin, DayMixin, BaseDateListView):
+ def get_dated_items(self) -> Tuple[Any, Any, Dict[str, Any]]: ...
+
+class DayArchiveView(MultipleObjectTemplateResponseMixin, BaseDayArchiveView):
+ template_name_suffix: str
+
+class BaseTodayArchiveView(BaseDayArchiveView):
+ def get_dated_items(self) -> Tuple[Any, Any, Dict[str, Any]]: ...
+
+class TodayArchiveView(MultipleObjectTemplateResponseMixin, BaseTodayArchiveView):
+ template_name_suffix: str
+
+class BaseDateDetailView(YearMixin, MonthMixin, DayMixin, DateMixin, BaseDetailView):
+ def get_object(self, queryset: Optional[Any] = ...) -> Any: ...
+
+class DateDetailView(SingleObjectTemplateResponseMixin, BaseDateDetailView):
+ template_name_suffix: str
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/detail.pyi b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/detail.pyi
index e82fc016f..5230a8e57 100644
--- a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/detail.pyi
+++ b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/detail.pyi
@@ -1,5 +1,32 @@
-from django.views.generic.base import View
+from typing import Any, Dict, List, Optional, Type
-class DetailView(View):
- model: type
- def get_context_data(self, **kwargs): ...
+from django.db.models import Model
+from django.http.request import HttpRequest
+from django.http.response import HttpResponse
+from django.views.generic.base import ContextMixin, TemplateResponseMixin, View
+
+class SingleObjectMixin(ContextMixin):
+ model: Optional[Type[Model]]
+ queryset: Optional[Any]
+ slug_field: str
+ context_object_name: Optional[str]
+ slug_url_kwarg: str
+ pk_url_kwarg: str
+ query_pk_and_slug: bool
+ object: Any
+ def get_object(self, queryset: Optional[Any] = ...) -> Any: ...
+ def get_queryset(self) -> Any: ...
+ def get_slug_field(self) -> str: ...
+ def get_context_object_name(self, obj: Any) -> Optional[str]: ...
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: ...
+
+class BaseDetailView(SingleObjectMixin, View):
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+
+class SingleObjectTemplateResponseMixin(TemplateResponseMixin):
+ template_name_field: Optional[str]
+ template_name_suffix: str
+ def get_template_names(self) -> List[str]: ...
+
+class DetailView(SingleObjectTemplateResponseMixin, BaseDetailView):
+ pass
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/edit.pyi b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/edit.pyi
new file mode 100644
index 000000000..97f3742fa
--- /dev/null
+++ b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/edit.pyi
@@ -0,0 +1,74 @@
+from typing import Any, Dict, List, Optional, Type
+
+from django.forms import Form
+from django.http.request import HttpRequest
+from django.http.response import HttpResponse
+from django.views.generic.base import ContextMixin, TemplateResponseMixin, View
+from django.views.generic.detail import (
+ BaseDetailView,
+ SingleObjectMixin,
+ SingleObjectTemplateResponseMixin,
+)
+
+class FormMixin(ContextMixin):
+ initial: Dict[str, Any]
+ form_class: Optional[Type[Form]]
+ success_url: Optional[str]
+ prefix: Optional[str]
+ def get_initial(self) -> Dict[str, Any]: ...
+ def get_prefix(self) -> Optional[str]: ...
+ def get_form_class(self) -> Type[Form]: ...
+ def get_form(self, form_class: Optional[Type[Form]] = ...) -> Form: ...
+ def get_form_kwargs(self) -> Dict[str, Any]: ...
+ def get_success_url(self) -> str: ...
+ def form_valid(self, form: Form) -> HttpResponse: ...
+ def form_invalid(self, form: Form) -> HttpResponse: ...
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: ...
+
+class ModelFormMixin(FormMixin, SingleObjectMixin):
+ fields: Optional[Any]
+ def get_form_class(self) -> Type[Form]: ...
+ def get_form_kwargs(self) -> Dict[str, Any]: ...
+ def get_success_url(self) -> str: ...
+ def form_valid(self, form: Form) -> HttpResponse: ...
+
+class ProcessFormView(View):
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+ def put(self, *args: Any, **kwargs: Any) -> HttpResponse: ...
+
+class BaseFormView(FormMixin, ProcessFormView):
+ pass
+
+class FormView(TemplateResponseMixin, BaseFormView):
+ pass
+
+class BaseCreateView(ModelFormMixin, ProcessFormView):
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+
+class CreateView(SingleObjectTemplateResponseMixin, BaseCreateView):
+ template_name_suffix: str
+
+class BaseUpdateView(ModelFormMixin, ProcessFormView):
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+
+class UpdateView(SingleObjectTemplateResponseMixin, BaseUpdateView):
+ template_name_suffix: str
+
+class DeletionMixin:
+ success_url: Optional[str]
+ def delete(
+ self, request: HttpRequest, *args: Any, **kwargs: Any
+ ) -> HttpResponse: ...
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+ def get_success_url(self) -> str: ...
+
+class BaseDeleteView(DeletionMixin, FormMixin, BaseDetailView):
+ form_class: Type[Form]
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+ def form_valid(self, form: Form) -> HttpResponse: ...
+
+class DeleteView(SingleObjectTemplateResponseMixin, BaseDeleteView):
+ template_name_suffix: str
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/list.pyi b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/list.pyi
index 73ed1996f..add51a734 100644
--- a/python-frontend/typeshed_serializer/resources/custom/django/views/generic/list.pyi
+++ b/python-frontend/typeshed_serializer/resources/custom/django/views/generic/list.pyi
@@ -1,5 +1,49 @@
-from django.views.generic.base import View
+from typing import Any, Dict, List, Optional, Tuple, Type
-class ListView(View):
- model: type
- def get_context_data(self, **kwargs): ...
+from django.core.paginator import Paginator
+from django.db.models import Model
+from django.http.request import HttpRequest
+from django.http.response import HttpResponse
+from django.views.generic.base import ContextMixin, TemplateResponseMixin, View
+
+class MultipleObjectMixin(ContextMixin):
+ allow_empty: bool
+ queryset: Optional[Any]
+ model: Optional[Type[Model]]
+ paginate_by: Optional[int]
+ paginate_orphans: int
+ context_object_name: Optional[str]
+ paginator_class: Type[Paginator]
+ page_kwarg: str
+ ordering: Optional[Any]
+ object_list: Any
+ def get_queryset(self) -> Any: ...
+ def get_ordering(self) -> Optional[Any]: ...
+ def paginate_queryset(
+ self, queryset: Any, page_size: int
+ ) -> Tuple[Paginator, Any, Any, bool]: ...
+ def get_paginate_by(self, queryset: Any) -> Optional[int]: ...
+ def get_paginator(
+ self,
+ queryset: Any,
+ per_page: int,
+ orphans: int = ...,
+ allow_empty_first_page: bool = ...,
+ **kwargs: Any,
+ ) -> Paginator: ...
+ def get_paginate_orphans(self) -> int: ...
+ def get_allow_empty(self) -> bool: ...
+ def get_context_object_name(self, object_list: Any) -> Optional[str]: ...
+ def get_context_data(
+ self, *, object_list: Optional[Any] = ..., **kwargs: Any
+ ) -> Dict[str, Any]: ...
+
+class BaseListView(MultipleObjectMixin, View):
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ...
+
+class MultipleObjectTemplateResponseMixin(TemplateResponseMixin):
+ template_name_suffix: str
+ def get_template_names(self) -> List[str]: ...
+
+class ListView(MultipleObjectTemplateResponseMixin, BaseListView):
+ pass
diff --git a/python-frontend/typeshed_serializer/tests/test_serializers.py b/python-frontend/typeshed_serializer/tests/test_serializers.py
index 0ca5bd184..1d8853a28 100644
--- a/python-frontend/typeshed_serializer/tests/test_serializers.py
+++ b/python-frontend/typeshed_serializer/tests/test_serializers.py
@@ -74,7 +74,7 @@ def test_custom_stubs_serializer(typeshed_custom_stubs):
custom_stubs_serializer.serialize()
assert custom_stubs_serializer.get_build_result.call_count == 1
# Not every files from "typeshed_custom_stubs" build are serialized, as some are builtins
- assert symbols.save_module.call_count == 333
+ assert symbols.save_module.call_count == 335
def test_importer_serializer():
From adade472df66734099e37c0cb2df386d7a90db7f Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Mon, 2 Mar 2026 09:58:26 +0100
Subject: [PATCH 009/322] SONARPY-3859 Removed unused import
GitOrigin-RevId: adf8b8963b3c83eb761fd0baf7856a455f674296
---
.../org/sonar/python/checks/GenericExceptionRaisedCheck.java | 1 -
1 file changed, 1 deletion(-)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java b/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java
index c707d504e..f50d81bc5 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java
@@ -26,7 +26,6 @@
import org.sonar.plugins.python.api.tree.RaiseStatement;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.Tree.Kind;
-import org.sonar.plugins.python.api.types.v2.PythonType;
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
import org.sonar.python.tree.TreeUtils;
From 326240ce40dfd22a1163f3da1602e602e636ff54 Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Mon, 2 Mar 2026 14:41:04 +0100
Subject: [PATCH 010/322] SONARPY-3768 Create rule S8438: Django view functions
should declare URL parameters explicitly (#872)
Co-authored-by: Claude Opus 4.5
GitOrigin-RevId: 3976c172a3fe0f38fbdef787ebf2e8471cc8d84d
---
.../checks/FastAPIPathParametersCheck.java | 58 +-----------
.../checks/utils/FunctionParameterUtils.java | 86 +++++++++++++++++
.../plugins/python/api/DjangoViewInfo.java | 45 +++++++++
.../python/api/PythonInputFileContext.java | 4 +
.../python/api/PythonVisitorContext.java | 4 +
.../python/api/SubscriptionContext.java | 8 ++
.../org/sonar/python/SubscriptionVisitor.java | 7 ++
.../python/semantic/FunctionSymbolImpl.java | 13 +--
.../semantic/ProjectLevelSymbolTable.java | 94 ++++++++++++++-----
.../java/org/sonar/python/semantic/Scope.java | 3 +-
.../semantic/ProjectLevelSymbolTableTest.java | 87 +++++++++++++++++
11 files changed, 325 insertions(+), 84 deletions(-)
create mode 100644 python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java
create mode 100644 python-frontend/src/main/java/org/sonar/plugins/python/api/DjangoViewInfo.java
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FastAPIPathParametersCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FastAPIPathParametersCheck.java
index 6dda10e40..32facaf51 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FastAPIPathParametersCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FastAPIPathParametersCheck.java
@@ -31,12 +31,11 @@
import org.sonar.plugins.python.api.tree.Expression;
import org.sonar.plugins.python.api.tree.FunctionDef;
import org.sonar.plugins.python.api.tree.Tree;
-import org.sonar.plugins.python.api.types.v2.FunctionType;
-import org.sonar.plugins.python.api.types.v2.ParameterV2;
-import org.sonar.plugins.python.api.types.v2.PythonType;
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
import org.sonar.python.checks.utils.Expressions;
+import org.sonar.python.checks.utils.FunctionParameterUtils;
+import org.sonar.python.checks.utils.FunctionParameterUtils.FunctionParameterInfo;
import org.sonar.python.tree.TreeUtils;
@Rule(key = "S8411")
@@ -57,12 +56,6 @@ public class FastAPIPathParametersCheck extends PythonSubscriptionCheck {
TypeMatchers.isType("fastapi.APIRouter." + method)))
);
- private record FunctionParameterInfo(Set allParams, Set positionalOnlyParams, boolean hasVariadicKeyword) {
- static FunctionParameterInfo empty() {
- return new FunctionParameterInfo(Set.of(), Set.of(), false);
- }
- }
-
@Override
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, FastAPIPathParametersCheck::checkFunction);
@@ -90,7 +83,7 @@ private static void checkDecorator(SubscriptionContext ctx, Decorator decorator,
return;
}
- FunctionParameterInfo paramInfo = extractFunctionParameters(functionDef);
+ FunctionParameterInfo paramInfo = FunctionParameterUtils.extractFunctionParameters(functionDef);
reportIssues(ctx, functionDef, pathParams, paramInfo);
}
@@ -114,54 +107,13 @@ private static Optional extractStringValue(Expression expression) {
.map(Expressions::unescape);
}
- private static FunctionParameterInfo extractFunctionParameters(FunctionDef functionDef) {
- return getFunctionType(functionDef)
- .map(FastAPIPathParametersCheck::buildParameterInfo)
- .orElse(FunctionParameterInfo.empty());
- }
-
- private static Optional getFunctionType(FunctionDef functionDef) {
- PythonType functionType = functionDef.name().typeV2();
- if (functionType instanceof FunctionType funcType) {
- return Optional.of(funcType);
- }
- return Optional.empty();
- }
-
- private static FunctionParameterInfo buildParameterInfo(FunctionType functionType) {
- Set allParams = new HashSet<>();
- Set positionalOnlyParams = new HashSet<>();
- boolean hasVariadicKeyword = functionType.parameters().stream()
- .anyMatch(param -> param.isVariadic() && param.isKeywordVariadic());
-
- functionType.parameters().stream()
- .filter(param -> !param.isVariadic())
- .forEach(param -> addParameter(param, allParams, positionalOnlyParams));
-
- return new FunctionParameterInfo(allParams, positionalOnlyParams, hasVariadicKeyword);
- }
-
- private static void addParameter(ParameterV2 param, Set allParams, Set positionalOnlyParams) {
- String paramName = param.name();
- if (paramName != null) {
- allParams.add(paramName);
- if (param.isPositionalOnly()) {
- positionalOnlyParams.add(paramName);
- }
- }
- }
-
private static void reportIssues(SubscriptionContext ctx, FunctionDef functionDef, Set pathParams, FunctionParameterInfo paramInfo) {
pathParams.stream()
- .filter(param -> isMissingFromSignature(param, paramInfo))
+ .filter(paramInfo::isMissingFromSignature)
.forEach(param -> ctx.addIssue(functionDef.name(), String.format(MISSING_PARAM_MESSAGE, param)));
pathParams.stream()
- .filter(paramInfo.positionalOnlyParams::contains)
+ .filter(paramInfo.positionalOnlyParams()::contains)
.forEach(param -> ctx.addIssue(functionDef.name(), String.format(POSITIONAL_ONLY_MESSAGE, param)));
}
-
- private static boolean isMissingFromSignature(String pathParam, FunctionParameterInfo paramInfo) {
- return !paramInfo.allParams.contains(pathParam) && !paramInfo.hasVariadicKeyword;
- }
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java b/python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java
new file mode 100644
index 000000000..11021fd9f
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java
@@ -0,0 +1,86 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) 2011-2025 SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the Sonar Source-Available License for more details.
+ *
+ * You should have received a copy of the Sonar Source-Available License
+ * along with this program; if not, see https://sonarsource.com/license/ssal/
+ */
+package org.sonar.python.checks.utils;
+
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+import org.sonar.plugins.python.api.tree.FunctionDef;
+import org.sonar.plugins.python.api.types.v2.FunctionType;
+import org.sonar.plugins.python.api.types.v2.ParameterV2;
+import org.sonar.plugins.python.api.types.v2.PythonType;
+
+/**
+ * Utility class for extracting function parameter information.
+ * Shared between FastAPIPathParametersCheck and DjangoViewUrlParametersCheck.
+ */
+public final class FunctionParameterUtils {
+
+ private FunctionParameterUtils() {
+ // Utility class
+ }
+
+ /**
+ * Information about a function's parameters for path/URL parameter checks.
+ */
+ public record FunctionParameterInfo(Set allParams, Set positionalOnlyParams, boolean hasVariadicKeyword) {
+ public static FunctionParameterInfo empty() {
+ return new FunctionParameterInfo(Set.of(), Set.of(), false);
+ }
+
+ public boolean isMissingFromSignature(String param) {
+ return !allParams.contains(param) && !hasVariadicKeyword;
+ }
+ }
+
+ public static FunctionParameterInfo extractFunctionParameters(FunctionDef functionDef) {
+ return getFunctionType(functionDef)
+ .map(FunctionParameterUtils::buildParameterInfo)
+ .orElse(FunctionParameterInfo.empty());
+ }
+
+ public static Optional getFunctionType(FunctionDef functionDef) {
+ PythonType functionType = functionDef.name().typeV2();
+ if (functionType instanceof FunctionType funcType) {
+ return Optional.of(funcType);
+ }
+ return Optional.empty();
+ }
+
+ private static FunctionParameterInfo buildParameterInfo(FunctionType functionType) {
+ Set allParams = new HashSet<>();
+ Set positionalOnlyParams = new HashSet<>();
+ boolean hasVariadicKeyword = functionType.parameters().stream()
+ .anyMatch(param -> param.isVariadic() && param.isKeywordVariadic());
+
+ functionType.parameters().stream()
+ .filter(param -> !param.isVariadic())
+ .forEach(param -> addParameter(param, allParams, positionalOnlyParams));
+
+ return new FunctionParameterInfo(allParams, positionalOnlyParams, hasVariadicKeyword);
+ }
+
+ private static void addParameter(ParameterV2 param, Set allParams, Set positionalOnlyParams) {
+ String paramName = param.name();
+ if (paramName != null) {
+ allParams.add(paramName);
+ if (param.isPositionalOnly()) {
+ positionalOnlyParams.add(paramName);
+ }
+ }
+ }
+}
diff --git a/python-frontend/src/main/java/org/sonar/plugins/python/api/DjangoViewInfo.java b/python-frontend/src/main/java/org/sonar/plugins/python/api/DjangoViewInfo.java
new file mode 100644
index 000000000..4ccf88be5
--- /dev/null
+++ b/python-frontend/src/main/java/org/sonar/plugins/python/api/DjangoViewInfo.java
@@ -0,0 +1,45 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) 2011-2025 SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the Sonar Source-Available License for more details.
+ *
+ * You should have received a copy of the Sonar Source-Available License
+ * along with this program; if not, see https://sonarsource.com/license/ssal/
+ */
+package org.sonar.plugins.python.api;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Holds metadata about a Django view function discovered during project analysis.
+ */
+public record DjangoViewInfo(Set urlPatterns) {
+
+ public DjangoViewInfo {
+ // Defensive copy to ensure immutability
+ urlPatterns = Set.copyOf(urlPatterns);
+ }
+
+ public static DjangoViewInfo withoutPatterns() {
+ return new DjangoViewInfo(Set.of());
+ }
+
+ public static DjangoViewInfo withPattern(String urlPattern) {
+ return new DjangoViewInfo(Set.of(urlPattern));
+ }
+
+ public DjangoViewInfo addPattern(String urlPattern) {
+ var newPatterns = new HashSet<>(urlPatterns);
+ newPatterns.add(urlPattern);
+ return new DjangoViewInfo(newPatterns);
+ }
+}
diff --git a/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonInputFileContext.java b/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonInputFileContext.java
index e3321c73d..8494f4754 100644
--- a/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonInputFileContext.java
+++ b/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonInputFileContext.java
@@ -74,4 +74,8 @@ public File workingDirectory() {
public SonarProduct sonarProduct() {
return sonarProduct;
}
+
+ protected ProjectLevelSymbolTable projectLevelSymbolTable() {
+ return projectLevelSymbolTable;
+ }
}
diff --git a/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonVisitorContext.java b/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonVisitorContext.java
index 3e00fb1d8..0c16653a3 100644
--- a/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonVisitorContext.java
+++ b/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonVisitorContext.java
@@ -124,6 +124,10 @@ public CallGraph callGraph() {
return callGraph;
}
+ public Optional getDjangoViewInfo(String fqn) {
+ return projectLevelSymbolTable().getDjangoViewInfo(fqn);
+ }
+
public static class Builder {
private final PythonFile pythonFile;
private final FileInput rootTree;
diff --git a/python-frontend/src/main/java/org/sonar/plugins/python/api/SubscriptionContext.java b/python-frontend/src/main/java/org/sonar/plugins/python/api/SubscriptionContext.java
index 273eecd58..38ae5a3b0 100644
--- a/python-frontend/src/main/java/org/sonar/plugins/python/api/SubscriptionContext.java
+++ b/python-frontend/src/main/java/org/sonar/plugins/python/api/SubscriptionContext.java
@@ -19,6 +19,7 @@
import com.google.common.annotations.Beta;
import java.io.File;
import java.util.Collection;
+import java.util.Optional;
import java.util.Set;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
@@ -76,4 +77,11 @@ public interface SubscriptionContext {
ProjectConfiguration projectConfiguration();
CallGraph callGraph();
+
+ /**
+ * Returns Django view information for the given fully qualified function name.
+ * @param fqn the fully qualified name of a function
+ * @return Optional containing DjangoViewInfo if the function is a Django view, empty otherwise
+ */
+ Optional getDjangoViewInfo(String fqn);
}
diff --git a/python-frontend/src/main/java/org/sonar/python/SubscriptionVisitor.java b/python-frontend/src/main/java/org/sonar/python/SubscriptionVisitor.java
index a87027018..ddd245bec 100644
--- a/python-frontend/src/main/java/org/sonar/python/SubscriptionVisitor.java
+++ b/python-frontend/src/main/java/org/sonar/python/SubscriptionVisitor.java
@@ -25,10 +25,12 @@
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
+import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
+import org.sonar.plugins.python.api.DjangoViewInfo;
import org.sonar.plugins.python.api.IssueLocation;
import org.sonar.plugins.python.api.LocationInFile;
import org.sonar.plugins.python.api.ProjectPythonVersion;
@@ -207,5 +209,10 @@ public RegexParseResult regexForStringElement(StringElement stringElement, FlagS
return regexCache.computeIfAbsent(stringElement.hashCode() + "-" + flagSet.getMask(),
s -> new RegexParser(new PythonAnalyzerRegexSource(stringElement), flagSet).parse());
}
+
+ @Override
+ public Optional getDjangoViewInfo(String fqn) {
+ return pythonVisitorContext.getDjangoViewInfo(fqn);
+ }
}
}
diff --git a/python-frontend/src/main/java/org/sonar/python/semantic/FunctionSymbolImpl.java b/python-frontend/src/main/java/org/sonar/python/semantic/FunctionSymbolImpl.java
index 2b0b0426f..305605a3a 100644
--- a/python-frontend/src/main/java/org/sonar/python/semantic/FunctionSymbolImpl.java
+++ b/python-frontend/src/main/java/org/sonar/python/semantic/FunctionSymbolImpl.java
@@ -24,6 +24,7 @@
import java.util.Optional;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
+import org.sonar.plugins.python.api.DjangoViewInfo;
import org.sonar.plugins.python.api.LocationInFile;
import org.sonar.plugins.python.api.PythonFile;
import org.sonar.plugins.python.api.symbols.FunctionSymbol;
@@ -64,7 +65,8 @@ public class FunctionSymbolImpl extends SymbolImpl implements FunctionSymbol {
private Symbol owner;
private static final String CLASS_METHOD_DECORATOR = "classmethod";
private static final String STATIC_METHOD_DECORATOR = "staticmethod";
- private boolean isDjangoView = false;
+ @Nullable
+ private DjangoViewInfo djangoViewInfo = null;
private boolean hasReadDeclaredReturnType = false;
FunctionSymbolImpl(FunctionDef functionDef, @Nullable String fullyQualifiedName, PythonFile pythonFile) {
@@ -121,7 +123,6 @@ public FunctionSymbolImpl(SymbolsProtos.FunctionSymbol functionSymbolProto, @Nul
functionDefinitionLocation = null;
declaredReturnType = anyType();
isStub = true;
- isDjangoView = false;
this.validForPythonVersions = new HashSet<>(validFor);
}
@@ -168,7 +169,7 @@ public void addParameter(ParameterImpl parameter) {
declaredReturnType = functionSymbolImpl.declaredReturnType();
}
isStub = functionSymbol.isStub();
- isDjangoView = functionSymbolImpl.isDjangoView();
+ djangoViewInfo = functionSymbolImpl.djangoViewInfo;
validForPythonVersions = functionSymbolImpl.validForPythonVersions;
}
@@ -361,11 +362,11 @@ public void setOwner(Symbol owner) {
}
public boolean isDjangoView() {
- return isDjangoView;
+ return djangoViewInfo != null;
}
- public void setIsDjangoView(boolean isDjangoView) {
- this.isDjangoView = isDjangoView;
+ public void setDjangoViewInfo(@Nullable DjangoViewInfo djangoViewInfo) {
+ this.djangoViewInfo = djangoViewInfo;
}
public static class ParameterImpl implements Parameter {
diff --git a/python-frontend/src/main/java/org/sonar/python/semantic/ProjectLevelSymbolTable.java b/python-frontend/src/main/java/org/sonar/python/semantic/ProjectLevelSymbolTable.java
index ef11777ab..419a5689c 100644
--- a/python-frontend/src/main/java/org/sonar/python/semantic/ProjectLevelSymbolTable.java
+++ b/python-frontend/src/main/java/org/sonar/python/semantic/ProjectLevelSymbolTable.java
@@ -22,12 +22,14 @@
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
+import org.sonar.plugins.python.api.DjangoViewInfo;
import org.sonar.plugins.python.api.PythonFile;
import org.sonar.plugins.python.api.TriBool;
import org.sonar.plugins.python.api.symbols.Symbol;
@@ -36,6 +38,8 @@
import org.sonar.plugins.python.api.tree.CallExpression;
import org.sonar.plugins.python.api.tree.FileInput;
import org.sonar.plugins.python.api.tree.RegularArgument;
+import org.sonar.plugins.python.api.tree.StringLiteral;
+import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.types.v2.FunctionType;
import org.sonar.plugins.python.api.types.v2.PythonType;
import org.sonar.plugins.python.api.types.v2.UnknownType;
@@ -58,7 +62,7 @@ public class ProjectLevelSymbolTable {
private final PythonTypeToDescriptorConverter pythonTypeToDescriptorConverter;
private final Map> globalDescriptorsByModuleName;
private Map globalDescriptorsByFQN;
- private final Set djangoViewsFQN;
+ private final Map djangoViews;
private final Map> importsByModule;
private final Set projectBasePackages;
private TypeShedDescriptorsProvider typeShedDescriptorsProvider = null;
@@ -82,7 +86,7 @@ public static ProjectLevelSymbolTable from(Map> globalDe
private ProjectLevelSymbolTable() {
this.pythonTypeToDescriptorConverter = new PythonTypeToDescriptorConverter();
this.globalDescriptorsByModuleName = new ConcurrentHashMap<>();
- this.djangoViewsFQN = new HashSet<>();
+ this.djangoViews = new ConcurrentHashMap<>();
this.importsByModule = new ConcurrentHashMap<>();
this.projectBasePackages = new HashSet<>();
}
@@ -115,7 +119,7 @@ public void addModule(FileInput fileInput, String packageName, PythonFile python
globalDescriptorsByModuleName.merge(fullyQualifiedModuleName, moduleDescriptors, ProjectLevelSymbolTable::mergeDescriptors);
addModuleToGlobalSymbolsByFQN(moduleDescriptors);
- DjangoViewsVisitor djangoViewsVisitor = new DjangoViewsVisitor(fullyQualifiedModuleName);
+ DjangoViewsVisitor djangoViewsVisitor = new DjangoViewsVisitor();
fileInput.accept(djangoViewsVisitor);
}
@@ -215,12 +219,15 @@ public Set descriptorsForModule(String moduleName) {
return globalDescriptorsByModuleName.get(moduleName);
}
- private synchronized void addDjangoView(String fqn) {
- djangoViewsFQN.add(fqn);
+ public boolean isDjangoView(@Nullable String fqn) {
+ return fqn != null && djangoViews.containsKey(fqn);
}
- public boolean isDjangoView(@Nullable String fqn) {
- return djangoViewsFQN.contains(fqn);
+ public Optional getDjangoViewInfo(@Nullable String fqn) {
+ if (fqn == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(djangoViews.get(fqn));
}
public synchronized void addProjectPackage(String projectPackage) {
@@ -274,43 +281,82 @@ public Collection stubFilesSymbols() {
private class DjangoViewsVisitor extends BaseTreeVisitor {
- String fullyQualifiedModuleName;
private TypeCheckBuilder confPathCall = null;
private TypeCheckBuilder pathCall = null;
-
- public DjangoViewsVisitor(String fullyQualifiedModuleName) {
- this.fullyQualifiedModuleName = fullyQualifiedModuleName;
- }
+ private TypeCheckBuilder confRePathCall = null;
+ private TypeCheckBuilder rePathCall = null;
@Override
public void visitFileInput(FileInput fileInput) {
TypeChecker typeChecker = new TypeChecker(new BasicTypeTable(new ProjectLevelTypeTable(ProjectLevelSymbolTable.this)));
confPathCall = typeChecker.typeCheckBuilder().isTypeWithName("django.urls.conf.path");
pathCall = typeChecker.typeCheckBuilder().isTypeWithName("django.urls.path");
+ confRePathCall = typeChecker.typeCheckBuilder().isTypeWithName("django.urls.conf.re_path");
+ rePathCall = typeChecker.typeCheckBuilder().isTypeWithName("django.urls.re_path");
super.visitFileInput(fileInput);
}
@Override
public void visitCallExpression(CallExpression callExpression) {
super.visitCallExpression(callExpression);
- if (isCallRegisteringDjangoView(callExpression)) {
- RegularArgument viewArgument = nthArgumentOrKeyword(1, "view", callExpression.arguments());
- if (viewArgument != null) {
- PythonType pythonType = viewArgument.expression().typeV2();
- if (pythonType instanceof UnknownType.UnresolvedImportType unresolvedImportType) {
- String importPath = unresolvedImportType.importPath();
- addDjangoView(importPath);
- } else if (pythonType instanceof FunctionType functionType) {
- addDjangoView(functionType.fullyQualifiedName());
- }
- }
+ if (!isCallRegisteringDjangoView(callExpression)) {
+ return;
}
+
+ extractViewFqnFromArgument(callExpression)
+ .ifPresent(fqn -> registerDjangoView(fqn, extractUrlPattern(callExpression)));
+ }
+
+ private Optional extractViewFqnFromArgument(CallExpression callExpression) {
+ return extractArgument(callExpression, 1, "view")
+ .map(arg -> arg.expression().typeV2())
+ .flatMap(this::extractFullyQualifiedName);
+ }
+
+ private Optional extractArgument(CallExpression callExpression, int position, String name) {
+ return Optional.ofNullable(nthArgumentOrKeyword(position, name, callExpression.arguments()));
+ }
+
+ private Optional extractFullyQualifiedName(PythonType pythonType) {
+ if (pythonType instanceof UnknownType.UnresolvedImportType unresolvedImportType) {
+ return Optional.of(unresolvedImportType.importPath());
+ } else if (pythonType instanceof FunctionType functionType) {
+ return Optional.ofNullable(functionType.fullyQualifiedName());
+ }
+ return Optional.empty();
+ }
+
+ private void registerDjangoView(String fqn, @Nullable String urlPattern) {
+ addDjangoView(fqn);
+ if (urlPattern != null) {
+ addDjangoViewUrlPattern(fqn, urlPattern);
+ }
+ }
+
+ @CheckForNull
+ private String extractUrlPattern(CallExpression callExpression) {
+ return extractArgument(callExpression, 0, "route")
+ .filter(arg -> arg.expression().is(Tree.Kind.STRING_LITERAL))
+ .map(arg -> ((StringLiteral) arg.expression()).trimmedQuotesValue())
+ .orElse(null);
}
private boolean isCallRegisteringDjangoView(CallExpression callExpression) {
TriBool isConfPathCall = confPathCall.check(callExpression.callee().typeV2());
TriBool isPathCall = pathCall.check(callExpression.callee().typeV2());
- return isConfPathCall.equals(TriBool.TRUE) || isPathCall.equals(TriBool.TRUE);
+ TriBool isConfRePathCall = confRePathCall.check(callExpression.callee().typeV2());
+ TriBool isRePathCall = rePathCall.check(callExpression.callee().typeV2());
+ return isConfPathCall.equals(TriBool.TRUE) || isPathCall.equals(TriBool.TRUE)
+ || isConfRePathCall.equals(TriBool.TRUE) || isRePathCall.equals(TriBool.TRUE);
+ }
+
+ private void addDjangoView(String fqn) {
+ djangoViews.computeIfAbsent(fqn, k -> DjangoViewInfo.withoutPatterns());
+ }
+
+ private void addDjangoViewUrlPattern(String fqn, String urlPattern) {
+ djangoViews.compute(fqn, (k, existing) ->
+ existing == null ? DjangoViewInfo.withPattern(urlPattern) : existing.addPattern(urlPattern));
}
}
}
diff --git a/python-frontend/src/main/java/org/sonar/python/semantic/Scope.java b/python-frontend/src/main/java/org/sonar/python/semantic/Scope.java
index 7f03dcbdb..1c380f18d 100644
--- a/python-frontend/src/main/java/org/sonar/python/semantic/Scope.java
+++ b/python-frontend/src/main/java/org/sonar/python/semantic/Scope.java
@@ -114,7 +114,8 @@ void addFunctionSymbol(FunctionDef functionDef, @Nullable String fullyQualifiedN
addBindingUsage(functionDef.name(), Usage.Kind.FUNC_DECLARATION, fullyQualifiedName);
} else {
FunctionSymbolImpl functionSymbol = new FunctionSymbolImpl(functionDef, fullyQualifiedName, pythonFile);
- functionSymbol.setIsDjangoView(projectLevelSymbolTable.isDjangoView(fullyQualifiedName));
+ projectLevelSymbolTable.getDjangoViewInfo(fullyQualifiedName)
+ .ifPresent(functionSymbol::setDjangoViewInfo);
((FunctionDefImpl) functionDef).setFunctionSymbol(functionSymbol);
symbols.add(functionSymbol);
symbolsByName.put(symbolName, functionSymbol);
diff --git a/python-frontend/src/test/java/org/sonar/python/semantic/ProjectLevelSymbolTableTest.java b/python-frontend/src/test/java/org/sonar/python/semantic/ProjectLevelSymbolTableTest.java
index 35611c299..914c96d75 100644
--- a/python-frontend/src/test/java/org/sonar/python/semantic/ProjectLevelSymbolTableTest.java
+++ b/python-frontend/src/test/java/org/sonar/python/semantic/ProjectLevelSymbolTableTest.java
@@ -1263,4 +1263,91 @@ void hasModuleWithPrefix_detectsNamespacePackages() {
assertThat(symbolTable.hasModuleWithPrefix("acme.ma")).isFalse();
}
+ @Test
+ void django_views_with_url_patterns() {
+ String[] urls = {
+ "from django.urls import path",
+ "import views",
+ "urlpatterns = [",
+ " path('article//', views.article_detail, name='article_detail'),",
+ " path('user//post//', views.user_post, name='user_post'),",
+ "]"
+ };
+
+ ProjectLevelSymbolTable projectSymbolTable = empty();
+ projectSymbolTable.addModule(parseWithoutSymbols(urls), "", pythonFile("urls.py"));
+
+ assertThat(projectSymbolTable.isDjangoView("views.article_detail")).isTrue();
+ assertThat(projectSymbolTable.getDjangoViewInfo("views.article_detail"))
+ .isPresent()
+ .hasValueSatisfying(info -> assertThat(info.urlPatterns()).containsExactly("article//"));
+
+ assertThat(projectSymbolTable.isDjangoView("views.user_post")).isTrue();
+ assertThat(projectSymbolTable.getDjangoViewInfo("views.user_post"))
+ .isPresent()
+ .hasValueSatisfying(info -> assertThat(info.urlPatterns()).containsExactly("user//post//"));
+ }
+
+ @Test
+ void django_views_with_empty_url_patterns() {
+ String[] urls = {
+ "from django.urls import path",
+ "import views",
+ "urlpatterns = [path('', views.no_pattern_view)]"
+ };
+
+ ProjectLevelSymbolTable projectSymbolTable = empty();
+ projectSymbolTable.addModule(parseWithoutSymbols(urls), "", pythonFile("urls.py"));
+
+ assertThat(projectSymbolTable.isDjangoView("views.no_pattern_view")).isTrue();
+ assertThat(projectSymbolTable.getDjangoViewInfo("views.no_pattern_view"))
+ .isPresent()
+ .hasValueSatisfying(info -> assertThat(info.urlPatterns()).containsExactly(""));
+ }
+
+ @Test
+ void django_views_with_non_string_route() {
+ // Test case where route argument is not a STRING_LITERAL
+ String[] urls = {
+ "from django.urls import path",
+ "import views",
+ "route_var = 'dynamic-route'",
+ "urlpatterns = [path(route_var, views.dynamic_view)]"
+ };
+
+ ProjectLevelSymbolTable projectSymbolTable = empty();
+ projectSymbolTable.addModule(parseWithoutSymbols(urls), "", pythonFile("urls.py"));
+
+ assertThat(projectSymbolTable.isDjangoView("views.dynamic_view")).isTrue();
+ assertThat(projectSymbolTable.getDjangoViewInfo("views.dynamic_view"))
+ .isPresent()
+ .hasValueSatisfying(info -> assertThat(info.urlPatterns()).isEmpty());
+ }
+
+ @Test
+ void django_views_with_re_path() {
+ // Test re_path() with named groups
+ String[] urls = {
+ "from django.urls import re_path",
+ "import views",
+ "urlpatterns = [",
+ " re_path(r'^items/(?P\\d+)/$', views.item_detail),",
+ " re_path(r'^items/(?P\\d+)/(?P[\\w-]+)/$', views.item_with_slug),",
+ "]"
+ };
+
+ ProjectLevelSymbolTable projectSymbolTable = empty();
+ projectSymbolTable.addModule(parseWithoutSymbols(urls), "", pythonFile("urls.py"));
+
+ assertThat(projectSymbolTable.isDjangoView("views.item_detail")).isTrue();
+ assertThat(projectSymbolTable.getDjangoViewInfo("views.item_detail"))
+ .isPresent()
+ .hasValueSatisfying(info -> assertThat(info.urlPatterns()).containsExactly("^items/(?P\\d+)/$"));
+
+ assertThat(projectSymbolTable.isDjangoView("views.item_with_slug")).isTrue();
+ assertThat(projectSymbolTable.getDjangoViewInfo("views.item_with_slug"))
+ .isPresent()
+ .hasValueSatisfying(info -> assertThat(info.urlPatterns()).containsExactly("^items/(?P\\d+)/(?P[\\w-]+)/$"));
+ }
+
}
From 5066ddf2ad4eb8ea5a1f553e333506bec04c60be Mon Sep 17 00:00:00 2001
From: Sebastian Zumbrunn
Date: Mon, 2 Mar 2026 17:23:59 +0100
Subject: [PATCH 011/322] SONARPY-3844 Create ruling-diff-comment action (#895)
Co-authored-by: Thomas Serre <118730793+thomas-serre-sonarsource@users.noreply.github.com>
GitOrigin-RevId: 45dc70b3e7bfc393134a26919308e5f888ef1569
---
.../actions/ruling-diff-comment/action.yml | 38 ++
.../ruling-diff-comment/pyproject.toml | 9 +
.../ruling-diff-comment/ruling_diff.py | 74 +++
.../ruling-diff-comment/ruling_diff_core.py | 41 ++
.../ruling_diff_core_lib/__init__.py | 41 ++
.../ruling_diff_core_lib/comment_rendering.py | 104 ++++
.../models_and_constants.py | 71 +++
.../ruling_diff_core_lib/ruling_diff_logic.py | 146 +++++
.../snippet_generation.py | 153 ++++++
.../ruling-diff-comment/ruling_diff_io.py | 332 ++++++++++++
.../ruling-diff-comment/test_ruling_diff.py | 497 ++++++++++++++++++
.github/actions/ruling-diff-comment/uv.lock | 8 +
12 files changed, 1514 insertions(+)
create mode 100644 .github/actions/ruling-diff-comment/action.yml
create mode 100644 .github/actions/ruling-diff-comment/pyproject.toml
create mode 100644 .github/actions/ruling-diff-comment/ruling_diff.py
create mode 100644 .github/actions/ruling-diff-comment/ruling_diff_core.py
create mode 100644 .github/actions/ruling-diff-comment/ruling_diff_core_lib/__init__.py
create mode 100644 .github/actions/ruling-diff-comment/ruling_diff_core_lib/comment_rendering.py
create mode 100644 .github/actions/ruling-diff-comment/ruling_diff_core_lib/models_and_constants.py
create mode 100644 .github/actions/ruling-diff-comment/ruling_diff_core_lib/ruling_diff_logic.py
create mode 100644 .github/actions/ruling-diff-comment/ruling_diff_core_lib/snippet_generation.py
create mode 100644 .github/actions/ruling-diff-comment/ruling_diff_io.py
create mode 100644 .github/actions/ruling-diff-comment/test_ruling_diff.py
create mode 100644 .github/actions/ruling-diff-comment/uv.lock
diff --git a/.github/actions/ruling-diff-comment/action.yml b/.github/actions/ruling-diff-comment/action.yml
new file mode 100644
index 000000000..010e838ad
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/action.yml
@@ -0,0 +1,38 @@
+name: 'Ruling Diff Comment'
+description: 'Posts a human-readable summary of ruling file changes on PRs'
+
+inputs:
+ pr-number:
+ description: 'Pull request number'
+ required: true
+ repository:
+ description: 'owner/repo'
+ required: true
+ base-sha:
+ description: 'Base commit SHA for diff'
+ required: true
+ head-sha:
+ description: 'Head commit SHA for diff'
+ required: true
+
+runs:
+ using: 'composite'
+ steps:
+ - name: Run unit tests
+ shell: bash
+ run: uv run --project "${{ github.action_path }}" python -m unittest discover -v -s "${{ github.action_path }}" -p "test_ruling_diff.py"
+
+ - name: Generate and post ruling diff comment
+ shell: bash
+ env:
+ GH_TOKEN: ${{ env.GH_TOKEN }}
+ PR_NUMBER: ${{ inputs.pr-number }}
+ REPOSITORY: ${{ inputs.repository }}
+ BASE_SHA: ${{ inputs.base-sha }}
+ HEAD_SHA: ${{ inputs.head-sha }}
+ run: |
+ uv run --project "${{ github.action_path }}" python "${{ github.action_path }}/ruling_diff.py" \
+ --pr-number "$PR_NUMBER" \
+ --repository "$REPOSITORY" \
+ --base-sha "$BASE_SHA" \
+ --head-sha "$HEAD_SHA"
diff --git a/.github/actions/ruling-diff-comment/pyproject.toml b/.github/actions/ruling-diff-comment/pyproject.toml
new file mode 100644
index 000000000..89b0ba651
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/pyproject.toml
@@ -0,0 +1,9 @@
+[project]
+name = "ruling-diff-comment"
+version = "0.1.0"
+description = "GitHub Action helper for ruling diff comments"
+requires-python = ">=3.10"
+dependencies = []
+
+[tool.uv]
+package = false
diff --git a/.github/actions/ruling-diff-comment/ruling_diff.py b/.github/actions/ruling-diff-comment/ruling_diff.py
new file mode 100644
index 000000000..f4468b8a9
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/ruling_diff.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+import argparse
+import logging
+import os
+import sys
+
+from ruling_diff_core import build_rule_diffs, format_comment
+from ruling_diff_io import (
+ GitHubActionIO,
+ get_changed_ruling_files,
+ post_or_update_comment,
+)
+
+
+def configure_logging() -> None:
+ level = logging.DEBUG if os.environ.get("RUNNER_DEBUG") else logging.INFO
+ logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Generate and post ruling diff comment"
+ )
+ parser.add_argument("--pr-number", required=True)
+ parser.add_argument("--repository", required=True)
+ parser.add_argument("--base-sha", required=True)
+ parser.add_argument("--head-sha", required=True)
+ args = parser.parse_args()
+ if "/" not in args.repository:
+ raise ValueError("--repository must be in owner/repo format")
+ return args
+
+
+def has_required_context(args: argparse.Namespace) -> bool:
+ return bool(
+ args.pr_number.strip() and args.base_sha.strip() and args.head_sha.strip()
+ )
+
+
+def main() -> None:
+ configure_logging()
+ args = parse_args()
+ if not has_required_context(args):
+ logging.info("Missing pr/base/head arguments. Skipping ruling diff comment.")
+ return
+
+ changed_files = get_changed_ruling_files(args.base_sha, args.head_sha)
+ if not changed_files:
+ logging.info("No changed ruling json files found. Nothing to do.")
+ return
+
+ logging.info("Found %d changed ruling json files", len(changed_files))
+ io = GitHubActionIO()
+ rule_diffs = build_rule_diffs(
+ changed_files,
+ args.base_sha,
+ args.head_sha,
+ io,
+ )
+ if not rule_diffs:
+ logging.info("Changed files have no issue deltas. No comment will be posted.")
+ return
+
+ comment = format_comment(rule_diffs)
+ post_or_update_comment(args.pr_number, args.repository, comment)
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except Exception as exc:
+ logging.error("Failed to generate ruling diff comment: %s", exc)
+ sys.exit(1)
diff --git a/.github/actions/ruling-diff-comment/ruling_diff_core.py b/.github/actions/ruling-diff-comment/ruling_diff_core.py
new file mode 100644
index 000000000..c8e633ea0
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/ruling_diff_core.py
@@ -0,0 +1,41 @@
+from ruling_diff_core_lib.comment_rendering import format_comment
+from ruling_diff_core_lib.models_and_constants import (
+ COMMENT_MARKER,
+ COMMENT_SOFT_LIMIT,
+ EXPECTED_RULING_ROOT,
+ IssueDiff,
+ RuleDiff,
+ Snippet,
+)
+from ruling_diff_core_lib.ruling_diff_logic import (
+ build_rule_diffs,
+ diff_ruling_jsons,
+ parse_ruling_path,
+ parse_ruling_relative_path,
+ parse_rule_filename,
+ strip_project_key,
+)
+from ruling_diff_core_lib.snippet_generation import (
+ render_file_level_snippet,
+ render_line_snippet,
+ render_snippet,
+)
+
+__all__ = [
+ "COMMENT_MARKER",
+ "COMMENT_SOFT_LIMIT",
+ "EXPECTED_RULING_ROOT",
+ "IssueDiff",
+ "RuleDiff",
+ "Snippet",
+ "build_rule_diffs",
+ "diff_ruling_jsons",
+ "format_comment",
+ "parse_ruling_path",
+ "parse_ruling_relative_path",
+ "parse_rule_filename",
+ "render_file_level_snippet",
+ "render_line_snippet",
+ "render_snippet",
+ "strip_project_key",
+]
diff --git a/.github/actions/ruling-diff-comment/ruling_diff_core_lib/__init__.py b/.github/actions/ruling-diff-comment/ruling_diff_core_lib/__init__.py
new file mode 100644
index 000000000..c8e633ea0
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/ruling_diff_core_lib/__init__.py
@@ -0,0 +1,41 @@
+from ruling_diff_core_lib.comment_rendering import format_comment
+from ruling_diff_core_lib.models_and_constants import (
+ COMMENT_MARKER,
+ COMMENT_SOFT_LIMIT,
+ EXPECTED_RULING_ROOT,
+ IssueDiff,
+ RuleDiff,
+ Snippet,
+)
+from ruling_diff_core_lib.ruling_diff_logic import (
+ build_rule_diffs,
+ diff_ruling_jsons,
+ parse_ruling_path,
+ parse_ruling_relative_path,
+ parse_rule_filename,
+ strip_project_key,
+)
+from ruling_diff_core_lib.snippet_generation import (
+ render_file_level_snippet,
+ render_line_snippet,
+ render_snippet,
+)
+
+__all__ = [
+ "COMMENT_MARKER",
+ "COMMENT_SOFT_LIMIT",
+ "EXPECTED_RULING_ROOT",
+ "IssueDiff",
+ "RuleDiff",
+ "Snippet",
+ "build_rule_diffs",
+ "diff_ruling_jsons",
+ "format_comment",
+ "parse_ruling_path",
+ "parse_ruling_relative_path",
+ "parse_rule_filename",
+ "render_file_level_snippet",
+ "render_line_snippet",
+ "render_snippet",
+ "strip_project_key",
+]
diff --git a/.github/actions/ruling-diff-comment/ruling_diff_core_lib/comment_rendering.py b/.github/actions/ruling-diff-comment/ruling_diff_core_lib/comment_rendering.py
new file mode 100644
index 000000000..6eba3c594
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/ruling_diff_core_lib/comment_rendering.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from ruling_diff_core_lib.models_and_constants import (
+ COMMENT_MARKER,
+ COMMENT_SOFT_LIMIT,
+ RuleDiff,
+ Snippet,
+)
+
+
+def format_comment(
+ rule_diffs: list[RuleDiff], soft_limit: int = COMMENT_SOFT_LIMIT
+) -> str:
+ if not rule_diffs:
+ return "\n".join(
+ [
+ COMMENT_MARKER,
+ "## Ruling Diff Summary",
+ "",
+ "No issue deltas detected."
+ ]
+ )
+ comment = format_comment_header(rule_diffs)
+ return append_rule_sections_with_soft_limit(comment, rule_diffs, soft_limit)
+
+
+def format_comment_header(rule_diffs: list[RuleDiff]) -> str:
+ added = sum(len(diff.added_lines) for rule in rule_diffs for diff in rule.file_diffs)
+ removed = sum(
+ len(diff.removed_lines) for rule in rule_diffs for diff in rule.file_diffs
+ )
+ return "\n".join(
+ [
+ COMMENT_MARKER,
+ "## Ruling Diff Summary",
+ "",
+ f"Detected changes in {len(rule_diffs)} rule files: {removed} issues removed, {added} issues added.",
+ "",
+ ]
+ )
+
+
+def append_rule_sections_with_soft_limit(
+ comment: str, rule_diffs: list[RuleDiff], soft_limit: int
+) -> str:
+ sections = [format_rule_section(rule_diff) for rule_diff in rule_diffs]
+ accepted_sections: list[str] = []
+ truncated_count = 0
+ for index, section in enumerate(sections):
+ candidate = comment + "\n\n".join(accepted_sections + [section])
+ if len(candidate) > soft_limit:
+ truncated_count = len(sections) - index
+ break
+ accepted_sections.append(section)
+ if accepted_sections:
+ comment += "\n\n".join(accepted_sections)
+ if truncated_count:
+ comment = append_truncation_notice(comment, truncated_count, bool(accepted_sections))
+ return comment
+
+
+def append_truncation_notice(comment: str, count: int, has_sections: bool) -> str:
+ separator = "\n\n" if has_sections else ""
+ return (
+ comment
+ + separator
+ + f"... and {count} more rules with changes (diff too large to display fully)"
+ )
+
+
+def format_rule_section(rule_diff: RuleDiff) -> str:
+ lines = ["", f"{format_rule_summary(rule_diff)}
", ""]
+ if not rule_diff.snippets:
+ lines.append("No source snippets available for this rule.")
+ else:
+ for snippet in rule_diff.snippets:
+ lines.append(format_snippet_block(snippet))
+ lines.append("")
+ lines.append(" ")
+ return "\n".join(lines)
+
+
+def format_rule_summary(rule_diff: RuleDiff) -> str:
+ removed = sum(len(diff.removed_lines) for diff in rule_diff.file_diffs)
+ added = sum(len(diff.added_lines) for diff in rule_diff.file_diffs)
+ summary_parts = [
+ f"{rule_diff.rule_key} ({rule_diff.repo}) on {rule_diff.project}",
+ f"{removed} issues removed, {added} issues added",
+ ]
+ if rule_diff.is_new_file:
+ summary_parts.append("new ruling file")
+ if rule_diff.is_deleted_file:
+ summary_parts.append("deleted ruling file")
+ return " - ".join(summary_parts)
+
+
+def format_snippet_block(snippet: Snippet) -> str:
+ return "\n".join([format_snippet_header(snippet), "```python", snippet.body, "```"])
+
+
+def format_snippet_header(snippet: Snippet) -> str:
+ label = "Added" if snippet.change_kind == "added" else "Removed"
+ location = "file-level" if snippet.line_number == 0 else f"line {snippet.line_number}"
+ return f"**{label}** `{snippet.file_path}` ({location})"
diff --git a/.github/actions/ruling-diff-comment/ruling_diff_core_lib/models_and_constants.py b/.github/actions/ruling-diff-comment/ruling_diff_core_lib/models_and_constants.py
new file mode 100644
index 000000000..d5e5339f5
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/ruling_diff_core_lib/models_and_constants.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Protocol
+
+RulingJson = dict[str, list[int]]
+OptionalRulingJson = RulingJson | None
+SourceLines = list[str]
+OptionalSourceLines = SourceLines | None
+SourceCache = dict[tuple[str, str], OptionalSourceLines]
+
+
+class RulingDiffIO(Protocol):
+ def load_json_at_ref(self, path: str, ref: str) -> OptionalRulingJson:
+ ...
+
+ def load_text_at_ref(self, path: str, ref: str) -> str | None:
+ ...
+
+ def resolve_source_path(self, project: str, file_path: str) -> str:
+ ...
+
+EXPECTED_RULING_ROOT = (
+ "private/its-enterprise/ruling/src/test/resources/expected_ruling"
+)
+COMMENT_MARKER = ""
+COMMENT_SOFT_LIMIT = 60000
+SNIPPET_CONTEXT = 5
+MAX_SNIPPETS_PER_FILE = 3
+MAX_SNIPPETS_PER_RULE = 15
+
+PROJECT_SOURCE_OVERRIDES = {
+ "buildbot": "private/its-enterprise/sources_ruling/buildbot-0.8.6p1",
+ "buildbot-slave": "private/its-enterprise/sources_ruling/buildbot-slave-0.8.6p1",
+ "django": "private/its-enterprise/sources_ruling/django-2.2.3",
+ "django-cms": "private/its-enterprise/sources_ruling/django-cms-3.7.1",
+ "docker-compose": "private/its-enterprise/sources_ruling/docker-compose-1.24.1",
+ "mypy": "private/its-enterprise/sources_ruling/mypy-0.782",
+ "numpy": "private/its-enterprise/sources_ruling/numpy-1.16.4",
+ "tornado": "private/its-enterprise/sources_ruling/tornado-2.3",
+ "twisted": "private/its-enterprise/sources_ruling/twisted-12.1.0",
+ "sources_internal_ruling": "private/its-enterprise/sources_internal_ruling",
+ "namespace_basic": "private/its-enterprise/sources_internal_namespace_ruling/basic_namespace",
+ "namespace_mixed": "private/its-enterprise/sources_internal_namespace_ruling/mixed_namespace",
+}
+
+
+@dataclass(frozen=True)
+class IssueDiff:
+ file_path: str
+ added_lines: list[int]
+ removed_lines: list[int]
+
+
+@dataclass(frozen=True)
+class Snippet:
+ file_path: str
+ line_number: int
+ change_kind: str
+ body: str
+
+
+@dataclass(frozen=True)
+class RuleDiff:
+ project: str
+ repo: str
+ rule_key: str
+ file_diffs: list[IssueDiff]
+ snippets: list[Snippet]
+ is_new_file: bool
+ is_deleted_file: bool
diff --git a/.github/actions/ruling-diff-comment/ruling_diff_core_lib/ruling_diff_logic.py b/.github/actions/ruling-diff-comment/ruling_diff_core_lib/ruling_diff_logic.py
new file mode 100644
index 000000000..e958ae146
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/ruling_diff_core_lib/ruling_diff_logic.py
@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+from collections import Counter
+from pathlib import PurePosixPath
+
+from ruling_diff_core_lib.models_and_constants import (
+ EXPECTED_RULING_ROOT,
+ IssueDiff,
+ OptionalRulingJson,
+ RulingDiffIO,
+ RuleDiff,
+ RulingJson,
+ SourceCache,
+ Snippet,
+)
+from ruling_diff_core_lib.snippet_generation import build_snippets_for_rule
+
+
+def parse_ruling_path(path: str) -> tuple[str, str, str]:
+ prefix = f"{EXPECTED_RULING_ROOT}/"
+ if not path.startswith(prefix):
+ raise ValueError(f"Unexpected ruling path outside expected root: {path}")
+ relative_path = path[len(prefix) :]
+ project, filename = parse_ruling_relative_path(relative_path)
+ repository, rule_key = parse_rule_filename(filename)
+ return project, repository, rule_key
+
+
+def parse_ruling_relative_path(relative_path: str) -> tuple[str, str]:
+ parts = PurePosixPath(relative_path).parts
+ if len(parts) != 2:
+ raise ValueError(
+ f"Expected '/-.json' path, got: {relative_path}"
+ )
+ return parts[0], parts[1]
+
+
+def parse_rule_filename(filename: str) -> tuple[str, str]:
+ if not filename.endswith(".json"):
+ raise ValueError(f"Expected json filename, got: {filename}")
+ basename = filename[:-5]
+ if "-" not in basename:
+ raise ValueError(f"Expected '-.json', got: {filename}")
+ repository, rule_key = basename.rsplit("-", 1)
+ if not repository:
+ raise ValueError(f"Missing repo in filename: {filename}")
+ if not rule_key.startswith("S") or not rule_key[1:].isdigit():
+ raise ValueError(f"Invalid rule key in filename: {filename}")
+ return repository, rule_key
+
+
+def strip_project_key(key: str) -> str:
+ return key.split(":", 1)[1] if ":" in key else key
+
+
+def diff_ruling_jsons(
+ old: OptionalRulingJson, new: OptionalRulingJson
+) -> list[IssueDiff]:
+ old_map = old or {}
+ new_map = new or {}
+ return [
+ issue_diff
+ for issue_diff in (
+ diff_single_file_key(key, old_map, new_map)
+ for key in sorted(set(old_map) | set(new_map))
+ )
+ if issue_diff is not None
+ ]
+
+
+def diff_single_file_key(
+ key: str, old_map: RulingJson, new_map: RulingJson
+) -> IssueDiff | None:
+ old_counter = Counter(old_map.get(key, []))
+ new_counter = Counter(new_map.get(key, []))
+ added_lines: list[int] = expand_line_counter(new_counter - old_counter)
+ removed_lines: list[int] = expand_line_counter(old_counter - new_counter)
+ if not added_lines and not removed_lines:
+ return None
+ return IssueDiff(
+ file_path=strip_project_key(key),
+ added_lines=added_lines,
+ removed_lines=removed_lines,
+ )
+
+
+def expand_line_counter(counter: Counter[int]) -> list[int]:
+ line_numbers: list[int] = []
+ for line_number in sorted(counter):
+ line_numbers.extend([line_number] * counter[line_number])
+ return line_numbers
+
+
+def build_rule_diffs(
+ changed_files: list[str],
+ base_sha: str,
+ head_sha: str,
+ io: RulingDiffIO,
+) -> list[RuleDiff]:
+ source_cache: SourceCache = {}
+ diffs = [
+ build_rule_diff_for_file(
+ path,
+ base_sha,
+ head_sha,
+ source_cache,
+ io,
+ )
+ for path in sorted(changed_files)
+ ]
+ return sorted(
+ [rule_diff for rule_diff in diffs if rule_diff is not None],
+ key=lambda diff: (diff.project, diff.repo, diff.rule_key),
+ )
+
+
+def build_rule_diff_for_file(
+ path: str,
+ base_sha: str,
+ head_sha: str,
+ source_cache: SourceCache,
+ io: RulingDiffIO,
+) -> RuleDiff | None:
+ project, repository, rule_key = parse_ruling_path(path)
+ old_json: OptionalRulingJson = io.load_json_at_ref(path, base_sha)
+ new_json: OptionalRulingJson = io.load_json_at_ref(path, head_sha)
+ file_diffs: list[IssueDiff] = diff_ruling_jsons(old_json, new_json)
+ if not file_diffs:
+ return None
+ snippets: list[Snippet] = build_snippets_for_rule(
+ project,
+ file_diffs,
+ source_cache,
+ base_sha,
+ head_sha,
+ io,
+ )
+ return RuleDiff(
+ project=project,
+ repo=repository,
+ rule_key=rule_key,
+ file_diffs=file_diffs,
+ snippets=snippets,
+ is_new_file=old_json is None,
+ is_deleted_file=new_json is None,
+ )
diff --git a/.github/actions/ruling-diff-comment/ruling_diff_core_lib/snippet_generation.py b/.github/actions/ruling-diff-comment/ruling_diff_core_lib/snippet_generation.py
new file mode 100644
index 000000000..40e3a1f82
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/ruling_diff_core_lib/snippet_generation.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+from ruling_diff_core_lib.models_and_constants import (
+ IssueDiff,
+ MAX_SNIPPETS_PER_FILE,
+ MAX_SNIPPETS_PER_RULE,
+ SNIPPET_CONTEXT,
+ OptionalSourceLines,
+ RulingDiffIO,
+ Snippet,
+ SourceCache,
+ SourceLines,
+)
+
+
+def unique_line_numbers_preserving_order(lines: list[int]) -> list[int]:
+ return list(dict.fromkeys(lines))
+
+
+def render_snippet(
+ lines: OptionalSourceLines, issue_line: int, context: int = SNIPPET_CONTEXT
+) -> str:
+ if lines is None:
+ return "(source file not found at this revision)"
+ if issue_line == 0:
+ return render_file_level_snippet(lines, context)
+ return render_line_snippet(lines, issue_line, context)
+
+
+def render_file_level_snippet(lines: SourceLines, context: int) -> str:
+ if not lines:
+ return ">>> FILE-LEVEL ISSUE\n(empty file)"
+ end = min(len(lines), 1 + (2 * context))
+ content = [f" {index:>6} | {lines[index - 1]}" for index in range(1, end + 1)]
+ return "\n".join([">>> FILE-LEVEL ISSUE", *content])
+
+
+def render_line_snippet(lines: SourceLines, issue_line: int, context: int) -> str:
+ if not lines:
+ return f">>> ISSUE HERE (line {issue_line})\n(empty file)"
+ clamped_line = max(1, min(issue_line, len(lines)))
+ prefix = []
+ if issue_line != clamped_line:
+ prefix.append(
+ f"(requested line {issue_line} not present, showing closest line {clamped_line})"
+ )
+ body = render_line_window(lines, clamped_line, context)
+ return "\n".join(prefix + body)
+
+
+def render_line_window(lines: SourceLines, center_line: int, context: int) -> list[str]:
+ start = max(1, center_line - context)
+ end = min(len(lines), center_line + context)
+ rendered: list[str] = []
+ for number in range(start, end + 1):
+ marker = ">>>" if number == center_line else " "
+ rendered.append(f"{marker} {number:>6} | {lines[number - 1]}")
+ return rendered
+
+
+def build_snippets_for_rule(
+ project: str,
+ file_diffs: list[IssueDiff],
+ source_cache: SourceCache,
+ base_sha: str,
+ head_sha: str,
+ io: RulingDiffIO,
+) -> list[Snippet]:
+ snippets: list[Snippet] = []
+ for file_diff in file_diffs:
+ snippets.extend(
+ collect_snippets_for_file(
+ project=project,
+ file_diff=file_diff,
+ source_cache=source_cache,
+ base_sha=base_sha,
+ head_sha=head_sha,
+ io=io,
+ )
+ )
+ if len(snippets) >= MAX_SNIPPETS_PER_RULE:
+ break
+ return snippets[:MAX_SNIPPETS_PER_RULE]
+
+
+def collect_snippets_for_file(
+ *,
+ project: str,
+ file_diff: IssueDiff,
+ source_cache: SourceCache,
+ base_sha: str,
+ head_sha: str,
+ io: RulingDiffIO,
+) -> list[Snippet]:
+ snippets: list[Snippet] = []
+ for change_kind, lines in (
+ ("removed", unique_line_numbers_preserving_order(file_diff.removed_lines)),
+ ("added", unique_line_numbers_preserving_order(file_diff.added_lines)),
+ ):
+ for line_number in lines[:MAX_SNIPPETS_PER_FILE]:
+ snippets.append(
+ create_issue_snippet(
+ project=project,
+ file_path=file_diff.file_path,
+ line_number=line_number,
+ change_kind=change_kind,
+ source_cache=source_cache,
+ base_sha=base_sha,
+ head_sha=head_sha,
+ io=io,
+ )
+ )
+ return snippets
+
+
+def create_issue_snippet(
+ *,
+ project: str,
+ file_path: str,
+ line_number: int,
+ change_kind: str,
+ source_cache: SourceCache,
+ base_sha: str,
+ head_sha: str,
+ io: RulingDiffIO,
+) -> Snippet:
+ ref = head_sha if change_kind == "added" else base_sha
+ source_path = io.resolve_source_path(project, file_path)
+ lines = load_source_lines_with_cache(source_cache, ref, source_path, io)
+ body = (
+ f"(source file not found at this revision: {file_path})"
+ if lines is None
+ else render_snippet(lines, line_number)
+ )
+ return Snippet(
+ file_path=file_path,
+ line_number=line_number,
+ change_kind=change_kind,
+ body=body,
+ )
+
+
+def load_source_lines_with_cache(
+ cache: SourceCache,
+ ref: str,
+ path: str,
+ io: RulingDiffIO,
+) -> OptionalSourceLines:
+ key = (ref, path)
+ if key not in cache:
+ content = io.load_text_at_ref(path, ref)
+ cache[key] = None if content is None else content.splitlines()
+ return cache[key]
diff --git a/.github/actions/ruling-diff-comment/ruling_diff_io.py b/.github/actions/ruling-diff-comment/ruling_diff_io.py
new file mode 100644
index 000000000..65b80f595
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/ruling_diff_io.py
@@ -0,0 +1,332 @@
+from __future__ import annotations
+
+import json
+import logging
+import subprocess
+from pathlib import Path
+
+from ruling_diff_core_lib.models_and_constants import (
+ COMMENT_MARKER,
+ EXPECTED_RULING_ROOT,
+ PROJECT_SOURCE_OVERRIDES,
+)
+
+RULING_SOURCES_SUBMODULE = "private/its-enterprise/sources_ruling"
+SOURCES_INTERNAL_RULING_ROOT = "private/its-enterprise/sources_internal_ruling"
+SOURCES_INTERNAL_NAMESPACE_RULING_ROOT = (
+ "private/its-enterprise/sources_internal_namespace_ruling"
+)
+
+
+class CommandError(RuntimeError):
+ pass
+
+
+class GitHubActionIO:
+ def load_json_at_ref(self, path: str, ref: str) -> dict[str, list[int]] | None:
+ return load_json_at_ref(path, ref)
+
+ def load_text_at_ref(self, path: str, ref: str) -> str | None:
+ return load_text_at_ref(path, ref)
+
+ def resolve_source_path(self, project: str, file_path: str) -> str:
+ if project == "project":
+ return self._resolve_project_source_path(file_path)
+ source_root = PROJECT_SOURCE_OVERRIDES.get(
+ project, f"{RULING_SOURCES_SUBMODULE}/{project}"
+ )
+ return f"{source_root}/{file_path.lstrip('/')}"
+
+ def _resolve_project_source_path(self, file_path: str) -> str:
+ clean_path = file_path.lstrip("/")
+ primary_candidate = f"{RULING_SOURCES_SUBMODULE}/{clean_path}"
+ candidates = [primary_candidate]
+ candidates.extend(
+ self._with_direct_children_prefixes(RULING_SOURCES_SUBMODULE, clean_path)
+ )
+ candidates.append(f"{SOURCES_INTERNAL_RULING_ROOT}/{clean_path}")
+ candidates.append(f"{SOURCES_INTERNAL_NAMESPACE_RULING_ROOT}/{clean_path}")
+ candidates.extend(
+ self._with_direct_children_prefixes(
+ SOURCES_INTERNAL_NAMESPACE_RULING_ROOT, clean_path
+ )
+ )
+ for candidate in candidates:
+ if Path(candidate).is_file():
+ return candidate
+ return primary_candidate
+
+ def _with_direct_children_prefixes(self, root: str, file_path: str) -> list[str]:
+ root_path = Path(root)
+ if not root_path.is_dir():
+ return []
+ return [
+ f"{root}/{child.name}/{file_path}"
+ for child in sorted(root_path.iterdir(), key=lambda path: path.name)
+ if child.is_dir() and not child.name.startswith(".")
+ ]
+
+
+def run_command(command: list[str]) -> str:
+ result = subprocess.run(command, capture_output=True, text=True)
+ if result.returncode != 0:
+ raise CommandError(
+ format_command_failure(
+ command, result.stdout, result.stderr, result.returncode
+ )
+ )
+ return result.stdout
+
+
+def format_command_failure(
+ command: list[str], stdout: str, stderr: str, returncode: int
+) -> str:
+ return (
+ f"Command failed with exit code {returncode}: {' '.join(command)}\n"
+ f"stdout: {stdout}\n"
+ f"stderr: {stderr}"
+ )
+
+
+def run_gh_json(command: list[str]) -> dict | list:
+ output = run_command(["gh", *command])
+ try:
+ return json.loads(output)
+ except json.JSONDecodeError as exc:
+ raise CommandError(f"Could not parse JSON from gh output: {exc}") from exc
+
+
+def run_gh_paginated_items(endpoint: str) -> list[dict]:
+ output = run_command(["gh", "api", "--paginate", endpoint])
+ docs = parse_json_documents(output)
+ items: list[dict] = []
+ for doc in docs:
+ if not isinstance(doc, list):
+ raise CommandError("Unexpected response type while listing paginated items")
+ for item in doc:
+ if isinstance(item, dict):
+ items.append(item)
+ return items
+
+
+def parse_json_documents(content: str) -> list[object]:
+ decoder = json.JSONDecoder()
+ index = 0
+ documents: list[object] = []
+ while index < len(content):
+ while index < len(content) and content[index].isspace():
+ index += 1
+ if index >= len(content):
+ break
+ document, next_index = decoder.raw_decode(content, index)
+ documents.append(document)
+ index = next_index
+ return documents
+
+
+def get_changed_ruling_files(base_sha: str, head_sha: str) -> list[str]:
+ output = run_command(
+ [
+ "git",
+ "diff",
+ "--name-only",
+ f"{base_sha}...{head_sha}",
+ "--",
+ f"{EXPECTED_RULING_ROOT}/",
+ ]
+ )
+ changed = [
+ path
+ for path in (line.strip() for line in output.splitlines())
+ if is_ruling_json(path)
+ ]
+ return sorted(set(changed))
+
+
+def is_ruling_json(path: str) -> bool:
+ return (
+ bool(path)
+ and path.endswith(".json")
+ and path.startswith(f"{EXPECTED_RULING_ROOT}/")
+ )
+
+
+def _is_missing_at_ref(stderr: str) -> bool:
+ return any(
+ marker in stderr
+ for marker in ("exists on disk, but not in", "does not exist in", "path '")
+ )
+
+
+def load_json_at_ref(path: str, ref: str) -> dict[str, list[int]] | None:
+ result = subprocess.run(
+ ["git", "show", f"{ref}:{path}"], capture_output=True, text=True
+ )
+ if result.returncode != 0:
+ if _is_missing_at_ref(result.stderr):
+ return None
+ raise CommandError(
+ f"Failed to read file at ref: git show {ref}:{path}\nstdout: {result.stdout}\nstderr: {result.stderr}"
+ )
+ return parse_ruling_json(result.stdout, path, ref)
+
+
+def parse_ruling_json(content: str, path: str, ref: str) -> dict[str, list[int]]:
+ try:
+ data = json.loads(content)
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"Malformed JSON in {path} at {ref}: {exc}") from exc
+ if not isinstance(data, dict):
+ raise ValueError(f"Ruling file {path} at {ref} must be a JSON object")
+ return normalize_ruling_json(data, path, ref)
+
+
+def normalize_ruling_json(data: dict, path: str, ref: str) -> dict[str, list[int]]:
+ normalized: dict[str, list[int]] = {}
+ for key, value in data.items():
+ if not isinstance(key, str):
+ raise ValueError(f"Ruling file {path} at {ref} has non-string key")
+ if not isinstance(value, list) or not all(isinstance(v, int) for v in value):
+ raise ValueError(
+ f"Ruling file {path} at {ref} has non-integer line list for key {key}"
+ )
+ normalized[key] = value
+ return normalized
+
+
+def load_text_at_ref(path: str, ref: str) -> str | None:
+ if is_ruling_source_path(path):
+ return load_submodule_text_at_ref(path, ref)
+
+ result = subprocess.run(
+ ["git", "show", f"{ref}:{path}"], capture_output=True, text=True
+ )
+ if result.returncode == 0:
+ return result.stdout
+ if _is_missing_at_ref(result.stderr):
+ return load_text_with_workspace_fallback(path, ref)
+ raise CommandError(
+ f"Failed to read source file at ref: git show {ref}:{path}\nstdout: {result.stdout}\nstderr: {result.stderr}"
+ )
+
+
+def is_ruling_source_path(path: str) -> bool:
+ return path.startswith(f"{RULING_SOURCES_SUBMODULE}/")
+
+
+def load_submodule_text_at_ref(path: str, ref: str) -> str | None:
+ submodule_commit = get_submodule_commit_for_ref(ref)
+ if submodule_commit is None:
+ return load_text_with_workspace_fallback(path, ref)
+
+ submodule_relative_path = path[len(f"{RULING_SOURCES_SUBMODULE}/") :]
+ content = read_submodule_file_at_commit(submodule_commit, submodule_relative_path)
+ if content is not None:
+ return content
+
+ fetch_submodule_commit(submodule_commit)
+ content = read_submodule_file_at_commit(submodule_commit, submodule_relative_path)
+ if content is not None:
+ return content
+
+ logging.warning(
+ "Source file '%s' not found in submodule commit %s for %s",
+ path,
+ submodule_commit,
+ ref,
+ )
+ return load_text_with_workspace_fallback(path, ref)
+
+
+def get_submodule_commit_for_ref(ref: str) -> str | None:
+ result = subprocess.run(
+ ["git", "rev-parse", f"{ref}:{RULING_SOURCES_SUBMODULE}"],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ logging.warning(
+ "Could not resolve ruling sources submodule commit for %s: %s",
+ ref,
+ result.stderr.strip(),
+ )
+ return None
+ return result.stdout.strip()
+
+
+def read_submodule_file_at_commit(commit: str, relative_path: str) -> str | None:
+ result = subprocess.run(
+ ["git", "-C", RULING_SOURCES_SUBMODULE, "show", f"{commit}:{relative_path}"],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode == 0:
+ return result.stdout
+ return None
+
+
+def fetch_submodule_commit(commit: str) -> None:
+ subprocess.run(
+ [
+ "git",
+ "-C",
+ RULING_SOURCES_SUBMODULE,
+ "fetch",
+ "--depth",
+ "1",
+ "origin",
+ commit,
+ ],
+ capture_output=True,
+ text=True,
+ )
+
+
+def load_text_with_workspace_fallback(path: str, ref: str) -> str | None:
+ workspace_content = load_workspace_text(path)
+ if workspace_content is None:
+ logging.warning("Source file '%s' not found at %s", path, ref)
+ return None
+ logging.warning("Source file '%s' not found at %s, using workspace copy", path, ref)
+ return workspace_content
+
+
+def load_workspace_text(path: str) -> str | None:
+ workspace_path = Path(path)
+ if not workspace_path.is_file():
+ return None
+ return workspace_path.read_text(encoding="utf-8")
+
+
+def get_existing_comment_id(pr_number: str, repository: str) -> str | None:
+ comments = run_gh_paginated_items(
+ f"repos/{repository}/issues/{pr_number}/comments?per_page=100"
+ )
+ for comment in comments:
+ if COMMENT_MARKER in comment.get("body", ""):
+ return str(comment["id"])
+ return None
+
+
+def post_or_update_comment(pr_number: str, repository: str, body: str) -> None:
+ comment_id = get_existing_comment_id(pr_number, repository)
+ if comment_id is None:
+ logging.info("Posting new ruling diff comment on PR #%s", pr_number)
+ run_command(
+ ["gh", "pr", "comment", pr_number, "--repo", repository, "--body", body]
+ )
+ return
+ logging.info(
+ "Updating existing ruling diff comment %s on PR #%s", comment_id, pr_number
+ )
+ run_command(
+ [
+ "gh",
+ "api",
+ "--method",
+ "PATCH",
+ f"repos/{repository}/issues/comments/{comment_id}",
+ "-f",
+ f"body={body}",
+ ]
+ )
diff --git a/.github/actions/ruling-diff-comment/test_ruling_diff.py b/.github/actions/ruling-diff-comment/test_ruling_diff.py
new file mode 100644
index 000000000..459f6f43d
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/test_ruling_diff.py
@@ -0,0 +1,497 @@
+import pathlib
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+
+MODULE_DIR = pathlib.Path(__file__).parent
+if str(MODULE_DIR) not in sys.path:
+ sys.path.insert(0, str(MODULE_DIR))
+
+import ruling_diff_core as core
+import ruling_diff_io as io
+
+
+IssueDiff = core.IssueDiff
+RuleDiff = core.RuleDiff
+Snippet = core.Snippet
+
+
+class FakeRulingDiffIO:
+ def __init__(
+ self,
+ json_by_ref_path: dict[tuple[str, str], dict[str, list[int]] | None],
+ text_by_ref_path: dict[tuple[str, str], str | None],
+ ) -> None:
+ self.json_by_ref_path = json_by_ref_path
+ self.text_by_ref_path = text_by_ref_path
+ self.load_json_calls: list[tuple[str, str]] = []
+ self.load_text_calls: list[tuple[str, str]] = []
+ self.resolve_calls: list[tuple[str, str]] = []
+
+ def load_json_at_ref(self, path: str, ref: str) -> dict[str, list[int]] | None:
+ self.load_json_calls.append((path, ref))
+ return self.json_by_ref_path.get((path, ref))
+
+ def load_text_at_ref(self, path: str, ref: str) -> str | None:
+ self.load_text_calls.append((path, ref))
+ return self.text_by_ref_path.get((path, ref))
+
+ def resolve_source_path(self, project: str, file_path: str) -> str:
+ self.resolve_calls.append((project, file_path))
+ return f"sources/{project}/{file_path.lstrip('/')}"
+
+
+class ParsePathTest(unittest.TestCase):
+ def test_parse_ruling_path(self) -> None:
+ path = "private/its-enterprise/ruling/src/test/resources/expected_ruling/airflow/python-S1066.json"
+ self.assertEqual(("airflow", "python", "S1066"), core.parse_ruling_path(path))
+
+ def test_parse_ruling_path_with_pythonenterprise(self) -> None:
+ path = "private/its-enterprise/ruling/src/test/resources/expected_ruling/specific-rules/pythonenterprise-S7471.json"
+ self.assertEqual(
+ ("specific-rules", "pythonenterprise", "S7471"),
+ core.parse_ruling_path(path),
+ )
+
+
+class DiffLogicTest(unittest.TestCase):
+ def test_diff_ruling_jsons_added_issues(self) -> None:
+ diffs = core.diff_ruling_jsons({"proj:a.py": [1, 2]}, {"proj:a.py": [1, 2, 3]})
+ self.assertEqual(1, len(diffs))
+ self.assertEqual("a.py", diffs[0].file_path)
+ self.assertEqual([3], diffs[0].added_lines)
+
+ def test_diff_ruling_jsons_removed_issues(self) -> None:
+ diffs = core.diff_ruling_jsons({"proj:a.py": [1, 2, 3]}, {"proj:a.py": [1]})
+ self.assertEqual([2, 3], diffs[0].removed_lines)
+
+ def test_diff_ruling_jsons_new_file_entry(self) -> None:
+ diffs = core.diff_ruling_jsons(
+ {"proj:a.py": [1]},
+ {"proj:a.py": [1], "proj:b.py": [5]},
+ )
+ self.assertEqual("b.py", diffs[0].file_path)
+ self.assertEqual([5], diffs[0].added_lines)
+
+ def test_diff_ruling_jsons_removed_file_entry(self) -> None:
+ diffs = core.diff_ruling_jsons(
+ {"proj:a.py": [1], "proj:b.py": [5]},
+ {"proj:a.py": [1]},
+ )
+ self.assertEqual("b.py", diffs[0].file_path)
+ self.assertEqual([5], diffs[0].removed_lines)
+
+ def test_diff_ruling_jsons_new_ruling_file(self) -> None:
+ diffs = core.diff_ruling_jsons(None, {"proj:a.py": [10]})
+ self.assertEqual([10], diffs[0].added_lines)
+
+ def test_diff_ruling_jsons_deleted_ruling_file(self) -> None:
+ diffs = core.diff_ruling_jsons({"proj:a.py": [10]}, None)
+ self.assertEqual([10], diffs[0].removed_lines)
+
+ def test_diff_ruling_jsons_no_changes(self) -> None:
+ self.assertEqual(
+ [],
+ core.diff_ruling_jsons(
+ {"proj:a.py": [10], "proj:b.py": [11, 12]},
+ {"proj:a.py": [10], "proj:b.py": [11, 12]},
+ ),
+ )
+
+ def test_duplicate_line_numbers_preserved(self) -> None:
+ diffs = core.diff_ruling_jsons({"proj:a.py": [297]}, {"proj:a.py": [297, 297]})
+ self.assertEqual([297], diffs[0].added_lines)
+
+
+class FormattingTest(unittest.TestCase):
+ def test_format_comment_single_rule(self) -> None:
+ rule_diff = RuleDiff(
+ project="airflow",
+ repo="python",
+ rule_key="S107",
+ file_diffs=[IssueDiff("airflow/hooks/a.py", [10, 11], [8])],
+ snippets=[
+ Snippet(
+ file_path="airflow/hooks/a.py",
+ line_number=10,
+ change_kind="added",
+ body=">>> 10 | x = 1",
+ )
+ ],
+ is_new_file=False,
+ is_deleted_file=False,
+ )
+ comment = core.format_comment([rule_diff])
+ self.assertIn("## Ruling Diff Summary", comment)
+ self.assertIn("", comment)
+ self.assertIn("**Added** `airflow/hooks/a.py` (line 10)", comment)
+ self.assertIn(">>> 10 | x = 1", comment)
+ self.assertIn("```python", comment)
+
+ def test_format_comment_multiple_rules(self) -> None:
+ rule_diffs = [
+ RuleDiff(
+ project="airflow",
+ repo="python",
+ rule_key="S107",
+ file_diffs=[IssueDiff("airflow/hooks/a.py", [10], [])],
+ snippets=[
+ Snippet("airflow/hooks/a.py", 10, "added", ">>> 10 | return 1")
+ ],
+ is_new_file=False,
+ is_deleted_file=False,
+ ),
+ RuleDiff(
+ project="django",
+ repo="python",
+ rule_key="S3699",
+ file_diffs=[IssueDiff("django/core/b.py", [20], [15])],
+ snippets=[
+ Snippet(
+ "django/core/b.py", 15, "removed", ">>> 15 | return None"
+ )
+ ],
+ is_new_file=False,
+ is_deleted_file=False,
+ ),
+ ]
+ comment = core.format_comment(rule_diffs)
+ self.assertIn("Detected changes in 2 rule files", comment)
+ self.assertIn("S107", comment)
+ self.assertIn("S3699", comment)
+
+ def test_format_comment_respects_collapse(self) -> None:
+ rule_diff = RuleDiff(
+ project="airflow",
+ repo="python",
+ rule_key="S107",
+ file_diffs=[IssueDiff("airflow/hooks/a.py", [10], [])],
+ snippets=[
+ Snippet("airflow/hooks/a.py", 10, "added", ">>> 10 | return 1")
+ ],
+ is_new_file=False,
+ is_deleted_file=False,
+ )
+ comment = core.format_comment([rule_diff])
+ self.assertIn("", comment)
+ self.assertIn(" ", comment)
+
+ def test_strip_project_key_from_path(self) -> None:
+ self.assertEqual(
+ "airflow/foo.py", core.strip_project_key("airflow:airflow/foo.py")
+ )
+
+ def test_line_zero_displayed_as_file_level(self) -> None:
+ rule_diff = RuleDiff(
+ project="specific-rules",
+ repo="python",
+ rule_key="S1451",
+ file_diffs=[IssueDiff("S1716.py", [0], [0])],
+ snippets=[Snippet("S1716.py", 0, "added", ">>> FILE-LEVEL ISSUE")],
+ is_new_file=False,
+ is_deleted_file=False,
+ )
+ comment = core.format_comment([rule_diff])
+ self.assertIn("file-level", comment)
+ self.assertIn(">>> FILE-LEVEL ISSUE", comment)
+
+ def test_format_comment_truncates_when_limit_reached(self) -> None:
+ rule_diffs = [
+ RuleDiff(
+ project=f"project-{index}",
+ repo="python",
+ rule_key=f"S{1000 + index}",
+ file_diffs=[IssueDiff("a.py", [1], [2])],
+ snippets=[
+ Snippet(
+ "a.py", 1, "added", "\n".join([f"line {i}" for i in range(50)])
+ )
+ ],
+ is_new_file=False,
+ is_deleted_file=False,
+ )
+ for index in range(5)
+ ]
+ comment = core.format_comment(rule_diffs, soft_limit=500)
+ self.assertIn("diff too large to display fully", comment)
+
+
+class SnippetRenderingTest(unittest.TestCase):
+ def test_render_line_snippet_uses_plus_minus_five_lines(self) -> None:
+ lines = [f"line {i}" for i in range(1, 21)]
+ rendered = core.render_line_snippet(lines, issue_line=10, context=5)
+ self.assertIn(" 5 | line 5", rendered)
+ self.assertIn(">>> 10 | line 10", rendered)
+ self.assertIn(" 15 | line 15", rendered)
+
+ def test_render_line_snippet_handles_out_of_range_line(self) -> None:
+ rendered = core.render_line_snippet(["alpha", "beta"], issue_line=99, context=5)
+ self.assertIn("requested line 99 not present", rendered)
+ self.assertIn(">>> 2 | beta", rendered)
+
+ def test_render_file_level_snippet_marker(self) -> None:
+ rendered = core.render_file_level_snippet(["a", "b", "c"], context=5)
+ self.assertIn(">>> FILE-LEVEL ISSUE", rendered)
+
+ def test_render_snippet_missing_source_placeholder(self) -> None:
+ rendered = core.render_snippet(None, issue_line=12, context=5)
+ self.assertEqual("(source file not found at this revision)", rendered)
+
+
+class BuildRuleDiffsWithIOTest(unittest.TestCase):
+ def test_build_rule_diffs_uses_io_object_and_respects_refs(self) -> None:
+ changed_file = (
+ "private/its-enterprise/ruling/src/test/resources/expected_ruling/"
+ "airflow/python-S107.json"
+ )
+ io_impl = FakeRulingDiffIO(
+ json_by_ref_path={
+ (changed_file, "base-sha"): {"airflow:a.py": [2]},
+ (changed_file, "head-sha"): {"airflow:a.py": [2, 7]},
+ },
+ text_by_ref_path={
+ ("sources/airflow/a.py", "head-sha"): "\n".join(
+ [f"line {index}" for index in range(1, 12)]
+ ),
+ },
+ )
+
+ diffs = core.build_rule_diffs([changed_file], "base-sha", "head-sha", io_impl)
+
+ self.assertEqual(1, len(diffs))
+ self.assertEqual("airflow", diffs[0].project)
+ self.assertEqual("python", diffs[0].repo)
+ self.assertEqual("S107", diffs[0].rule_key)
+ self.assertEqual([7], diffs[0].file_diffs[0].added_lines)
+ self.assertEqual([], diffs[0].file_diffs[0].removed_lines)
+ self.assertIn((changed_file, "base-sha"), io_impl.load_json_calls)
+ self.assertIn((changed_file, "head-sha"), io_impl.load_json_calls)
+ self.assertEqual([("airflow", "a.py")], io_impl.resolve_calls)
+ self.assertEqual([("sources/airflow/a.py", "head-sha")], io_impl.load_text_calls)
+
+ def test_build_rule_diffs_caches_source_loads_per_ref_and_path(self) -> None:
+ changed_file = (
+ "private/its-enterprise/ruling/src/test/resources/expected_ruling/"
+ "airflow/python-S107.json"
+ )
+ io_impl = FakeRulingDiffIO(
+ json_by_ref_path={
+ (changed_file, "base-sha"): {"airflow:a.py": [1]},
+ (changed_file, "head-sha"): {"airflow:a.py": [2, 2]},
+ },
+ text_by_ref_path={
+ ("sources/airflow/a.py", "base-sha"): "base\ncontent\n",
+ ("sources/airflow/a.py", "head-sha"): "head\ncontent\n",
+ },
+ )
+
+ core.build_rule_diffs([changed_file], "base-sha", "head-sha", io_impl)
+
+ self.assertEqual(1, io_impl.load_text_calls.count(("sources/airflow/a.py", "base-sha")))
+ self.assertEqual(1, io_impl.load_text_calls.count(("sources/airflow/a.py", "head-sha")))
+
+ def test_build_rule_diffs_missing_source_produces_placeholder_snippet(self) -> None:
+ changed_file = (
+ "private/its-enterprise/ruling/src/test/resources/expected_ruling/"
+ "airflow/python-S107.json"
+ )
+ io_impl = FakeRulingDiffIO(
+ json_by_ref_path={
+ (changed_file, "base-sha"): {"airflow:a.py": [1]},
+ (changed_file, "head-sha"): {"airflow:a.py": [1, 3]},
+ },
+ text_by_ref_path={("sources/airflow/a.py", "head-sha"): None},
+ )
+
+ diffs = core.build_rule_diffs([changed_file], "base-sha", "head-sha", io_impl)
+
+ self.assertEqual(1, len(diffs[0].snippets))
+ self.assertEqual(
+ "(source file not found at this revision: a.py)",
+ diffs[0].snippets[0].body,
+ )
+
+
+class SourceLoadingTest(unittest.TestCase):
+ @patch("ruling_diff_io.subprocess.run")
+ def test_load_text_at_ref_reads_from_submodule_commit(self, mocked_run) -> None:
+ mocked_run.side_effect = [
+ subprocess.CompletedProcess(
+ args=["git", "rev-parse"],
+ returncode=0,
+ stdout="subsha123\n",
+ stderr="",
+ ),
+ subprocess.CompletedProcess(
+ args=["git", "-C", "sources", "show"],
+ returncode=0,
+ stdout="print('from submodule')\n",
+ stderr="",
+ ),
+ ]
+
+ content = io.load_text_at_ref(
+ "private/its-enterprise/sources_ruling/project/foo.py", "deadbeef"
+ )
+
+ self.assertEqual("print('from submodule')\n", content)
+
+ @patch("ruling_diff_io.subprocess.run")
+ def test_load_text_at_ref_falls_back_to_workspace_copy_with_warning(
+ self, mocked_run
+ ) -> None:
+ mocked_run.return_value = subprocess.CompletedProcess(
+ args=["git"],
+ returncode=128,
+ stdout="",
+ stderr="fatal: path 'private/its-enterprise/sources_ruling/foo.py' exists on disk, but not in 'deadbeef'",
+ )
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as tmp:
+ tmp.write("print('from workspace')\n")
+ tmp_path = tmp.name
+ try:
+ with self.assertLogs(level="WARNING") as logs:
+ content = io.load_text_at_ref(tmp_path, "deadbeef")
+ self.assertEqual("print('from workspace')\n", content)
+ self.assertTrue(any("using workspace copy" in log for log in logs.output))
+ finally:
+ pathlib.Path(tmp_path).unlink(missing_ok=True)
+
+ @patch("ruling_diff_io.subprocess.run")
+ def test_load_text_at_ref_warns_when_source_missing(self, mocked_run) -> None:
+ mocked_run.return_value = subprocess.CompletedProcess(
+ args=["git"],
+ returncode=128,
+ stdout="",
+ stderr="fatal: path 'missing.py' exists on disk, but not in 'deadbeef'",
+ )
+ with self.assertLogs(level="WARNING") as logs:
+ content = io.load_text_at_ref("missing.py", "deadbeef")
+ self.assertIsNone(content)
+ self.assertTrue(any("not found at deadbeef" in log for log in logs.output))
+
+
+class GitHubCommentLookupTest(unittest.TestCase):
+ @patch("ruling_diff_io.run_command")
+ def test_get_existing_comment_id_reads_all_pages(self, mocked_run_command) -> None:
+ mocked_run_command.return_value = (
+ '[{"id": 1, "body": "first"}]\n'
+ '[{"id": 2, "body": "text "}]\n'
+ )
+
+ comment_id = io.get_existing_comment_id(
+ "895", "SonarSource/sonar-python-enterprise"
+ )
+
+ self.assertEqual("2", comment_id)
+
+ def test_parse_json_documents_handles_multiple_arrays(self) -> None:
+ documents = io.parse_json_documents('[{"a":1}]\n[{"b":2}]')
+ self.assertEqual(2, len(documents))
+
+
+class GitHubActionIOTest(unittest.TestCase):
+ def test_resolve_source_path_for_project_rulings_uses_path_directly(self) -> None:
+ io_impl = io.GitHubActionIO()
+ self.assertEqual(
+ "private/its-enterprise/sources_ruling/biopython/Bio/Nexus/Nexus.py",
+ io_impl.resolve_source_path("project", "biopython/Bio/Nexus/Nexus.py"),
+ )
+
+ def test_resolve_source_path_for_project_rulings_falls_back_to_sources_child(self) -> None:
+ io_impl = io.GitHubActionIO()
+ self.assertEqual(
+ "private/its-enterprise/sources_ruling/specific-rules/S1716.py",
+ io_impl.resolve_source_path("project", "S1716.py"),
+ )
+
+ def test_resolve_source_path_for_project_rulings_falls_back_to_sources_internal(
+ self,
+ ) -> None:
+ io_impl = io.GitHubActionIO()
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ sources_ruling = f"{tmp_dir}/sources_ruling"
+ sources_internal = f"{tmp_dir}/sources_internal_ruling"
+ sources_namespace = f"{tmp_dir}/sources_internal_namespace_ruling"
+ pathlib.Path(sources_ruling).mkdir(parents=True, exist_ok=True)
+ pathlib.Path(sources_internal).mkdir(parents=True, exist_ok=True)
+ pathlib.Path(sources_namespace).mkdir(parents=True, exist_ok=True)
+ target = f"{sources_internal}/foo.py"
+ pathlib.Path(target).write_text("x\n", encoding="utf-8")
+ with (
+ patch.object(io, "RULING_SOURCES_SUBMODULE", sources_ruling),
+ patch.object(io, "SOURCES_INTERNAL_RULING_ROOT", sources_internal),
+ patch.object(
+ io,
+ "SOURCES_INTERNAL_NAMESPACE_RULING_ROOT",
+ sources_namespace,
+ ),
+ ):
+ self.assertEqual(target, io_impl.resolve_source_path("project", "foo.py"))
+
+ def test_resolve_source_path_for_project_rulings_falls_back_to_namespace_child(
+ self,
+ ) -> None:
+ io_impl = io.GitHubActionIO()
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ sources_ruling = f"{tmp_dir}/sources_ruling"
+ sources_internal = f"{tmp_dir}/sources_internal_ruling"
+ sources_namespace = f"{tmp_dir}/sources_internal_namespace_ruling"
+ namespace_child = f"{sources_namespace}/basic_namespace"
+ pathlib.Path(sources_ruling).mkdir(parents=True, exist_ok=True)
+ pathlib.Path(sources_internal).mkdir(parents=True, exist_ok=True)
+ pathlib.Path(namespace_child).mkdir(parents=True, exist_ok=True)
+ target = f"{namespace_child}/foo.py"
+ pathlib.Path(target).write_text("x\n", encoding="utf-8")
+ with (
+ patch.object(io, "RULING_SOURCES_SUBMODULE", sources_ruling),
+ patch.object(io, "SOURCES_INTERNAL_RULING_ROOT", sources_internal),
+ patch.object(
+ io,
+ "SOURCES_INTERNAL_NAMESPACE_RULING_ROOT",
+ sources_namespace,
+ ),
+ ):
+ self.assertEqual(target, io_impl.resolve_source_path("project", "foo.py"))
+
+ def test_resolve_source_path_for_project_rulings_returns_primary_on_miss(self) -> None:
+ io_impl = io.GitHubActionIO()
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ sources_ruling = f"{tmp_dir}/sources_ruling"
+ sources_internal = f"{tmp_dir}/sources_internal_ruling"
+ sources_namespace = f"{tmp_dir}/sources_internal_namespace_ruling"
+ pathlib.Path(sources_ruling).mkdir(parents=True, exist_ok=True)
+ pathlib.Path(sources_internal).mkdir(parents=True, exist_ok=True)
+ pathlib.Path(sources_namespace).mkdir(parents=True, exist_ok=True)
+ primary = f"{sources_ruling}/missing.py"
+ with (
+ patch.object(io, "RULING_SOURCES_SUBMODULE", sources_ruling),
+ patch.object(io, "SOURCES_INTERNAL_RULING_ROOT", sources_internal),
+ patch.object(
+ io,
+ "SOURCES_INTERNAL_NAMESPACE_RULING_ROOT",
+ sources_namespace,
+ ),
+ ):
+ self.assertEqual(primary, io_impl.resolve_source_path("project", "missing.py"))
+
+ def test_resolve_source_path_uses_project_overrides(self) -> None:
+ io_impl = io.GitHubActionIO()
+ self.assertEqual(
+ "private/its-enterprise/sources_ruling/mypy-0.782/pkg/file.py",
+ io_impl.resolve_source_path("mypy", "pkg/file.py"),
+ )
+
+ def test_resolve_source_path_uses_default_project_root(self) -> None:
+ io_impl = io.GitHubActionIO()
+ self.assertEqual(
+ "private/its-enterprise/sources_ruling/custom-project/pkg/file.py",
+ io_impl.resolve_source_path("custom-project", "/pkg/file.py"),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/actions/ruling-diff-comment/uv.lock b/.github/actions/ruling-diff-comment/uv.lock
new file mode 100644
index 000000000..a3e2f3075
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/uv.lock
@@ -0,0 +1,8 @@
+version = 1
+revision = 3
+requires-python = ">=3.10"
+
+[[package]]
+name = "ruling-diff-comment"
+version = "0.1.0"
+source = { virtual = "." }
From 772831eb2ffd2ef629cf6bb66b113be4349b854c Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Tue, 3 Mar 2026 14:45:07 +0100
Subject: [PATCH 012/322] SONARPY-3862 S6542 Removed check on variadic
parameters (#916)
GitOrigin-RevId: b1d88e70a41c08684962983e79221c71ecb862d1
---
.../checks/UseOfAnyAsTypeHintCheck.java | 9 ++++++++
.../useOfAnyAsTypeHint.py | 23 +++++++++++++++++++
2 files changed, 32 insertions(+)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/UseOfAnyAsTypeHintCheck.java b/python-checks/src/main/java/org/sonar/python/checks/UseOfAnyAsTypeHintCheck.java
index f3376927e..5cb0d97cf 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/UseOfAnyAsTypeHintCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/UseOfAnyAsTypeHintCheck.java
@@ -26,6 +26,7 @@
import org.sonar.plugins.python.api.tree.Decorator;
import org.sonar.plugins.python.api.tree.FunctionDef;
import org.sonar.plugins.python.api.tree.Name;
+import org.sonar.plugins.python.api.tree.Parameter;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.TypeAnnotation;
import org.sonar.python.semantic.SymbolUtils;
@@ -55,12 +56,20 @@ private static void checkForAnyInReturnTypeAndParameters(SubscriptionContext ctx
TypeAnnotation typeAnnotation = (TypeAnnotation) ctx.syntaxNode();
Optional.of(typeAnnotation)
.filter(UseOfAnyAsTypeHintCheck::isTypeAny)
+ .filter(Predicate.not(UseOfAnyAsTypeHintCheck::isVariadicParameter))
.map(annotation -> (FunctionDef) TreeUtils.firstAncestorOfKind(annotation, Tree.Kind.FUNCDEF))
.filter(Predicate.not(UseOfAnyAsTypeHintCheck::hasFunctionOverrideOrOverloadDecorator))
.filter(Predicate.not(UseOfAnyAsTypeHintCheck::canFunctionBeAnOverride))
.ifPresent(functionDef -> ctx.addIssue(typeAnnotation.expression(), MESSAGE));
}
+ private static boolean isVariadicParameter(TypeAnnotation typeAnnotation) {
+ return Optional.ofNullable(typeAnnotation.parent())
+ .flatMap(TreeUtils.toOptionalInstanceOfMapper(Parameter.class))
+ .map(Parameter::starToken)
+ .isPresent();
+ }
+
private static boolean isTypeAny(@Nullable TypeAnnotation typeAnnotation) {
return Optional.ofNullable(typeAnnotation)
.map(TypeAnnotation::expression)
diff --git a/python-checks/src/test/resources/checks/useOfAnyAsTypeHintCheck/useOfAnyAsTypeHint.py b/python-checks/src/test/resources/checks/useOfAnyAsTypeHintCheck/useOfAnyAsTypeHint.py
index 1571aef0b..923b178c1 100644
--- a/python-checks/src/test/resources/checks/useOfAnyAsTypeHintCheck/useOfAnyAsTypeHint.py
+++ b/python-checks/src/test/resources/checks/useOfAnyAsTypeHintCheck/useOfAnyAsTypeHint.py
@@ -126,3 +126,26 @@ class LocalClassWithAnnotatedMember:
class LocalClassChild(LocalClassWithAnnotatedMember):
def my_member(self, param: Any) -> Any: # OK, defined in parent
...
+
+# Variadic parameters (*args, **kwargs) should not raise issues
+def function_with_args(*args: Any) -> None: # Compliant
+ pass
+
+def function_with_kwargs(**kwargs: Any) -> None: # Compliant
+ pass
+
+def function_with_both(*some_args: Any, **kwargs: Any) -> None: # Compliant
+ pass
+
+def function_with_mixed(param: Any, *args: Any, **kwargs: Any) -> str: # Noncompliant
+ pass
+
+class ClassWithVariadicMethods:
+ def method_with_args(self, *args: Any) -> None: # Compliant
+ pass
+
+ def method_with_kwargs(self, **kwargs: Any) -> None: # Compliant
+ pass
+
+ def method_with_both(self, param: Any, *args: Any, **kwargs: Any) -> Any: # Noncompliant 2
+ pass
From bcacc61f126fc4739586c7edc18cb6c68f038f10 Mon Sep 17 00:00:00 2001
From: Marc Jasper
Date: Tue, 3 Mar 2026 16:40:44 +0100
Subject: [PATCH 013/322] SONARPY-3855 Fix typo in the Django Model class stub
file (#902)
GitOrigin-RevId: 801fa94069c36f85b167800511af38f63c1946d2
---
.../django/DjangoModelStrMethodCheck.java | 22 +++++++++++++----
.../django/DjangoModelStringFieldCheck.java | 6 +++--
.../django.apps.config.protobuf | 24 +++++++++----------
.../custom_protobuf/django.apps.protobuf | 20 ++++++++--------
.../django.apps.registry.protobuf | 24 +++++++++----------
.../django.db.models.base.protobuf | 16 +++++++++++++
.../custom_protobuf/django.db.models.protobuf | 15 ++++++++----
.../checksums/custom.checksum | 2 +-
.../django/db/models/{base.ipy => base.pyi} | 0
.../tests/test_serializers.py | 2 +-
10 files changed, 83 insertions(+), 48 deletions(-)
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.base.protobuf
rename python-frontend/typeshed_serializer/resources/custom/django/db/models/{base.ipy => base.pyi} (100%)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/django/DjangoModelStrMethodCheck.java b/python-checks/src/main/java/org/sonar/python/checks/django/DjangoModelStrMethodCheck.java
index 99d0cd9da..ff7b4f020 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/django/DjangoModelStrMethodCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/django/DjangoModelStrMethodCheck.java
@@ -16,14 +16,17 @@
*/
package org.sonar.python.checks.django;
-import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.sonar.check.Rule;
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
+import org.sonar.plugins.python.api.SubscriptionContext;
import org.sonar.plugins.python.api.symbols.Symbol;
import org.sonar.plugins.python.api.tree.ClassDef;
+import org.sonar.plugins.python.api.tree.RegularArgument;
import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
import org.sonar.python.checks.utils.Expressions;
import org.sonar.python.tree.TreeUtils;
@@ -34,14 +37,13 @@
public class DjangoModelStrMethodCheck extends PythonSubscriptionCheck {
public static final String MESSAGE = "Define a \"__str__\" method for this Django model.";
- private static final List DJANGO_MODEL_FQN = List.of("django.db.models.Model");
+ private static final TypeMatcher IS_DJANGO_MODEL = TypeMatchers.isType("django.db.models.base.Model");
@Override
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Tree.Kind.CLASSDEF, ctx -> {
var classDef = (ClassDef) ctx.syntaxNode();
- var parentClassesFQN = TreeUtils.getParentClassesFQN(classDef);
- if (DJANGO_MODEL_FQN.equals(parentClassesFQN)) {
+ if (isDirectDjangoModelSubclass(classDef, ctx)) {
if (isAbstractModel(classDef)) {
return;
}
@@ -54,6 +56,18 @@ public void initialize(Context context) {
});
}
+ private static boolean isDirectDjangoModelSubclass(ClassDef classDef, SubscriptionContext ctx) {
+ var args = classDef.args();
+ if (args == null) {
+ return false;
+ }
+ return args.arguments().stream()
+ .filter(RegularArgument.class::isInstance)
+ .map(RegularArgument.class::cast)
+ .map(RegularArgument::expression)
+ .anyMatch(expr -> IS_DJANGO_MODEL.isTrueFor(expr, ctx));
+ }
+
private static boolean isAbstractModel(ClassDef classDef) {
return getMetaClass(classDef)
.flatMap(metaClass -> getFieldAssignment(metaClass, "abstract"))
diff --git a/python-checks/src/main/java/org/sonar/python/checks/django/DjangoModelStringFieldCheck.java b/python-checks/src/main/java/org/sonar/python/checks/django/DjangoModelStringFieldCheck.java
index 3f90201cc..a1307b9a7 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/django/DjangoModelStringFieldCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/django/DjangoModelStringFieldCheck.java
@@ -34,6 +34,8 @@
import org.sonar.plugins.python.api.tree.Statement;
import org.sonar.plugins.python.api.tree.StatementList;
import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
import org.sonar.python.quickfix.TextEditUtils;
import org.sonar.python.tree.TreeUtils;
@@ -45,7 +47,7 @@ public class DjangoModelStringFieldCheck extends PythonSubscriptionCheck {
private static final String REPLACE_QUICK_FIX_MESSAGE = "Replace with \"blank=True\"";
private static final String REMOVE_QUICK_FIX_MESSAGE = "Remove the \"null=true\" flag";
- private static final String DJANGO_MODEL_FQN = "django.db.models.Model";
+ private static final TypeMatcher IS_DJANGO_MODEL = TypeMatchers.isOrExtendsType("django.db.models.base.Model");
public static final Set FIELD_TYPES_FQN = Set.of(
"django.db.models.CharField",
"django.db.models.TextField"
@@ -55,7 +57,7 @@ public class DjangoModelStringFieldCheck extends PythonSubscriptionCheck {
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Tree.Kind.CLASSDEF, ctx -> {
var classDef = (ClassDef) ctx.syntaxNode();
- if (TreeUtils.getParentClassesFQN(classDef).contains(DJANGO_MODEL_FQN)) {
+ if (IS_DJANGO_MODEL.isTrueFor(classDef.name(), ctx)) {
var modelClassBodyStatements = classDef.body().statements();
if (isNotManaged(modelClassBodyStatements)) {
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.config.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.config.protobuf
index 2ee0d2596..21d815f96 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.config.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.config.protobuf
@@ -1,5 +1,5 @@
-django.apps.config
+django.apps.config
AppConfigdjango.apps.config.AppConfig"builtins.object*
__init__%django.apps.config.AppConfig.__init__"
None*F
@@ -8,22 +8,22 @@
args
Any*
kwargs
-Any*
- get_model&django.apps.config.AppConfig.get_model"
- Type[Any]
-Any"type*F
+Any*
+ get_model&django.apps.config.AppConfig.get_model"g
+!Type[django.db.models.base.Model]:
+django.db.models.base.Model"django.db.models.base.Model"type*F
self<
django.apps.config.AppConfig"django.apps.config.AppConfig*,
model_name
builtins.str"builtins.str*3
require_ready
-
builtins.bool"
builtins.bool *
+
builtins.bool"
builtins.bool *
-get_models'django.apps.config.AppConfig.get_models"K
-typing.Iterator[Type[Any]]
- Type[Any]
-Any"type"typing.Iterator*F
+get_models'django.apps.config.AppConfig.get_models"
+2typing.Iterator[Type[django.db.models.base.Model]]g
+!Type[django.db.models.base.Model]:
+django.db.models.base.Model"django.db.models.base.Model"type"typing.Iterator*F
self<
django.apps.config.AppConfig"django.apps.config.AppConfig*:
include_auto_created
@@ -33,6 +33,4 @@ get_models'django.apps.config.AppConfig.get_models"K
__annotations__"django.apps.config.__annotations__W
builtins.dict[builtins.str,Any]
builtins.str"builtins.str
-Any"
builtins.dict**
-Modeldjango.apps.config.Model
-Any
\ No newline at end of file
+Any"
builtins.dict
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.protobuf
index 5430acbc4..cf264df6d 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.protobuf
@@ -1,5 +1,5 @@
-django.apps
+django.apps
AppConfigdjango.apps.config.AppConfig"builtins.object*
__init__%django.apps.config.AppConfig.__init__"
None*F
@@ -8,22 +8,22 @@
args
Any*
kwargs
-Any*
- get_model&django.apps.config.AppConfig.get_model"
- Type[Any]
-Any"type*F
+Any*
+ get_model&django.apps.config.AppConfig.get_model"g
+!Type[django.db.models.base.Model]:
+django.db.models.base.Model"django.db.models.base.Model"type*F
self<
django.apps.config.AppConfig"django.apps.config.AppConfig*,
model_name
builtins.str"builtins.str*3
require_ready
-
builtins.bool"
builtins.bool *
+
builtins.bool"
builtins.bool *
-get_models'django.apps.config.AppConfig.get_models"K
-typing.Iterator[Type[Any]]
- Type[Any]
-Any"type"typing.Iterator*F
+get_models'django.apps.config.AppConfig.get_models"
+2typing.Iterator[Type[django.db.models.base.Model]]g
+!Type[django.db.models.base.Model]:
+django.db.models.base.Model"django.db.models.base.Model"type"typing.Iterator*F
self<
django.apps.config.AppConfig"django.apps.config.AppConfig*:
include_auto_created
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.registry.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.registry.protobuf
index d68212d9d..259c8fd73 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.registry.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.apps.registry.protobuf
@@ -1,5 +1,5 @@
-django.apps.registry
+django.apps.registry
Appsdjango.apps.registry.Apps"builtins.object*
__init__"django.apps.registry.Apps.__init__"
None*@
@@ -8,21 +8,21 @@
args
Any*
kwargs
-Any*
+Any*
-get_models$django.apps.registry.Apps.get_models"G
-builtins.list[Type[Any]]
- Type[Any]
-Any"type"
builtins.list*@
+get_models$django.apps.registry.Apps.get_models"
+0builtins.list[Type[django.db.models.base.Model]]g
+!Type[django.db.models.base.Model]:
+django.db.models.base.Model"django.db.models.base.Model"type"
builtins.list*@
self6
django.apps.registry.Apps"django.apps.registry.Apps*:
include_auto_created
builtins.bool"
builtins.bool *5
include_swapped
-
builtins.bool"
builtins.bool *
- get_model#django.apps.registry.Apps.get_model"
- Type[Any]
-Any"type*@
+
builtins.bool"
builtins.bool *
+ get_model#django.apps.registry.Apps.get_model"g
+!Type[django.db.models.base.Model]:
+django.db.models.base.Model"django.db.models.base.Model"type*@
self6
django.apps.registry.Apps"django.apps.registry.Apps*+
app_label
@@ -37,8 +37,6 @@ model_nameD
__annotations__$django.apps.registry.__annotations__W
builtins.dict[builtins.str,Any]
builtins.str"builtins.str
-Any"
builtins.dict*,
-Modeldjango.apps.registry.Model
-Any*Y
+Any"
builtins.dict*Y
appsdjango.apps.registry.apps6
django.apps.registry.Apps"django.apps.registry.Apps
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.base.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.base.protobuf
new file mode 100644
index 000000000..14f12f8d4
--- /dev/null
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.base.protobuf
@@ -0,0 +1,16 @@
+
+django.db.models.base;
+ ModelBasedjango.db.models.base.ModelBase"
builtins.type
+Modeldjango.db.models.base.Model"builtins.object*
+__init__$django.db.models.base.Model.__init__"
+None*D
+self:
+django.db.models.base.Model"django.db.models.base.Model*
+args
+Any*
+kwargs
+Any@bdjango.db.models.base.ModelBase*
+__annotations__%django.db.models.base.__annotations__W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.protobuf
index 540a0048c..a0de420e9 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.protobuf
@@ -10,7 +10,16 @@
kwargs
Anyrw
object'django.db.models.manager.Manager.objectD
- django.db.models.manager.Manager" django.db.models.manager.Manager*q
+ django.db.models.manager.Manager" django.db.models.manager.Manager
+Modeldjango.db.models.base.Model"builtins.object*
+__init__$django.db.models.base.Model.__init__"
+None*D
+self:
+django.db.models.base.Model"django.db.models.base.Model*
+args
+Any*
+kwargs
+Any@bdjango.db.models.base.ModelBase*q
__path__django.db.models.__path__J
builtins.list[builtins.str]
builtins.str"builtins.str"
builtins.list*
@@ -18,6 +27,4 @@
builtins.dict[builtins.str,Any]
builtins.str"builtins.str
Any"
builtins.dict*%
-managerdjango.db.models.manager *(
-Modeldjango.db.models.Model
-Any
\ No newline at end of file
+managerdjango.db.models.manager
\ No newline at end of file
diff --git a/python-frontend/typeshed_serializer/checksums/custom.checksum b/python-frontend/typeshed_serializer/checksums/custom.checksum
index fe1f5520e..ecace0c6d 100644
--- a/python-frontend/typeshed_serializer/checksums/custom.checksum
+++ b/python-frontend/typeshed_serializer/checksums/custom.checksum
@@ -1,2 +1,2 @@
ff99a5ab4ee8349e8b21eeb3668e2e4990aa198e31cc27ca31aee9299e0bed67
-5ed498acd62426c597e0c2ab3ff0ab186b5cc72f27538e9d7627a996c36f4568
\ No newline at end of file
+7886ceeda4958304a326174baba1ff7ac6c0298352b3e9699aa7322185d94f69
\ No newline at end of file
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/db/models/base.ipy b/python-frontend/typeshed_serializer/resources/custom/django/db/models/base.pyi
similarity index 100%
rename from python-frontend/typeshed_serializer/resources/custom/django/db/models/base.ipy
rename to python-frontend/typeshed_serializer/resources/custom/django/db/models/base.pyi
diff --git a/python-frontend/typeshed_serializer/tests/test_serializers.py b/python-frontend/typeshed_serializer/tests/test_serializers.py
index 1d8853a28..17fd39834 100644
--- a/python-frontend/typeshed_serializer/tests/test_serializers.py
+++ b/python-frontend/typeshed_serializer/tests/test_serializers.py
@@ -74,7 +74,7 @@ def test_custom_stubs_serializer(typeshed_custom_stubs):
custom_stubs_serializer.serialize()
assert custom_stubs_serializer.get_build_result.call_count == 1
# Not every files from "typeshed_custom_stubs" build are serialized, as some are builtins
- assert symbols.save_module.call_count == 335
+ assert symbols.save_module.call_count == 336
def test_importer_serializer():
From 3270c6cc1cf8b358c686408552e22d707ac79866 Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Tue, 3 Mar 2026 16:58:14 +0100
Subject: [PATCH 014/322] SONARPY-3865 S8438 Reducing scope to target **kwargs
only (#924)
GitOrigin-RevId: f48f0e0ef3bb0b23cedbebe48b160aa647ff37b9
---
.../org/sonar/python/checks/utils/FunctionParameterUtils.java | 1 -
1 file changed, 1 deletion(-)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java b/python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java
index 11021fd9f..a509fb37e 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java
@@ -26,7 +26,6 @@
/**
* Utility class for extracting function parameter information.
- * Shared between FastAPIPathParametersCheck and DjangoViewUrlParametersCheck.
*/
public final class FunctionParameterUtils {
From 4c4d88ebf85866d35b0104da3070b9e72d57a2bc Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Thu, 5 Mar 2026 07:55:57 +0100
Subject: [PATCH 015/322] SONARPY-3778 Create rule S8443: Django Command
classes should inherit from BaseCommand (#883)
Co-authored-by: Claude Opus 4.5
GitOrigin-RevId: 5c7e0b6eaf4e3a6edc9908b898f60e14f404b6ff
---
.../django.core.management.base.protobuf | 108 +++++++++++++++++
.../django.core.management.protobuf | 109 ++++++++++++++++++
.../custom_protobuf/django.core.protobuf | 9 ++
.../django.views.generic.detail.protobuf | 12 +-
.../django.views.generic.list.protobuf | 12 +-
.../checksums/custom.checksum | 4 +-
.../resources/custom/django/core/__init__.pyi | 0
.../django/core/management/__init__.pyi | 1 +
.../custom/django/core/management/base.pyi | 39 +++++++
.../tests/test_serializers.py | 2 +-
10 files changed, 281 insertions(+), 15 deletions(-)
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.core.management.base.protobuf
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.core.management.protobuf
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.core.protobuf
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/core/__init__.pyi
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/core/management/__init__.pyi
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/core/management/base.pyi
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.core.management.base.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.core.management.base.protobuf
new file mode 100644
index 000000000..c4b7d5e75
--- /dev/null
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.core.management.base.protobuf
@@ -0,0 +1,108 @@
+
+django.core.management.base
+BaseCommand'django.core.management.base.BaseCommand"builtins.object*
+__init__0django.core.management.base.BaseCommand.__init__"
+None*\
+selfR
+'django.core.management.base.BaseCommand"'django.core.management.base.BaseCommand*
+stdoutu
+#Union[typing.IO[builtins.str],None]B
+typing.IO[builtins.str]
+builtins.str"builtins.str" typing.IO
+None *
+stderru
+#Union[typing.IO[builtins.str],None]B
+typing.IO[builtins.str]
+builtins.str"builtins.str" typing.IO
+None *.
+no_color
+
builtins.bool"
builtins.bool *1
+force_color
+
builtins.bool"
builtins.bool *
+
create_parser5django.core.management.base.BaseCommand.create_parser"2
+argparse.ArgumentParser"argparse.ArgumentParser*\
+selfR
+'django.core.management.base.BaseCommand"'django.core.management.base.BaseCommand*+
+ prog_name
+builtins.str"builtins.str*T
+
+subcommandD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None*
+
add_arguments5django.core.management.base.BaseCommand.add_arguments"
+None*\
+selfR
+'django.core.management.base.BaseCommand"'django.core.management.base.BaseCommand*>
+parser2
+argparse.ArgumentParser"argparse.ArgumentParser*
+handle.django.core.management.base.BaseCommand.handle"D
+Union[builtins.str,None]
+builtins.str"builtins.str
+None*\
+selfR
+'django.core.management.base.BaseCommand"'django.core.management.base.BaseCommand*
+args
+Any*
+options
+Any*
+execute/django.core.management.base.BaseCommand.execute"D
+Union[builtins.str,None]
+builtins.str"builtins.str
+None*\
+selfR
+'django.core.management.base.BaseCommand"'django.core.management.base.BaseCommand*
+args
+Any*
+options
+Any*
+
+print_help2django.core.management.base.BaseCommand.print_help"
+None*\
+selfR
+'django.core.management.base.BaseCommand"'django.core.management.base.BaseCommand*+
+ prog_name
+builtins.str"builtins.str*T
+
+subcommandD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None*
+get_version3django.core.management.base.BaseCommand.get_version"
+builtins.str"builtins.str*\
+selfR
+'django.core.management.base.BaseCommand"'django.core.management.base.BaseCommandrR
+help,django.core.management.base.BaseCommand.help
+builtins.str"builtins.strr~
+_called_from_command_lineAdjango.core.management.base.BaseCommand._called_from_command_line
+
builtins.bool"
builtins.boolrp
+output_transaction:django.core.management.base.BaseCommand.output_transaction
+
builtins.bool"
builtins.boolr
+requires_migrations_checksBdjango.core.management.base.BaseCommand.requires_migrations_checks
+
builtins.bool"
builtins.boolr
+requires_system_checks>django.core.management.base.BaseCommand.requires_system_checks
+1Union[builtins.str,typing.Sequence[builtins.str]]
+builtins.str"builtins.strN
+typing.Sequence[builtins.str]
+builtins.str"builtins.str"typing.Sequencer
+base_stealth_options
+parser2
+argparse.ArgumentParser"argparse.ArgumentParser*
+handle.django.core.management.base.BaseCommand.handle"D
+Union[builtins.str,None]
+builtins.str"builtins.str
+None*\
+selfR
+'django.core.management.base.BaseCommand"'django.core.management.base.BaseCommand*
+args
+Any*
+options
+Any*
+execute/django.core.management.base.BaseCommand.execute"D
+Union[builtins.str,None]
+builtins.str"builtins.str
+None*\
+selfR
+'django.core.management.base.BaseCommand"'django.core.management.base.BaseCommand*
+args
+Any*
+options
+Any*
+
+print_help2django.core.management.base.BaseCommand.print_help"
+None*\
+selfR
+'django.core.management.base.BaseCommand"'django.core.management.base.BaseCommand*+
+ prog_name
+builtins.str"builtins.str*T
+
+subcommandD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None*
+get_version3django.core.management.base.BaseCommand.get_version"
+builtins.str"builtins.str*\
+selfR
+'django.core.management.base.BaseCommand"'django.core.management.base.BaseCommandrR
+help,django.core.management.base.BaseCommand.help
+builtins.str"builtins.strr~
+_called_from_command_lineAdjango.core.management.base.BaseCommand._called_from_command_line
+
builtins.bool"
builtins.boolrp
+output_transaction:django.core.management.base.BaseCommand.output_transaction
+
builtins.bool"
builtins.boolr
+requires_migrations_checksBdjango.core.management.base.BaseCommand.requires_migrations_checks
+
builtins.bool"
builtins.boolr
+requires_system_checks>django.core.management.base.BaseCommand.requires_system_checks
+1Union[builtins.str,typing.Sequence[builtins.str]]
+builtins.str"builtins.strN
+typing.Sequence[builtins.str]
+builtins.str"builtins.str"typing.Sequencer
+base_stealth_options None: ...
+ def create_parser(
+ self,
+ prog_name: str,
+ subcommand: str | None,
+ ) -> argparse.ArgumentParser: ...
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None: ...
+ def handle(self, *args: Any, **options: Any) -> str | None: ...
+ def execute(self, *args: Any, **options: Any) -> str | None: ...
+ def print_help(self, prog_name: str, subcommand: str | None) -> None: ...
+ def get_version(self) -> str: ...
diff --git a/python-frontend/typeshed_serializer/tests/test_serializers.py b/python-frontend/typeshed_serializer/tests/test_serializers.py
index 17fd39834..b46888e5c 100644
--- a/python-frontend/typeshed_serializer/tests/test_serializers.py
+++ b/python-frontend/typeshed_serializer/tests/test_serializers.py
@@ -74,7 +74,7 @@ def test_custom_stubs_serializer(typeshed_custom_stubs):
custom_stubs_serializer.serialize()
assert custom_stubs_serializer.get_build_result.call_count == 1
# Not every files from "typeshed_custom_stubs" build are serialized, as some are builtins
- assert symbols.save_module.call_count == 336
+ assert symbols.save_module.call_count == 339
def test_importer_serializer():
From b68ef89d1dac7841882ac19439a7c5835b997059 Mon Sep 17 00:00:00 2001
From: Thomas Serre
<118730793+thomas-serre-sonarsource@users.noreply.github.com>
Date: Thu, 5 Mar 2026 15:42:00 +0100
Subject: [PATCH 016/322] SONARPY-3424 S112: Raise when passing a newly
constructed Exception or BaseException to function (#935)
GitOrigin-RevId: 41f1ed016cc89b82303afa1f2ef50dca4645e55e
---
.../checks/GenericExceptionRaisedCheck.java | 63 +++++++++++++------
.../genericExceptionRaised.py | 13 ++++
2 files changed, 58 insertions(+), 18 deletions(-)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java b/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java
index f50d81bc5..03032b30f 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/GenericExceptionRaisedCheck.java
@@ -19,11 +19,15 @@
import java.util.List;
import org.sonar.check.Rule;
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
+import org.sonar.plugins.python.api.SubscriptionContext;
import org.sonar.plugins.python.api.symbols.v2.SymbolV2;
import org.sonar.plugins.python.api.symbols.v2.UsageV2;
+import org.sonar.plugins.python.api.tree.Argument;
+import org.sonar.plugins.python.api.tree.CallExpression;
import org.sonar.plugins.python.api.tree.Expression;
import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.RaiseStatement;
+import org.sonar.plugins.python.api.tree.RegularArgument;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.Tree.Kind;
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
@@ -36,6 +40,8 @@
@Rule(key = "S112")
public class GenericExceptionRaisedCheck extends PythonSubscriptionCheck {
+ private static final String MESSAGE = "Replace this generic exception class with a more specific one.";
+
private final TypeMatcher isExceptionOrBaseExceptionMatcher = TypeMatchers.any(
TypeMatchers.isObjectOfType(EXCEPTION),
TypeMatchers.isObjectOfType(BASE_EXCEPTION),
@@ -43,35 +49,56 @@ public class GenericExceptionRaisedCheck extends PythonSubscriptionCheck {
TypeMatchers.isType(BASE_EXCEPTION)
);
+ private final TypeMatcher isObjectOfTypeExceptionOrBaseExceptionMatcher = TypeMatchers.any(
+ TypeMatchers.isObjectOfType(EXCEPTION),
+ TypeMatchers.isObjectOfType(BASE_EXCEPTION)
+ );
+
@Override
public void initialize(Context context) {
- context.registerSyntaxNodeConsumer(Kind.RAISE_STMT, ctx -> {
- RaiseStatement raise = (RaiseStatement) ctx.syntaxNode();
- List expressions = raise.expressions();
- if (expressions.isEmpty()) {
- return;
- }
+ context.registerSyntaxNodeConsumer(Kind.RAISE_STMT, this::checkRaise);
+ context.registerSyntaxNodeConsumer(Kind.CALL_EXPR, this::checkFunctionCall);
+ }
+
+ private void checkRaise(SubscriptionContext ctx) {
+ RaiseStatement raise = (RaiseStatement) ctx.syntaxNode();
+ List expressions = raise.expressions();
+ if (expressions.isEmpty()) {
+ return;
+ }
- Expression expression = expressions.get(0);
- if (!isExceptionOrBaseExceptionMatcher.isTrueFor(expression, ctx)) {
- return;
+ Expression expression = expressions.get(0);
+ if (!isExceptionOrBaseExceptionMatcher.isTrueFor(expression, ctx)) {
+ return;
+ }
+ if (!isExceptionFunctionLocal(expression, raise)) {
+ return;
+ }
+ ctx.addIssue(expression, MESSAGE);
+ }
+
+ private void checkFunctionCall(SubscriptionContext ctx) {
+ CallExpression call = (CallExpression) ctx.syntaxNode();
+ List arguments = call.arguments();
+ for (Argument arg : arguments) {
+ if (!(arg instanceof RegularArgument regArg) || regArg.keywordArgument() != null) {
+ continue;
}
- if (!isExceptionFunctionLocal(expression, raise)) {
- return;
+ Expression argExpr = regArg.expression();
+ if (isObjectOfTypeExceptionOrBaseExceptionMatcher.isTrueFor(argExpr, ctx) && isExceptionFunctionLocal(argExpr, call)) {
+ ctx.addIssue(argExpr, MESSAGE);
}
-
- ctx.addIssue(expression, "Replace this generic exception class with a more specific one.");
- });
+ }
}
- private static boolean isExceptionFunctionLocal(Expression expression, RaiseStatement raise) {
+ private static boolean isExceptionFunctionLocal(Expression expression, Tree contextTree) {
if (!(expression instanceof Name name)) return true;
SymbolV2 symbolV2 = name.symbolV2();
- return symbolV2 == null || isLocalVariable(symbolV2, raise);
+ return symbolV2 == null || isLocalVariable(symbolV2, contextTree);
}
- private static boolean isLocalVariable(SymbolV2 symbol, Tree raiseStatement) {
- Tree function = TreeUtils.firstAncestorOfKind(raiseStatement, Kind.FUNCDEF);
+ private static boolean isLocalVariable(SymbolV2 symbol, Tree contextTree) {
+ Tree function = TreeUtils.firstAncestorOfKind(contextTree, Kind.FUNCDEF);
if (function == null) {
return false;
}
diff --git a/python-checks/src/test/resources/checks/genericException/genericExceptionRaised.py b/python-checks/src/test/resources/checks/genericException/genericExceptionRaised.py
index 1cf03e093..73295530f 100644
--- a/python-checks/src/test/resources/checks/genericException/genericExceptionRaised.py
+++ b/python-checks/src/test/resources/checks/genericException/genericExceptionRaised.py
@@ -55,3 +55,16 @@ def raised_exception_is_the_parameter(exception: BaseException):
global_exception = BaseException()
def raise_global_exception():
raise global_exception
+
+def handle_exception(e: Exception):
+ raise e
+
+def intermediate_handle_exception(e: Exception):
+ handle_exception(e)
+
+def constructed_exceptions_passed_to_handle_exception_raise():
+ handle_exception(Exception()) # Noncompliant
+ handle_exception(BaseException()) # Noncompliant
+
+def is_instance_do_not_raise(e):
+ isInstance(e, Exception)
From 68297ba75dc31bac57a80691f87495cf37194f51 Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Fri, 6 Mar 2026 08:42:09 +0100
Subject: [PATCH 017/322] SONARPY-3772 Implement S8439: Django view functions
should include all URL parameters (#887)
Co-authored-by: Claude Opus 4.5
GitOrigin-RevId: 11f315cd45432aad488a71d85ecb98401f661a2a
---
.../org/sonar/python/checks/utils/FunctionParameterUtils.java | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java b/python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java
index a509fb37e..38a3a7f07 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/utils/FunctionParameterUtils.java
@@ -30,7 +30,6 @@
public final class FunctionParameterUtils {
private FunctionParameterUtils() {
- // Utility class
}
/**
@@ -60,7 +59,7 @@ public static Optional getFunctionType(FunctionDef functionDef) {
return Optional.empty();
}
- private static FunctionParameterInfo buildParameterInfo(FunctionType functionType) {
+ public static FunctionParameterInfo buildParameterInfo(FunctionType functionType) {
Set allParams = new HashSet<>();
Set positionalOnlyParams = new HashSet<>();
boolean hasVariadicKeyword = functionType.parameters().stream()
From 670160acf1f2e8fa742ece60defad46d716b3482 Mon Sep 17 00:00:00 2001
From: Marc Jasper
Date: Fri, 6 Mar 2026 15:06:20 +0100
Subject: [PATCH 018/322] SONARPY-3777 Create rule S8486: Django middleware
should call super().__init__() with appropriate parameters (#942)
GitOrigin-RevId: 206bc88b5254dc61cbeb86663b072ceedadfb2b8
---
.../django.contrib.auth.middleware.protobuf | 25 +++++++++++++++++++
.../django.contrib.auth.protobuf | 11 ++++++++
.../custom_protobuf/django.contrib.protobuf | 10 ++++++++
.../types/custom_protobuf/django.protobuf | 3 ++-
.../django.utils.deprecation.protobuf | 23 +++++++++++++++++
.../resources/custom/django/__init__.pyi | 1 +
.../custom/django/contrib/__init__.pyi | 1 +
.../custom/django/contrib/auth/__init__.pyi | 1 +
.../custom/django/contrib/auth/middleware.pyi | 9 +++++++
.../custom/django/utils/__init__.pyi | 1 +
.../custom/django/utils/deprecation.pyi | 6 +++++
.../tests/test_serializers.py | 2 +-
12 files changed, 91 insertions(+), 2 deletions(-)
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.middleware.protobuf
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.protobuf
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.protobuf
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.utils.deprecation.protobuf
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/contrib/__init__.pyi
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/contrib/auth/__init__.pyi
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/contrib/auth/middleware.pyi
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/utils/deprecation.pyi
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.middleware.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.middleware.protobuf
new file mode 100644
index 000000000..6698b0e6c
--- /dev/null
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.middleware.protobuf
@@ -0,0 +1,25 @@
+
+django.contrib.auth.middleware
+RemoteUserMiddleware3django.contrib.auth.middleware.RemoteUserMiddleware"builtins.object*
+__init__ None: ...
+ def __call__(self, request: Any) -> Any: ...
+
+class PersistentRemoteUserMiddleware(RemoteUserMiddleware): ...
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/utils/__init__.pyi b/python-frontend/typeshed_serializer/resources/custom/django/utils/__init__.pyi
index 86ca4ac69..67dc54c09 100644
--- a/python-frontend/typeshed_serializer/resources/custom/django/utils/__init__.pyi
+++ b/python-frontend/typeshed_serializer/resources/custom/django/utils/__init__.pyi
@@ -1 +1,2 @@
import django.utils.html as html
+import django.utils.deprecation as deprecation
\ No newline at end of file
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/utils/deprecation.pyi b/python-frontend/typeshed_serializer/resources/custom/django/utils/deprecation.pyi
new file mode 100644
index 000000000..933bce189
--- /dev/null
+++ b/python-frontend/typeshed_serializer/resources/custom/django/utils/deprecation.pyi
@@ -0,0 +1,6 @@
+from typing import Any, Callable
+
+class MiddlewareMixin:
+ get_response: Callable[..., Any]
+ def __init__(self, get_response: Callable[..., Any]) -> None: ...
+ def __call__(self, request: Any) -> Any: ...
diff --git a/python-frontend/typeshed_serializer/tests/test_serializers.py b/python-frontend/typeshed_serializer/tests/test_serializers.py
index b46888e5c..9cf9a717b 100644
--- a/python-frontend/typeshed_serializer/tests/test_serializers.py
+++ b/python-frontend/typeshed_serializer/tests/test_serializers.py
@@ -74,7 +74,7 @@ def test_custom_stubs_serializer(typeshed_custom_stubs):
custom_stubs_serializer.serialize()
assert custom_stubs_serializer.get_build_result.call_count == 1
# Not every files from "typeshed_custom_stubs" build are serialized, as some are builtins
- assert symbols.save_module.call_count == 339
+ assert symbols.save_module.call_count == 343
def test_importer_serializer():
From 27baffef51c6f207ee26d4bbc2aafbf270fed27a Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Fri, 6 Mar 2026 15:29:58 +0100
Subject: [PATCH 019/322] SONARPY-3776: Extend S5344: Detect direct password
assignment to Django User model instead of using set_password() or
create_user() (#920)
GitOrigin-RevId: b92267be8d757cafc0d293e80871330f844c4a9b
---
.../hotspots/FastHashingOrPlainTextCheck.java | 225 ++++++++++--------
.../fastHashingOrPlainText.py | 40 ++++
.../django.contrib.auth.models.protobuf | 72 ++++++
.../django.contrib.auth.protobuf | 71 +++++-
.../custom_protobuf/django.utils.protobuf | 3 +-
.../org/sonar/python/types/TypeShedTest.java | 10 +
.../checksums/custom.checksum | 4 +-
.../resources/custom/django/__init__.pyi | 1 +
.../custom/django/contrib/auth/__init__.pyi | 9 +
.../custom/django/contrib/auth/models.pyi | 36 +++
10 files changed, 361 insertions(+), 110 deletions(-)
create mode 100644 python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.models.protobuf
create mode 100644 python-frontend/typeshed_serializer/resources/custom/django/contrib/auth/models.pyi
diff --git a/python-checks/src/main/java/org/sonar/python/checks/hotspots/FastHashingOrPlainTextCheck.java b/python-checks/src/main/java/org/sonar/python/checks/hotspots/FastHashingOrPlainTextCheck.java
index 72e915e76..1d724533d 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/hotspots/FastHashingOrPlainTextCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/hotspots/FastHashingOrPlainTextCheck.java
@@ -37,6 +37,8 @@
import org.sonar.plugins.python.api.tree.StringLiteral;
import org.sonar.plugins.python.api.tree.SubscriptionExpression;
import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
import org.sonar.python.checks.hotspots.CommonValidationUtils.ArgumentValidator;
import org.sonar.python.checks.hotspots.CommonValidationUtils.CallValidator;
import org.sonar.python.tree.TreeUtils;
@@ -58,149 +60,144 @@ public class FastHashingOrPlainTextCheck extends PythonSubscriptionCheck {
private static final String ARGON2_MESSAGE = "Use secure Argon2 parameters.";
private static final String BCRYPT_MESSAGE = "Use strong bcrypt parameters.";
private static final String DJANGO_MESSAGE = "Use a secure hashing algorithm to store passwords.";
+ private static final String DJANGO_PASSWORD_MESSAGE = "Use set_password() or create_user() to properly hash passwords.";
private static final Set PBKDF2_ALGOS = Set.of(
"sha1",
"sha256",
- "sha512"
- );
+ "sha512");
private static final String ROUNDS = "rounds";
private static final Set DJANGO_FIRST_FORBIDDEN_HASHERS = Set.of(
"django.contrib.auth.hashers.SHA1PasswordHasher",
"django.contrib.auth.hashers.MD5PasswordHasher",
"django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher",
"django.contrib.auth.hashers.UnsaltedMD5PasswordHasher",
- "django.contrib.auth.hashers.CryptPasswordHasher"
- );
-
+ "django.contrib.auth.hashers.CryptPasswordHasher");
private static final ArgumentValidator SCRYPT_R = new ArgumentValidator(
3, "r", (ctx, argument) -> {
- if (isLessThan(argument.expression(), 8)) {
- ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), 8)) {
+ ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
+ }
+ });
private static final ArgumentValidator SCRYPT_BUFLEN = new ArgumentValidator(
5, "buflen", (ctx, argument) -> {
- if (isLessThan(argument.expression(), 32)) {
- ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), 32)) {
+ ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
+ }
+ });
private static final ArgumentValidator SCRYPT_N = new ArgumentValidator(
2, "N", (ctx, argument) -> {
- if (isLessThan(argument.expression(), (int) Math.pow(2, 13)) || isLessThanExponent(argument.expression(), 13)) {
- ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), (int) Math.pow(2, 13)) || isLessThanExponent(argument.expression(), 13)) {
+ ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
+ }
+ });
private static final ArgumentValidator HASHLIB_R = new ArgumentValidator(
3, "r", (ctx, argument) -> {
- if (isLessThan(argument.expression(), 8)) {
- ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), 8)) {
+ ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
+ }
+ });
private static final ArgumentValidator HASHLIB_N = new ArgumentValidator(
2, "n", (ctx, argument) -> {
- if (isLessThan(argument.expression(), (int) Math.pow(2, 13)) || isLessThanExponent(argument.expression(), 13)) {
- ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), (int) Math.pow(2, 13)) || isLessThanExponent(argument.expression(), 13)) {
+ ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
+ }
+ });
private static final ArgumentValidator HASHLIB_DKLEN = new ArgumentValidator(
6, "dklen", (ctx, argument) -> {
- if (isLessThan(argument.expression(), 32)) {
- ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), 32)) {
+ ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
+ }
+ });
private static final ArgumentValidator CRYPTOGRAPHY_R = new ArgumentValidator(
3, "r", (ctx, argument) -> {
- if (isLessThan(argument.expression(), 8)) {
- ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), 8)) {
+ ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
+ }
+ });
private static final ArgumentValidator CRYPTOGRAPHY_N = new ArgumentValidator(
2, "n", (ctx, argument) -> {
- if (isLessThan(argument.expression(), (int) Math.pow(2, 13)) || isLessThanExponent(argument.expression(), 13)) {
- ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), (int) Math.pow(2, 13)) || isLessThanExponent(argument.expression(), 13)) {
+ ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
+ }
+ });
private static final ArgumentValidator CRYPTOGRAPHY_LENGTH = new ArgumentValidator(
1, "length", (ctx, argument) -> {
- if (isLessThan(argument.expression(), 32)) {
- ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), 32)) {
+ ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
+ }
+ });
private static final ArgumentValidator PASSLIB_BLOCK_SIZE = new ArgumentValidator(
3, "block_size", (ctx, argument) -> {
- if (isLessThan(argument.expression(), 8)) {
- ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), 8)) {
+ ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
+ }
+ });
private static final ArgumentValidator PASSLIB_ROUNDS = new ArgumentValidator(
2, ROUNDS, (ctx, argument) -> {
- if (isLessThan(argument.expression(), 12)) {
- ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
- }
- });
-
+ if (isLessThan(argument.expression(), 12)) {
+ ctx.addIssue(argument, SCRYPT_PARAMETERS_MESSAGE);
+ }
+ });
private static final ArgumentValidator PASSLIB_PBKDF2 = new ArgumentValidator(
2, ROUNDS, (ctx, argument) -> {
- if (isLessThan(argument.expression(), 100_000)) {
- ctx.addIssue(argument, PBKDF2_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), 100_000)) {
+ ctx.addIssue(argument, PBKDF2_MESSAGE);
+ }
+ });
private static final CallValidator CRYPTOGRAPHY_PBKDF2 = new PBKDF2Validator(0, "algorithm", 3, "iterations");
private static final CallValidator HASHLIB_PBKDF2 = new PBKDF2Validator(0, "hash_name", 3, "iterations");
private static final CallValidator PASSLIB_MISSING_ROUNDS = new MissingArgumentValidator(
- 2, ROUNDS, PBKDF2_MESSAGE
- );
+ 2, ROUNDS, PBKDF2_MESSAGE);
private static final CallValidator BCRYPT_GENSALT = new ArgumentValidator(
0, ROUNDS, (ctx, argument) -> {
- if (isLessThan(argument.expression(), 12)) {
- ctx.addIssue(argument, BCRYPT_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), 12)) {
+ ctx.addIssue(argument, BCRYPT_MESSAGE);
+ }
+ });
private static final CallValidator BCRYPT_KDF = new ArgumentValidator(
3, ROUNDS, (ctx, argument) -> {
- if (isLessThan(argument.expression(), 4096) || isLessThanExponent(argument.expression(), 12)) {
- ctx.addIssue(argument, BCRYPT_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), 4096) || isLessThanExponent(argument.expression(), 12)) {
+ ctx.addIssue(argument, BCRYPT_MESSAGE);
+ }
+ });
private static final CallValidator PASSLIB_BCRYPT = new ArgumentValidator(
3, ROUNDS, (ctx, argument) -> {
- if (isLessThan(argument.expression(), 12)) {
- ctx.addIssue(argument, BCRYPT_MESSAGE);
- }
- });
+ if (isLessThan(argument.expression(), 12)) {
+ ctx.addIssue(argument, BCRYPT_MESSAGE);
+ }
+ });
private static final CallValidator FLASK_BCRYPT = new ArgumentValidator(
1, ROUNDS, (ctx, argument) -> {
- if (isLessThan(argument.expression(), 12)) {
- ctx.addIssue(argument, BCRYPT_MESSAGE);
- }
- });
-
+ if (isLessThan(argument.expression(), 12)) {
+ ctx.addIssue(argument, BCRYPT_MESSAGE);
+ }
+ });
private TypeCheckBuilder argon2IDTypeChecker = null;
private final CallValidator argon2Type = new ArgumentValidator(
0, "type", (ctx, argument) -> {
- if (argon2IDTypeChecker.check(argument.expression().typeV2()) == TriBool.FALSE) {
- ctx.addIssue(argument, "Use Argon2ID to improve the security of the passwords.");
- }
- });
+ if (argon2IDTypeChecker.check(argument.expression().typeV2()) == TriBool.FALSE) {
+ ctx.addIssue(argument, "Use Argon2ID to improve the security of the passwords.");
+ }
+ });
private TypeCheckBuilder argon2VersionTypeChecker = null;
private final CallValidator argon2Version = new ArgumentValidator(
1, "version", (ctx, argument) -> {
- var typeCheck = argon2VersionTypeChecker.check(argument.expression().typeV2());
- if (typeCheck == TriBool.TRUE) {
- return;
- }
- if (!isEqualTo(argument.expression(), 19)) {
- ctx.addIssue(argument, "Use the latest version of Argon2 ID.");
- }
- });
+ var typeCheck = argon2VersionTypeChecker.check(argument.expression().typeV2());
+ if (typeCheck == TriBool.TRUE) {
+ return;
+ }
+ if (!isEqualTo(argument.expression(), 19)) {
+ ctx.addIssue(argument, "Use the latest version of Argon2 ID.");
+ }
+ });
private final Map> callExpressionValidators = Map.ofEntries(
Map.entry("scrypt.hash", List.of(SCRYPT_R, SCRYPT_BUFLEN, SCRYPT_N)),
@@ -216,24 +213,24 @@ public class FastHashingOrPlainTextCheck extends PythonSubscriptionCheck {
Map.entry("passlib.handlers.argon2._Argon2Common.using", List.of(new Argon2PasswordHasherValidator(3, 4, 5))),
Map.entry("bcrypt.gensalt", List.of(BCRYPT_GENSALT)),
Map.entry("bcrypt.kdf", List.of(BCRYPT_KDF)),
- Map.entry("flask_bcrypt.generate_password_hash", List.of(FLASK_BCRYPT))
- );
+ Map.entry("flask_bcrypt.generate_password_hash", List.of(FLASK_BCRYPT)));
private static final Map> CALL_EXPRESSION_VALIDATORS_V1 = Map.ofEntries(
- Map.entry("flask_bcrypt.Bcrypt.generate_password_hash", List.of(FLASK_BCRYPT))
- );
+ Map.entry("flask_bcrypt.Bcrypt.generate_password_hash", List.of(FLASK_BCRYPT)));
private static final Map> QUALIFIED_EXPR_VALIDATOR = Map.of(
"passlib.hash.pbkdf2_sha1.using", List.of(PASSLIB_PBKDF2),
"passlib.hash.pbkdf2_sha256.using", List.of(PASSLIB_PBKDF2, PASSLIB_MISSING_ROUNDS),
"passlib.hash.pbkdf2_sha512.using", List.of(PASSLIB_PBKDF2, PASSLIB_MISSING_ROUNDS),
- "passlib.hash.bcrypt.using", List.of(PASSLIB_BCRYPT)
- );
+ "passlib.hash.bcrypt.using", List.of(PASSLIB_BCRYPT));
+ private static final TypeMatcher djangoUserInstanceMatcher = TypeMatchers.isObjectInstanceOf("django.contrib.auth.models.AbstractBaseUser");
+ private static final TypeMatcher djangoUserManagerCreateTypeChecker = TypeMatchers.isType("django.contrib.auth.models.UserManager.create");
private TypeCheckBuilder argon2CheapestProfileTypeChecker = null;
private TypeCheckBuilder flaskConfigTypeChecker = null;
private TypeCheckMap> typeCheckMap = null;
+
@Override
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::registerTypeCheckers);
@@ -245,7 +242,8 @@ public void initialize(Context context) {
});
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkCallExpr);
context.registerSyntaxNodeConsumer(Tree.Kind.NAME, this::checkName);
- context.registerSyntaxNodeConsumer(Tree.Kind.ASSIGNMENT_STMT, subscriptionContext -> checkAssignment(subscriptionContext, flaskConfigTypeChecker));
+ context.registerSyntaxNodeConsumer(Tree.Kind.ASSIGNMENT_STMT, subscriptionContext -> checkAssignmentOnFlaskConfig(subscriptionContext, flaskConfigTypeChecker));
+ context.registerSyntaxNodeConsumer(Tree.Kind.ASSIGNMENT_STMT, FastHashingOrPlainTextCheck::checkDjangoUserPasswordAssignment);
}
private static void checkDjangoHasher(SubscriptionContext subscriptionContext) {
@@ -275,6 +273,7 @@ private void registerTypeCheckers(SubscriptionContext subscriptionContext) {
flaskConfigTypeChecker = subscriptionContext.typeChecker().typeCheckBuilder().isInstanceOf("flask.config.Config");
argon2IDTypeChecker = subscriptionContext.typeChecker().typeCheckBuilder().isTypeWithFqn("argon2.low_level.Type.ID");
argon2VersionTypeChecker = subscriptionContext.typeChecker().typeCheckBuilder().isTypeWithFqn("argon2.low_level.ARGON2_VERSION");
+
typeCheckMap = new TypeCheckMap<>();
callExpressionValidators.forEach((key, value) -> {
var typeCheckBuilder = subscriptionContext.typeChecker().typeCheckBuilder().isTypeWithFqn(key);
@@ -282,7 +281,7 @@ private void registerTypeCheckers(SubscriptionContext subscriptionContext) {
});
}
- private static void checkAssignment(SubscriptionContext subscriptionContext, TypeCheckBuilder flaskConfigTypeChecker) {
+ private static void checkAssignmentOnFlaskConfig(SubscriptionContext subscriptionContext, TypeCheckBuilder flaskConfigTypeChecker) {
var stmt = (AssignmentStatement) subscriptionContext.syntaxNode();
var lhsSubscription = stmt.lhsExpressions().stream().findFirst()
.map(ExpressionList::expressions)
@@ -313,6 +312,17 @@ private static boolean subscriptionIsFlaskBcryptConfig(Expression expression, Ty
return subscriptMatch.isPresent();
}
+ private static void checkDjangoUserPasswordAssignment(SubscriptionContext subscriptionContext) {
+ var assignment = (AssignmentStatement) subscriptionContext.syntaxNode();
+ // Check if LHS is `user.password = ...` where user is an instance of AbstractBaseUser
+ if (assignment.lhsExpressions().get(0).expressions().size() == 1
+ && assignment.lhsExpressions().get(0).expressions().get(0) instanceof QualifiedExpression qualifiedExpression
+ && "password".equals(qualifiedExpression.name().name())
+ && djangoUserInstanceMatcher.isTrueFor(qualifiedExpression.qualifier(), subscriptionContext)) {
+ subscriptionContext.addIssue(assignment.lhsExpressions().get(0), DJANGO_PASSWORD_MESSAGE);
+ }
+ }
+
private void checkName(SubscriptionContext subscriptionContext) {
var name = (Name) subscriptionContext.syntaxNode();
if (argon2CheapestProfileTypeChecker.check(name.typeV2()) != TriBool.TRUE) {
@@ -347,18 +357,27 @@ private void checkCallExpr(SubscriptionContext subscriptionContext) {
var configs = QUALIFIED_EXPR_VALIDATOR.getOrDefault(fqn, List.of());
configs.forEach(config -> config.validate(subscriptionContext, callExpression));
}
+
+ // Check for Django User.objects.create(password=...) - should use create_user() instead
+ checkDjangoUserManagerCreate(subscriptionContext, callExpression);
+ }
+
+ private static void checkDjangoUserManagerCreate(SubscriptionContext subscriptionContext, CallExpression callExpression) {
+ if (!djangoUserManagerCreateTypeChecker.isTrueFor(callExpression.callee(), subscriptionContext)) {
+ return;
+ }
+ Optional.ofNullable(TreeUtils.argumentByKeyword("password", callExpression.arguments()))
+ .ifPresent(passwordArg -> subscriptionContext.addIssue(passwordArg, DJANGO_PASSWORD_MESSAGE));
}
record PBKDF2Validator(
int algoPosition,
String algoKeyword,
int iterationsPosition,
- String iterationsKeyword
- ) implements CallValidator {
+ String iterationsKeyword) implements CallValidator {
@Override
public void validate(SubscriptionContext ctx, CallExpression callExpression) {
- var algoArgument =
- nthArgumentOrKeywordOptional(algoPosition, algoKeyword, callExpression.arguments());
+ var algoArgument = nthArgumentOrKeywordOptional(algoPosition, algoKeyword, callExpression.arguments());
var algoString = algoArgument
.map(RegularArgument::expression)
.map(CommonValidationUtils::singleAssignedString)
@@ -382,17 +401,13 @@ public void validate(SubscriptionContext ctx, CallExpression callExpression) {
record Argon2PasswordHasherValidator(
int timeCostPosition,
int memoryCostPosition,
- int parallelismPosition
- ) implements CallValidator {
+ int parallelismPosition) implements CallValidator {
@Override
public void validate(SubscriptionContext ctx, CallExpression callExpression) {
- var timeCostArgument =
- nthArgumentOrKeyword(timeCostPosition, "time_cost", callExpression.arguments());
- var memoryCostArgument =
- nthArgumentOrKeyword(memoryCostPosition, "memory_cost", callExpression.arguments());
- var parallelismArgument =
- nthArgumentOrKeyword(parallelismPosition, "parallelism", callExpression.arguments());
+ var timeCostArgument = nthArgumentOrKeyword(timeCostPosition, "time_cost", callExpression.arguments());
+ var memoryCostArgument = nthArgumentOrKeyword(memoryCostPosition, "memory_cost", callExpression.arguments());
+ var parallelismArgument = nthArgumentOrKeyword(parallelismPosition, "parallelism", callExpression.arguments());
var isTimeCostNOk = timeCostArgument != null && isLessThan(timeCostArgument.expression(), 5);
var isMemoryCostNOk = memoryCostArgument != null && isLessThan(memoryCostArgument.expression(), 7168);
diff --git a/python-checks/src/test/resources/checks/fastHashingOrPlainText/fastHashingOrPlainText.py b/python-checks/src/test/resources/checks/fastHashingOrPlainText/fastHashingOrPlainText.py
index 23856e7ec..cb4459254 100644
--- a/python-checks/src/test/resources/checks/fastHashingOrPlainText/fastHashingOrPlainText.py
+++ b/python-checks/src/test/resources/checks/fastHashingOrPlainText/fastHashingOrPlainText.py
@@ -368,3 +368,43 @@ def flask_config():
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher", # OK outside of settings.py
]
+
+
+## Django User Password
+
+def django_user_password_assignment():
+ from django.contrib.auth.models import User
+
+ user = User()
+ user.username = 'john'
+ user.password = 'mysecretpassword' # Noncompliant {{Use set_password() or create_user() to properly hash passwords.}}
+ user.save()
+
+ # Compliant - using set_password()
+ user2 = User()
+ user2.set_password('mysecretpassword')
+ user2.save()
+
+ # Compliant - setting other attributes
+ user3 = User()
+ user3.username = 'jane'
+ user3.email = 'jane@example.com'
+
+
+def django_user_create():
+ from django.contrib.auth.models import User
+
+ # Using create() with password argument
+ user = User.objects.create(
+ username='john',
+ password='mysecretpassword' # Noncompliant {{Use set_password() or create_user() to properly hash passwords.}}
+ )
+
+ # Compliant - using create_user()
+ user = User.objects.create_user(
+ username='john',
+ password='mysecretpassword'
+ )
+
+ # Compliant - using create() without password argument
+ user = User.objects.create(username='john')
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.models.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.models.protobuf
new file mode 100644
index 000000000..dbd0fd24a
--- /dev/null
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.models.protobuf
@@ -0,0 +1,72 @@
+
+django.contrib.auth.models
+
+UserManager&django.contrib.auth.models.UserManager" django.db.models.manager.Manager*
+create-django.contrib.auth.models.UserManager.create"Z
++django.contrib.auth.models.AbstractBaseUser"+django.contrib.auth.models.AbstractBaseUser*Z
+selfP
+&django.contrib.auth.models.UserManager"&django.contrib.auth.models.UserManager*
+kwargs
+Any*
+create_user2django.contrib.auth.models.UserManager.create_user"Z
++django.contrib.auth.models.AbstractBaseUser"+django.contrib.auth.models.AbstractBaseUser*Z
+selfP
+&django.contrib.auth.models.UserManager"&django.contrib.auth.models.UserManager**
+username
+builtins.str"builtins.str*Q
+emailD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None *T
+passwordD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None *
+extra_fields
+Any*
+create_superuser7django.contrib.auth.models.UserManager.create_superuser"Z
++django.contrib.auth.models.AbstractBaseUser"+django.contrib.auth.models.AbstractBaseUser*Z
+selfP
+&django.contrib.auth.models.UserManager"&django.contrib.auth.models.UserManager**
+username
+builtins.str"builtins.str*Q
+emailD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None *T
+passwordD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None *
+extra_fields
+Any
+AbstractBaseUser+django.contrib.auth.models.AbstractBaseUser"django.db.models.base.Model"*SonarPythonAnalyzerFakeStub.CustomStubBase*
+set_password8django.contrib.auth.models.AbstractBaseUser.set_password"
+None*d
+selfZ
++django.contrib.auth.models.AbstractBaseUser"+django.contrib.auth.models.AbstractBaseUser*.
+raw_password
+builtins.str"builtins.str*
+check_password:django.contrib.auth.models.AbstractBaseUser.check_password"
+
builtins.bool"
builtins.bool*d
+selfZ
++django.contrib.auth.models.AbstractBaseUser"+django.contrib.auth.models.AbstractBaseUser*.
+raw_password
+builtins.str"builtins.strr^
+password4django.contrib.auth.models.AbstractBaseUser.password
+builtins.str"builtins.str
+AbstractUser'django.contrib.auth.models.AbstractUser"+django.contrib.auth.models.AbstractBaseUserrZ
+username0django.contrib.auth.models.AbstractUser.username
+builtins.str"builtins.strrT
+email-django.contrib.auth.models.AbstractUser.email
+builtins.str"builtins.strr
+objects/django.contrib.auth.models.AbstractUser.objectsP
+&django.contrib.auth.models.UserManager"&django.contrib.auth.models.UserManager
+Userdjango.contrib.auth.models.User"'django.contrib.auth.models.AbstractUserr
+objects'django.contrib.auth.models.User.objectsP
+&django.contrib.auth.models.UserManager"&django.contrib.auth.models.UserManagere
+
AnonymousUser(django.contrib.auth.models.AnonymousUser"*SonarPythonAnalyzerFakeStub.CustomStubBase*
+__annotations__*django.contrib.auth.models.__annotations__W
+builtins.dict[builtins.str,Any]
+builtins.str"builtins.str
+Any"
builtins.dict
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.protobuf
index 3635f8625..f199eeb1b 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.contrib.auth.protobuf
@@ -1,5 +1,71 @@
-django.contrib.auth*t
+django.contrib.auth
+AbstractBaseUser+django.contrib.auth.models.AbstractBaseUser"django.db.models.base.Model"*SonarPythonAnalyzerFakeStub.CustomStubBase*
+set_password8django.contrib.auth.models.AbstractBaseUser.set_password"
+None*d
+selfZ
++django.contrib.auth.models.AbstractBaseUser"+django.contrib.auth.models.AbstractBaseUser*.
+raw_password
+builtins.str"builtins.str*
+check_password:django.contrib.auth.models.AbstractBaseUser.check_password"
+
builtins.bool"
builtins.bool*d
+selfZ
++django.contrib.auth.models.AbstractBaseUser"+django.contrib.auth.models.AbstractBaseUser*.
+raw_password
+builtins.str"builtins.strr^
+password4django.contrib.auth.models.AbstractBaseUser.password
+builtins.str"builtins.str
+AbstractUser'django.contrib.auth.models.AbstractUser"+django.contrib.auth.models.AbstractBaseUserrZ
+username0django.contrib.auth.models.AbstractUser.username
+builtins.str"builtins.strrT
+email-django.contrib.auth.models.AbstractUser.email
+builtins.str"builtins.strr
+objects/django.contrib.auth.models.AbstractUser.objectsP
+&django.contrib.auth.models.UserManager"&django.contrib.auth.models.UserManagere
+
AnonymousUser(django.contrib.auth.models.AnonymousUser"*SonarPythonAnalyzerFakeStub.CustomStubBase
+Userdjango.contrib.auth.models.User"'django.contrib.auth.models.AbstractUserr
+objects'django.contrib.auth.models.User.objectsP
+&django.contrib.auth.models.UserManager"&django.contrib.auth.models.UserManager
+
+UserManager&django.contrib.auth.models.UserManager" django.db.models.manager.Manager*
+create-django.contrib.auth.models.UserManager.create"Z
++django.contrib.auth.models.AbstractBaseUser"+django.contrib.auth.models.AbstractBaseUser*Z
+selfP
+&django.contrib.auth.models.UserManager"&django.contrib.auth.models.UserManager*
+kwargs
+Any*
+create_user2django.contrib.auth.models.UserManager.create_user"Z
++django.contrib.auth.models.AbstractBaseUser"+django.contrib.auth.models.AbstractBaseUser*Z
+selfP
+&django.contrib.auth.models.UserManager"&django.contrib.auth.models.UserManager**
+username
+builtins.str"builtins.str*Q
+emailD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None *T
+passwordD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None *
+extra_fields
+Any*
+create_superuser7django.contrib.auth.models.UserManager.create_superuser"Z
++django.contrib.auth.models.AbstractBaseUser"+django.contrib.auth.models.AbstractBaseUser*Z
+selfP
+&django.contrib.auth.models.UserManager"&django.contrib.auth.models.UserManager**
+username
+builtins.str"builtins.str*Q
+emailD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None *T
+passwordD
+Union[builtins.str,None]
+builtins.str"builtins.str
+None *
+extra_fields
+Any*t
__path__django.contrib.auth.__path__J
builtins.list[builtins.str]
builtins.str"builtins.str"
builtins.list*
@@ -8,4 +74,5 @@
builtins.str"builtins.str
Any"
builtins.dict*.
-middlewaredjango.contrib.auth.middleware
\ No newline at end of file
+middlewaredjango.contrib.auth.middleware *&
+modelsdjango.contrib.auth.models
\ No newline at end of file
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.utils.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.utils.protobuf
index f324db003..ef90d1a14 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.utils.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.utils.protobuf
@@ -7,4 +7,5 @@
builtins.dict[builtins.str,Any]
builtins.str"builtins.str
Any"
builtins.dict*
-htmldjango.utils.html
\ No newline at end of file
+htmldjango.utils.html *)
+deprecationdjango.utils.deprecation
\ No newline at end of file
diff --git a/python-frontend/src/test/java/org/sonar/python/types/TypeShedTest.java b/python-frontend/src/test/java/org/sonar/python/types/TypeShedTest.java
index 0453c37e8..4e2e7e4b7 100644
--- a/python-frontend/src/test/java/org/sonar/python/types/TypeShedTest.java
+++ b/python-frontend/src/test/java/org/sonar/python/types/TypeShedTest.java
@@ -284,6 +284,16 @@ void package_django() {
assertThat(responseSymbol.fullyQualifiedName()).isEqualTo("django.http.response.HttpResponse");
}
+ @Test
+ void package_django_contrib_auth() {
+ Map djangoAuthSymbols = symbolsForModule("django.contrib.auth.models");
+ assertThat(djangoAuthSymbols).isNotEmpty();
+ Symbol userSymbol = djangoAuthSymbols.get("User");
+ assertThat(userSymbol).isNotNull();
+ assertThat(userSymbol.kind()).isEqualTo(Kind.CLASS);
+ assertThat(userSymbol.fullyQualifiedName()).isEqualTo("django.contrib.auth.models.User");
+ }
+
@Test
void return_type_hints() {
Map symbols = symbolsForModule("typing");
diff --git a/python-frontend/typeshed_serializer/checksums/custom.checksum b/python-frontend/typeshed_serializer/checksums/custom.checksum
index 9473e666d..ccd0a7cd0 100644
--- a/python-frontend/typeshed_serializer/checksums/custom.checksum
+++ b/python-frontend/typeshed_serializer/checksums/custom.checksum
@@ -1,2 +1,2 @@
-1c8010bc70819847c25235e37ca98a48d47747b77edc77887b1bdd3ee5c49521
-c854205ed8f8a5d5935b2164fd2f15bf962159b9a8045e243aec3c3b4e393e76
\ No newline at end of file
+843addd5d5f7af6ecacd2ed21b251d4a89a44fdbdcc9dd2af52fb12939f697fe
+d67269ba8194374e815fa405dfbf957e07d196202cd8d3328d4a5a40f90628b5
\ No newline at end of file
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/__init__.pyi b/python-frontend/typeshed_serializer/resources/custom/django/__init__.pyi
index 0358717a8..759277930 100644
--- a/python-frontend/typeshed_serializer/resources/custom/django/__init__.pyi
+++ b/python-frontend/typeshed_serializer/resources/custom/django/__init__.pyi
@@ -6,3 +6,4 @@ import django.conf as conf
import django.apps as apps
import django.contrib as contrib
import django.views as views
+import django.contrib as contrib
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/contrib/auth/__init__.pyi b/python-frontend/typeshed_serializer/resources/custom/django/contrib/auth/__init__.pyi
index 43bfc5921..32af52e1f 100644
--- a/python-frontend/typeshed_serializer/resources/custom/django/contrib/auth/__init__.pyi
+++ b/python-frontend/typeshed_serializer/resources/custom/django/contrib/auth/__init__.pyi
@@ -1 +1,10 @@
import django.contrib.auth.middleware as middleware
+import django.contrib.auth.models as models
+
+from .models import (
+ AbstractBaseUser as AbstractBaseUser,
+ AbstractUser as AbstractUser,
+ AnonymousUser as AnonymousUser,
+ User as User,
+ UserManager as UserManager,
+)
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/contrib/auth/models.pyi b/python-frontend/typeshed_serializer/resources/custom/django/contrib/auth/models.pyi
new file mode 100644
index 000000000..ef9351c37
--- /dev/null
+++ b/python-frontend/typeshed_serializer/resources/custom/django/contrib/auth/models.pyi
@@ -0,0 +1,36 @@
+from SonarPythonAnalyzerFakeStub import CustomStubBase
+from django.db.models.base import Model
+from django.db.models.manager import Manager
+from typing import Any
+
+class UserManager(Manager):
+ def create(self, **kwargs: Any) -> "AbstractBaseUser": ...
+ def create_user(
+ self,
+ username: str,
+ email: str | None = None,
+ password: str | None = None,
+ **extra_fields: Any,
+ ) -> "AbstractBaseUser": ...
+ def create_superuser(
+ self,
+ username: str,
+ email: str | None = None,
+ password: str | None = None,
+ **extra_fields: Any,
+ ) -> "AbstractBaseUser": ...
+
+class AbstractBaseUser(Model, CustomStubBase):
+ password: str
+ def set_password(self, raw_password: str) -> None: ...
+ def check_password(self, raw_password: str) -> bool: ...
+
+class AbstractUser(AbstractBaseUser):
+ username: str
+ email: str
+ objects: UserManager
+
+class User(AbstractUser):
+ objects: UserManager
+
+class AnonymousUser(CustomStubBase): ...
From 1607e8dde17770fe1f5771ca4abc98bc01c0b82d Mon Sep 17 00:00:00 2001
From: Marc Jasper
Date: Fri, 6 Mar 2026 16:15:22 +0100
Subject: [PATCH 020/322] SONARPY-3773 Create rule S8440: Querysets should use
select_related() or prefetch_related() (#867)
Co-authored-by: Claude Sonnet 4.5
Co-authored-by: joke1196
GitOrigin-RevId: 25a9d8333c86cc4439624592deb46e90fc0690d0
---
.../django.db.models.base.protobuf | 6 +-
.../django.db.models.manager.protobuf | 316 ++++++++++++++++-
.../custom_protobuf/django.db.models.protobuf | 320 +++++++++++++++++-
.../custom/django/db/models/base.pyi | 3 +
.../custom/django/db/models/manager.pyi | 52 ++-
.../tests/test_serializers.py | 2 +-
6 files changed, 682 insertions(+), 17 deletions(-)
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.base.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.base.protobuf
index 14f12f8d4..dbf8c61df 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.base.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.base.protobuf
@@ -1,6 +1,6 @@
django.db.models.base;
- ModelBasedjango.db.models.base.ModelBase"
builtins.type
+ ModelBasedjango.db.models.base.ModelBase"
builtins.type
Modeldjango.db.models.base.Model"builtins.object*
__init__$django.db.models.base.Model.__init__"
None*D
@@ -9,7 +9,9 @@
args
Any*
kwargs
-Any@bdjango.db.models.base.ModelBase*
+Any@bdjango.db.models.base.ModelBasert
+objects#django.db.models.base.Model.objectsD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
__annotations__%django.db.models.base.__annotations__W
builtins.dict[builtins.str,Any]
builtins.str"builtins.str
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.manager.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.manager.protobuf
index 48b23d5c2..fb9cfbd84 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.manager.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.manager.protobuf
@@ -1,6 +1,314 @@
-django.db.models.manager
-Manager django.db.models.manager.Manager"*SonarPythonAnalyzerFakeStub.CustomStubBase*
+django.db.models.managerJ
+Manager django.db.models.manager.Manager"*SonarPythonAnalyzerFakeStub.CustomStubBase*
+all$django.db.models.manager.Manager.all"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+filter'django.db.models.manager.Manager.filter"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+exclude(django.db.models.manager.Manager.exclude"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+order_by)django.db.models.manager.Manager.order_by"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+field_names
+Any*
+reverse(django.db.models.manager.Manager.reverse"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+distinct)django.db.models.manager.Manager.distinct"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+field_names
+Any*
+select_related/django.db.models.manager.Manager.select_related"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+prefetch_related1django.db.models.manager.Manager.prefetch_related"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+lookups
+Any*
+defer&django.db.models.manager.Manager.defer"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+only%django.db.models.manager.Manager.only"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+using&django.db.models.manager.Manager.using"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+alias
+Any*
+annotate)django.db.models.manager.Manager.annotate"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+alias&django.db.models.manager.Manager.alias"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+none%django.db.models.manager.Manager.none"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+union&django.db.models.manager.Manager.union"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+other_qs
+Any*
+all
+Any *
+intersection-django.db.models.manager.Manager.intersection"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+other_qs
+Any*
+
+difference+django.db.models.manager.Manager.difference"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+other_qs
+Any*
+select_for_update2django.db.models.manager.Manager.select_for_update"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+nowait
+Any *
+skip_locked
+Any *
+of
+Any *
+no_key
+Any *
+extra&django.db.models.manager.Manager.extra"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+values'django.db.models.manager.Manager.values"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+expressions
+Any*
+values_list,django.db.models.manager.Manager.values_list"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+flat
+Any *
+named
+Any *
+dates&django.db.models.manager.Manager.dates"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+
+field_name
+Any*
+kind
+Any*
+order
+Any *
+ datetimes*django.db.models.manager.Manager.datetimes"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+
+field_name
+Any*
+kind
+Any*
+order
+Any *
+tzinfo
+Any *
+get$django.db.models.manager.Manager.get"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+create'django.db.models.manager.Manager.create"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+kwargs
+Any*
+
get_or_create.django.db.models.manager.Manager.get_or_create".
+builtins.tuple[Any]
+Any"builtins.tuple*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+defaults
+Any *
+kwargs
+Any*
+update_or_create1django.db.models.manager.Manager.update_or_create".
+builtins.tuple[Any]
+Any"builtins.tuple*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+defaults
+Any *
+create_defaults
+Any *
+kwargs
+Any*
+first&django.db.models.manager.Manager.first"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+last%django.db.models.manager.Manager.last"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+earliest)django.db.models.manager.Manager.earliest"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+latest'django.db.models.manager.Manager.latest"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+count&django.db.models.manager.Manager.count"
+builtins.int"builtins.int*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+exists'django.db.models.manager.Manager.exists"
+
builtins.bool"
builtins.bool*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+contains)django.db.models.manager.Manager.contains"
+
builtins.bool"
builtins.bool*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+obj
+Any*
+ aggregate*django.db.models.manager.Manager.aggregate"9
+builtins.dict[Any,Any]
+Any
+Any"
builtins.dict*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+in_bulk(django.db.models.manager.Manager.in_bulk"9
+builtins.dict[Any,Any]
+Any
+Any"
builtins.dict*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+id_list
+Any *
+
+field_name
+Any *
+update'django.db.models.manager.Manager.update"
+builtins.int"builtins.int*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+kwargs
+Any*
+delete'django.db.models.manager.Manager.delete".
+builtins.tuple[Any]
+Any"builtins.tuple*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+bulk_create,django.db.models.manager.Manager.bulk_create",
+builtins.list[Any]
+Any"
builtins.list*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+objs
+Any*
+
+batch_size
+Any *
+kwargs
+Any*
+bulk_update,django.db.models.manager.Manager.bulk_update"
+builtins.int"builtins.int*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+objs
+Any*
+fields
+Any*
+
+batch_size
+Any *
+iterator)django.db.models.manager.Manager.iterator"0
+typing.Iterator[Any]
+Any"typing.Iterator*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+
+chunk_size
+Any *
+explain(django.db.models.manager.Manager.explain"
+builtins.str"builtins.str*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+format
+Any *
+options
+Any*
raw$django.db.models.manager.Manager.raw"
Any*N
selfD
@@ -8,9 +316,7 @@
args
Any*
kwargs
-Anyrw
-object'django.db.models.manager.Manager.objectD
- django.db.models.manager.Manager" django.db.models.manager.Manager*
+Any*
__annotations__(django.db.models.manager.__annotations__W
builtins.dict[builtins.str,Any]
builtins.str"builtins.str
diff --git a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.protobuf b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.protobuf
index a0de420e9..44c5a2e49 100644
--- a/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.protobuf
+++ b/python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/django.db.models.protobuf
@@ -1,6 +1,314 @@
-django.db.models
-Manager django.db.models.manager.Manager"*SonarPythonAnalyzerFakeStub.CustomStubBase*
+django.db.modelsJ
+Manager django.db.models.manager.Manager"*SonarPythonAnalyzerFakeStub.CustomStubBase*
+all$django.db.models.manager.Manager.all"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+filter'django.db.models.manager.Manager.filter"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+exclude(django.db.models.manager.Manager.exclude"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+order_by)django.db.models.manager.Manager.order_by"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+field_names
+Any*
+reverse(django.db.models.manager.Manager.reverse"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+distinct)django.db.models.manager.Manager.distinct"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+field_names
+Any*
+select_related/django.db.models.manager.Manager.select_related"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+prefetch_related1django.db.models.manager.Manager.prefetch_related"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+lookups
+Any*
+defer&django.db.models.manager.Manager.defer"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+only%django.db.models.manager.Manager.only"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+using&django.db.models.manager.Manager.using"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+alias
+Any*
+annotate)django.db.models.manager.Manager.annotate"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+alias&django.db.models.manager.Manager.alias"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+none%django.db.models.manager.Manager.none"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+union&django.db.models.manager.Manager.union"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+other_qs
+Any*
+all
+Any *
+intersection-django.db.models.manager.Manager.intersection"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+other_qs
+Any*
+
+difference+django.db.models.manager.Manager.difference"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+other_qs
+Any*
+select_for_update2django.db.models.manager.Manager.select_for_update"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+nowait
+Any *
+skip_locked
+Any *
+of
+Any *
+no_key
+Any *
+extra&django.db.models.manager.Manager.extra"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+values'django.db.models.manager.Manager.values"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+expressions
+Any*
+values_list,django.db.models.manager.Manager.values_list"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+flat
+Any *
+named
+Any *
+dates&django.db.models.manager.Manager.dates"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+
+field_name
+Any*
+kind
+Any*
+order
+Any *
+ datetimes*django.db.models.manager.Manager.datetimes"D
+ django.db.models.manager.Manager" django.db.models.manager.Manager*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+
+field_name
+Any*
+kind
+Any*
+order
+Any *
+tzinfo
+Any *
+get$django.db.models.manager.Manager.get"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+create'django.db.models.manager.Manager.create"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+kwargs
+Any*
+
get_or_create.django.db.models.manager.Manager.get_or_create".
+builtins.tuple[Any]
+Any"builtins.tuple*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+defaults
+Any *
+kwargs
+Any*
+update_or_create1django.db.models.manager.Manager.update_or_create".
+builtins.tuple[Any]
+Any"builtins.tuple*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+defaults
+Any *
+create_defaults
+Any *
+kwargs
+Any*
+first&django.db.models.manager.Manager.first"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+last%django.db.models.manager.Manager.last"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+earliest)django.db.models.manager.Manager.earliest"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+latest'django.db.models.manager.Manager.latest"
+Any*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+fields
+Any*
+count&django.db.models.manager.Manager.count"
+builtins.int"builtins.int*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+exists'django.db.models.manager.Manager.exists"
+
builtins.bool"
builtins.bool*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+contains)django.db.models.manager.Manager.contains"
+
builtins.bool"
builtins.bool*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+obj
+Any*
+ aggregate*django.db.models.manager.Manager.aggregate"9
+builtins.dict[Any,Any]
+Any
+Any"
builtins.dict*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+args
+Any*
+kwargs
+Any*
+in_bulk(django.db.models.manager.Manager.in_bulk"9
+builtins.dict[Any,Any]
+Any
+Any"
builtins.dict*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+id_list
+Any *
+
+field_name
+Any *
+update'django.db.models.manager.Manager.update"
+builtins.int"builtins.int*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+kwargs
+Any*
+delete'django.db.models.manager.Manager.delete".
+builtins.tuple[Any]
+Any"builtins.tuple*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+bulk_create,django.db.models.manager.Manager.bulk_create",
+builtins.list[Any]
+Any"
builtins.list*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+objs
+Any*
+
+batch_size
+Any *
+kwargs
+Any*
+bulk_update,django.db.models.manager.Manager.bulk_update"
+builtins.int"builtins.int*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+objs
+Any*
+fields
+Any*
+
+batch_size
+Any *
+iterator)django.db.models.manager.Manager.iterator"0
+typing.Iterator[Any]
+Any"typing.Iterator*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+
+chunk_size
+Any *
+explain(django.db.models.manager.Manager.explain"
+builtins.str"builtins.str*N
+selfD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*
+format
+Any *
+options
+Any*
raw$django.db.models.manager.Manager.raw"
Any*N
selfD
@@ -8,9 +316,7 @@
args
Any*
kwargs
-Anyrw
-object'django.db.models.manager.Manager.objectD
- django.db.models.manager.Manager" django.db.models.manager.Manager
+Any
Modeldjango.db.models.base.Model"builtins.object*
__init__$django.db.models.base.Model.__init__"
None*D
@@ -19,7 +325,9 @@
args
Any*
kwargs
-Any@bdjango.db.models.base.ModelBase*q
+Any@bdjango.db.models.base.ModelBasert
+objects#django.db.models.base.Model.objectsD
+ django.db.models.manager.Manager" django.db.models.manager.Manager*q
__path__django.db.models.__path__J
builtins.list[builtins.str]
builtins.str"builtins.str"
builtins.list*
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/db/models/base.pyi b/python-frontend/typeshed_serializer/resources/custom/django/db/models/base.pyi
index 5204be2f4..5a9176d1c 100644
--- a/python-frontend/typeshed_serializer/resources/custom/django/db/models/base.pyi
+++ b/python-frontend/typeshed_serializer/resources/custom/django/db/models/base.pyi
@@ -1,3 +1,6 @@
+from django.db.models.manager import Manager
+
class ModelBase(type): ...
class Model(metaclass=ModelBase):
+ objects: Manager
def __init__(self, *args, **kwargs) -> None: ...
diff --git a/python-frontend/typeshed_serializer/resources/custom/django/db/models/manager.pyi b/python-frontend/typeshed_serializer/resources/custom/django/db/models/manager.pyi
index 2c94b66a6..aa2eb9cdb 100644
--- a/python-frontend/typeshed_serializer/resources/custom/django/db/models/manager.pyi
+++ b/python-frontend/typeshed_serializer/resources/custom/django/db/models/manager.pyi
@@ -1,8 +1,54 @@
from SonarPythonAnalyzerFakeStub import CustomStubBase
-
-from typing import Any
+from typing import Any, Iterator
class Manager(CustomStubBase):
- object: Manager
+
+ # Queryset-returning methods (chainable)
+ def all(self) -> Manager: ...
+ def filter(self, *args, **kwargs) -> Manager: ...
+ def exclude(self, *args, **kwargs) -> Manager: ...
+ def order_by(self, *field_names) -> Manager: ...
+ def reverse(self) -> Manager: ...
+ def distinct(self, *field_names) -> Manager: ...
+ def select_related(self, *fields) -> Manager: ...
+ def prefetch_related(self, *lookups) -> Manager: ...
+ def defer(self, *fields) -> Manager: ...
+ def only(self, *fields) -> Manager: ...
+ def using(self, alias) -> Manager: ...
+ def annotate(self, *args, **kwargs) -> Manager: ...
+ def alias(self, *args, **kwargs) -> Manager: ...
+ def none(self) -> Manager: ...
+ def union(self, *other_qs, all=False) -> Manager: ...
+ def intersection(self, *other_qs) -> Manager: ...
+ def difference(self, *other_qs) -> Manager: ...
+ def select_for_update(self, nowait=False, skip_locked=False, of=(), no_key=False) -> Manager: ...
+ def extra(self, *args, **kwargs) -> Manager: ...
+ def values(self, *fields, **expressions) -> Manager: ...
+ def values_list(self, *fields, flat=False, named=False) -> Manager: ...
+ def dates(self, field_name, kind, order=...) -> Manager: ...
+ def datetimes(self, field_name, kind, order=..., tzinfo=None) -> Manager: ...
+
+ # Methods returning single objects
+ def get(self, *args, **kwargs) -> Any: ...
+ def create(self, **kwargs) -> Any: ...
+ def get_or_create(self, defaults=None, **kwargs) -> tuple: ...
+ def update_or_create(self, defaults=None, create_defaults=None, **kwargs) -> tuple: ...
+ def first(self) -> Any: ...
+ def last(self) -> Any: ...
+ def earliest(self, *fields) -> Any: ...
+ def latest(self, *fields) -> Any: ...
+
+ # Methods returning scalar/collection values
+ def count(self) -> int: ...
+ def exists(self) -> bool: ...
+ def contains(self, obj) -> bool: ...
+ def aggregate(self, *args, **kwargs) -> dict: ...
+ def in_bulk(self, id_list=None, *, field_name=...) -> dict: ...
+ def update(self, **kwargs) -> int: ...
+ def delete(self) -> tuple: ...
+ def bulk_create(self, objs, batch_size=None, **kwargs) -> list: ...
+ def bulk_update(self, objs, fields, batch_size=None) -> int: ...
+ def iterator(self, chunk_size=None) -> Iterator: ...
+ def explain(self, *, format=None, **options) -> str: ...
def raw(self, *args, **kwargs) -> Any: ...
diff --git a/python-frontend/typeshed_serializer/tests/test_serializers.py b/python-frontend/typeshed_serializer/tests/test_serializers.py
index 9cf9a717b..f65f43191 100644
--- a/python-frontend/typeshed_serializer/tests/test_serializers.py
+++ b/python-frontend/typeshed_serializer/tests/test_serializers.py
@@ -74,7 +74,7 @@ def test_custom_stubs_serializer(typeshed_custom_stubs):
custom_stubs_serializer.serialize()
assert custom_stubs_serializer.get_build_result.call_count == 1
# Not every files from "typeshed_custom_stubs" build are serialized, as some are builtins
- assert symbols.save_module.call_count == 343
+ assert symbols.save_module.call_count == 344
def test_importer_serializer():
From 919dc81acf68e3c2a5813dcc8230b89f78933a30 Mon Sep 17 00:00:00 2001
From: Thomas Serre
<118730793+thomas-serre-sonarsource@users.noreply.github.com>
Date: Mon, 9 Mar 2026 13:58:41 +0100
Subject: [PATCH 021/322] SONARPY-3873 S2068 false negative on default value
from os.getenv and os.environ.get (#941)
GitOrigin-RevId: 4f86dcc511307cd681747f3b2dd5a9baf88c0d60
---
.../hotspots/HardCodedCredentialsCheck.java | 51 ++++++++++++++-----
.../resources/checks/hardCodedCredentials.py | 6 +++
2 files changed, 45 insertions(+), 12 deletions(-)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/hotspots/HardCodedCredentialsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/hotspots/HardCodedCredentialsCheck.java
index 01053f7ad..ae7e8e484 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/hotspots/HardCodedCredentialsCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/hotspots/HardCodedCredentialsCheck.java
@@ -55,24 +55,28 @@
import org.sonar.plugins.python.api.tree.SubscriptionExpression;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.Tree.Kind;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
import org.sonar.python.tree.TreeUtils;
@Rule(key = "S2068")
public class HardCodedCredentialsCheck extends PythonSubscriptionCheck {
+ public static final String MESSAGE = "\"%s\" detected here, review this potentially hard-coded credential.";
private static final String DEFAULT_CREDENTIAL_WORDS = "password,passwd,pwd,passphrase";
private static final String FLASK_CONFIG_ASSIGNMENT_FQN = "flask.app.Flask.config";
private static final String FLASK_CONFIG_CREDENTIAL_KEY = "SECRET_KEY";
+ private static final TypeMatcher GETENV_MATCHER = TypeMatchers.isType("os.getenv");
+ private static final TypeMatcher ENVIRON_MATCHER = TypeMatchers.isType("os.environ");
+ private static final TypeMatcher GET_ENVIRON_MATCHER = TypeMatchers.isType("typing.Mapping.get");
+
@RuleProperty(
key = "credentialWords",
description = "Comma separated list of words identifying potential credentials",
defaultValue = DEFAULT_CREDENTIAL_WORDS)
public String credentialWords = DEFAULT_CREDENTIAL_WORDS;
-
- public static final String MESSAGE = "\"%s\" detected here, review this potentially hard-coded credential.";
-
private List variablePatterns = null;
private List literalPatterns = null;
private Map sensitiveArgumentByFQN;
@@ -132,14 +136,16 @@ private void handleDictionaryLiteral(DictionaryLiteral dictionaryLiteral, Subscr
}
private void checkKeyValuePair(KeyValuePair keyValuePair, SubscriptionContext ctx) {
- if (keyValuePair.key().is(Kind.STRING_LITERAL) && keyValuePair.value().is(Kind.STRING_LITERAL)) {
- String matchedCredential = matchedCredential(((StringLiteral) keyValuePair.key()).trimmedQuotesValue(), variablePatterns());
- if (matchedCredential != null) {
- StringLiteral literal = (StringLiteral) keyValuePair.value();
- if (isSuspiciousStringLiteral(literal)) {
- ctx.addIssue(keyValuePair, String.format(MESSAGE, matchedCredential));
- }
- }
+ if (!keyValuePair.key().is(Kind.STRING_LITERAL)) {
+ return;
+ }
+ String matchedCredential = matchedCredential(((StringLiteral) keyValuePair.key()).trimmedQuotesValue(), variablePatterns());
+ if (matchedCredential == null) {
+ return;
+ }
+ Expression value = keyValuePair.value();
+ if (isSuspiciousStringLiteral(value) || isSuspiciousEnvGetDefault(value, ctx)) {
+ ctx.addIssue(keyValuePair, String.format(MESSAGE, matchedCredential));
}
}
@@ -302,7 +308,7 @@ private void handleAssignmentStatement(AssignmentStatement assignmentStatement,
private void checkAssignedValue(AssignmentStatement assignmentStatement, String matchedCredential, SubscriptionContext ctx) {
Expression assignedValue = assignmentStatement.assignedValue();
- if (isSuspiciousStringLiteral(assignedValue) && !isFlaskConfigAssignment(assignedValue) ) {
+ if ((isSuspiciousStringLiteral(assignedValue) || isSuspiciousEnvGetDefault(assignedValue, ctx)) && !isFlaskConfigAssignment(assignedValue)) {
ctx.addIssue(assignmentStatement, String.format(MESSAGE, matchedCredential));
}
}
@@ -319,6 +325,27 @@ private boolean isSuspiciousStringLiteral(Tree tree) {
&& !isCredential(((StringLiteral) tree).trimmedQuotesValue(), variablePatterns());
}
+ private static boolean isSuspiciousEnvGetDefault(Expression expression, SubscriptionContext ctx) {
+ if (!expression.is(Kind.CALL_EXPR)) {
+ return false;
+ }
+ CallExpression call = (CallExpression) expression;
+ Expression callee = call.callee();
+ if (!GETENV_MATCHER.isTrueFor(call.callee(), ctx)
+ && !(callee instanceof QualifiedExpression qExpr && GET_ENVIRON_MATCHER.isTrueFor(callee, ctx) && ENVIRON_MATCHER.isTrueFor(qExpr.qualifier(), ctx))
+ ) {
+ return false;
+ }
+ return TreeUtils.nthArgumentOrKeywordOptional(1, "default", call.arguments())
+ .map(RegularArgument::expression)
+ .filter(HardCodedCredentialsCheck::isNonEmptyStringLiteral)
+ .isPresent();
+ }
+
+ private static boolean isNonEmptyStringLiteral(Expression expression) {
+ return expression.is(Kind.STRING_LITERAL) && !((StringLiteral) expression).trimmedQuotesValue().isEmpty();
+ }
+
private static boolean isCredential(String target, Stream patterns) {
return patterns.anyMatch(pattern -> pattern.matcher(target).find());
}
diff --git a/python-checks/src/test/resources/checks/hardCodedCredentials.py b/python-checks/src/test/resources/checks/hardCodedCredentials.py
index 605b04a61..fcc61cb45 100644
--- a/python-checks/src/test/resources/checks/hardCodedCredentials.py
+++ b/python-checks/src/test/resources/checks/hardCodedCredentials.py
@@ -44,6 +44,8 @@ def __init__(self):
self.passed = "passed"
fieldNameWithPasswordInIt = "azerty123" # Noncompliant {{"password" detected here, review this potentially hard-coded credential.}}
fieldNameWithPasswordInIt = os.getenv("password", "") # OK
+ fieldNameWithPasswordInIt = os.getenv("password", "hardcodedPassword") # Noncompliant
+ fieldNameWithPasswordInIt = os.environ.get("password", "hardcodedPassword") # Noncompliant
self.fieldNameWithPasswordInIt = "azerty123" # Noncompliant {{"password" detected here, review this potentially hard-coded credential.}}
self.fieldNameWithPasswordInIt = os.getenv("password", "") # OK
@@ -197,6 +199,8 @@ def somePassword(self, *, password="hello"): # Noncompliant
'USER': 'sonarsource',
'PASSWORD': 'azerty123', # Noncompliant
'PASSWORD': os.getenv('DB_PASSWORD'), # Compliant
+ 'PASSWORD': os.getenv("DB_PASSWORD", "hardcodedPassword"), # Noncompliant
+ 'PASSWORD': os.environ.get("DB_PASSWORD", "hardcodedPassword"), # Noncompliant
'HOST': 'localhost',
'PORT': '5432'
},
@@ -206,6 +210,8 @@ def somePassword(self, *, password="hello"): # Noncompliant
'USER': 'sonarsource',
'PASSWORD': 'azerty123', # Noncompliant
'PASSWORD': os.getenv('DB_PASSWORD'), # Compliant
+ 'PASSWORD': os.getenv("DB_PASSWORD", "hardcodedPassword"), # Noncompliant
+ 'PASSWORD': os.environ.get("DB_PASSWORD", "hardcodedPassword"), # Noncompliant
'HOST': 'localhost',
'PORT': '5432'
}
From daefc8715f0818bf5244dfa452356d72b7dd9fa3 Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Mon, 9 Mar 2026 14:30:53 +0100
Subject: [PATCH 022/322] SONARPY-3876 S1172 Fixing FPs on Django middleware
conventional function parameters (#944)
GitOrigin-RevId: 47e0aba447a9b6095c644691b76dee20c18e39cb
---
.../checks/UnusedFunctionParameterCheck.java | 18 +++++++++++
.../UnusedFunctionParameterCheckTest.java | 7 ++++
.../django/middleware.py | 32 +++++++++++++++++++
3 files changed, 57 insertions(+)
create mode 100644 python-checks/src/test/resources/checks/unusedFunctionParameter/django/middleware.py
diff --git a/python-checks/src/main/java/org/sonar/python/checks/UnusedFunctionParameterCheck.java b/python-checks/src/main/java/org/sonar/python/checks/UnusedFunctionParameterCheck.java
index ba90c7ee4..a15dba6a5 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/UnusedFunctionParameterCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/UnusedFunctionParameterCheck.java
@@ -41,6 +41,8 @@
import org.sonar.plugins.python.api.tree.Token;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.Tree.Kind;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
import org.sonar.plugins.python.api.tree.Trivia;
import org.sonar.python.checks.utils.CheckUtils;
import org.sonar.python.checks.utils.StringLiteralValuesCollector;
@@ -57,6 +59,12 @@ public class UnusedFunctionParameterCheck extends PythonSubscriptionCheck {
private static final Set AWS_LAMBDA_PARAMETERS = Set.of("event", "context");
+ private static final Set DJANGO_MIDDLEWARE_METHODS = Set.of(
+ "process_request", "process_exception", "process_view", "process_template_response");
+
+ private static final TypeMatcher MIDDLEWARE_MIXIN_MATCHER = TypeMatchers.isFunctionOwnerSatisfying(
+ TypeMatchers.isOrExtendsType("django.utils.deprecation.MiddlewareMixin"));
+
@Override
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Kind.FUNCDEF, ctx -> checkFunctionParameter(ctx, ((FunctionDef) ctx.syntaxNode())));
@@ -119,6 +127,7 @@ private static boolean isException(SubscriptionContext ctx, FunctionDef function
hasNonCallUsages(functionSymbol) ||
isTestFunction(ctx, functionDef) ||
isDjangoView(functionDef) ||
+ isDjangoMiddlewareFunction(ctx, functionDef) ||
isAbstractClass(functionDef);
}
@@ -201,4 +210,13 @@ private static boolean isDjangoView(FunctionDef functionDef) {
FunctionSymbolImpl functionSymbolImpl = (FunctionSymbolImpl) functionSymbol;
return functionSymbolImpl != null && functionSymbolImpl.isDjangoView();
}
+
+
+ private static boolean isDjangoMiddlewareFunction(SubscriptionContext ctx, FunctionDef functionDef) {
+ String functionName = functionDef.name().name();
+ if (!DJANGO_MIDDLEWARE_METHODS.contains(functionName)) {
+ return false;
+ }
+ return MIDDLEWARE_MIXIN_MATCHER.isTrueFor(functionDef.name(), ctx);
+ }
}
diff --git a/python-checks/src/test/java/org/sonar/python/checks/UnusedFunctionParameterCheckTest.java b/python-checks/src/test/java/org/sonar/python/checks/UnusedFunctionParameterCheckTest.java
index bb48f53dc..3e51bc241 100644
--- a/python-checks/src/test/java/org/sonar/python/checks/UnusedFunctionParameterCheckTest.java
+++ b/python-checks/src/test/java/org/sonar/python/checks/UnusedFunctionParameterCheckTest.java
@@ -57,4 +57,11 @@ void test_django() {
),
new UnusedFunctionParameterCheck());
}
+
+ @Test
+ void test_django_middleware() {
+ PythonCheckVerifier.verify(
+ "src/test/resources/checks/unusedFunctionParameter/django/middleware.py",
+ new UnusedFunctionParameterCheck());
+ }
}
diff --git a/python-checks/src/test/resources/checks/unusedFunctionParameter/django/middleware.py b/python-checks/src/test/resources/checks/unusedFunctionParameter/django/middleware.py
new file mode 100644
index 000000000..b1285ccdc
--- /dev/null
+++ b/python-checks/src/test/resources/checks/unusedFunctionParameter/django/middleware.py
@@ -0,0 +1,32 @@
+from django.utils.deprecation import MiddlewareMixin
+
+
+class MyMiddleware(MiddlewareMixin):
+ def process_request(self, request): # Compliant - Django middleware hook
+ print("logging")
+
+ def process_exception(
+ self, request, exception
+ ): # Compliant - Django middleware hook
+ print("logging")
+
+ def process_view(
+ self, request, view_func, view_args, view_kwargs
+ ): # Compliant - Django middleware hook
+ print("logging")
+
+ def process_template_response(
+ self, request, response
+ ): # Compliant - Django middleware hook
+ print("logging")
+
+ def some_other_method(self, foo): # Noncompliant
+ print("logging")
+
+
+class NotAMiddleware:
+ def process_request(self, request): # Noncompliant
+ print("logging")
+
+ def process_exception(self, request, exception): # Noncompliant 2
+ print("logging")
From 50598adc8f86cc225b68be2858735b2e1524d071 Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Tue, 10 Mar 2026 13:58:04 +0100
Subject: [PATCH 023/322] SONARPY-3887 Fix QG (#946)
GitOrigin-RevId: 77b6559037e7b34eaadbfa82a7b6192ff2bf3dd2
---
.../python/checks/hotspots/HardCodedCredentialsCheck.java | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/hotspots/HardCodedCredentialsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/hotspots/HardCodedCredentialsCheck.java
index ae7e8e484..ba52044dc 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/hotspots/HardCodedCredentialsCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/hotspots/HardCodedCredentialsCheck.java
@@ -17,6 +17,8 @@
package org.sonar.python.checks.hotspots;
import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.net.URL;
import java.util.Collection;
import java.util.Collections;
@@ -270,12 +272,12 @@ private static boolean isURLWithCredentials(StringLiteral stringLiteral) {
return false;
}
try {
- URL url = new URL(stringLiteral.trimmedQuotesValue());
+ URL url = new URI(stringLiteral.trimmedQuotesValue()).toURL();
String userInfo = url.getUserInfo();
if (userInfo != null && userInfo.matches("\\S+:\\S+")) {
return true;
}
- } catch (MalformedURLException e) {
+ } catch (URISyntaxException | MalformedURLException | IllegalArgumentException e) {
return false;
}
return false;
From bde7966d3d98b5b2de7c2d78df0d90fac792a859 Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Tue, 10 Mar 2026 15:14:45 +0100
Subject: [PATCH 024/322] SONARPY-3892 Updated rule metadata (#949)
GitOrigin-RevId: c4bf2e467841a6dd376dffb4fa16e876b92e7b31
---
.../org/sonar/l10n/py/rules/python/S2068.json | 4 +-
.../org/sonar/l10n/py/rules/python/S2612.html | 81 ++++++++++---------
.../org/sonar/l10n/py/rules/python/S2612.json | 7 +-
.../org/sonar/l10n/py/rules/python/S6984.html | 2 +-
.../org/sonar/l10n/py/rules/python/S8392.html | 4 +-
.../org/sonar/l10n/py/rules/python/S8396.html | 24 +++---
sonarpedia.json | 2 +-
7 files changed, 68 insertions(+), 56 deletions(-)
diff --git a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2068.json b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2068.json
index 1aa22f50d..f9d8468af 100644
--- a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2068.json
+++ b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2068.json
@@ -3,7 +3,7 @@
"type": "VULNERABILITY",
"code": {
"impacts": {
- "SECURITY": "BLOCKER"
+ "SECURITY": "MEDIUM"
},
"attribute": "TRUSTWORTHY"
},
@@ -16,7 +16,7 @@
"tags": [
"cwe"
],
- "defaultSeverity": "Blocker",
+ "defaultSeverity": "Major",
"ruleSpecification": "RSPEC-2068",
"sqKey": "S2068",
"scope": "Main",
diff --git a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2612.html b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2612.html
index 790a246a9..0f55162d5 100644
--- a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2612.html
+++ b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2612.html
@@ -1,52 +1,59 @@
+Why is this an issue?
In Unix file system permissions, the "others" category refers to all users except the owner of the file system resource and the
members of the group assigned to this resource.
Granting permissions to this category can lead to unintended access to files or directories that could allow attackers to obtain sensitive
information, disrupt services or elevate privileges.
-Ask Yourself Whether
-
- - The application is designed to be run on a multi-user environment.
- - Corresponding files and directories may contain confidential information.
-
-There is a risk if you answered yes to any of those questions.
-Recommended Secure Coding Practices
-The most restrictive possible permissions should be assigned to files and directories.
-Sensitive Code Example
-For os.umask:
+What is the potential impact?
+Unauthorized access to sensitive information
+When file or directory permissions grant access to all users on a system (often represented as "others" or "everyone" in permission models),
+attackers who gain access to any user account can read sensitive files containing credentials, configuration data, API keys, database passwords,
+personal information, or proprietary business data. This exposure can lead to data breaches, identity theft, compliance violations, and competitive
+disadvantage.
+Service disruption and data corruption
+Granting write permissions to broad user categories allows any user on the system to modify or delete critical files and directories. Attackers or
+compromised low-privileged accounts can corrupt application data, modify configuration files to alter system behavior or disrupt services, or delete
+important resources, leading to service outages, system instability, data loss, and denial of service.
+Privilege escalation
+When executable files or scripts have overly permissive permissions, especially when combined with special permission bits that allow programs to
+execute with the permissions of the file owner or group rather than the executing user, attackers can replace legitimate executables with malicious
+code. When these modified files are executed by privileged users or processes, the attacker’s code runs with elevated privileges, potentially enabling
+them to escalate from a low-privileged account to root or administrator access, install backdoors, or pivot to other systems in the network.
+How to fix it
+When using os.umask, set a restrictive umask value that prevents permissions for "others". The umask value 0o777 ensures
+that no permissions are granted to any category by default. This is the most secure approach as it requires explicit permission grants rather than
+implicit ones.
+Code examples
+Noncompliant code example
os.umask(0) # Sensitive
-For os.chmod, os.lchmod, and os.fchmod:
-
-os.chmod("/tmp/fs", stat.S_IRWXO) # Sensitive
-os.lchmod("/tmp/fs", stat.S_IRWXO) # Sensitive
-os.fchmod(fd, stat.S_IRWXO) # Sensitive
-
-Compliant Solution
-For os.umask:
+Compliant solution
os.umask(0o777)
-For os.chmod, os.lchmod, and os.fchmod:
-
-os.chmod("/tmp/fs", stat.S_IRWXU)
-os.lchmod("/tmp/fs", stat.S_IRWXU)
-os.fchmod(fd, stat.S_IRWXU)
-
-See
+Resources
+Documentation
+
+Standards
diff --git a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2612.json b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2612.json
index f4878e4fa..1fa044a04 100644
--- a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2612.json
+++ b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S2612.json
@@ -1,6 +1,6 @@
{
- "title": "Setting loose POSIX file permissions is security-sensitive",
- "type": "SECURITY_HOTSPOT",
+ "title": "File permissions should not be set to world-accessible values",
+ "type": "VULNERABILITY",
"code": {
"impacts": {
"SECURITY": "MEDIUM"
@@ -43,5 +43,6 @@
"STIG ASD_V5R3": [
"V-222430"
]
- }
+ },
+ "quickfix": "unknown"
}
diff --git a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S6984.html b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S6984.html
index 8cc03d353..5d9c32008 100644
--- a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S6984.html
+++ b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S6984.html
@@ -1,7 +1,7 @@
This rule raises an issue when an incorrect pattern is provided to an einops operation.
Why is this an issue?
The einops library provides a powerful and flexible way to manipulate tensors using the Einstein summation convention. The
-einops uses a different convention than the traditional one. In particular, the axis names
+einops uses a different convention than the traditional one. In particular, the axis names
can be more than one letter long and are separated by spaces.
How to fix it
Correct the syntax of the einops operation by balancing the parentheses and following the convention.
diff --git a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S8392.html b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S8392.html
index 1a9ba79a8..22f90d53b 100644
--- a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S8392.html
+++ b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S8392.html
@@ -117,9 +117,9 @@ Documentation
(127.0.0.1) and production (0.0.0.0) binding
- Flask Deployment Options - Official Flask documentation on secure deployment
practices
- - Flask Security Considerations - Flask security best practices and common
+
- Flask Security Considerations - Flask security best practices and common
vulnerabilities
- - Gunicorn Documentation - Production WSGI server for Python web applications
+ - Gunicorn Documentation - Production WSGI server for Python web applications
Standards
diff --git a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S8396.html b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S8396.html
index 8a1c66cdd..6c4f33a59 100644
--- a/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S8396.html
+++ b/python-checks/src/main/resources/org/sonar/l10n/py/rules/python/S8396.html
@@ -1,5 +1,5 @@
-This is an issue when a Pydantic model field uses Optional[Type] or Type | None type hints without providing an explicit
-default value, or when using Field(…) with the ellipsis operator.
+This is an issue when a Pydantic model field uses Optional[Type] without providing an explicit default value, or when using
+Field(…) with Optional[Type] and the ellipsis operator.
Why is this an issue?
In Pydantic models, there is a common misconception about what Optional[Type] means. Many developers assume that marking a field as
Optional[Type] makes it optional during validation, but this is not the case.
@@ -9,14 +9,17 @@ Why is this an issue?
To make a field truly optional (meaning it doesn’t need to be provided during validation), you must assign a default value. This is typically
None for optional fields, but can be any appropriate default value.
A particularly problematic pattern is using Field(…) with Optional[Type]. The ellipsis (…) is Pydantic’s
-way of explicitly marking a field as required. This creates a direct contradiction: the type hint says the field can be None, but the
-Field(…) says it must be provided. In this case, Pydantic prioritizes the ellipsis, making the field required despite the
-Optional annotation.
+way of explicitly marking a field as required. This creates a direct contradiction: the type hint says the field can be None, but
+Field(…) says it must be provided.
This mismatch between developer intent and actual behavior leads to unexpected validation errors in production, confusing API consumers who receive
"field required" errors for fields they reasonably expected to be optional based on the type hints.
+Exceptions
+Fields typed as Type | None, None | Type, or Union[Type, None] are compliant, with or without a default
+value.
+These annotations are explicit nullable type declarations and do not imply that the field may be omitted from input data.
What is the potential impact?
-When optional fields lack explicit default values, the application will reject valid requests where users omit fields they believe to be optional.
-This leads to:
+When Optional[…] fields lack explicit default values, the application may reject requests where users omit fields they believe to be
+optional. This leads to:
- Poor user experience with confusing "field required" validation errors
- API contract violations where the schema suggests fields are optional but validation requires them
@@ -26,11 +29,12 @@ What is the potential impact?
How to fix it
Add an explicit default value (typically None) to fields with Optional type hints. This makes the field truly optional
during validation while maintaining the type safety that allows None values.
+For Optional[…] fields, avoid the ellipsis form (Field(…)) and provide an explicit default instead.
Code examples
Noncompliant code example
from typing import Optional
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
class TwitterAccount(BaseModel):
username: str
@@ -42,8 +46,8 @@ Noncompliant code example
Compliant solution
-from typing import Optional
-from pydantic import BaseModel
+from typing import Optional, Union
+from pydantic import BaseModel, Field
class TwitterAccount(BaseModel):
username: str
diff --git a/sonarpedia.json b/sonarpedia.json
index d2c72f9bd..818f83554 100644
--- a/sonarpedia.json
+++ b/sonarpedia.json
@@ -3,7 +3,7 @@
"languages": [
"PY"
],
- "latest-update": "2026-02-18T12:35:08.274727508Z",
+ "latest-update": "2026-03-10T13:34:05.699012606Z",
"options": {
"no-language-in-filenames": true,
"preserve-filenames": true
From ffe96340d00a6f86b138aabec8a67036363bc0a4 Mon Sep 17 00:00:00 2001
From: David Kunzmann
Date: Thu, 12 Mar 2026 08:56:47 +0100
Subject: [PATCH 025/322] SONARPY-3896 Update checksum (#951)
GitOrigin-RevId: 2b31f0d08899972e68c0b5272d60ff2a02ee7935
---
python-frontend/typeshed_serializer/checksums/custom.checksum | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/python-frontend/typeshed_serializer/checksums/custom.checksum b/python-frontend/typeshed_serializer/checksums/custom.checksum
index ccd0a7cd0..4ff26fe03 100644
--- a/python-frontend/typeshed_serializer/checksums/custom.checksum
+++ b/python-frontend/typeshed_serializer/checksums/custom.checksum
@@ -1,2 +1,2 @@
-843addd5d5f7af6ecacd2ed21b251d4a89a44fdbdcc9dd2af52fb12939f697fe
-d67269ba8194374e815fa405dfbf957e07d196202cd8d3328d4a5a40f90628b5
\ No newline at end of file
+87337f7b40e10d53f0e6d2fa4e82dff6bdefea2daee4ad89099bd43ade6d5123
+d9033485534f02a01a57cc2cb030873d15b62a715bac2a6685adea2698259fc4
\ No newline at end of file
From 496a44cb6c6e708f0fa481750e453ebbaaadd864 Mon Sep 17 00:00:00 2001
From: "hashicorp-vault-sonar-prod[bot]"
<111297361+hashicorp-vault-sonar-prod[bot]@users.noreply.github.com>
Date: Thu, 12 Mar 2026 08:54:47 +0000
Subject: [PATCH 026/322] Prepare for next development iteration (#956)
Co-authored-by: joke1196 <6317721+joke1196@users.noreply.github.com>
GitOrigin-RevId: 52f5466cb7182405ab1035fee976a6f879382123
---
docs/pom.xml | 2 +-
docs/python-custom-rules-example/pom.xml | 2 +-
its/commons/pom.xml | 2 +-
its/plugin/it-python-plugin-test/pom.xml | 2 +-
its/plugin/pom.xml | 2 +-
its/plugin/python-custom-rules-plugin/pom.xml | 2 +-
its/pom.xml | 2 +-
pom.xml | 2 +-
python-checks-testkit/pom.xml | 2 +-
python-checks/pom.xml | 2 +-
python-commons/pom.xml | 2 +-
python-frontend/pom.xml | 2 +-
sonar-python-plugin/pom.xml | 2 +-
13 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/docs/pom.xml b/docs/pom.xml
index 4c268268c..b38c6856f 100644
--- a/docs/pom.xml
+++ b/docs/pom.xml
@@ -5,7 +5,7 @@
org.sonarsource.python
python
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
docs
diff --git a/docs/python-custom-rules-example/pom.xml b/docs/python-custom-rules-example/pom.xml
index b0813615c..4c40f44a3 100644
--- a/docs/python-custom-rules-example/pom.xml
+++ b/docs/python-custom-rules-example/pom.xml
@@ -6,7 +6,7 @@
org.sonarsource.python
docs
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
python-custom-rules-example
diff --git a/its/commons/pom.xml b/its/commons/pom.xml
index 6245d8ea3..ea6d73076 100644
--- a/its/commons/pom.xml
+++ b/its/commons/pom.xml
@@ -6,7 +6,7 @@
org.sonarsource.python
python-its
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
Python :: ITs :: Commons
diff --git a/its/plugin/it-python-plugin-test/pom.xml b/its/plugin/it-python-plugin-test/pom.xml
index 9fa5c35be..1bc56646c 100644
--- a/its/plugin/it-python-plugin-test/pom.xml
+++ b/its/plugin/it-python-plugin-test/pom.xml
@@ -7,7 +7,7 @@
it-python-plugin
org.sonarsource.python
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
it-python-plugin-test
diff --git a/its/plugin/pom.xml b/its/plugin/pom.xml
index d257012f3..7e62daae3 100644
--- a/its/plugin/pom.xml
+++ b/its/plugin/pom.xml
@@ -5,7 +5,7 @@
org.sonarsource.python
python-its
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
diff --git a/its/plugin/python-custom-rules-plugin/pom.xml b/its/plugin/python-custom-rules-plugin/pom.xml
index 1aef6254e..2342a95ed 100644
--- a/its/plugin/python-custom-rules-plugin/pom.xml
+++ b/its/plugin/python-custom-rules-plugin/pom.xml
@@ -6,7 +6,7 @@
org.sonarsource.python
it-python-plugin
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
python-custom-rules-plugin
diff --git a/its/pom.xml b/its/pom.xml
index b990c1794..086da1313 100644
--- a/its/pom.xml
+++ b/its/pom.xml
@@ -5,7 +5,7 @@
org.sonarsource.python
python
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
python-its
diff --git a/pom.xml b/pom.xml
index 4b455dbe9..6d9ee49a5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
org.sonarsource.python
python
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
pom
Python
diff --git a/python-checks-testkit/pom.xml b/python-checks-testkit/pom.xml
index d6e3bdf44..87cdb42e8 100644
--- a/python-checks-testkit/pom.xml
+++ b/python-checks-testkit/pom.xml
@@ -6,7 +6,7 @@
python
org.sonarsource.python
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
python-checks-testkit
diff --git a/python-checks/pom.xml b/python-checks/pom.xml
index 839411991..c436a132e 100644
--- a/python-checks/pom.xml
+++ b/python-checks/pom.xml
@@ -5,7 +5,7 @@
org.sonarsource.python
python
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
python-checks
diff --git a/python-commons/pom.xml b/python-commons/pom.xml
index 8ca5c3f69..bcea58727 100644
--- a/python-commons/pom.xml
+++ b/python-commons/pom.xml
@@ -6,7 +6,7 @@
org.sonarsource.python
python
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
python-commons
diff --git a/python-frontend/pom.xml b/python-frontend/pom.xml
index 29748228d..811d68a48 100644
--- a/python-frontend/pom.xml
+++ b/python-frontend/pom.xml
@@ -6,7 +6,7 @@
org.sonarsource.python
python
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
python-frontend
diff --git a/sonar-python-plugin/pom.xml b/sonar-python-plugin/pom.xml
index 496533123..eeac7a433 100644
--- a/sonar-python-plugin/pom.xml
+++ b/sonar-python-plugin/pom.xml
@@ -6,7 +6,7 @@
org.sonarsource.python
python
- 5.19-SNAPSHOT
+ 5.20-SNAPSHOT
sonar-python-plugin
From f8cf3fda01d23c640455b454031f9dfd3048b306 Mon Sep 17 00:00:00 2001
From: Sebastian Zumbrunn
Date: Thu, 12 Feb 2026 15:22:40 +0100
Subject: [PATCH 027/322] SONARPY-3800 [python-ai tools] Add caching
GitOrigin-RevId: e03c5a3e408dfba4402f01b6f2fa46a0b8da0d48
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index 43b7a5243..a4fc91cfd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,7 @@ venv
.coverage
cov.xml
__pycache__
+**/.python-ai-tool-cache/
python-frontend/typeshed_serializer/serializer/proto_out
python-frontend/typeshed_serializer/output/*
From 7c45b14ef16ecad47aae050b31a22d50ec3be185 Mon Sep 17 00:00:00 2001
From: Thomas Serre
<118730793+thomas-serre-sonarsource@users.noreply.github.com>
Date: Fri, 13 Mar 2026 11:57:25 +0100
Subject: [PATCH 028/322] SONARPY-3782 Store the CFG in the Analysis Context
(#948)
Co-authored-by: Marc Jasper
GitOrigin-RevId: 942f25043540a7108504c3bbe83c0a3a0bc6af7f
---
.../checks/AfterJumpStatementCheck.java | 4 +-
.../python/checks/ConsistentReturnCheck.java | 2 +-
.../python/checks/ConstantConditionCheck.java | 10 +---
.../sonar/python/checks/DeadStoreCheck.java | 2 +-
.../checks/FloatingPointEqualityCheck.java | 19 +++----
.../HttpNoContentNonEmptyBodyCheck.java | 8 +--
.../python/checks/IgnoredParameterCheck.java | 2 +-
.../python/checks/InfiniteRecursionCheck.java | 11 ++--
.../python/checks/InvariantReturnCheck.java | 2 +-
.../checks/LoopExecutingAtMostOnceCheck.java | 6 +--
.../checks/NumpyListOverGeneratorCheck.java | 9 ++--
...ytzTimeZoneInDatetimeConstructorCheck.java | 7 +--
.../sonar/python/checks/RandomSeedCheck.java | 16 +++---
.../python/checks/RedundantJumpCheck.java | 6 +--
.../ReferencedBeforeAssignmentCheck.java | 2 +-
...oadLeadsToUntrustedCodeExecutionCheck.java | 18 +++----
.../tests/UnconditionalAssertionCheck.java | 14 ++----
.../python/api/PythonVisitorContext.java | 48 ++++++++++++++++--
.../python/api/SubscriptionContext.java | 7 +++
.../org/sonar/python/SubscriptionVisitor.java | 13 +++++
.../sonar/python/TestPythonVisitorRunner.java | 4 +-
.../python/semantic/v2/TypeInferenceV2.java | 12 ++++-
.../sonar/python/SubscriptionVisitorTest.java | 50 +++++++++++++++++++
23 files changed, 171 insertions(+), 101 deletions(-)
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AfterJumpStatementCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AfterJumpStatementCheck.java
index 125108e79..5882b0664 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AfterJumpStatementCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AfterJumpStatementCheck.java
@@ -39,13 +39,13 @@ public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Kind.FILE_INPUT, ctx ->
{
FileInput fileInput = (FileInput) ctx.syntaxNode();
- checkCfg(ControlFlowGraph.build(fileInput, ctx.pythonFile()), ctx, fileInput.statements());
+ checkCfg(ctx.cfg(fileInput), ctx, fileInput.statements());
}
);
context.registerSyntaxNodeConsumer(Kind.FUNCDEF, ctx ->
{
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
- checkCfg(ControlFlowGraph.build(functionDef, ctx.pythonFile()), ctx, functionDef.body());
+ checkCfg(ctx.cfg(functionDef), ctx, functionDef.body());
}
);
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ConsistentReturnCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ConsistentReturnCheck.java
index 3af827e0f..956ff5cc7 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ConsistentReturnCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ConsistentReturnCheck.java
@@ -40,7 +40,7 @@ public class ConsistentReturnCheck extends PythonSubscriptionCheck {
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Kind.FUNCDEF, ctx -> {
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
- ControlFlowGraph cfg = ControlFlowGraph.build(functionDef, ctx.pythonFile());
+ ControlFlowGraph cfg = ctx.cfg(functionDef);
if (cfg == null || hasExceptOrFinally(cfg)) {
return;
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ConstantConditionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ConstantConditionCheck.java
index 5afd481ed..614a5faf0 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ConstantConditionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ConstantConditionCheck.java
@@ -33,7 +33,6 @@
import org.sonar.plugins.python.api.tree.IfStatement;
import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.UnaryExpression;
-import org.sonar.python.cfg.fixpoint.ReachingDefinitionsAnalysis;
import static org.sonar.plugins.python.api.tree.Tree.Kind.AND;
import static org.sonar.plugins.python.api.tree.Tree.Kind.NAME;
@@ -48,13 +47,6 @@ public class ConstantConditionCheck extends PythonVisitorCheck {
private static final String MESSAGE = "Replace this expression; used as a condition it will always be constant.";
private static final List ACCEPTED_DECORATORS = List.of("overload", "staticmethod", "classmethod");
- private ReachingDefinitionsAnalysis reachingDefinitionsAnalysis;
-
- @Override
- public void visitFileInput(FileInput fileInput) {
- reachingDefinitionsAnalysis = new ReachingDefinitionsAnalysis(getContext().pythonFile());
- super.visitFileInput(fileInput);
- }
@Override
public void visitIfStatement(IfStatement ifStatement) {
@@ -143,7 +135,7 @@ private void checkExpression(Expression expression) {
}
}
if (expression.is(NAME)) {
- Set valuesAtLocation = reachingDefinitionsAnalysis.valuesAtLocation(((Name) expression));
+ Set valuesAtLocation = getContext().valuesAtLocation(((Name) expression));
if (valuesAtLocation.size() == 1) {
Expression lastAssignedValue = valuesAtLocation.iterator().next();
if (isImmutableConstant(lastAssignedValue)) {
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DeadStoreCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DeadStoreCheck.java
index 6427b3133..ab20ea815 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DeadStoreCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DeadStoreCheck.java
@@ -70,7 +70,7 @@ public void initialize(Context context) {
if (TreeUtils.hasDescendant(functionDef, tree -> tree.is(Tree.Kind.TRY_STMT))) {
return;
}
- ControlFlowGraph cfg = ControlFlowGraph.build(functionDef, ctx.pythonFile());
+ ControlFlowGraph cfg = ctx.cfg(functionDef);
if (cfg == null) {
return;
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FloatingPointEqualityCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FloatingPointEqualityCheck.java
index 9641caa28..4112ed484 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FloatingPointEqualityCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FloatingPointEqualityCheck.java
@@ -32,7 +32,6 @@
import org.sonar.plugins.python.api.tree.ImportName;
import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.Tree;
-import org.sonar.python.cfg.fixpoint.ReachingDefinitionsAnalysis;
import org.sonar.python.quickfix.TextEditUtils;
import org.sonar.python.tree.TreeUtils;
import org.sonar.python.types.v2.TypeChecker;
@@ -52,7 +51,6 @@ public class FloatingPointEqualityCheck extends PythonSubscriptionCheck {
private static final String MATH_MODULE = "math";
- private ReachingDefinitionsAnalysis reachingDefinitionsAnalysis;
private static final List SUPPORTED_IS_CLOSE_MODULES = Arrays.asList("numpy", "torch", MATH_MODULE);
private String importedModuleForIsClose;
@@ -71,7 +69,6 @@ public void initialize(Context context) {
}
private void initializeAnalysis(SubscriptionContext ctx) {
- reachingDefinitionsAnalysis = new ReachingDefinitionsAnalysis(ctx.pythonFile());
importedModuleForIsClose = null;
importedAlias = null;
typeChecker = ctx.typeChecker();
@@ -80,19 +77,19 @@ private void initializeAnalysis(SubscriptionContext ctx) {
private void checkFloatingPointEquality(SubscriptionContext ctx) {
BinaryExpression binaryExpression = (BinaryExpression) ctx.syntaxNode();
String operator = binaryExpression.operator().value();
- if (("==".equals(operator) || "!=".equals(operator)) && isAnyOperandFloatingPoint(binaryExpression)) {
+ if (("==".equals(operator) || "!=".equals(operator)) && isAnyOperandFloatingPoint(binaryExpression, ctx)) {
PreciseIssue issue = ctx.addIssue(binaryExpression, MESSAGE);
issue.addQuickFix(createQuickFix(binaryExpression, operator));
}
}
- private boolean isAnyOperandFloatingPoint(BinaryExpression binaryExpression) {
+ private boolean isAnyOperandFloatingPoint(BinaryExpression binaryExpression, SubscriptionContext ctx) {
Expression leftOperand = binaryExpression.leftOperand();
Expression rightOperand = binaryExpression.rightOperand();
return isFloat(leftOperand) || isFloat(rightOperand) ||
- isAssignedFloat(leftOperand) || isAssignedFloat(rightOperand) ||
- isBinaryOperationWithFloat(leftOperand) || isBinaryOperationWithFloat(rightOperand);
+ isAssignedFloat(leftOperand, ctx) || isAssignedFloat(rightOperand, ctx) ||
+ isBinaryOperationWithFloat(leftOperand, ctx) || isBinaryOperationWithFloat(rightOperand, ctx);
}
private boolean isFloat(Expression expression) {
@@ -100,9 +97,9 @@ private boolean isFloat(Expression expression) {
return expression.is(Tree.Kind.NUMERIC_LITERAL) && isTypeFloat == TriBool.TRUE;
}
- private boolean isAssignedFloat(Expression expression) {
+ private boolean isAssignedFloat(Expression expression, SubscriptionContext ctx) {
if (expression.is(Tree.Kind.NAME)) {
- Set values = reachingDefinitionsAnalysis.valuesAtLocation((Name) expression);
+ Set values = ctx.valuesAtLocation((Name) expression);
if (!values.isEmpty()) {
return values.stream().allMatch(this::isFloat);
}
@@ -110,9 +107,9 @@ private boolean isAssignedFloat(Expression expression) {
return false;
}
- private boolean isBinaryOperationWithFloat(Expression expression) {
+ private boolean isBinaryOperationWithFloat(Expression expression, SubscriptionContext ctx) {
if (expression.is(BINARY_OPERATION_KINDS)) {
- return isAnyOperandFloatingPoint((BinaryExpression) expression);
+ return isAnyOperandFloatingPoint((BinaryExpression) expression, ctx);
}
return false;
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/HttpNoContentNonEmptyBodyCheck.java b/python-checks/src/main/java/org/sonar/python/checks/HttpNoContentNonEmptyBodyCheck.java
index 4cf748649..9bc881581 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/HttpNoContentNonEmptyBodyCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/HttpNoContentNonEmptyBodyCheck.java
@@ -32,7 +32,6 @@
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
-import org.sonar.python.cfg.fixpoint.ReachingDefinitionsAnalysis;
import org.sonar.python.checks.utils.Expressions;
import org.sonar.python.tree.TreeUtils;
@@ -41,8 +40,6 @@ public class HttpNoContentNonEmptyBodyCheck extends PythonSubscriptionCheck {
private static final String MESSAGE = "Return an empty body for this endpoint returning 204 status.";
- private ReachingDefinitionsAnalysis reachingDefinitionsAnalysis;
-
private static final TypeMatcher FASTAPI_RESPONSE_INSTANCE = TypeMatchers.isObjectOfType("fastapi.Response");
private static final TypeMatcher NONE_TYPE = TypeMatchers.isObjectOfType("NoneType");
@@ -59,9 +56,6 @@ public class HttpNoContentNonEmptyBodyCheck extends PythonSubscriptionCheck {
@Override
public void initialize(Context context) {
- context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, ctx ->
- reachingDefinitionsAnalysis = new ReachingDefinitionsAnalysis(ctx.pythonFile())
- );
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, this::checkFunctionDef);
}
@@ -176,7 +170,7 @@ private ValidationResult isValidResponseObject(SubscriptionContext ctx, Expressi
if (expr.is(Tree.Kind.NAME)) {
Name name = (Name) expr;
- var assignedValues = reachingDefinitionsAnalysis.valuesAtLocation(name);
+ var assignedValues = ctx.valuesAtLocation(name);
boolean anyInvalid = false;
for (Expression assignedValue : assignedValues) {
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IgnoredParameterCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IgnoredParameterCheck.java
index 1f0cbb685..6921e108b 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IgnoredParameterCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IgnoredParameterCheck.java
@@ -43,7 +43,7 @@ public class IgnoredParameterCheck extends PythonSubscriptionCheck {
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, ctx -> {
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
- ControlFlowGraph cfg = ControlFlowGraph.build(functionDef, ctx.pythonFile());
+ ControlFlowGraph cfg = ctx.cfg(functionDef);
if (cfg == null) {
return;
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/InfiniteRecursionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/InfiniteRecursionCheck.java
index 29e6d8d4b..459a35ec9 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/InfiniteRecursionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/InfiniteRecursionCheck.java
@@ -26,7 +26,6 @@
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
import org.sonar.check.Rule;
-import org.sonar.plugins.python.api.PythonFile;
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
import org.sonar.plugins.python.api.cfg.CfgBlock;
import org.sonar.plugins.python.api.cfg.ControlFlowGraph;
@@ -62,7 +61,7 @@ public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, ctx -> {
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
List allRecursiveCalls = new ArrayList<>();
- boolean endBlockIsReachable = collectRecursiveCallsAndCheckIfEndBlockIsReachable(functionDef, ctx.pythonFile(), allRecursiveCalls);
+ boolean endBlockIsReachable = collectRecursiveCallsAndCheckIfEndBlockIsReachable(functionDef, ctx.cfg(functionDef), allRecursiveCalls);
if (!allRecursiveCalls.isEmpty() && !endBlockIsReachable) {
String message = String.format(MESSAGE, functionDef.isMethodDefinition() ? "method" : "function");
PreciseIssue issue = ctx.addIssue(functionDef.name(), message);
@@ -71,13 +70,9 @@ public void initialize(Context context) {
});
}
- private static boolean collectRecursiveCallsAndCheckIfEndBlockIsReachable(FunctionDef functionDef, PythonFile pythonFile, List allRecursiveCalls) {
+ private static boolean collectRecursiveCallsAndCheckIfEndBlockIsReachable(FunctionDef functionDef, @Nullable ControlFlowGraph cfg, List allRecursiveCalls) {
Symbol functionSymbol = functionDef.name().symbol();
- if (functionSymbol == null) {
- return true;
- }
- ControlFlowGraph cfg = ControlFlowGraph.build(functionDef, pythonFile);
- if (cfg == null) {
+ if (functionSymbol == null || cfg == null) {
return true;
}
RecursiveCallCollector recursiveCallCollector = new RecursiveCallCollector(functionDef, functionSymbol);
diff --git a/python-checks/src/main/java/org/sonar/python/checks/InvariantReturnCheck.java b/python-checks/src/main/java/org/sonar/python/checks/InvariantReturnCheck.java
index 480969b36..c51290b48 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/InvariantReturnCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/InvariantReturnCheck.java
@@ -62,7 +62,7 @@ public class InvariantReturnCheck extends PythonSubscriptionCheck {
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Kind.FUNCDEF, ctx -> {
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
- ControlFlowGraph cfg = ControlFlowGraph.build(functionDef, ctx.pythonFile());
+ ControlFlowGraph cfg = ctx.cfg(functionDef);
if (cfg != null) {
List latestExecutedBlocks = collectLatestExecutedBlocks(cfg);
boolean allBlocksHaveReturnStatement = latestExecutedBlocks.stream().allMatch(LatestExecutedBlock::hasReturnStatement);
diff --git a/python-checks/src/main/java/org/sonar/python/checks/LoopExecutingAtMostOnceCheck.java b/python-checks/src/main/java/org/sonar/python/checks/LoopExecutingAtMostOnceCheck.java
index 098a0da85..9568747b9 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/LoopExecutingAtMostOnceCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/LoopExecutingAtMostOnceCheck.java
@@ -29,8 +29,6 @@
import org.sonar.plugins.python.api.cfg.ControlFlowGraph;
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
import org.sonar.plugins.python.api.SubscriptionContext;
-import org.sonar.plugins.python.api.tree.FileInput;
-import org.sonar.plugins.python.api.tree.FunctionDef;
import org.sonar.plugins.python.api.tree.Token;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.Tree.Kind;
@@ -43,10 +41,10 @@ public class LoopExecutingAtMostOnceCheck extends PythonSubscriptionCheck {
@Override
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Kind.FUNCDEF, ctx ->
- checkCfg(ControlFlowGraph.build((FunctionDef) ctx.syntaxNode(), ctx.pythonFile()), ctx)
+ checkCfg(ctx.cfg(ctx.syntaxNode()), ctx)
);
context.registerSyntaxNodeConsumer(Kind.FILE_INPUT, ctx ->
- checkCfg(ControlFlowGraph.build((FileInput) ctx.syntaxNode(), ctx.pythonFile()), ctx)
+ checkCfg(ctx.cfg(ctx.syntaxNode()), ctx)
);
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/NumpyListOverGeneratorCheck.java b/python-checks/src/main/java/org/sonar/python/checks/NumpyListOverGeneratorCheck.java
index 1957848dd..fdeffc0af 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/NumpyListOverGeneratorCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/NumpyListOverGeneratorCheck.java
@@ -30,18 +30,15 @@
import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.RegularArgument;
import org.sonar.plugins.python.api.tree.Tree;
-import org.sonar.python.cfg.fixpoint.ReachingDefinitionsAnalysis;
import org.sonar.python.tree.TreeUtils;
@Rule(key = "S6714")
public class NumpyListOverGeneratorCheck extends PythonSubscriptionCheck {
public static final String MESSAGE = "Pass a list to \"np.array\" instead of passing a generator.";
- private ReachingDefinitionsAnalysis reachingDefinitionsAnalysis;
@Override
public void initialize(Context context) {
- context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, ctx -> reachingDefinitionsAnalysis = new ReachingDefinitionsAnalysis((ctx.pythonFile())));
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkNumpyArrayCall);
}
@@ -63,7 +60,7 @@ private void checkGeneratorCallee(CallExpression call, SubscriptionContext ctx)
.filter(arg -> arg.is(Tree.Kind.REGULAR_ARGUMENT))
.map(RegularArgument.class::cast)
.map(RegularArgument::expression)
- .filter(regArg -> (regArg.is(Tree.Kind.GENERATOR_EXPR) || this.isNamedGeneratorExpression(regArg)))
+ .filter(regArg -> (regArg.is(Tree.Kind.GENERATOR_EXPR) || isNamedGeneratorExpression(regArg, ctx)))
.isEmpty()) {
return;
}
@@ -79,10 +76,10 @@ private void checkGeneratorCallee(CallExpression call, SubscriptionContext ctx)
}
}
- private boolean isNamedGeneratorExpression(Expression expression) {
+ private static boolean isNamedGeneratorExpression(Expression expression, SubscriptionContext ctx) {
return Optional.of(expression)
.flatMap(TreeUtils.toOptionalInstanceOfMapper(Name.class))
- .map(name -> this.reachingDefinitionsAnalysis.valuesAtLocation(name))
+ .map(ctx::valuesAtLocation)
.filter(NumpyListOverGeneratorCheck::checkSetProperties)
.isPresent();
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/PytzTimeZoneInDatetimeConstructorCheck.java b/python-checks/src/main/java/org/sonar/python/checks/PytzTimeZoneInDatetimeConstructorCheck.java
index af95e147f..b647e0aaf 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/PytzTimeZoneInDatetimeConstructorCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/PytzTimeZoneInDatetimeConstructorCheck.java
@@ -27,21 +27,16 @@
import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.RegularArgument;
import org.sonar.plugins.python.api.tree.Tree;
-import org.sonar.python.cfg.fixpoint.ReachingDefinitionsAnalysis;
import org.sonar.python.tree.TreeUtils;
@Rule(key = "S6887")
public class PytzTimeZoneInDatetimeConstructorCheck extends PythonSubscriptionCheck {
- private ReachingDefinitionsAnalysis reachingDefinitionsAnalysis;
private static final String MESSAGE = "Don't pass a \"pytz.timezone\" to the \"datetime.datetime\" constructor.";
private static final String SECONDARY_MESSAGE = "The pytz.timezone is created here.";
@Override
public void initialize(Context context) {
- context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT,
- ctx -> reachingDefinitionsAnalysis = new ReachingDefinitionsAnalysis(ctx.pythonFile()));
-
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkCallExpression);
}
@@ -67,7 +62,7 @@ private void checkArgument(RegularArgument argument, SubscriptionContext context
}
context.addIssue(argument, MESSAGE);
} else if (argument.expression().is(Tree.Kind.NAME)) {
- List allSecondaryLocations = reachingDefinitionsAnalysis.valuesAtLocation((Name) argument.expression()).stream()
+ List allSecondaryLocations = context.valuesAtLocation((Name) argument.expression()).stream()
.filter(expression -> expression.is(Tree.Kind.CALL_EXPR))
.map(CallExpression.class::cast)
.filter(call -> Optional.ofNullable(call.calleeSymbol()).map(symbol ->"pytz.timezone".equals(symbol.fullyQualifiedName())).orElse(false))
diff --git a/python-checks/src/main/java/org/sonar/python/checks/RandomSeedCheck.java b/python-checks/src/main/java/org/sonar/python/checks/RandomSeedCheck.java
index 80065986b..cf5682406 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/RandomSeedCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/RandomSeedCheck.java
@@ -32,7 +32,6 @@
import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.RegularArgument;
import org.sonar.plugins.python.api.tree.Tree;
-import org.sonar.python.cfg.fixpoint.ReachingDefinitionsAnalysis;
import org.sonar.python.checks.cdk.CdkPredicate;
import org.sonar.python.semantic.ClassSymbolImpl;
import org.sonar.python.semantic.SymbolUtils;
@@ -63,8 +62,6 @@ public class RandomSeedCheck extends PythonSubscriptionCheck {
private static final String MESSAGE = "Provide a seed for this random generator.";
private static final String SKLEARN_MESSAGE = "Provide a seed for the random_state parameter.";
- private ReachingDefinitionsAnalysis reachingDefinitionsAnalysis;
-
private static Predicate keywordAbsentOrNotIn(String keyword, String... restrictedValues) {
Set restrictedValueSet = Set.of(restrictedValues);
return call -> {
@@ -99,7 +96,6 @@ private static Predicate probabilityArgAbsent() {
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT,
ctx -> {
- this.reachingDefinitionsAnalysis = new ReachingDefinitionsAnalysis(ctx.pythonFile());
this.typeCheckMap = new TypeCheckMap<>();
SKLEARN_EXCEPTIONS.forEach((fqn, predicate) -> this.typeCheckMap.put(ctx.typeChecker().typeCheckBuilder().isTypeWithFqn(fqn), predicate));
});
@@ -114,12 +110,12 @@ private void checkEmptySeedCall(SubscriptionContext ctx) {
maybeCalleeSymbol
.map(Symbol::fullyQualifiedName)
.map(SEED_METHODS_TO_CHECK::get)
- .filter(argName -> isArgumentAbsentOrNone(TreeUtils.nthArgumentOrKeyword(0, argName, call.arguments())))
+ .filter(argName -> isArgumentAbsentOrNone(TreeUtils.nthArgumentOrKeyword(0, argName, call.arguments()), ctx))
.map(arg -> MESSAGE)
.or(() -> maybeCalleeSymbol
.filter(symbol -> symbol.fullyQualifiedName() != null && symbol.fullyQualifiedName().startsWith(SKLEARN_FQN))
.filter(RandomSeedCheck::hasRandomStateParameter)
- .filter(symbol -> isArgumentAbsentOrNone(TreeUtils.argumentByKeyword(SKLEARN_ARG_NAME, call.arguments())))
+ .filter(symbol -> isArgumentAbsentOrNone(TreeUtils.argumentByKeyword(SKLEARN_ARG_NAME, call.arguments()), ctx))
.filter(symbol -> !isSKLearnException(call))
.map(symbol -> SKLEARN_MESSAGE))
.ifPresent(message -> ctx.addIssue(call.callee(), message));
@@ -156,14 +152,14 @@ private static Optional isFunctionWithRandomStateParameter(Symbol calle
.anyMatch(SKLEARN_ARG_NAME::equals));
}
- private boolean isArgumentAbsentOrNone(@Nullable RegularArgument arg) {
- return arg == null || arg.expression().is(Tree.Kind.NONE) || isAssignedNone(arg.expression());
+ private static boolean isArgumentAbsentOrNone(@Nullable RegularArgument arg, SubscriptionContext ctx) {
+ return arg == null || arg.expression().is(Tree.Kind.NONE) || isAssignedNone(arg.expression(), ctx);
}
- private boolean isAssignedNone(Expression exp) {
+ private static boolean isAssignedNone(Expression exp, SubscriptionContext ctx) {
return Optional.of(exp)
.flatMap(TreeUtils.toOptionalInstanceOfMapper(Name.class))
- .map(reachingDefinitionsAnalysis::valuesAtLocation)
+ .map(ctx::valuesAtLocation)
.filter(Predicate.not(Set::isEmpty))
.filter(values -> values.stream().allMatch(value -> value.is(Tree.Kind.NONE))).isPresent();
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/RedundantJumpCheck.java b/python-checks/src/main/java/org/sonar/python/checks/RedundantJumpCheck.java
index b9abd605d..bcb8ee75c 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/RedundantJumpCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/RedundantJumpCheck.java
@@ -24,8 +24,6 @@
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
import org.sonar.plugins.python.api.SubscriptionContext;
import org.sonar.plugins.python.api.cfg.ControlFlowGraph;
-import org.sonar.plugins.python.api.tree.FileInput;
-import org.sonar.plugins.python.api.tree.FunctionDef;
import org.sonar.plugins.python.api.tree.ReturnStatement;
import org.sonar.plugins.python.api.tree.Statement;
import org.sonar.plugins.python.api.tree.StatementList;
@@ -43,8 +41,8 @@ public class RedundantJumpCheck extends PythonSubscriptionCheck {
@Override
public void initialize(Context context) {
- context.registerSyntaxNodeConsumer(Kind.FILE_INPUT, ctx -> checkCfg(ControlFlowGraph.build((FileInput) ctx.syntaxNode(), ctx.pythonFile()), ctx));
- context.registerSyntaxNodeConsumer(Kind.FUNCDEF, ctx -> checkCfg(ControlFlowGraph.build((FunctionDef) ctx.syntaxNode(), ctx.pythonFile()), ctx));
+ context.registerSyntaxNodeConsumer(Kind.FILE_INPUT, ctx -> checkCfg(ctx.cfg(ctx.syntaxNode()), ctx));
+ context.registerSyntaxNodeConsumer(Kind.FUNCDEF, ctx -> checkCfg(ctx.cfg(ctx.syntaxNode()), ctx));
}
private static void checkCfg(@Nullable ControlFlowGraph cfg, SubscriptionContext ctx) {
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ReferencedBeforeAssignmentCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ReferencedBeforeAssignmentCheck.java
index 92bcde0bb..2ed277441 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ReferencedBeforeAssignmentCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ReferencedBeforeAssignmentCheck.java
@@ -47,7 +47,7 @@ public void initialize(Context context) {
if (TreeUtils.hasDescendant(functionDef, tree -> tree.is(Tree.Kind.TRY_STMT))) {
return;
}
- ControlFlowGraph cfg = ControlFlowGraph.build(functionDef, ctx.pythonFile());
+ ControlFlowGraph cfg = ctx.cfg(functionDef);
if (cfg == null) {
return;
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/TorchLoadLeadsToUntrustedCodeExecutionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/TorchLoadLeadsToUntrustedCodeExecutionCheck.java
index ca4a452a4..2b9f192e6 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/TorchLoadLeadsToUntrustedCodeExecutionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/TorchLoadLeadsToUntrustedCodeExecutionCheck.java
@@ -20,6 +20,7 @@
import java.util.Set;
import org.sonar.check.Rule;
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
+import org.sonar.plugins.python.api.SubscriptionContext;
import org.sonar.plugins.python.api.symbols.Symbol;
import org.sonar.plugins.python.api.tree.Argument;
import org.sonar.plugins.python.api.tree.CallExpression;
@@ -27,7 +28,6 @@
import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.RegularArgument;
import org.sonar.plugins.python.api.tree.Tree;
-import org.sonar.python.cfg.fixpoint.ReachingDefinitionsAnalysis;
import org.sonar.python.checks.utils.Expressions;
import org.sonar.python.tree.TreeUtils;
@@ -39,39 +39,33 @@ public class TorchLoadLeadsToUntrustedCodeExecutionCheck extends PythonSubscript
public static final String PYTHON_FALSE = "False";
public static final String WEIGHTS_ONLY = "weights_only";
- private ReachingDefinitionsAnalysis reachingDefinitionsAnalysis;
-
@Override
public void initialize(Context context) {
- context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, ctx -> reachingDefinitionsAnalysis =
- new ReachingDefinitionsAnalysis(ctx.pythonFile()));
-
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, ctx -> {
CallExpression callExpression = (CallExpression) ctx.syntaxNode();
Symbol calleeSymbol = callExpression.calleeSymbol();
if (calleeSymbol != null && TORCH_LOAD.equals(calleeSymbol.fullyQualifiedName())
- && isWeightsOnlyNotFoundOrSetToFalse(callExpression.arguments())) {
+ && isWeightsOnlyNotFoundOrSetToFalse(callExpression.arguments(), ctx)) {
ctx.addIssue(callExpression.callee(), MESSAGE);
}
});
}
- private boolean isWeightsOnlyNotFoundOrSetToFalse(List arguments) {
+ private static boolean isWeightsOnlyNotFoundOrSetToFalse(List arguments, SubscriptionContext ctx) {
RegularArgument weightsOnlyArg = TreeUtils.argumentByKeyword(WEIGHTS_ONLY, arguments);
if (weightsOnlyArg == null) return !Expressions.containsSpreadOperator(arguments);
if (weightsOnlyArg.expression() instanceof Name name) {
- return PYTHON_FALSE.equals(name.name()) || isNameSetToFalse(name);
+ return PYTHON_FALSE.equals(name.name()) || isNameSetToFalse(name, ctx);
}
return false;
}
- private boolean isNameSetToFalse(Name name) {
- Set values = reachingDefinitionsAnalysis.valuesAtLocation(name);
+ private static boolean isNameSetToFalse(Name name, SubscriptionContext ctx) {
+ Set values = ctx.valuesAtLocation(name);
return values.size() == 1 && values.stream()
.flatMap(TreeUtils.toStreamInstanceOfMapper(Name.class))
.map(Name::name).allMatch(PYTHON_FALSE::equals);
}
-
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/tests/UnconditionalAssertionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/tests/UnconditionalAssertionCheck.java
index 3ed7546af..96c2cd428 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/tests/UnconditionalAssertionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/tests/UnconditionalAssertionCheck.java
@@ -34,7 +34,6 @@
import org.sonar.plugins.python.api.tree.NumericLiteral;
import org.sonar.plugins.python.api.tree.RegularArgument;
import org.sonar.plugins.python.api.tree.Tree;
-import org.sonar.python.cfg.fixpoint.ReachingDefinitionsAnalysis;
import org.sonar.python.checks.utils.CheckUtils;
import org.sonar.python.tree.TreeUtils;
@@ -58,13 +57,8 @@ public class UnconditionalAssertionCheck extends PythonSubscriptionCheck {
private static final Set ACCEPTED_DECORATORS = Set.of("overload", "staticmethod", "classmethod");
- private ReachingDefinitionsAnalysis reachingDefinitionsAnalysis;
-
@Override
public void initialize(Context context) {
- context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, ctx ->
- reachingDefinitionsAnalysis = new ReachingDefinitionsAnalysis(ctx.pythonFile()));
-
context.registerSyntaxNodeConsumer(Tree.Kind.ASSERT_STMT, ctx -> {
AssertStatement assertStatement = (AssertStatement) ctx.syntaxNode();
Expression condition = assertStatement.condition();
@@ -115,13 +109,13 @@ private static boolean isFalseOrZeroLiteral(Expression expression) {
}
private void checkNoneAssertion(SubscriptionContext ctx, CallExpression call, RegularArgument arg) {
- if (isUnconditional(arg)) {
+ if (isUnconditional(arg, ctx)) {
ctx.addIssue(call, NONE_MESSAGE);
}
}
private void checkBooleanAssertion(SubscriptionContext ctx, RegularArgument arg) {
- if (isUnconditional(arg)) {
+ if (isUnconditional(arg, ctx)) {
ctx.addIssue(arg, BOOLEAN_MESSAGE);
}
}
@@ -132,7 +126,7 @@ private static void checkIsAssertion(SubscriptionContext ctx, CallExpression cal
}
}
- private boolean isUnconditional(RegularArgument argument) {
+ private static boolean isUnconditional(RegularArgument argument, SubscriptionContext ctx) {
Expression expression = argument.expression();
if (isConstant(expression)) {
return true;
@@ -151,7 +145,7 @@ private boolean isUnconditional(RegularArgument argument) {
}
if (expression.is(NAME)) {
- Set valuesAtLocation = reachingDefinitionsAnalysis.valuesAtLocation(((Name) expression));
+ Set valuesAtLocation = ctx.valuesAtLocation(((Name) expression));
if (valuesAtLocation.size() == 1) {
return CheckUtils.isImmutableConstant(valuesAtLocation.iterator().next());
}
diff --git a/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonVisitorContext.java b/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonVisitorContext.java
index 0c16653a3..5d3fae34d 100644
--- a/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonVisitorContext.java
+++ b/python-frontend/src/main/java/org/sonar/plugins/python/api/PythonVisitorContext.java
@@ -21,16 +21,23 @@
import java.io.File;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
import org.sonar.api.SonarProduct;
import org.sonar.plugins.python.api.PythonCheck.PreciseIssue;
import org.sonar.plugins.python.api.caching.CacheContext;
+import org.sonar.plugins.python.api.cfg.ControlFlowGraph;
import org.sonar.plugins.python.api.project.configuration.ProjectConfiguration;
+import org.sonar.plugins.python.api.tree.Expression;
import org.sonar.plugins.python.api.tree.FileInput;
+import org.sonar.plugins.python.api.tree.Name;
+import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.types.v2.ModuleType;
import org.sonar.python.caching.CacheContextImpl;
+import org.sonar.python.cfg.fixpoint.ReachingDefinitionsAnalysis;
import org.sonar.python.semantic.ProjectLevelSymbolTable;
import org.sonar.python.semantic.SymbolTableBuilder;
import org.sonar.python.semantic.v2.SymbolTableBuilderV2;
@@ -50,6 +57,8 @@ public class PythonVisitorContext extends PythonInputFileContext {
private final List issues;
private final ProjectConfiguration projectConfiguration;
private final CallGraph callGraph;
+ private final Map cfgMap;
+ private final ReachingDefinitionsAnalysis reachingDefinitionsAnalysis;
private final TypeTable typeTable;
private PythonVisitorContext(FileInput rootTree,
@@ -61,12 +70,15 @@ private PythonVisitorContext(FileInput rootTree,
ProjectConfiguration projectConfiguration,
ModuleType moduleType,
CallGraph callGraph,
- TypeTable typeTable
+ TypeTable typeTable,
+ Map cfgMap
) {
super(pythonFile, workingDirectory, cacheContext, sonarProduct, projectLevelSymbolTable);
this.moduleType = moduleType;
this.projectConfiguration = projectConfiguration;
this.callGraph = callGraph;
+ this.cfgMap = cfgMap;
+ this.reachingDefinitionsAnalysis = new ReachingDefinitionsAnalysis(pythonFile);
this.rootTree = rootTree;
this.parsingException = null;
this.typeTable = typeTable;
@@ -82,6 +94,8 @@ public PythonVisitorContext(PythonFile pythonFile, RecognitionException parsingE
this.typeChecker = new TypeChecker(this.typeTable);
this.projectConfiguration = new ProjectConfiguration();
this.callGraph = CallGraph.EMPTY;
+ this.cfgMap = Map.of();
+ this.reachingDefinitionsAnalysis = new ReachingDefinitionsAnalysis(pythonFile);
this.issues = new ArrayList<>();
this.moduleType = null;
}
@@ -110,6 +124,10 @@ public List getIssues() {
return issues;
}
+ public Set valuesAtLocation(Name name) {
+ return reachingDefinitionsAnalysis.valuesAtLocation(name);
+ }
+
@CheckForNull
@Beta
public ModuleType moduleType() {
@@ -124,6 +142,10 @@ public CallGraph callGraph() {
return callGraph;
}
+ public ControlFlowGraph cfg(Tree tree) {
+ return cfgMap.get(tree);
+ }
+
public Optional getDjangoViewInfo(String fqn) {
return projectLevelSymbolTable().getDjangoViewInfo(fqn);
}
@@ -141,6 +163,7 @@ public static class Builder {
private Optional callGraph = Optional.empty();
private Optional packageName = Optional.empty();
private Optional moduleType = Optional.empty();
+ private Optional