diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 23070909a8..08d5644361 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1 @@
-.github/CODEOWNERS @SonarSource/quality-data-ml-squad
+.github/CODEOWNERS @SonarSource/code-quality-core-languages-parsers-squad
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
deleted file mode 100644
index 4ae372795e..0000000000
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ /dev/null
@@ -1,7 +0,0 @@
-Part of
-
diff --git a/.github/actions/ruling-diff-comment/action.yml b/.github/actions/ruling-diff-comment/action.yml
new file mode 100644
index 0000000000..010e838ad2
--- /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 0000000000..89b0ba6514
--- /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 0000000000..f4468b8a93
--- /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 0000000000..c8e633ea06
--- /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 0000000000..c8e633ea06
--- /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 0000000000..6eba3c5944
--- /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 0000000000..d5e5339f5d
--- /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 0000000000..e77ee245eb
--- /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:
+ raise ValueError(f"Missing 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 0000000000..40e3a1f829
--- /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 0000000000..65b80f595f
--- /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 0000000000..5b7ed077bd
--- /dev/null
+++ b/.github/actions/ruling-diff-comment/test_ruling_diff.py
@@ -0,0 +1,508 @@
+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),
+ )
+
+ def test_parse_ruling_path_with_legacy_key(self) -> None:
+ path = "private/its-enterprise/ruling/src/test/resources/expected_ruling/scikit-learn/python-LineLength.json"
+ self.assertEqual(
+ ("scikit-learn", "python", "LineLength"),
+ core.parse_ruling_path(path),
+ )
+
+ def test_parse_rule_filename_rejects_empty_rule_key(self) -> None:
+ with self.assertRaises(ValueError):
+ core.parse_rule_filename("python-.json")
+
+ def test_parse_rule_filename_rejects_empty_repository(self) -> None:
+ with self.assertRaises(ValueError):
+ core.parse_rule_filename("-S1066.json")
+
+
+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 0000000000..a3e2f30753
--- /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 = "." }
diff --git a/.github/actions/setup-orchestrator-cache/action.yml b/.github/actions/setup-orchestrator-cache/action.yml
index b937f3e770..8a9e92e445 100644
--- a/.github/actions/setup-orchestrator-cache/action.yml
+++ b/.github/actions/setup-orchestrator-cache/action.yml
@@ -12,15 +12,17 @@ runs:
- name: Calculate orchestrator cache key
id: set-paths
shell: bash
+ env:
+ SQ_VERSION: ${{ inputs.sq-version }}
run: |
# Get current month for cache rotation
CURRENT_MONTH=$(date +"%B")
- CACHE_KEY="orchestrator-${{ github.workflow }}-${{ inputs.sq-version }}-${CURRENT_MONTH}"
+ CACHE_KEY="orchestrator-${{ github.workflow }}-${SQ_VERSION}-${CURRENT_MONTH}"
echo "cache-key=${CACHE_KEY}" >> $GITHUB_OUTPUT
- name: Setup orchestrator cache
- uses: SonarSource/ci-github-actions/cache@v1
+ uses: SonarSource/gh-action_cache@v1
with:
path: "~/.sonar/orchestrator/"
key: "${{ steps.set-paths.outputs.cache-key }}"
diff --git a/.github/instructions/rules-implementation.instructions.md b/.github/instructions/rules-implementation.instructions.md
index 9666d1bab4..6340de151d 100644
--- a/.github/instructions/rules-implementation.instructions.md
+++ b/.github/instructions/rules-implementation.instructions.md
@@ -1,67 +1,10 @@
---
applyTo: "**/*checks*/**/*.java"
---
-Here is some information about basic rule implementation:
+# Rule implementation
-- When implementing a new rule, the rule has to be added to [OpenSourceCheckList.java](../../python-checks/src/main/java/org/sonar/python/checks/OpenSourceCheckList.java).
-- The class name ends in `Check`, for example `SleepZeroInAsyncCheck`
-- Annotate the class with @Rule(key = "YourRuleKey"):
-Replace "YourRuleKey" with a unique identifier for your rule (e.g., "S1234").
-- Always start out by having the rule extend `PythonSubscriptionCheck` as a first step
-- Override the initialize(Context context) method:
- This method is called once when the analysis starts.
- Use context.registerSyntaxNodeConsumer(Kind.YOUR_TARGET_KIND, this::yourCheckMethod) to register a callback method for specific Python Abstract Syntax Tree (AST) node kinds you want to inspect.
- Kind.YOUR_TARGET_KIND refers to the type of Python code structure you're interested in (e.g., Kind.CALL_EXPR for function calls, Kind.FUNCTION_DEF for function definitions, Kind.IF_STMT for if statements, etc.).
- Make sure to use a Kind that exists in the Enum.
-this::yourCheckMethod is a method reference to the method in your rule class that will be called when a node of the specified Kind is encountered.
-- Implement your check method(s) (e.g., yourCheckMethod(SubscriptionContext context)):
- This method will be called for each AST node of the Kind you registered in initialize.
- Get the current AST node being visited using context.syntaxNode(). You'll likely need to cast it to its specific type (e.g., (CallExpression) context.syntaxNode()).
- Implement the logic to analyze the AST node and its properties.
- You can use TreeUtils for helper functions to navigate or inspect the AST (e.g., TreeUtils.firstAncestorOfKind(...), TreeUtils.nthArgumentOrKeyword(...)).
- If you need to perform type checking:
- You can initialize a TypeCheckMap in a method registered for Kind.FILE_INPUT (as seen with initializeTypeCheckMap in the example).
- Use context.typeChecker().typeCheckBuilder() to define type checks (e.g., checking for a fully qualified name like isTypeWithFqn("module.submodule.function")).
- Always prefer using the type checkers instead fully qualified names on the symbol
-- Report issues:
- If your rule's conditions are met and an issue should be raised, use context.addIssue(treeNode, message).
- treeNode is the AST node to which the issue should be attached.
- message is the description of the issue.
- You can add secondary locations to an issue using issue.secondary(anotherTreeNode, secondaryMessage).
-- Define message strings:
- Use constants for messages (e.g., private static final String MESSAGE = "Your informative message here.").
- Use String.format() if your messages need to include dynamic parts.
-- (Optional) Create helper records or classes:
- For more complex logic or to hold state/configuration related to specific checks (like MessageHolder in the example), you can define inner records or classes.
+Do **not** follow outdated guidance that may appear elsewhere. The single source of truth is:
-Commonly Used APIs and Utilities for Rule Implementation
-- TreeUtils (org.sonar.python.tree.TreeUtils):
- firstAncestorOfKind(tree, Kind...): Find the first ancestor node of a given kind.
- firstAncestor(tree, Predicate): Find the first ancestor matching a predicate.
- hasDescendant(tree, Predicate): Check if a tree has a descendant matching a predicate.
- getSymbolFromTree(tree): Get the symbol associated with a tree node, if any.
- getClassSymbolFromDef(classDef), getFunctionSymbolFromDef(functionDef): Get class/function symbol from definition node.
- nthArgumentOrKeyword(int pos, String keyword, List): Get the nth or keyword argument from a call. Also has an Optional variant
- argumentByKeyword(String keyword, List): Get argument by keyword.
- isBooleanLiteral(tree): Check if a tree is a boolean literal.
- toOptionalInstanceOf(Class, tree), toStreamInstanceOfMapper(Class): Safe casting and mapping for tree nodes. Useful for Optionals and avoiding nulls.
- firstChild(tree, Predicate): Find the first child matching a predicate.
-- Type Checking:
- TypeCheckMap and TypeCheckBuilder (org.sonar.python.types.v2): Used to map type checks to actions or data, e.g., for matching function calls by type.
- context.typeChecker().typeCheckBuilder().isTypeWithFqn("..."), isTypeOrInstanceWithName("..."), etc.: Build type checks for use in rules.
-- SubscriptionContext (org.sonar.plugins.python.api.SubscriptionContext):
- syntaxNode(): Get the current AST node being visited.
- addIssue(tree, message): Report an issue at a given node.
- typeChecker(): Access type checking utilities.
- pythonFile(): Access file-level information.
-- Tree.Kind (org.sonar.plugins.python.api.tree.Tree.Kind):
- Used to register node consumers and to check node types (e.g., Kind.CALL_EXPR, Kind.FUNCDEF, Kind.CLASSDEF, etc.).
-Patterns:
- Use context.registerSyntaxNodeConsumer(Kind.X, this::method) in initialize to register callbacks for AST node kinds.
- Use type checks and TypeCheckMap for rules that depend on type information.
- Prefer using TypeV2 instead of the V1 symbols
- When initializing TypeCheckBuilder objects, make a separate method.
- In general, the callbacks should be in their own functions and not a lambda
- Make sure to use the correct classes, for exemple most of the Tree objects have names that do not end in Tree. Before using, make sure the types exist.
- When building type checkers, always recreate the builder from the context. Do not reuse one.
- When casting to a tree, use a variable name that makes sense like expr, callExpression, ...
\ No newline at end of file
+`.claude/skills/rule-implementation/SKILL.md`
+
+Read that skill and follow it whenever implementing, placing, or wiring a new or existing Python check (open-source or enterprise).
diff --git a/.github/renovate.json b/.github/renovate.json
new file mode 100644
index 0000000000..7bf2d231e3
--- /dev/null
+++ b/.github/renovate.json
@@ -0,0 +1,32 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "github>SonarSource/renovate-config:quality-corelang-squad"
+ ],
+ "ignorePaths": [
+ "python-frontend/typeshed_serializer/**"
+ ],
+ "packageRules": [
+ {
+ "description": "Keep Python on 3.9 for typeshed serialization",
+ "matchManagers": [
+ "mise"
+ ],
+ "matchDepNames": [
+ "python"
+ ],
+ "allowedVersions": "<3.10"
+ },
+ {
+ "description": "Keep protobuf Java and protoc compatible with the Python runtime used for typeshed serialization",
+ "matchDatasources": [
+ "maven"
+ ],
+ "matchPackageNames": [
+ "com.google.protobuf:protobuf-java",
+ "com.google.protobuf:protoc"
+ ],
+ "allowedVersions": "<=4.29.3"
+ }
+ ]
+}
diff --git a/.github/scripts/verify-fix-versions.sh b/.github/scripts/verify-fix-versions.sh
index 1d93e10095..fb364717f2 100644
--- a/.github/scripts/verify-fix-versions.sh
+++ b/.github/scripts/verify-fix-versions.sh
@@ -46,8 +46,20 @@ get_commits_since_tag() {
extract_jira_tickets() {
local commits="$1"
+ local tickets
+ local extract_exit_code=0
# Extract SONARPY-XXXX patterns, remove duplicates, and sort
- echo "$commits" | grep -oE "${JIRA_PROJECT_KEY}-[0-9]+" | sort -u
+ set +e
+ tickets=$(printf '%s\n' "$commits" | grep -oE "${JIRA_PROJECT_KEY}-[0-9]+" | sort -u)
+ extract_exit_code=$?
+ set -e
+
+ if [[ "$extract_exit_code" -gt 1 ]]; then
+ echo "Error: Failed to extract Jira tickets from commits" >&2
+ exit 1
+ fi
+
+ printf '%s' "$tickets"
}
build_jql_query() {
@@ -293,4 +305,3 @@ main() {
main "$@"
-
diff --git a/.github/workflows/SlackNotify.yml b/.github/workflows/SlackNotify.yml
index 9c168d68ca..bc7c655c42 100644
--- a/.github/workflows/SlackNotify.yml
+++ b/.github/workflows/SlackNotify.yml
@@ -19,4 +19,4 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
uses: SonarSource/gh-action_slack-notify@1.0.1
with:
- slackChannel: squad-python-notifs
+ slackChannel: squad-corelang-notifs
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4c039882eb..8e043f711b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -48,9 +48,9 @@ jobs:
git sparse-checkout set stubs/sklearn
git checkout
- - uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3.5.1
+ - uses: jdx/mise-action@e79ddf65a11cec7b0e882bedced08d6e976efb2d # v3.6.2
with:
- version: 2025.12.12
+ version: 2026.5.15
cache: false
env:
MISE_ENV: test-and-analyze
diff --git a/.gitignore b/.gitignore
index 43b7a5243b..a4fc91cfd6 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/*
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000000..6be127ca60
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,13 @@
+{
+ "mcpServers": {
+ "sonarqube": {
+ "command": "sonar",
+ "args": [
+ "run",
+ "mcp",
+ "--project",
+ "SonarSource_sonar-python-enterprise"
+ ]
+ }
+ }
+}
diff --git a/NOTICE.txt b/NOTICE.txt
index cb53f06841..2713507737 100644
--- a/NOTICE.txt
+++ b/NOTICE.txt
@@ -1,6 +1,11 @@
-Python
-Copyright (C) 2011-2025 SonarSource Sàrl
+Copyright (C) SonarSource Sàrl
mailto:info AT sonarsource DOT com
This product includes software developed at
-SonarSource (http://www.sonarsource.com/).
+SonarSource (https://sonarsource.com/).
+
+See LICENSE.txt file for details of the
+applicable license.
+
+For further legal information, see
+https://sonarsource.com/legal/
diff --git a/docs/pom.xml b/docs/pom.xml
index 4c268268c0..2a3d972ff1 100644
--- a/docs/pom.xml
+++ b/docs/pom.xml
@@ -5,7 +5,7 @@
org.sonarsource.pythonpython
- 5.19-SNAPSHOT
+ 5.30-SNAPSHOTdocs
diff --git a/docs/python-custom-rules-example/pom.xml b/docs/python-custom-rules-example/pom.xml
index b0813615ce..ecf69487f2 100644
--- a/docs/python-custom-rules-example/pom.xml
+++ b/docs/python-custom-rules-example/pom.xml
@@ -6,7 +6,7 @@
org.sonarsource.pythondocs
- 5.19-SNAPSHOT
+ 5.30.0.36259python-custom-rules-example
@@ -29,8 +29,8 @@
sonar-analyzer-commons
- org.sonarsource.sonarqube
- sonar-plugin-api-impl
+ org.sonarsource.scanner.engine
+ sensor-test-fixturestest
@@ -76,8 +76,8 @@
maven-compiler-plugin3.15.0
- 17
- 17
+ 21
+ 21
diff --git a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/CustomPythonRuleRepository.java b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/CustomPythonRuleRepository.java
index 3b2c7282e3..cb8de1fd24 100644
--- a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/CustomPythonRuleRepository.java
+++ b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/CustomPythonRuleRepository.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2011-2025 SonarSource Sàrl - mailto:info AT sonarsource DOT com
+ * Copyright (C) SonarSource Sàrl - mailto:info AT sonarsource DOT com
* This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
*/
package org.sonar.samples.python;
diff --git a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/CustomPythonRulesPlugin.java b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/CustomPythonRulesPlugin.java
index 98497c808e..3b5ad3e8e6 100644
--- a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/CustomPythonRulesPlugin.java
+++ b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/CustomPythonRulesPlugin.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2011-2025 SonarSource Sàrl - mailto:info AT sonarsource DOT com
+ * Copyright (C) SonarSource Sàrl - mailto:info AT sonarsource DOT com
* This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
*/
package org.sonar.samples.python;
diff --git a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/RulesList.java b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/RulesList.java
index 731857b294..8b807159f9 100644
--- a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/RulesList.java
+++ b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/RulesList.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2011-2025 SonarSource Sàrl - mailto:info AT sonarsource DOT com
+ * Copyright (C) SonarSource Sàrl - mailto:info AT sonarsource DOT com
* This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
*/
package org.sonar.samples.python;
diff --git a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/CustomPythonSubscriptionCheck.java b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/CustomPythonSubscriptionCheck.java
index 6b95f7a9c9..79ef935640 100644
--- a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/CustomPythonSubscriptionCheck.java
+++ b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/CustomPythonSubscriptionCheck.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2011-2025 SonarSource Sàrl - mailto:info AT sonarsource DOT com
+ * Copyright (C) SonarSource Sàrl - mailto:info AT sonarsource DOT com
* This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
*/
package org.sonar.samples.python.checks;
diff --git a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/CustomPythonVisitorCheck.java b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/CustomPythonVisitorCheck.java
index dd432e417f..576e88206b 100644
--- a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/CustomPythonVisitorCheck.java
+++ b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/CustomPythonVisitorCheck.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2011-2025 SonarSource Sàrl - mailto:info AT sonarsource DOT com
+ * Copyright (C) SonarSource Sàrl - mailto:info AT sonarsource DOT com
* This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
*/
package org.sonar.samples.python.checks;
diff --git a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/package-info.java b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/package-info.java
index ec4f65e778..9eb6b26be4 100644
--- a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/package-info.java
+++ b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/checks/package-info.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2011-2025 SonarSource Sàrl - mailto:info AT sonarsource DOT com
+ * Copyright (C) SonarSource Sàrl - mailto:info AT sonarsource DOT com
* This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
*/
@ParametersAreNonnullByDefault
diff --git a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/package-info.java b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/package-info.java
index 0541ae28d7..176bab3330 100644
--- a/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/package-info.java
+++ b/docs/python-custom-rules-example/src/main/java/org/sonar/samples/python/package-info.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2011-2025 SonarSource Sàrl - mailto:info AT sonarsource DOT com
+ * Copyright (C) SonarSource Sàrl - mailto:info AT sonarsource DOT com
* This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
*/
@ParametersAreNonnullByDefault
diff --git a/docs/python-custom-rules-example/src/main/resources/license-header.txt b/docs/python-custom-rules-example/src/main/resources/license-header.txt
index 27a2c5c0c2..e4522e0453 100644
--- a/docs/python-custom-rules-example/src/main/resources/license-header.txt
+++ b/docs/python-custom-rules-example/src/main/resources/license-header.txt
@@ -1,2 +1,2 @@
-Copyright (C) ${license.years} ${license.owner} - ${license.mailto}
+Copyright (C) ${license.owner} - ${license.mailto}
This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
\ No newline at end of file
diff --git a/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/CustomPythonRuleRepositoryTest.java b/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/CustomPythonRuleRepositoryTest.java
index 71388e1bb8..734c22eeb7 100644
--- a/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/CustomPythonRuleRepositoryTest.java
+++ b/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/CustomPythonRuleRepositoryTest.java
@@ -1,14 +1,14 @@
/*
- * Copyright (C) 2011-2025 SonarSource Sàrl - mailto:info AT sonarsource DOT com
+ * Copyright (C) SonarSource Sàrl - mailto:info AT sonarsource DOT com
* This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
*/
package org.sonar.samples.python;
+import com.sonarsource.scanner.engine.sensor.test.fixtures.TestSonarRuntime;
import org.junit.jupiter.api.Test;
import org.sonar.api.SonarEdition;
import org.sonar.api.SonarQubeSide;
import org.sonar.api.SonarRuntime;
-import org.sonar.api.internal.SonarRuntimeImpl;
import org.sonar.api.server.rule.RulesDefinition;
import org.sonar.api.utils.Version;
@@ -18,7 +18,7 @@ class CustomPythonRuleRepositoryTest {
@Test
void test_rule_repository() {
- SonarRuntime sonarRuntime = SonarRuntimeImpl.forSonarQube(Version.create(9, 9), SonarQubeSide.SCANNER, SonarEdition.DEVELOPER);
+ SonarRuntime sonarRuntime = TestSonarRuntime.forSonarQube(Version.create(9, 9), SonarQubeSide.SCANNER, SonarEdition.DEVELOPER);
CustomPythonRuleRepository customPythonRuleRepository = new CustomPythonRuleRepository(sonarRuntime);
RulesDefinition.Context context = new RulesDefinition.Context();
customPythonRuleRepository.define(context);
diff --git a/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/CustomPythonRulesPluginTest.java b/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/CustomPythonRulesPluginTest.java
index 0f67bb6abd..9f0c7e6c3f 100644
--- a/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/CustomPythonRulesPluginTest.java
+++ b/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/CustomPythonRulesPluginTest.java
@@ -1,16 +1,15 @@
/*
- * Copyright (C) 2011-2025 SonarSource Sàrl - mailto:info AT sonarsource DOT com
+ * Copyright (C) SonarSource Sàrl - mailto:info AT sonarsource DOT com
* This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
*/
package org.sonar.samples.python;
+import com.sonarsource.scanner.engine.sensor.test.fixtures.TestSonarRuntime;
import org.junit.jupiter.api.Test;
import org.sonar.api.Plugin;
import org.sonar.api.SonarEdition;
import org.sonar.api.SonarQubeSide;
import org.sonar.api.SonarRuntime;
-import org.sonar.api.internal.PluginContextImpl;
-import org.sonar.api.internal.SonarRuntimeImpl;
import org.sonar.api.utils.Version;
import static org.assertj.core.api.Assertions.assertThat;
@@ -18,8 +17,8 @@
class CustomPythonRulesPluginTest {
@Test
void test() {
- SonarRuntime sonarRuntime = SonarRuntimeImpl.forSonarQube(Version.create(9, 9), SonarQubeSide.SCANNER, SonarEdition.DEVELOPER);
- Plugin.Context context = new PluginContextImpl.Builder().setSonarRuntime(sonarRuntime).build();
+ SonarRuntime sonarRuntime = TestSonarRuntime.forSonarQube(Version.create(9, 9), SonarQubeSide.SCANNER, SonarEdition.DEVELOPER);
+ Plugin.Context context = new Plugin.Context(sonarRuntime);
new CustomPythonRulesPlugin().define(context);
assertThat(context.getExtensions()).hasSize(1);
}
diff --git a/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/checks/CustomPythonSubscriptionCheckTest.java b/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/checks/CustomPythonSubscriptionCheckTest.java
index 8bf2576ca4..8a647c954f 100644
--- a/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/checks/CustomPythonSubscriptionCheckTest.java
+++ b/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/checks/CustomPythonSubscriptionCheckTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2011-2025 SonarSource Sàrl - mailto:info AT sonarsource DOT com
+ * Copyright (C) SonarSource Sàrl - mailto:info AT sonarsource DOT com
* This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
*/
package org.sonar.samples.python.checks;
diff --git a/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/checks/CustomPythonVisitorCheckTest.java b/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/checks/CustomPythonVisitorCheckTest.java
index 4d7129f4ec..4baf79b817 100644
--- a/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/checks/CustomPythonVisitorCheckTest.java
+++ b/docs/python-custom-rules-example/src/test/java/org/sonar/samples/python/checks/CustomPythonVisitorCheckTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2011-2025 SonarSource Sàrl - mailto:info AT sonarsource DOT com
+ * Copyright (C) SonarSource Sàrl - mailto:info AT sonarsource DOT com
* This code is released under [MIT No Attribution](https://opensource.org/licenses/MIT-0) license.
*/
package org.sonar.samples.python.checks;
diff --git a/its/commons/pom.xml b/its/commons/pom.xml
index 6245d8ea3d..9972187433 100644
--- a/its/commons/pom.xml
+++ b/its/commons/pom.xml
@@ -6,7 +6,7 @@
org.sonarsource.pythonpython-its
- 5.19-SNAPSHOT
+ 5.30.0.36259Python :: ITs :: Commons
diff --git a/its/commons/src/test/java/com/sonar/python/it/ConcurrentOrchestratorExtension.java b/its/commons/src/test/java/com/sonar/python/it/ConcurrentOrchestratorExtension.java
index 1376942bcd..2153b333aa 100644
--- a/its/commons/src/test/java/com/sonar/python/it/ConcurrentOrchestratorExtension.java
+++ b/its/commons/src/test/java/com/sonar/python/it/ConcurrentOrchestratorExtension.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/its/commons/src/test/java/com/sonar/python/it/IssueListAssert.java b/its/commons/src/test/java/com/sonar/python/it/IssueListAssert.java
index 9711892cd3..4c60760146 100644
--- a/its/commons/src/test/java/com/sonar/python/it/IssueListAssert.java
+++ b/its/commons/src/test/java/com/sonar/python/it/IssueListAssert.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/its/commons/src/test/java/com/sonar/python/it/IssueListAssertTest.java b/its/commons/src/test/java/com/sonar/python/it/IssueListAssertTest.java
index afe59aca66..2d53d04db7 100644
--- a/its/commons/src/test/java/com/sonar/python/it/IssueListAssertTest.java
+++ b/its/commons/src/test/java/com/sonar/python/it/IssueListAssertTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/its/commons/src/test/java/com/sonar/python/it/PluginLocator.java b/its/commons/src/test/java/com/sonar/python/it/PluginLocator.java
index b870b4f0af..cf391e79a1 100644
--- a/its/commons/src/test/java/com/sonar/python/it/PluginLocator.java
+++ b/its/commons/src/test/java/com/sonar/python/it/PluginLocator.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -33,7 +33,7 @@ public enum Plugins {
new LocalPlugin("../python-custom-rules-plugin/target", "python-custom-rules-plugin-*.jar"),
new LocalPlugin("../../../its/plugin/python-custom-rules-plugin/target", "python-custom-rules-plugin-*.jar")),
PYTHON_CUSTOM_RULES_EXAMPLE(
- new LocalPlugin("../../docs/python-custom-rules-example/target", "python-custom-rules-example-*.jar"),
+ new LocalPlugin("../../../docs/python-custom-rules-example/target", "python-custom-rules-example-*.jar"),
new LocalPlugin("../../../docs/python-custom-rules-example/target", "python-custom-rules-example-*.jar"));
private final LocalPlugin ossPlugin;
diff --git a/its/commons/src/test/java/com/sonar/python/it/TestsUtils.java b/its/commons/src/test/java/com/sonar/python/it/TestsUtils.java
index 485fe8a6a9..8fb3222de7 100644
--- a/its/commons/src/test/java/com/sonar/python/it/TestsUtils.java
+++ b/its/commons/src/test/java/com/sonar/python/it/TestsUtils.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -57,10 +57,8 @@ private static ConcurrentOrchestratorExtension.ConcurrentOrchestratorExtensionBu
var builder = ConcurrentOrchestratorExtension.builderEnv()
.useDefaultAdminCredentialsForBuilds(true)
.setSonarVersion(System.getProperty(SQ_VERSION_PROPERTY, DEFAULT_SQ_VERSION))
- // Disable telemetry waiting for ORCH-497
+ // Disable telemetry to avoid external network calls during tests
.setServerProperty("sonar.telemetry.enable", "false")
-
- // Custom rules plugin
.addPlugin(Plugins.PYTHON_CUSTOM_RULES.get(isEnterprise))
.addPlugin(Plugins.PYTHON_CUSTOM_RULES_EXAMPLE.get(isEnterprise))
.restoreProfileAtStartup(FileLocation.of("profiles/profile-python-custom-rules-example.xml"))
diff --git a/its/plugin/it-python-plugin-test/pom.xml b/its/plugin/it-python-plugin-test/pom.xml
index 9fa5c35be7..87da778e3e 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-pluginorg.sonarsource.python
- 5.19-SNAPSHOT
+ 5.30.0.36259it-python-plugin-test
@@ -35,7 +35,21 @@
org.sonarsource.sonarlint.core
- sonarlint-core
+ sonarlint-core-test-utils
+ ${sonarlint-core.version}
+ test
+
+
+ org.sonarsource.sonarlint.core
+ sonarlint-rpc-protocol
+ ${sonarlint-core.version}
+ test
+
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ 2.22test
@@ -60,6 +74,12 @@
${sonar.version}test
+
+ org.awaitility
+ awaitility
+ 4.3.0
+ test
+ org.sonarsource.orchestratorsonar-orchestrator-junit5
diff --git a/its/plugin/it-python-plugin-test/profiles/nosonar.xml b/its/plugin/it-python-plugin-test/profiles/nosonar.xml
index aa89d670ff..4da1b3fb8c 100644
--- a/its/plugin/it-python-plugin-test/profiles/nosonar.xml
+++ b/its/plugin/it-python-plugin-test/profiles/nosonar.xml
@@ -22,5 +22,10 @@
S1309INFO
+
+ python
+ S4423
+ INFO
+
diff --git a/its/plugin/it-python-plugin-test/projects/coverage_project_projectbasedir/app/coverage.xml b/its/plugin/it-python-plugin-test/projects/coverage_project_projectbasedir/app/coverage.xml
new file mode 100644
index 0000000000..0b21d1a5f8
--- /dev/null
+++ b/its/plugin/it-python-plugin-test/projects/coverage_project_projectbasedir/app/coverage.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ src
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/its/plugin/it-python-plugin-test/projects/coverage_project_projectbasedir/app/src/prod.py b/its/plugin/it-python-plugin-test/projects/coverage_project_projectbasedir/app/src/prod.py
new file mode 100644
index 0000000000..7d4290a117
--- /dev/null
+++ b/its/plugin/it-python-plugin-test/projects/coverage_project_projectbasedir/app/src/prod.py
@@ -0,0 +1 @@
+x = 1
diff --git a/its/plugin/it-python-plugin-test/projects/nosonar/nosec-project/main.py b/its/plugin/it-python-plugin-test/projects/nosonar/nosec-project/main.py
new file mode 100644
index 0000000000..8789095fa9
--- /dev/null
+++ b/its/plugin/it-python-plugin-test/projects/nosonar/nosec-project/main.py
@@ -0,0 +1,26 @@
+import ssl
+
+# Bare nosec suppresses every rule on the line (Bandit semantics).
+ssl.SSLContext(ssl.PROTOCOL_TLSv1)
+ssl.SSLContext(ssl.PROTOCOL_TLSv1) # nosec
+
+# Scoped nosec with a Bandit ID narrows suppression to that ID; B502 has no Sonar mapping, so S4423 still fires.
+ssl.SSLContext(ssl.PROTOCOL_TLSv1) # nosec B502 legacy peer
+
+# Selective suppression by Sonar rule key.
+ssl.SSLContext(ssl.PROTOCOL_TLSv1) # nosec S4423
+
+# Selective suppression narrows to the listed keys; OneStatementPerLine still fires.
+ssl.SSLContext(ssl.PROTOCOL_TLSv1); ssl.SSLContext(ssl.PROTOCOL_TLSv1_1) # nosec S4423
+
+# Baseline: two S4423 + OneStatementPerLine all fire.
+ssl.SSLContext(ssl.PROTOCOL_TLSv1); ssl.SSLContext(ssl.PROTOCOL_TLSv1_1)
+
+# Bare nosec suppresses security rules but lets non-security rules (e.g. OneStatementPerLine) through.
+ssl.SSLContext(ssl.PROTOCOL_TLSv1); ssl.SSLContext(ssl.PROTOCOL_TLSv1_1) # nosec
+
+# Trailing directive on the last statement.
+ssl.SSLContext(ssl.PROTOCOL_TLSv1) # nosec
+
+# Selective suppression lets S4423 through
+ssl.SSLContext(ssl.PROTOCOL_TLSv1) # nosec S1234
diff --git a/its/plugin/it-python-plugin-test/projects/ruff_project/ruff-report-absolute-path-from-other-machine.json b/its/plugin/it-python-plugin-test/projects/ruff_project/ruff-report-absolute-path-from-other-machine.json
new file mode 100644
index 0000000000..7a87797c24
--- /dev/null
+++ b/its/plugin/it-python-plugin-test/projects/ruff_project/ruff-report-absolute-path-from-other-machine.json
@@ -0,0 +1,34 @@
+[
+ {
+ "code": "F821",
+ "end_location": {
+ "column": 48,
+ "row": 7
+ },
+ "filename": "/home/other-user/some/other/checkout/ruff_project/src/file1.py",
+ "fix": null,
+ "location": {
+ "column": 42,
+ "row": 7
+ },
+ "message": "Undefined name `random`",
+ "noqa_row": 7,
+ "url": "https://beta.ruff.rs/docs/rules/undefined-name"
+ },
+ {
+ "code": "E501",
+ "end_location": {
+ "column": 109,
+ "row": 7
+ },
+ "filename": "/home/other-user/some/other/checkout/ruff_project/src/file1.py",
+ "fix": null,
+ "location": {
+ "column": 89,
+ "row": 7
+ },
+ "message": "Line too long (108 > 88 characters)",
+ "noqa_row": 7,
+ "url": "https://beta.ruff.rs/docs/rules/line-too-long"
+ }
+]
diff --git a/its/plugin/it-python-plugin-test/projects/test_code/main_src/.gitkeep b/its/plugin/it-python-plugin-test/projects/test_code/main_src/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/BanditReportTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/BanditReportTest.java
index 79f67e8f6d..6cf1f85989 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/BanditReportTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/BanditReportTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CPDTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CPDTest.java
index 4db54af4ba..eff82729e4 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CPDTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CPDTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CoverageTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CoverageTest.java
index fcd6f6482d..97f4ee9592 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CoverageTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CoverageTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -25,6 +25,7 @@
import java.io.File;
import java.util.HashMap;
import java.util.Map;
+import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -33,10 +34,12 @@
public class CoverageTest {
private static final String COVERAGE_PROJECT = "projects/coverage_project";
+ private static final String COVERAGE_PROJECT_PROJECT_BASEDIR = "projects/coverage_project_projectbasedir";
@RegisterExtension
public static final ConcurrentOrchestratorExtension ORCHESTRATOR = TestsUtils.dynamicOrchestrator;
private static final String PROJECT_KEY = "coverage_project";
+ private static final String PROJECT_BASEDIR_KEY = "coverage_project_projectbasedir";
private static final String LINES_TO_COVER = "lines_to_cover";
private static final String COVERAGE = "coverage";
private static final String LINE_COVERAGE = "line_coverage";
@@ -44,6 +47,8 @@ public class CoverageTest {
private static final String EMPTY_XML = "empty.xml";
private static final String DEPRECATED_COVERAGE_REPORT_PATH = "sonar.python.coverage.reportPath";
private static final String COVERAGE_REPORT_PATHS = "sonar.python.coverage.reportPaths";
+ private static final Pattern LINE_SEPARATOR = Pattern.compile("[\\r\\n]+");
+ private static final Pattern EMPTY_REPORT_LOG = Pattern.compile("The report '[^']*' seems to be empty, ignoring");
@Test
void basic_coverage_reports_with_unix_paths() {
@@ -113,14 +118,35 @@ void empty_coverage_report() {
.setProperty(COVERAGE_REPORT_PATHS, EMPTY_XML);
BuildResult buildResult = ORCHESTRATOR.executeBuild(build);
- int nbLog = 0;
- for (String s : buildResult.getLogs().split("[\\r\\n]+")) {
- if (s.matches(".*The report '[^']*' seems to be empty, ignoring.*")) {
- nbLog++;
- }
- }
+ long nbLog = LINE_SEPARATOR.splitAsStream(buildResult.getLogs())
+ .filter(line -> EMPTY_REPORT_LOG.matcher(line).find())
+ .count();
assertThat(nbLog).isEqualTo(1);
assertThat(TestsUtils.getMeasureAsDouble(PROJECT_KEY, COVERAGE)).isZero();
}
+ @Test
+ void relative_source_paths_are_resolved_from_project_base_dir() {
+ File projectDir = new File(COVERAGE_PROJECT_PROJECT_BASEDIR);
+ File projectBaseDir = new File(projectDir, "app");
+ SonarScanner build = ORCHESTRATOR.createSonarScanner()
+ .setProjectDir(projectDir)
+ .setProperty("sonar.projectKey", PROJECT_BASEDIR_KEY)
+ .setProperty("sonar.projectName", PROJECT_BASEDIR_KEY)
+ .setProperty("sonar.projectVersion", "1")
+ .setProperty("sonar.sourceEncoding", "UTF8")
+ .setProperty("sonar.projectBaseDir", projectBaseDir.getAbsolutePath())
+ .setProperty("sonar.sources", "src")
+ .setProperty(COVERAGE_REPORT_PATHS, "coverage.xml");
+
+ ORCHESTRATOR.executeBuild(build);
+
+ Map expected = new HashMap<>();
+ expected.put(LINES_TO_COVER, 1);
+ expected.put(COVERAGE, 100);
+ expected.put(LINE_COVERAGE, 100);
+ expected.put(BRANCH_COVERAGE, null);
+ TestsUtils.assertProjectMeasures(PROJECT_BASEDIR_KEY, expected);
+ }
+
}
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CustomRulesExampleTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CustomRulesExampleTest.java
index 7cfab5ff47..742d90354e 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CustomRulesExampleTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CustomRulesExampleTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CustomRulesTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CustomRulesTest.java
index 640190912d..ee062eada3 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CustomRulesTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/CustomRulesTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/Flake8ReportTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/Flake8ReportTest.java
index 5ee3c1c5ef..27d97e7fa4 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/Flake8ReportTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/Flake8ReportTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
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 6efd0d53db..8a84908a5e 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -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/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/MypyReportTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/MypyReportTest.java
index 1a585a69a0..c089adb3c9 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/MypyReportTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/MypyReportTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/NoSonarTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/NoSonarTest.java
index be2da2eee9..365b3f08ef 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/NoSonarTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/NoSonarTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -31,6 +31,7 @@ public class NoSonarTest {
private static final String NO_SONAR_PROJECT_KEY = "nosonar";
private static final String EXTERNAL_ISSUE_PROJECT_KEY = "external-issues";
private static final String NOQA_PROJECT_KEY = "noqa";
+ private static final String NO_SEC_PROJECT_KEY = "nosec";
private static final String PROFILE_NAME = "nosonar";
@@ -132,6 +133,48 @@ void test_noqa() {
.containsIssue(22, "python:S1309").doesNotContainIssue(22, "python:PrintStatementUsage");
}
+ @Test
+ void test_nosec() {
+ analyzeProject(createScanner(NO_SEC_PROJECT_KEY, "projects/nosonar/nosec-project"));
+
+ IssueListAssert.assertThat(issues(NO_SEC_PROJECT_KEY))
+ .hasSize(15)
+ // bare nosec suppresses security rules on the line; S1309 still fires (whitelisted)
+ .containsIssue(4, "python:S4423")
+ .doesNotContainIssue(5, "python:S4423")
+ .containsIssue(5, "python:S1309")
+
+ // scoped bandit ID has no Sonar mapping, so S4423 still fires
+ .containsIssue(8, "python:S4423")
+ .containsIssue(8, "python:S1309")
+
+ // selective suppression by Sonar rule key
+ .doesNotContainIssue(11, "python:S4423")
+ .containsIssue(11, "python:S1309")
+
+ // selective suppression narrows to listed keys; OneStatementPerLine still fires
+ .doesNotContainIssue(14, "python:S4423")
+ .containsIssue(14, "python:OneStatementPerLine")
+ .containsIssue(14, "python:S1309")
+
+ // baseline: 2 x S4423 + OneStatementPerLine all fire
+ .containsIssue(17, "python:S4423")
+ .containsIssue(17, "python:OneStatementPerLine")
+
+ // bare nosec suppresses security rules (S4423) but not non-security rules (OneStatementPerLine)
+ .doesNotContainIssue(20, "python:S4423")
+ .containsIssue(20, "python:OneStatementPerLine")
+ .containsIssue(20, "python:S1309")
+
+ // nosec at end of file
+ .doesNotContainIssue(23, "python:S4423")
+ .containsIssue(23, "python:S1309")
+
+ // scoped nosec with unrelated key does not suppress S4423
+ .containsIssue(26, "python:S4423")
+ .containsIssue(26, "python:S1309");
+ }
+
private void analyzeProject(SonarScanner scanner) {
String projectKey = scanner.getProperty("sonar.projectKey");
ORCHESTRATOR.getServer().provisionProject(projectKey, projectKey);
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/NotebookPluginTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/NotebookPluginTest.java
index ab62281b76..a2641eae78 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/NotebookPluginTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/NotebookPluginTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/PylintReportTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/PylintReportTest.java
index 38aa064175..49ccd8552b 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/PylintReportTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/PylintReportTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/RuffReportTest.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/RuffReportTest.java
index 54056c29f6..449c145a49 100644
--- a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/RuffReportTest.java
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/RuffReportTest.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2012-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -66,4 +66,29 @@ void import_report() {
}
+ @Test
+ void import_report_with_absolute_path_from_different_base_dir() {
+ final String projectKey = "ruff_project_absolute_path";
+ ORCHESTRATOR.getServer().provisionProject(projectKey, projectKey);
+ ORCHESTRATOR.getServer().associateProjectToQualityProfile(projectKey, "py", "no_rule");
+ ORCHESTRATOR.executeBuild(
+ ORCHESTRATOR.createSonarScanner()
+ .setProjectDir(new File("projects/ruff_project"))
+ .setProjectKey(projectKey)
+ .setProjectName(projectKey)
+ .setProperty("sonar.python.ruff.reportPaths", "ruff-report-absolute-path-from-other-machine.json"));
+
+ List issues = issues(projectKey).stream().sorted(Comparator.comparing(Issues.Issue::getRule))
+ .toList();
+ assertThat(issues).hasSize(2);
+
+ Issues.Issue firstIssue = issues.get(0);
+ assertThat(firstIssue.getComponent()).isEqualTo(projectKey + ":src/file1.py");
+ assertThat(firstIssue.getRule()).isEqualTo("external_ruff:E501");
+
+ Issues.Issue secondIssue = issues.get(1);
+ assertThat(secondIssue.getComponent()).isEqualTo(projectKey + ":src/file1.py");
+ assertThat(secondIssue.getRule()).isEqualTo("external_ruff:F821");
+ }
+
}
diff --git a/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/SonarLintBackendTestUtils.java b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/SonarLintBackendTestUtils.java
new file mode 100644
index 0000000000..ed8154f19f
--- /dev/null
+++ b/its/plugin/it-python-plugin-test/src/test/java/com/sonar/python/it/plugin/SonarLintBackendTestUtils.java
@@ -0,0 +1,150 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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 com.sonar.python.it.plugin;
+
+import static org.awaitility.Awaitility.await;
+
+import com.sonar.python.it.PluginLocator;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.regex.Pattern;
+import org.sonarsource.sonarlint.core.rpc.protocol.backend.file.DidOpenFileParams;
+import org.sonarsource.sonarlint.core.rpc.protocol.backend.rules.StandaloneRuleConfigDto;
+import org.sonarsource.sonarlint.core.rpc.protocol.backend.rules.UpdateStandaloneRulesConfigurationParams;
+import org.sonarsource.sonarlint.core.rpc.protocol.client.issue.RaisedIssueDto;
+import org.sonarsource.sonarlint.core.rpc.protocol.common.ClientFileDto;
+import org.sonarsource.sonarlint.core.rpc.protocol.common.Language;
+import org.sonarsource.sonarlint.core.test.utils.SonarLintBackendFixture;
+import org.sonarsource.sonarlint.core.test.utils.SonarLintTestRpcServer;
+import org.sonarsource.sonarlint.core.test.utils.junit5.SonarLintTestHarness;
+import org.sonarsource.sonarlint.core.test.utils.plugins.Plugin;
+
+final class SonarLintBackendTestUtils {
+
+ static final String CONFIG_SCOPE_ID = "python-sonarlint-test";
+ private static final Duration TIMEOUT = Duration.ofSeconds(15);
+
+ private SonarLintBackendTestUtils() {
+ // utility class
+ }
+
+ static ClientFileDto writeFile(Path baseDir, String relativePath, String content, Language language) {
+ var filePath = baseDir.resolve(relativePath);
+ try {
+ Files.createDirectories(filePath.getParent());
+ Files.writeString(filePath, content, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ return clientFile(baseDir, filePath, language);
+ }
+
+ static ClientFileDto copyFile(Path baseDir, Path sourceFile, String relativePath, Language language) {
+ var targetPath = baseDir.resolve(relativePath);
+ try {
+ Files.createDirectories(targetPath.getParent());
+ Files.copy(sourceFile, targetPath);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ return clientFile(baseDir, targetPath, language);
+ }
+
+ static SonarLintBackendFixture.FakeSonarLintRpcClient createClient(SonarLintTestHarness harness, Path baseDir, ClientFileDto... inputFiles) {
+ return harness.newFakeClient()
+ .withInitialFs(CONFIG_SCOPE_ID, baseDir, List.of(inputFiles))
+ .build();
+ }
+
+ static SonarLintTestRpcServer startBackend(SonarLintTestHarness harness, SonarLintBackendFixture.FakeSonarLintRpcClient client,
+ Set languages, Set activeRuleKeys) {
+ var backend = harness.newBackend()
+ .withStandaloneEmbeddedPluginAndEnabledLanguage(new Plugin(languages, pythonPluginLocation(), "", ""))
+ .withUnboundConfigScope(CONFIG_SCOPE_ID)
+ .start(client);
+
+ var rulesByKey = backend.getRulesService().listAllStandaloneRulesDefinitions().join().getRulesByKey();
+ if (!rulesByKey.keySet().containsAll(activeRuleKeys)) {
+ throw new IllegalStateException("Missing standalone SonarLint rules: " + activeRuleKeys.stream()
+ .filter(ruleKey -> !rulesByKey.containsKey(ruleKey))
+ .toList());
+ }
+
+ var ruleConfigByKey = rulesByKey.values().stream()
+ .filter(rule -> languages.contains(rule.getLanguage()))
+ .collect(Collectors.toMap(
+ rule -> rule.getKey(),
+ rule -> new StandaloneRuleConfigDto(activeRuleKeys.contains(rule.getKey()), Map.of())));
+ backend.getRulesService().updateStandaloneRulesConfiguration(new UpdateStandaloneRulesConfigurationParams(ruleConfigByKey));
+ return backend;
+ }
+
+ static void openFile(SonarLintTestRpcServer backend, ClientFileDto inputFile) {
+ backend.getFileService().didOpenFile(new DidOpenFileParams(CONFIG_SCOPE_ID, inputFile.getUri()));
+ }
+
+ static void awaitIssues(SonarLintBackendFixture.FakeSonarLintRpcClient client,
+ Consumer
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AbstractCallExpressionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AbstractCallExpressionCheck.java
index 3f592f7f1d..91111a4401 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AbstractCallExpressionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AbstractCallExpressionCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AbstractDuplicateKeyCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AbstractDuplicateKeyCheck.java
index 482a793fe5..24e5757730 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AbstractDuplicateKeyCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AbstractDuplicateKeyCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AbstractFunctionNameCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AbstractFunctionNameCheck.java
index d44c31cf3f..93447dcfc7 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AbstractFunctionNameCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AbstractFunctionNameCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AbstractNameCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AbstractNameCheck.java
index 1acaa53c23..4039b839aa 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AbstractNameCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AbstractNameCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AbstractStringFormatCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AbstractStringFormatCheck.java
index ff0c382ff1..ddabbe786a 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AbstractStringFormatCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AbstractStringFormatCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AbstractUnreadPrivateMembersCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AbstractUnreadPrivateMembersCheck.java
index 62976e8497..3c770b0242 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AbstractUnreadPrivateMembersCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AbstractUnreadPrivateMembersCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
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 125108e79d..04e13a6e29 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -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/AllBranchesAreIdenticalCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AllBranchesAreIdenticalCheck.java
index 1aaf70bcfc..b6c16ac207 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AllBranchesAreIdenticalCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AllBranchesAreIdenticalCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ArgumentNumberCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ArgumentNumberCheck.java
index 43443cdf59..930d1863ea 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ArgumentNumberCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ArgumentNumberCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ArgumentTypeCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ArgumentTypeCheck.java
index 76e57db0d7..1c66936e5a 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ArgumentTypeCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ArgumentTypeCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AsyncAwsLambdaHandlerCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AsyncAwsLambdaHandlerCheck.java
index 29123d750e..699a27446d 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AsyncAwsLambdaHandlerCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AsyncAwsLambdaHandlerCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AsyncFunctionNotAsyncCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AsyncFunctionNotAsyncCheck.java
index cfaba0e560..c2c60a4f12 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AsyncFunctionNotAsyncCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AsyncFunctionNotAsyncCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -16,20 +16,32 @@
*/
package org.sonar.python.checks;
+import java.util.List;
+import java.util.Set;
import javax.annotation.Nullable;
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.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.AwaitExpression;
import org.sonar.plugins.python.api.tree.BaseTreeVisitor;
+import org.sonar.plugins.python.api.tree.CallExpression;
import org.sonar.plugins.python.api.tree.ComprehensionFor;
+import org.sonar.plugins.python.api.tree.DictionaryLiteral;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.ExpressionList;
import org.sonar.plugins.python.api.tree.ForStatement;
import org.sonar.plugins.python.api.tree.FunctionDef;
+import org.sonar.plugins.python.api.tree.KeyValuePair;
+import org.sonar.plugins.python.api.tree.ListLiteral;
+import org.sonar.plugins.python.api.tree.Name;
+import org.sonar.plugins.python.api.tree.RegularArgument;
import org.sonar.plugins.python.api.tree.ReturnStatement;
import org.sonar.plugins.python.api.tree.Statement;
import org.sonar.plugins.python.api.tree.StatementList;
+import org.sonar.plugins.python.api.tree.StringLiteral;
import org.sonar.plugins.python.api.tree.Token;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.WithStatement;
@@ -37,30 +49,31 @@
import org.sonar.plugins.python.api.tree.YieldStatement;
import org.sonar.plugins.python.api.types.v2.ClassType;
import org.sonar.plugins.python.api.types.v2.FunctionType;
+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.CheckUtils;
-import org.sonar.python.types.v2.TypeCheckBuilder;
+import org.sonar.python.tree.TreeUtils;
@Rule(key = "S7503")
public class AsyncFunctionNotAsyncCheck extends PythonSubscriptionCheck {
private static final String MESSAGE = "Use asynchronous features in this function or remove the `async` keyword.";
- private TypeCheckBuilder notImplementedTypeChecker;
+ private static final Set AIO_CONSUMER_SUBSCRIBE_CALLBACK_KWARGS = Set.of("on_assign", "on_revoke", "on_lost");
+
+ private static final TypeMatcher NOT_IMPLEMENTED_MATCHER = TypeMatchers.isType("builtins.NotImplemented");
+ private static final TypeMatcher HTTPX_ASYNC_CLIENT_MATCHER = TypeMatchers.isOrExtendsType("httpx.AsyncClient");
+ private static final TypeMatcher AIO_CONSUMER_SUBSCRIBE_MATCHER = TypeMatchers.isType("confluent_kafka.aio.AIOConsumer.subscribe");
@Override
public void initialize(Context context) {
- context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::setupTypeChecks);
- context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, this::checkAsyncFunction);
- }
-
- private void setupTypeChecks(SubscriptionContext ctx) {
- notImplementedTypeChecker = ctx.typeChecker().typeCheckBuilder().isTypeWithName("NotImplemented");
+ context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, AsyncFunctionNotAsyncCheck::checkAsyncFunction);
}
- private void checkAsyncFunction(SubscriptionContext ctx) {
+ private static void checkAsyncFunction(SubscriptionContext ctx) {
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
Token asyncKeyword = functionDef.asyncKeyword();
- if (asyncKeyword == null || isException(functionDef)) {
+ if (asyncKeyword == null || isException(functionDef, ctx)) {
return;
}
AsyncFeatureVisitor visitor = new AsyncFeatureVisitor();
@@ -71,12 +84,67 @@ private void checkAsyncFunction(SubscriptionContext ctx) {
}
}
- private boolean isException(FunctionDef functionDef) {
+ private static boolean isException(FunctionDef functionDef, SubscriptionContext ctx) {
return CheckUtils.isAbstract(functionDef) ||
- isTrivialFunction(functionDef.body()) ||
+ isTrivialFunction(functionDef.body(), ctx) ||
isDunderMethod(functionDef) ||
!functionDef.decorators().isEmpty() ||
- mightBeOverridingMethod(functionDef);
+ mightBeOverridingMethod(functionDef) ||
+ isExemptedCoroutineCallback(functionDef, ctx);
+ }
+
+ private static boolean isExemptedCoroutineCallback(FunctionDef functionDef, SubscriptionContext ctx) {
+ SymbolV2 symbol = functionDef.name().symbolV2();
+ if (symbol == null) {
+ return false;
+ }
+ return symbol.usages().stream()
+ .filter(usage -> usage.kind() == UsageV2.Kind.OTHER)
+ .anyMatch(usage -> isCoroutineCallbackUsage(usage.tree(), ctx));
+ }
+
+ private static boolean isCoroutineCallbackUsage(Tree usageTree, SubscriptionContext ctx) {
+ return isHttpxAsyncClientEventHookCallback(usageTree, ctx) || isAioConsumerSubscribeCallback(usageTree, ctx);
+ }
+
+ private static boolean isHttpxAsyncClientEventHookCallback(Tree usageTree, SubscriptionContext ctx) {
+ CallExpression call = TreeUtils.firstAncestorOfClass(usageTree, CallExpression.class);
+ if (call == null || !HTTPX_ASYNC_CLIENT_MATCHER.isTrueFor(call.callee(), ctx)) {
+ return false;
+ }
+ RegularArgument eventHooksArg = TreeUtils.argumentByKeyword("event_hooks", call.arguments());
+ if (eventHooksArg == null || !(eventHooksArg.expression() instanceof DictionaryLiteral dict)) {
+ return false;
+ }
+ return dict.elements().stream()
+ .filter(KeyValuePair.class::isInstance)
+ .map(KeyValuePair.class::cast)
+ .filter(kv -> isRequestOrResponseKey(kv.key()))
+ .map(KeyValuePair::value)
+ .filter(ListLiteral.class::isInstance)
+ .map(ListLiteral.class::cast)
+ .map(ListLiteral::elements)
+ .map(ExpressionList::expressions)
+ .flatMap(List::stream)
+ .anyMatch(expr -> expr == usageTree);
+ }
+
+ private static boolean isRequestOrResponseKey(Expression key) {
+ return key instanceof StringLiteral stringLiteral &&
+ ("request".equals(stringLiteral.trimmedQuotesValue()) || "response".equals(stringLiteral.trimmedQuotesValue()));
+ }
+
+ private static boolean isAioConsumerSubscribeCallback(Tree usageTree, SubscriptionContext ctx) {
+ RegularArgument arg = TreeUtils.firstAncestorOfClass(usageTree, RegularArgument.class);
+ if (arg == null || arg.expression() != usageTree) {
+ return false;
+ }
+ Name keyword = arg.keywordArgument();
+ if (keyword == null || !AIO_CONSUMER_SUBSCRIBE_CALLBACK_KWARGS.contains(keyword.name())) {
+ return false;
+ }
+ CallExpression call = TreeUtils.firstAncestorOfClass(arg, CallExpression.class);
+ return call != null && AIO_CONSUMER_SUBSCRIBE_MATCHER.isTrueFor(call.callee(), ctx);
}
private static boolean isDunderMethod(FunctionDef functionDef) {
@@ -84,18 +152,18 @@ private static boolean isDunderMethod(FunctionDef functionDef) {
return methodName.startsWith("__");
}
- private boolean isTrivialFunction(StatementList body) {
+ private static boolean isTrivialFunction(StatementList body, SubscriptionContext ctx) {
for (Statement statement : body.statements()) {
- if (!CheckUtils.isEmptyStatement(statement) && !statement.is(Tree.Kind.RAISE_STMT) && !isReturnNotImplemented(statement)) {
+ if (!CheckUtils.isEmptyStatement(statement) && !statement.is(Tree.Kind.RAISE_STMT) && !isReturnNotImplemented(statement, ctx)) {
return false;
}
}
return true;
}
- private boolean isReturnNotImplemented(Statement statement) {
+ private static boolean isReturnNotImplemented(Statement statement, SubscriptionContext ctx) {
return statement.is(Tree.Kind.RETURN_STMT) &&
- ((ReturnStatement) statement).expressions().stream().allMatch(e -> notImplementedTypeChecker.check(e.typeV2()) == TriBool.TRUE);
+ ((ReturnStatement) statement).expressions().stream().allMatch(e -> NOT_IMPLEMENTED_MATCHER.isTrueFor(e, ctx));
}
private static boolean mightBeOverridingMethod(FunctionDef functionDef) {
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AsyncFunctionWithTimeoutCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AsyncFunctionWithTimeoutCheck.java
index 9f5fb83ff8..4375acfcb4 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AsyncFunctionWithTimeoutCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AsyncFunctionWithTimeoutCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AsyncLongSleepCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AsyncLongSleepCheck.java
index b945bb01c8..def3df16b5 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AsyncLongSleepCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AsyncLongSleepCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AsyncWithContextManagerCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AsyncWithContextManagerCheck.java
index 6aa5996f11..5eb05ca264 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AsyncWithContextManagerCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AsyncWithContextManagerCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AsyncioTaskNotStoredCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AsyncioTaskNotStoredCheck.java
index 9085ae5c23..515eac81cb 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AsyncioTaskNotStoredCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AsyncioTaskNotStoredCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsClientExceptionNotCaughtCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsClientExceptionNotCaughtCheck.java
index 8a02247923..2747f63ef8 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsClientExceptionNotCaughtCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsClientExceptionNotCaughtCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsCustomMetricNamespaceCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsCustomMetricNamespaceCheck.java
index ca0dfd1818..3db87d5eed 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsCustomMetricNamespaceCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsCustomMetricNamespaceCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsExpectedBucketOwnerCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsExpectedBucketOwnerCheck.java
index 88dbafbddf..65d22d62c7 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsExpectedBucketOwnerCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsExpectedBucketOwnerCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsHardcodedRegionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsHardcodedRegionCheck.java
index a9c55839fc..57cfc5ee58 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsHardcodedRegionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsHardcodedRegionCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaClientInstantiationCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaClientInstantiationCheck.java
index 06713fddb5..ac8777dd54 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaClientInstantiationCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaClientInstantiationCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaCrossCallCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaCrossCallCheck.java
index 34ea0b1943..29c5024519 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaCrossCallCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaCrossCallCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaReservedEnvironmentVariableCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaReservedEnvironmentVariableCheck.java
index 27e87e25c2..8b93de8551 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaReservedEnvironmentVariableCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaReservedEnvironmentVariableCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaReturnValueAreSerializableCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaReturnValueAreSerializableCheck.java
index b0674fe921..dfb4c1c80d 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaReturnValueAreSerializableCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaReturnValueAreSerializableCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaTmpCleanupCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaTmpCleanupCheck.java
index 2fa10a1245..730a4233ce 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaTmpCleanupCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsLambdaTmpCleanupCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsLongTermAccessKeysCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsLongTermAccessKeysCheck.java
index 00d7889e3f..cffe7e0bac 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsLongTermAccessKeysCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsLongTermAccessKeysCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsMissingPaginationCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsMissingPaginationCheck.java
index ee24801c66..482538eba6 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsMissingPaginationCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsMissingPaginationCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/AwsWaitersInsteadOfCustomPollingCheck.java b/python-checks/src/main/java/org/sonar/python/checks/AwsWaitersInsteadOfCustomPollingCheck.java
index a704f8c30f..8330066cab 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/AwsWaitersInsteadOfCustomPollingCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/AwsWaitersInsteadOfCustomPollingCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BackslashInStringCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BackslashInStringCheck.java
index 63c5fc6077..f0dd403303 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/BackslashInStringCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/BackslashInStringCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BackticksUsageCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BackticksUsageCheck.java
index 39389e7940..88d8b7125a 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/BackticksUsageCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/BackticksUsageCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BareRaiseInFinallyCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BareRaiseInFinallyCheck.java
index 8ffcc2ceaf..6ab547a02d 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/BareRaiseInFinallyCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/BareRaiseInFinallyCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupClassListCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupClassListCheck.java
new file mode 100644
index 0000000000..970b3bf6d0
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupClassListCheck.java
@@ -0,0 +1,145 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.Optional;
+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.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.ListLiteral;
+import org.sonar.plugins.python.api.tree.QualifiedExpression;
+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.tree.TreeUtils;
+
+@Rule(key = "S8906")
+public class BeautifulSoupClassListCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE_TEMPLATE = "Use \"%s()\" with a chained CSS class selector instead.";
+ private static final String MESSAGE_SELECT_ONE = MESSAGE_TEMPLATE.formatted("select_one");
+ private static final String MESSAGE_SELECT = MESSAGE_TEMPLATE.formatted("select");
+
+
+ private static final TypeMatcher IS_BS4_PAGE_ELEMENT_INSTANCE = TypeMatchers.isObjectInstanceOf("bs4.element.PageElement");
+
+ private static TypeMatcher bs4Matcher(String suffix) {
+ return TypeMatchers.isType("bs4.element." + suffix);
+ }
+
+ // Single-result methods: the correct CSS replacement is select_one().
+ // Defined on Tag: find. Defined on PageElement: find_parent, find_next, find_next_sibling, find_previous, find_previous_sibling.
+ private static final TypeMatcher IS_BS4_SINGLE_RESULT_CALL = TypeMatchers.any(
+ bs4Matcher("Tag.find"),
+ bs4Matcher("PageElement.find_parent"),
+ bs4Matcher("PageElement.find_next"),
+ bs4Matcher("PageElement.find_next_sibling"),
+ bs4Matcher("PageElement.find_previous"),
+ bs4Matcher("PageElement.find_previous_sibling")
+ );
+
+ // List-returning methods: the correct CSS replacement is select().
+ // Defined on Tag: find_all. Defined on PageElement: find_parents, find_all_next, find_next_siblings, find_all_previous, find_previous_siblings.
+ private static final TypeMatcher IS_BS4_LIST_RESULT_CALL = TypeMatchers.any(
+ bs4Matcher("Tag.find_all"),
+ bs4Matcher("PageElement.find_parents"),
+ bs4Matcher("PageElement.find_all_next"),
+ bs4Matcher("PageElement.find_next_siblings"),
+ bs4Matcher("PageElement.find_all_previous"),
+ bs4Matcher("PageElement.find_previous_siblings")
+ );
+
+ // Deprecated camelCase aliases; type inference cannot resolve them (they are simple assignments in the stubs).
+ // Instead we check: method name is in this set AND qualifier is a bs4.element.Tag instance.
+ // BeautifulSoup extends Tag, so isObjectInstanceOf("bs4.element.Tag") covers both.
+ private static final Set DEPRECATED_SINGLE_RESULT_NAMES = Set.of(
+ "findChild",
+ "findParent",
+ "findNext",
+ "findNextSibling",
+ "findPrevious",
+ "findPreviousSibling"
+ );
+
+ private static final Set DEPRECATED_LIST_RESULT_NAMES = Set.of(
+ "findAll",
+ "findChildren",
+ "findParents",
+ "findAllNext",
+ "findNextSiblings",
+ "findAllPrevious",
+ "findPreviousSiblings"
+ );
+
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, BeautifulSoupClassListCheck::checkCallExpression);
+ }
+
+ private static void checkCallExpression(SubscriptionContext ctx) {
+ CallExpression call = (CallExpression) ctx.syntaxNode();
+ RegularArgument classArg = TreeUtils.argumentByKeyword("class_", call.arguments());
+ if (classArg == null || !classArg.expression().is(Tree.Kind.LIST_LITERAL)) {
+ return;
+ }
+ ListLiteral listLiteral = (ListLiteral) classArg.expression();
+ if (listLiteral.elements().expressions().size() < 2) {
+ return;
+ }
+ messageForCall(call, ctx).ifPresent(message -> ctx.addIssue(classArg, message));
+ }
+
+ /**
+ * Returns the appropriate issue message if the call targets a bs4 search method, or empty otherwise.
+ * Single-result methods (find, find_parent, …) → MESSAGE_SELECT_ONE.
+ * List-returning methods (find_all, find_parents, …) → MESSAGE_SELECT.
+ */
+ private static Optional messageForCall(CallExpression call, SubscriptionContext ctx) {
+ Expression callee = call.callee();
+ // Path 1: modern snake_case methods — resolved via type inference
+ if (IS_BS4_SINGLE_RESULT_CALL.isTrueFor(callee, ctx)) {
+ return Optional.of(MESSAGE_SELECT_ONE);
+ }
+ if (IS_BS4_LIST_RESULT_CALL.isTrueFor(callee, ctx)) {
+ return Optional.of(MESSAGE_SELECT);
+ }
+ // Path 2: deprecated camelCase aliases — detected by method name + qualifier type
+ if (callee instanceof QualifiedExpression qualifiedExpression) {
+ return messageForDeprecatedAlias(qualifiedExpression, ctx);
+ }
+ return Optional.empty();
+ }
+
+ private static Optional messageForDeprecatedAlias(QualifiedExpression qualifiedExpression, SubscriptionContext ctx) {
+ String name = qualifiedExpression.name().name();
+ if (!IS_BS4_PAGE_ELEMENT_INSTANCE.isTrueFor(qualifiedExpression.qualifier(), ctx)) {
+ return Optional.empty();
+ }
+ if (DEPRECATED_SINGLE_RESULT_NAMES.contains(name)) {
+ return Optional.of(MESSAGE_SELECT_ONE);
+ }
+ if (DEPRECATED_LIST_RESULT_NAMES.contains(name)) {
+ return Optional.of(MESSAGE_SELECT);
+ }
+ return Optional.empty();
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupDeprecatedNamesCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupDeprecatedNamesCheck.java
new file mode 100644
index 0000000000..e88a099ba5
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupDeprecatedNamesCheck.java
@@ -0,0 +1,186 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+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.quickfix.PythonQuickFix;
+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.QualifiedExpression;
+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.quickfix.TextEditUtils;
+import org.sonar.python.tree.TreeUtils;
+
+@Rule(key = "S8900")
+public class BeautifulSoupDeprecatedNamesCheck extends PythonSubscriptionCheck {
+
+ // All deprecated methods and attributes are defined on PageElement (the base class),
+ // so using isObjectInstanceOf covers BeautifulSoup, Tag, and their subclasses.
+ private static final TypeMatcher BS4_MATCHER = TypeMatchers.isObjectInstanceOf("bs4.element.PageElement");
+
+ // Maps deprecated method names to their modern equivalents
+ private static final Map DEPRECATED_METHODS;
+ static {
+ Map m = new LinkedHashMap<>();
+ m.put("findAll", "find_all");
+ m.put("findChild", "find");
+ m.put("findChildren", "find_all");
+ m.put("findNext", "find_next");
+ m.put("findAllNext", "find_all_next");
+ m.put("findPrevious", "find_previous");
+ m.put("findAllPrevious", "find_all_previous");
+ m.put("findNextSibling", "find_next_sibling");
+ m.put("findNextSiblings", "find_next_siblings");
+ m.put("findPreviousSibling", "find_previous_sibling");
+ m.put("findPreviousSiblings", "find_previous_siblings");
+ m.put("findParent", "find_parent");
+ m.put("findParents", "find_parents");
+ m.put("replaceWith", "replace_with");
+ m.put("getText", "get_text");
+ DEPRECATED_METHODS = Collections.unmodifiableMap(m);
+ }
+
+ // Maps deprecated attribute names to their modern equivalents
+ private static final Map DEPRECATED_ATTRS;
+ static {
+ Map m = new LinkedHashMap<>();
+ m.put("nextSibling", "next_sibling");
+ m.put("previousSibling", "previous_sibling");
+ DEPRECATED_ATTRS = Collections.unmodifiableMap(m);
+ }
+
+ // Modern find-family method names that accept the text= keyword argument.
+ // replaceWith/replace_with and getText/get_text are intentionally excluded.
+ private static final Set FIND_FAMILY_MODERN_METHODS;
+ static {
+ Set s = new LinkedHashSet<>();
+ s.add("find");
+ s.add("find_all");
+ s.add("find_next");
+ s.add("find_all_next");
+ s.add("find_previous");
+ s.add("find_all_previous");
+ s.add("find_next_sibling");
+ s.add("find_next_siblings");
+ s.add("find_previous_sibling");
+ s.add("find_previous_siblings");
+ FIND_FAMILY_MODERN_METHODS = Collections.unmodifiableSet(s);
+ }
+
+ // Union of modern find-family names and all deprecated names that map to one of them.
+ // Derived to avoid duplicating the deprecated keys from DEPRECATED_METHODS.
+ private static final Set FIND_FAMILY_METHODS;
+ static {
+ LinkedHashSet s = Stream.concat(
+ FIND_FAMILY_MODERN_METHODS.stream(),
+ DEPRECATED_METHODS.entrySet().stream()
+ .filter(e -> FIND_FAMILY_MODERN_METHODS.contains(e.getValue()))
+ .map(Map.Entry::getKey)
+ ).collect(Collectors.toCollection(LinkedHashSet::new));
+ FIND_FAMILY_METHODS = Collections.unmodifiableSet(s);
+ }
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, BeautifulSoupDeprecatedNamesCheck::checkCallExpression);
+ context.registerSyntaxNodeConsumer(Tree.Kind.QUALIFIED_EXPR, BeautifulSoupDeprecatedNamesCheck::checkQualifiedExpression);
+ }
+
+ private static void checkCallExpression(SubscriptionContext ctx) {
+ CallExpression callExpression = (CallExpression) ctx.syntaxNode();
+
+ if (!(callExpression.callee() instanceof QualifiedExpression qualifiedExpr)) {
+ return;
+ }
+
+ String methodName = qualifiedExpr.name().name();
+ Expression receiver = qualifiedExpr.qualifier();
+
+ if (!BS4_MATCHER.isTrueFor(receiver, ctx)) {
+ return;
+ }
+
+ // Check for deprecated method name
+ // We need to use the name as findAll FQN resolves to find_all
+ String modernMethod = DEPRECATED_METHODS.get(methodName);
+ if (modernMethod != null) {
+ PreciseIssue issue = ctx.addIssue(qualifiedExpr.name(),
+ String.format("Replace the deprecated '%s()' method with '%s()'.", methodName, modernMethod));
+ issue.addQuickFix(PythonQuickFix.newQuickFix(
+ "Replace '%s()' with '%s()'".formatted(methodName, modernMethod),
+ TextEditUtils.replace(qualifiedExpr.name(), modernMethod)
+ ));
+ }
+
+ // Check for deprecated text= keyword argument (in any find-family method)
+ if (FIND_FAMILY_METHODS.contains(methodName)) {
+ checkDeprecatedTextKeyword(ctx, callExpression.arguments());
+ }
+ }
+
+ private static void checkDeprecatedTextKeyword(SubscriptionContext ctx, List arguments) {
+ RegularArgument textArg = TreeUtils.argumentByKeyword("text", arguments);
+ if (textArg != null && textArg.keywordArgument() != null) {
+ PreciseIssue issue = ctx.addIssue(textArg.keywordArgument(),
+ "Replace the deprecated 'text' keyword argument with 'string'.");
+ issue.addQuickFix(PythonQuickFix.newQuickFix(
+ "Replace 'text' with 'string'",
+ TextEditUtils.replace(textArg.keywordArgument(), "string")
+ ));
+ }
+ }
+
+ private static void checkQualifiedExpression(SubscriptionContext ctx) {
+ QualifiedExpression qualifiedExpr = (QualifiedExpression) ctx.syntaxNode();
+
+ // Skip if this is the callee of a call expression — that case is handled by checkCallExpression
+ if (qualifiedExpr.parent() instanceof CallExpression parentCall
+ && parentCall.callee() == qualifiedExpr) {
+ return;
+ }
+
+ String attrName = qualifiedExpr.name().name();
+ String modernAttr = DEPRECATED_ATTRS.get(attrName);
+ if (modernAttr == null) {
+ return;
+ }
+
+ Expression qualifier = qualifiedExpr.qualifier();
+ if (BS4_MATCHER.isTrueFor(qualifier, ctx)) {
+ PreciseIssue issue = ctx.addIssue(qualifiedExpr.name(),
+ String.format("Replace the deprecated '%s' attribute with '%s'.", attrName, modernAttr));
+ issue.addQuickFix(PythonQuickFix.newQuickFix(
+ "Replace '%s' with '%s'".formatted(attrName, modernAttr),
+ TextEditUtils.replace(qualifiedExpr.name(), modernAttr)
+ ));
+ }
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupNoneCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupNoneCheck.java
new file mode 100644
index 0000000000..8467084618
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupNoneCheck.java
@@ -0,0 +1,305 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.List;
+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.v2.SymbolV2;
+import org.sonar.plugins.python.api.tree.AssertStatement;
+import org.sonar.plugins.python.api.tree.AssignmentStatement;
+import org.sonar.plugins.python.api.tree.ExceptClause;
+import org.sonar.plugins.python.api.tree.TryStatement;
+import org.sonar.plugins.python.api.tree.BinaryExpression;
+import org.sonar.plugins.python.api.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.IfStatement;
+import org.sonar.plugins.python.api.tree.IsExpression;
+import org.sonar.plugins.python.api.tree.Name;
+import org.sonar.plugins.python.api.tree.QualifiedExpression;
+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.tree.UnaryExpression;
+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 = "S8904")
+public class BeautifulSoupNoneCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Check if this element exists before accessing it with `%s`.";
+
+ private static TypeMatcher bs4Matcher(String suffix) {
+ return TypeMatchers.isType("bs4.element." + suffix);
+ }
+
+ // Covers find/select_one (defined on Tag) and the find_* / find_parent methods (defined on PageElement)
+ private static final TypeMatcher IS_BS4_SEARCH_CALL = TypeMatchers.any(
+ bs4Matcher("Tag.find"),
+ bs4Matcher("Tag.select_one"),
+ bs4Matcher("PageElement.find_next"),
+ bs4Matcher("PageElement.find_previous"),
+ bs4Matcher("PageElement.find_next_sibling"),
+ bs4Matcher("PageElement.find_previous_sibling"),
+ bs4Matcher("PageElement.find_parent"));
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.QUALIFIED_EXPR, BeautifulSoupNoneCheck::checkQualifiedExpr);
+ context.registerSyntaxNodeConsumer(Tree.Kind.SUBSCRIPTION, BeautifulSoupNoneCheck::checkSubscription);
+ }
+
+ private static void checkQualifiedExpr(SubscriptionContext ctx) {
+ QualifiedExpression qe = (QualifiedExpression) ctx.syntaxNode();
+ // When a QualifiedExpression is the callee of a BS4 search call (e.g. "soup.find"),
+ // skip it — unless its qualifier is itself a BS4 search call result (e.g. "soup.find("div").find").
+ // The former is safe (soup is not None); the latter is unsafe (soup.find("div") may be None).
+ if (qe.parent() instanceof CallExpression callParent
+ && callParent.callee() == qe
+ && IS_BS4_SEARCH_CALL.isTrueFor(qe, ctx)
+ && !(qe.qualifier() instanceof CallExpression qualifierCall && isBS4SearchCall(qualifierCall, ctx))) {
+ return;
+ }
+ checkObjectAccess(qe.qualifier(), qe.name(), "." + qe.name().name(), ctx);
+ }
+
+ private static void checkSubscription(SubscriptionContext ctx) {
+ SubscriptionExpression se = (SubscriptionExpression) ctx.syntaxNode();
+ if (isAssignmentTarget(se)) {
+ return;
+ }
+ checkObjectAccess(se.object(), se, subscriptDescription(se), ctx);
+ }
+
+ private static String subscriptDescription(SubscriptionExpression se) {
+ var subscripts = se.subscripts().expressions();
+ if (subscripts.size() == 1 && subscripts.get(0) instanceof StringLiteral str) {
+ return String.format("[%s]", str.trimmedQuotesValue());
+ }
+ return "[]";
+ }
+
+ private static void checkObjectAccess(Expression object, Tree issueLocation, String accessDescription, SubscriptionContext ctx) {
+ if (isInsideCatchingTry(issueLocation, ctx)) {
+ return;
+ }
+ // Case 1: direct inline chaining — the object is itself a BS4 search call.
+ // Only raise on the first unsafe access in a chain: skip if the object's own qualifier is
+ // already a BS4 search call (meaning an earlier issue was already raised for that access).
+ // e.g. soup.find("div").find("p").text — raise on the first find, not the second
+ if (object instanceof CallExpression callExpr && isBS4SearchCall(callExpr, ctx)) {
+ if (callExpr.callee() instanceof QualifiedExpression innerQe
+ && innerQe.qualifier() instanceof CallExpression innerQualifierCall
+ && isBS4SearchCall(innerQualifierCall, ctx)) {
+ return;
+ }
+ // Highlight the name of the call that produced the potentially-None object
+ // e.g. for soup.find("p").text → highlight "find"; for soup.find("div").find("p") → highlight the first "find"
+ Tree calleeNameLocation = callExpr.callee() instanceof QualifiedExpression calleeQe
+ ? calleeQe.name()
+ : issueLocation;
+ ctx.addIssue(calleeNameLocation, MESSAGE.formatted(accessDescription));
+ return;
+ }
+ // Case 2: variable whose value at this location comes from a BS4 search call
+ if (object instanceof Name name) {
+ checkNameAccess(name, issueLocation, accessDescription, ctx);
+ }
+ }
+
+ private static boolean isInsideCatchingTry(Tree tree, SubscriptionContext ctx) {
+ TryStatement enclosingTry = (TryStatement) TreeUtils.firstAncestorOfKind(tree, Tree.Kind.TRY_STMT);
+ return enclosingTry != null
+ && TreeUtils.hasDescendant(enclosingTry.body(), t -> t == tree)
+ && catchesAttributeError(enclosingTry.exceptClauses(), ctx);
+ }
+
+ private static void checkNameAccess(Name name, Tree issueLocation, String accessDescription, SubscriptionContext ctx) {
+ Set values = ctx.valuesAtLocation(name);
+ if (values.isEmpty()) {
+ return;
+ }
+ boolean allBS4Calls = values.stream()
+ .allMatch(v -> v instanceof CallExpression callExpr && isBS4SearchCall(callExpr, ctx));
+ if (!allBS4Calls) {
+ return;
+ }
+ if (isGuardedByNoneCheck(name)) {
+ return;
+ }
+ ctx.addIssue(issueLocation, MESSAGE.formatted(accessDescription));
+ }
+
+ private static boolean isBS4SearchCall(CallExpression callExpr, SubscriptionContext ctx) {
+ return IS_BS4_SEARCH_CALL.isTrueFor(callExpr.callee(), ctx);
+ }
+
+ /**
+ * Returns true if the Name is guarded against None. Recognises three patterns:
+ * 1. Enclosing if-body guard: walks *all* enclosing IfStatements (not just the nearest) so that
+ * nested ifs inside a guard are covered. Conditions may include "and"-chains.
+ * e.g. "if name:", "if name is not None:", "if name != None:", "if name is not None and other:"
+ * 2. Early-exit guard: a preceding usage of the same symbol appears as the subject of an
+ * IfStatement that exits unconditionally (return/raise/continue/break) when the name is
+ * None or falsy: "if name is None: return" / "if not name: return"
+ * 3. Assert guard: a preceding assert statement whose condition matches conditionChecksForTruthy:
+ * "assert name" / "assert name is not None"
+ */
+ private static boolean isGuardedByNoneCheck(Name name) {
+ // Pattern 1: walk all enclosing IfStatements, not just the nearest
+ Tree cursor = name.parent();
+ while (cursor != null) {
+ if (cursor instanceof IfStatement ifStmt
+ && TreeUtils.hasDescendant(ifStmt.body(), t -> t == name)
+ && conditionChecksForTruthy(ifStmt.condition(), name)) {
+ return true;
+ }
+ cursor = cursor.parent();
+ }
+
+ SymbolV2 symbol = name.symbolV2();
+ if (symbol == null) {
+ return false;
+ }
+ int nameLine = name.firstToken().line();
+
+ // Pattern 2: an earlier usage of the same symbol is guarded by an early-exit if-statement
+ if (symbol.usages().stream()
+ .filter(u -> u.tree().firstToken().line() < nameLine)
+ .map(u -> TreeUtils.firstAncestorOfKind(u.tree(), Tree.Kind.IF_STMT))
+ .filter(IfStatement.class::isInstance)
+ .map(IfStatement.class::cast)
+ .anyMatch(ifStmt -> isEarlyExitNoneGuard(ifStmt, name))) {
+ return true;
+ }
+
+ // Pattern 3: assert statement whose condition guards the name
+ return symbol.usages().stream()
+ .filter(u -> u.tree().firstToken().line() < nameLine)
+ .map(u -> TreeUtils.firstAncestorOfKind(u.tree(), Tree.Kind.ASSERT_STMT))
+ .filter(AssertStatement.class::isInstance)
+ .map(AssertStatement.class::cast)
+ .anyMatch(assertStmt -> conditionChecksForTruthy(assertStmt.condition(), name));
+ }
+
+ private static final TypeMatcher IS_ATTRIBUTE_ERROR =
+ TypeMatchers.isOrExtendsType("builtins.AttributeError");
+
+ private static boolean catchesAttributeError(List exceptClauses, SubscriptionContext ctx) {
+ for (ExceptClause clause : exceptClauses) {
+ Expression exception = clause.exception();
+ if (exception == null) {
+ // bare "except:" — too broad, do not suppress
+ return false;
+ }
+ // check each exception type in the clause (handles "except (AttributeError, TypeError):")
+ List caught = TreeUtils.flattenTuples(exception).toList();
+ for (Expression expr : caught) {
+ if (IS_ATTRIBUTE_ERROR.isTrueFor(expr, ctx)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if the if-statement exits unconditionally (return/raise/continue/break) and
+ * its condition tests the name for None or falsiness.
+ * Recognised conditions: "name is None", "not name", "name == None"
+ */
+ private static boolean isEarlyExitNoneGuard(IfStatement ifStmt, Name name) {
+ var stmts = ifStmt.body().statements();
+ if (stmts.isEmpty()) {
+ return false;
+ }
+ if (!stmts.get(stmts.size() - 1).is(Tree.Kind.RETURN_STMT, Tree.Kind.RAISE_STMT, Tree.Kind.CONTINUE_STMT, Tree.Kind.BREAK_STMT)) {
+ return false;
+ }
+ return conditionChecksForNoneOrFalsy(ifStmt.condition(), name);
+ }
+
+ /**
+ * Checks if the condition tests the name for None or falsiness (the inverse of a positive guard).
+ * Recognised: "not name", "name is None", "name == None"
+ */
+ private static boolean conditionChecksForNoneOrFalsy(Expression condition, Name name) {
+ // "if not element:"
+ if (condition instanceof UnaryExpression unary
+ && "not".equals(unary.operator().value())
+ && nameMatchesExpressionName(name, unary.expression())) {
+ return true;
+ }
+ // "if element is None:" / "if None is element:"
+ if (condition instanceof IsExpression isExpr && isExpr.notToken() == null) {
+ return nameComparedToNone(name, isExpr.leftOperand(), isExpr.rightOperand());
+ }
+ // "if element == None:" / "if None == element:"
+ if (condition instanceof BinaryExpression binaryExpr
+ && condition.is(Tree.Kind.COMPARISON)
+ && "==".equals(binaryExpr.operator().value())) {
+ return nameComparedToNone(name, binaryExpr.leftOperand(), binaryExpr.rightOperand());
+ }
+ return false;
+ }
+
+ private static boolean conditionChecksForTruthy(Expression condition, Name name) {
+ // "if element:"
+ if (nameMatchesExpressionName(name, condition)) {
+ return true;
+ }
+ // "if and other:" / "if other and :" — recurse into both operands of "and"
+ if (condition instanceof BinaryExpression andExpr
+ && condition.is(Tree.Kind.AND)
+ && (conditionChecksForTruthy(andExpr.leftOperand(), name)
+ || conditionChecksForTruthy(andExpr.rightOperand(), name))) {
+ return true;
+ }
+ // "if element is not None:" / "if None is not element:"
+ if (condition instanceof IsExpression isExpr && isExpr.notToken() != null) {
+ return nameComparedToNone(name, isExpr.leftOperand(), isExpr.rightOperand());
+ }
+ // "if element != None:" / "if None != element:"
+ if (condition instanceof BinaryExpression binaryExpr
+ && condition.is(Tree.Kind.COMPARISON)
+ && "!=".equals(binaryExpr.operator().value())) {
+ return nameComparedToNone(name, binaryExpr.leftOperand(), binaryExpr.rightOperand());
+ }
+ return false;
+ }
+
+ /** Returns true if one operand is the name and the other is None (in either order). */
+ private static boolean nameComparedToNone(Name name, Expression left, Expression right) {
+ return (nameMatchesExpressionName(name, left) && right.is(Tree.Kind.NONE))
+ || (nameMatchesExpressionName(name, right) && left.is(Tree.Kind.NONE));
+ }
+
+ private static boolean nameMatchesExpressionName(Name name, Expression operand) {
+ return operand instanceof Name opName && opName.name().equals(name.name());
+ }
+
+ private static boolean isAssignmentTarget(SubscriptionExpression subscriptionExpression) {
+ return TreeUtils.firstAncestor(subscriptionExpression,
+ t -> t.is(Tree.Kind.ASSIGNMENT_STMT)
+ && ((AssignmentStatement) t).lhsExpressions().stream()
+ .flatMap(lhs -> lhs.expressions().stream())
+ .anyMatch(expr -> expr.equals(subscriptionExpression))) != null;
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupParserCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupParserCheck.java
new file mode 100644
index 0000000000..ccd733ecd6
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupParserCheck.java
@@ -0,0 +1,57 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+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.tree.Argument;
+import org.sonar.plugins.python.api.tree.CallExpression;
+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.tree.TreeUtils;
+
+@Rule(key = "S8905")
+public class BeautifulSoupParserCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Specify the parser to use for \"BeautifulSoup\".";
+ private static final TypeMatcher BEAUTIFUL_SOUP_MATCHER = TypeMatchers.isType("bs4.BeautifulSoup");
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, BeautifulSoupParserCheck::checkCallExpression);
+ }
+
+ private static void checkCallExpression(SubscriptionContext ctx) {
+ CallExpression callExpression = (CallExpression) ctx.syntaxNode();
+
+ if (!BEAUTIFUL_SOUP_MATCHER.isTrueFor(callExpression.callee(), ctx)) {
+ return;
+ }
+
+ List arguments = callExpression.arguments();
+
+ var features = TreeUtils.nthArgumentOrKeyword(1, "features", arguments);
+ var builder = TreeUtils.nthArgumentOrKeyword(2, "builder", arguments);
+
+ if (features == null && builder == null) {
+ ctx.addIssue(callExpression.callee(), MESSAGE);
+ }
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupRawHtmlInsertionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupRawHtmlInsertionCheck.java
new file mode 100644
index 0000000000..7166b0d562
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/BeautifulSoupRawHtmlInsertionCheck.java
@@ -0,0 +1,75 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.Map;
+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.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.QualifiedExpression;
+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.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;
+
+@Rule(key = "S8903")
+public class BeautifulSoupRawHtmlInsertionCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Use \"new_tag()\" instead of inserting raw HTML strings.";
+
+ private record MethodSpec(int argIndex, String keyword) {}
+
+ private static final Map INSERTION_METHODS = Map.of(
+ TypeMatchers.isType("bs4.element.PageElement.insert"), new MethodSpec(1, "new_child"),
+ TypeMatchers.isType("bs4.element.PageElement.append"), new MethodSpec(0, "tag"),
+ TypeMatchers.isType("bs4.element.PageElement.extend"), new MethodSpec(0, "tags")
+ );
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, BeautifulSoupRawHtmlInsertionCheck::checkCallExpression);
+ }
+
+ private static boolean looksLikeHtmlMarkup(Expression expr) {
+ StringLiteral literal = Expressions.extractStringLiteral(expr);
+ return literal != null &&
+ literal.trimmedQuotesValue().startsWith("<") &&
+ literal.trimmedQuotesValue().endsWith(">");
+ }
+
+ private static void checkCallExpression(SubscriptionContext ctx) {
+ CallExpression callExpr = (CallExpression) ctx.syntaxNode();
+
+ if (!(callExpr.callee() instanceof QualifiedExpression qualifiedExpr)) {
+ return;
+ }
+
+ INSERTION_METHODS.entrySet().stream()
+ .filter(e -> e.getKey().isTrueFor(qualifiedExpr, ctx))
+ .map(Map.Entry::getValue)
+ .findFirst()
+ .flatMap(spec -> TreeUtils.nthArgumentOrKeywordOptional(spec.argIndex(), spec.keyword(), callExpr.arguments()))
+ .map(RegularArgument::expression)
+ .filter(BeautifulSoupRawHtmlInsertionCheck::looksLikeHtmlMarkup)
+ .ifPresent(contentExpr -> ctx.addIssue(qualifiedExpr.name(), MESSAGE));
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BindToAllNetworkInterfacesCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BindToAllNetworkInterfacesCheck.java
index 542c6856aa..bdc5f1e101 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/BindToAllNetworkInterfacesCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/BindToAllNetworkInterfacesCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BooleanCheckNotInvertedCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BooleanCheckNotInvertedCheck.java
index 84b4a714d9..b25ed8b2e3 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/BooleanCheckNotInvertedCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/BooleanCheckNotInvertedCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -157,6 +157,7 @@ private static boolean isSetComparison(BinaryExpression binaryExpression) {
binaryExpression.leftOperand().type(),
binaryExpression.rightOperand().type()
);
- return inferredTypeSet.stream().anyMatch(inferredType -> inferredType.mustBeOrExtend(BuiltinTypes.SET));
+ return inferredTypeSet.stream().anyMatch(inferredType ->
+ inferredType.mustBeOrExtend(BuiltinTypes.SET) || inferredType.mustBeOrExtend(BuiltinTypes.FROZENSET));
}
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BooleanExpressionInExceptCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BooleanExpressionInExceptCheck.java
index ec195c4765..6c37c06785 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/BooleanExpressionInExceptCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/BooleanExpressionInExceptCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -41,6 +41,10 @@ public class BooleanExpressionInExceptCheck extends PythonSubscriptionCheck {
public static final String MESSAGE = "Rewrite this \"except\" expression as a tuple of exception classes.";
public static final String QUICK_FIX_MESSAGE = "Replace with a tuple";
+ private static final Kind[] FLAGGED_BINARY_KINDS = {
+ Kind.OR, Kind.AND, Kind.BITWISE_OR, Kind.BITWISE_AND
+ };
+
@Override
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Kind.EXCEPT_CLAUSE, BooleanExpressionInExceptCheck::checkExceptClause);
@@ -52,7 +56,7 @@ private static void checkExceptClause(SubscriptionContext ctx) {
Optional.of(except)
.map(ExceptClause::exception)
.map(Expressions::removeParentheses)
- .filter(exception -> exception.is(Kind.OR, Kind.AND))
+ .filter(exception -> exception.is(FLAGGED_BINARY_KINDS))
.ifPresent(exception -> {
var issue = ctx.addIssue(exception, MESSAGE);
addQuickFix(issue, exception);
@@ -61,7 +65,7 @@ private static void checkExceptClause(SubscriptionContext ctx) {
private static List collectNames(Expression expression) {
expression = Expressions.removeParentheses(expression);
- if (expression.is(Kind.OR, Kind.AND)) {
+ if (expression.is(FLAGGED_BINARY_KINDS)) {
var binaryExpression = (BinaryExpression) expression;
var leftExceptions = collectNames(binaryExpression.leftOperand());
var rightExceptions = collectNames(binaryExpression.rightOperand());
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BreakContinueOutsideLoopCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BreakContinueOutsideLoopCheck.java
index 8f8a6d4e82..d3ec578e55 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/BreakContinueOutsideLoopCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/BreakContinueOutsideLoopCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BuiltinGenericsOverTypingModuleCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BuiltinGenericsOverTypingModuleCheck.java
index 8fa6cca16f..75bb50b158 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/BuiltinGenericsOverTypingModuleCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/BuiltinGenericsOverTypingModuleCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BuiltinShadowingAssignmentCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BuiltinShadowingAssignmentCheck.java
index b9df7c8685..f5e0980749 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/BuiltinShadowingAssignmentCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/BuiltinShadowingAssignmentCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/BusyWaitingInAsyncCheck.java b/python-checks/src/main/java/org/sonar/python/checks/BusyWaitingInAsyncCheck.java
index 0cb5a143c3..c42ef13c17 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/BusyWaitingInAsyncCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/BusyWaitingInAsyncCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CancellationReraisedInAsyncCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CancellationReraisedInAsyncCheck.java
index 9ada400d9a..2c3c77c04a 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CancellationReraisedInAsyncCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CancellationReraisedInAsyncCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CancellationScopeNoCheckpointCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CancellationScopeNoCheckpointCheck.java
index ffec6922a9..3d457ee37b 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CancellationScopeNoCheckpointCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CancellationScopeNoCheckpointCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CaughtExceptionsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CaughtExceptionsCheck.java
index 2de88606a0..e63cc68f0a 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CaughtExceptionsCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CaughtExceptionsCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ChangeMethodContractCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ChangeMethodContractCheck.java
index c660005b81..71cd98c13d 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ChangeMethodContractCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ChangeMethodContractCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ChildAndParentExceptionCaughtCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ChildAndParentExceptionCaughtCheck.java
index 98b3b62978..afd2d432af 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ChildAndParentExceptionCaughtCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ChildAndParentExceptionCaughtCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ChildRouterBeforeParentRegistrationCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ChildRouterBeforeParentRegistrationCheck.java
index ccbad390ad..d6c236e041 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ChildRouterBeforeParentRegistrationCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ChildRouterBeforeParentRegistrationCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CipherBlockChainingCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CipherBlockChainingCheck.java
index 97d1184cf4..d038fe96bb 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CipherBlockChainingCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CipherBlockChainingCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ClassComplexityCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ClassComplexityCheck.java
index 99ab78a151..433ec6219c 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ClassComplexityCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ClassComplexityCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ClassFieldDefinedMultipleTimesCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ClassFieldDefinedMultipleTimesCheck.java
new file mode 100644
index 0000000000..eab3dff8b5
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/ClassFieldDefinedMultipleTimesCheck.java
@@ -0,0 +1,94 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+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.tree.AnnotatedAssignment;
+import org.sonar.plugins.python.api.tree.AssignmentStatement;
+import org.sonar.plugins.python.api.tree.ClassDef;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.ExpressionList;
+import org.sonar.plugins.python.api.tree.Name;
+import org.sonar.plugins.python.api.tree.Statement;
+import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.plugins.python.api.tree.Tree.Kind;
+
+@Rule(key = "S8512")
+public class ClassFieldDefinedMultipleTimesCheck extends PythonSubscriptionCheck {
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Kind.CLASSDEF, ctx -> checkClass(ctx, (ClassDef) ctx.syntaxNode()));
+ }
+
+ private static void checkClass(SubscriptionContext ctx, ClassDef classDef) {
+ Map> fieldDefinitions = new LinkedHashMap<>();
+
+ for (Statement stmt : classDef.body().statements()) {
+ collectFieldDefinition(stmt, fieldDefinitions);
+ }
+
+ reportDuplicateDefinitions(ctx, fieldDefinitions);
+ }
+
+ private static void collectFieldDefinition(Statement stmt, Map> fieldDefinitions) {
+ if (stmt.is(Kind.ASSIGNMENT_STMT)) {
+ getAssignmentName((AssignmentStatement) stmt)
+ .ifPresent(name -> fieldDefinitions.computeIfAbsent(name.name(), k -> new ArrayList<>()).add(name));
+ } else if (stmt.is(Kind.ANNOTATED_ASSIGNMENT)) {
+ getAnnotatedAssignmentName((AnnotatedAssignment) stmt)
+ .ifPresent(name -> fieldDefinitions.computeIfAbsent(name.name(), k -> new ArrayList<>()).add(name));
+ }
+ }
+
+ private static Optional getAssignmentName(AssignmentStatement assignment) {
+ List lhsList = assignment.lhsExpressions();
+ if (lhsList.size() != 1) {
+ return Optional.empty();
+ }
+ List expressions = lhsList.get(0).expressions();
+ if (expressions.size() == 1 && expressions.get(0).is(Kind.NAME)) {
+ return Optional.of((Name) expressions.get(0));
+ }
+ return Optional.empty();
+ }
+
+ private static Optional getAnnotatedAssignmentName(AnnotatedAssignment annotated) {
+ if (annotated.variable().is(Kind.NAME) && annotated.assignedValue() != null) {
+ return Optional.of((Name) annotated.variable());
+ }
+ return Optional.empty();
+ }
+
+ private static void reportDuplicateDefinitions(SubscriptionContext ctx, Map> fieldDefinitions) {
+ fieldDefinitions.forEach((name, definitions) -> {
+ for (int i = 0; i < definitions.size() - 1; i++) {
+ Tree current = definitions.get(i);
+ Tree next = definitions.get(i + 1);
+ String message = String.format("Remove this assignment; \"%s\" is assigned again on line %d.", name, next.firstToken().line());
+ ctx.addIssue(current, message).secondary(next, "Reassignment.");
+ }
+ });
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ClassMethodFirstArgumentNameCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ClassMethodFirstArgumentNameCheck.java
index 4cd3ab25bc..cd6f86fdad 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ClassMethodFirstArgumentNameCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ClassMethodFirstArgumentNameCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ClassNameCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ClassNameCheck.java
index 377c1c83b1..19910cc06b 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ClassNameCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ClassNameCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CognitiveComplexityFunctionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CognitiveComplexityFunctionCheck.java
index 7a431c2be7..4eab6d6101 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CognitiveComplexityFunctionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CognitiveComplexityFunctionCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -22,8 +22,10 @@
import org.sonar.check.RuleProperty;
import org.sonar.plugins.python.api.IssueLocation;
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
+import org.sonar.plugins.python.api.SubscriptionContext;
import org.sonar.plugins.python.api.tree.FunctionDef;
import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.python.checks.utils.GeneratedFileUtils;
import org.sonar.python.metrics.CognitiveComplexityVisitor;
@Rule(key = CognitiveComplexityFunctionCheck.CHECK_KEY)
@@ -33,6 +35,8 @@ public class CognitiveComplexityFunctionCheck extends PythonSubscriptionCheck {
public static final String CHECK_KEY = "S3776";
private static final int DEFAULT_THRESHOLD = 15;
+ private Boolean isGeneratedFile;
+
@RuleProperty(
key = "threshold",
description = "The maximum authorized complexity.",
@@ -43,7 +47,7 @@ public class CognitiveComplexityFunctionCheck extends PythonSubscriptionCheck {
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, ctx -> {
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
- if (isInnerFunction(functionDef)) {
+ if (isInnerFunction(functionDef) || isGeneratedFile(ctx)) {
return;
}
List secondaryLocations = new ArrayList<>();
@@ -57,6 +61,18 @@ public void initialize(Context context) {
});
}
+ @Override
+ public void leaveFile() {
+ isGeneratedFile = null;
+ }
+
+ private boolean isGeneratedFile(SubscriptionContext ctx) {
+ if (isGeneratedFile == null) {
+ isGeneratedFile = GeneratedFileUtils.isGeneratedFileForReadabilityRules(ctx.pythonFile(), ctx.syntaxNode());
+ }
+ return isGeneratedFile;
+ }
+
private static boolean isInnerFunction(FunctionDef functionDef) {
Tree parent = functionDef.parent();
while (parent != null) {
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CollapsibleIfStatementsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CollapsibleIfStatementsCheck.java
index c20c2b0b26..fc204fc40d 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CollapsibleIfStatementsCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CollapsibleIfStatementsCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CollectionCreationWrappedInConstructorCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CollectionCreationWrappedInConstructorCheck.java
index e9ff371d2d..824e7d1af4 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CollectionCreationWrappedInConstructorCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CollectionCreationWrappedInConstructorCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CollectionLengthComparisonCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CollectionLengthComparisonCheck.java
index 96e8380d52..946dadbaf6 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CollectionLengthComparisonCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CollectionLengthComparisonCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -20,19 +20,23 @@
import java.util.EnumSet;
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.tree.BinaryExpression;
import org.sonar.plugins.python.api.tree.CallExpression;
import org.sonar.plugins.python.api.tree.Expression;
import org.sonar.plugins.python.api.tree.NumericLiteral;
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.api.PythonPunctuator;
-import org.sonar.plugins.python.api.symbols.Symbol;
import static org.sonar.python.checks.utils.Expressions.removeParentheses;
@Rule(key = "S3981")
public class CollectionLengthComparisonCheck extends PythonSubscriptionCheck {
+ private static final TypeMatcher LEN_MATCHER = TypeMatchers.isType("len");
+
private static final EnumSet INVALID_OPERATORS =
EnumSet.of(PythonPunctuator.LT, PythonPunctuator.GT_EQU);
@@ -46,8 +50,8 @@ public void initialize(Context context) {
Expression left = removeParentheses(comparison.leftOperand());
Expression right = removeParentheses(comparison.rightOperand());
TokenType operator = comparison.operator().type();
- if ((isCallToLen(left) && isZero(right) && INVALID_OPERATORS.contains(operator))
- || (isCallToLen(right) && isZero(left) && INVALID_REVERSE_OPERATORS.contains(operator))) {
+ if ((isCallToLen(left, ctx) && isZero(right) && INVALID_OPERATORS.contains(operator))
+ || (isCallToLen(right, ctx) && isZero(left) && INVALID_REVERSE_OPERATORS.contains(operator))) {
ctx.addIssue(comparison, "The length of a collection is always \">=0\", so update this test to either \"==0\" or \">0\".");
}
});
@@ -57,12 +61,8 @@ private static boolean isZero(Expression expression) {
return expression.is(Kind.NUMERIC_LITERAL) && "0".equals(((NumericLiteral) expression).valueAsString());
}
- private static boolean isCallToLen(Expression expression) {
- if (expression.is(Kind.CALL_EXPR)) {
- Symbol calleeSymbol = ((CallExpression) expression).calleeSymbol();
- return calleeSymbol != null && "len".equals(calleeSymbol.fullyQualifiedName());
- }
- return false;
+ private static boolean isCallToLen(Expression expression, SubscriptionContext ctx) {
+ return expression.is(Kind.CALL_EXPR) && LEN_MATCHER.isTrueFor(((CallExpression) expression).callee(), ctx);
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CommentRegularExpressionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CommentRegularExpressionCheck.java
index ebf8b56900..998672ca31 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CommentRegularExpressionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CommentRegularExpressionCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CommentedCodeCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CommentedCodeCheck.java
index 69724a792c..e04c317558 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CommentedCodeCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CommentedCodeCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -33,6 +33,7 @@
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.Trivia;
import org.sonar.python.checks.utils.Expressions;
+import org.sonar.python.checks.utils.GeneratedFileUtils;
import org.sonar.python.parser.PythonParser;
import org.sonar.python.tree.PythonTreeMaker;
@@ -50,6 +51,7 @@ public class CommentedCodeCheck extends PythonSubscriptionCheck {
private final PythonParser parser = PythonParser.create();
private Pattern exceptionPattern;
+ private Boolean isGeneratedFile;
@RuleProperty(
key = "exception",
@@ -62,6 +64,9 @@ public void initialize(Context context) {
exceptionPattern = Pattern.compile(exception);
context.registerSyntaxNodeConsumer(Tree.Kind.TOKEN, ctx -> {
+ if (isGeneratedFile(ctx)) {
+ return;
+ }
Token token = (Token) ctx.syntaxNode();
List> groupedTrivias = groupTrivias(token);
for (List triviaGroup : groupedTrivias) {
@@ -70,6 +75,9 @@ public void initialize(Context context) {
});
context.registerSyntaxNodeConsumer(Tree.Kind.STRING_LITERAL, ctx -> {
+ if (isGeneratedFile(ctx)) {
+ return;
+ }
StringLiteral stringLiteral = (StringLiteral) ctx.syntaxNode();
if (isMultilineComment(stringLiteral)) {
visitMultilineComment(stringLiteral, ctx);
@@ -77,6 +85,18 @@ public void initialize(Context context) {
});
}
+ @Override
+ public void leaveFile() {
+ isGeneratedFile = null;
+ }
+
+ private boolean isGeneratedFile(SubscriptionContext ctx) {
+ if (isGeneratedFile == null) {
+ isGeneratedFile = GeneratedFileUtils.isGeneratedFileForReadabilityRules(ctx.pythonFile(), ctx.syntaxNode());
+ }
+ return isGeneratedFile;
+ }
+
private static boolean isMultilineComment(StringLiteral stringLiteral) {
Tree parent = stringLiteral.parent();
StringElement firstElement = stringLiteral.stringElements().get(0);
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ComparisonToNoneCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ComparisonToNoneCheck.java
index c304892f32..3bdf18c6a5 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ComparisonToNoneCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ComparisonToNoneCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CompressionModulesFromNamespaceCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CompressionModulesFromNamespaceCheck.java
index a737fc0845..b2aeee661c 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CompressionModulesFromNamespaceCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CompressionModulesFromNamespaceCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ConfusingTypeCheckingCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ConfusingTypeCheckingCheck.java
index 7421d049af..f755cada50 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ConfusingTypeCheckingCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ConfusingTypeCheckingCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ConfusingWalrusCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ConfusingWalrusCheck.java
index b867557f3b..1893496c44 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ConfusingWalrusCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ConfusingWalrusCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
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 3af827e0f7..76d5ebb68d 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -17,7 +17,6 @@
package org.sonar.python.checks;
import java.util.List;
-import java.util.stream.Collectors;
import org.sonar.check.Rule;
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
import org.sonar.plugins.python.api.SubscriptionContext;
@@ -40,16 +39,16 @@ 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;
}
- List endStatements = cfg.end().predecessors().stream()
+ List endStatements = cfg.end().predecessors().stream()
.map(block -> parentStatement(block.elements().get(block.elements().size() - 1)))
.filter(s -> !s.is(Kind.RAISE_STMT, Kind.ASSERT_STMT, Kind.WITH_STMT) && !isWhileTrue(s))
- .collect(Collectors.toList());
+ .toList();
- List returnsWithValue = endStatements.stream()
+ List returnsWithValue = endStatements.stream()
.filter(s -> s.is(Kind.RETURN_STMT) && hasValue((ReturnStatement) s))
.toList();
@@ -68,9 +67,9 @@ private static boolean isWhileTrue(Statement statement) {
return statement.is(Kind.WHILE_STMT) && Expressions.isTruthy(((WhileStatement) statement).condition());
}
- private static void addIssue(SubscriptionContext ctx, FunctionDef functionDef, List endStatements) {
+ private static void addIssue(SubscriptionContext ctx, FunctionDef functionDef, List endStatements) {
PreciseIssue issue = ctx.addIssue(functionDef.name(), MESSAGE);
- for (Tree statement : endStatements) {
+ for (Statement statement : endStatements) {
if (statement.is(Kind.RETURN_STMT)) {
ReturnStatement returnStatement = (ReturnStatement) statement;
boolean hasValue = hasValue(returnStatement);
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 5afd481edf..2d265ec024 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -28,12 +28,10 @@
import org.sonar.plugins.python.api.tree.ComprehensionIf;
import org.sonar.plugins.python.api.tree.ConditionalExpression;
import org.sonar.plugins.python.api.tree.Expression;
-import org.sonar.plugins.python.api.tree.FileInput;
import org.sonar.plugins.python.api.tree.HasSymbol;
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 +46,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 +134,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/ConstantValueDictComprehensionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ConstantValueDictComprehensionCheck.java
index 17eb24edd0..c14c880e36 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ConstantValueDictComprehensionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ConstantValueDictComprehensionCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ControlFlowInTaskGroupCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ControlFlowInTaskGroupCheck.java
index 75329c100a..050c2e2b39 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ControlFlowInTaskGroupCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ControlFlowInTaskGroupCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/CorsMiddlewareOrderingCheck.java b/python-checks/src/main/java/org/sonar/python/checks/CorsMiddlewareOrderingCheck.java
index 988a3dc72f..eadfa8e241 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/CorsMiddlewareOrderingCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/CorsMiddlewareOrderingCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DataClassFunctionCallDefaultCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DataClassFunctionCallDefaultCheck.java
new file mode 100644
index 0000000000..91444c8535
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/DataClassFunctionCallDefaultCheck.java
@@ -0,0 +1,224 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.Set;
+import java.util.stream.Stream;
+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.tree.AnnotatedAssignment;
+import org.sonar.plugins.python.api.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.ClassDef;
+import org.sonar.plugins.python.api.tree.Decorator;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.QualifiedExpression;
+import org.sonar.plugins.python.api.tree.RegularArgument;
+import org.sonar.plugins.python.api.tree.SubscriptionExpression;
+import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.plugins.python.api.tree.TypeAnnotation;
+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 = "S8685")
+public class DataClassFunctionCallDefaultCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Use \"field(default_factory=...)\" instead of a function call as a default value.";
+ private static final String DEFAULT_KEYWORD = "default";
+
+ private static final TypeMatcher IS_DATACLASS_DECORATOR = TypeMatchers.isType("dataclasses.dataclass");
+ private static final TypeMatcher IS_DATACLASSES_FIELD = TypeMatchers.isType("dataclasses.field");
+ private static final TypeMatcher IS_CLASS_VAR = TypeMatchers.isType("typing.ClassVar");
+
+ // Allowlist of callables whose result should be re-evaluated per dataclass instance.
+ // Calls to anything outside this list are assumed safe (e.g. user-defined helpers, frozen-dataclass
+ // constructors, wrappers around dataclasses.field(default_factory=...)) — we prefer FNs over FPs here.
+ //
+ // NOTE: random.* top-level functions are intentionally absent. In typeshed they are aliased from a
+ // module-level Random() instance (`randint = _inst.randint`, ...) and the V2 typeshed serializer
+ // flattens the bound-method type to CallableType[builtins.function], so isType("random.randint")
+ // resolves through Unknown. They are matched separately below via the random module qualifier.
+ private static final TypeMatcher IS_PROBLEMATIC_FACTORY = TypeMatchers.any(Stream.of(
+ // Current time / clock readings
+ "datetime.datetime.now",
+ "datetime.datetime.utcnow",
+ "datetime.datetime.today",
+ "datetime.datetime.fromtimestamp",
+ "datetime.datetime.utcfromtimestamp",
+ "datetime.date.today",
+ "datetime.date.fromtimestamp",
+ "time.time",
+ "time.time_ns",
+ "time.monotonic",
+ "time.monotonic_ns",
+ "time.perf_counter",
+ "time.perf_counter_ns",
+ "time.process_time",
+ "time.process_time_ns",
+ "time.localtime",
+ "time.gmtime",
+ // UUIDs
+ "uuid.uuid1",
+ "uuid.uuid3",
+ "uuid.uuid4",
+ "uuid.uuid5",
+ // Secrets
+ "secrets.token_hex",
+ "secrets.token_bytes",
+ "secrets.token_urlsafe",
+ "secrets.choice",
+ "secrets.randbelow",
+ "secrets.randbits",
+ // OS randomness
+ "os.urandom",
+ // Mutable container constructors — shared across all instances otherwise
+ "builtins.list",
+ "builtins.dict",
+ "builtins.set",
+ "builtins.bytearray",
+ "collections.defaultdict",
+ "collections.OrderedDict",
+ "collections.Counter",
+ "collections.deque").map(TypeMatchers::isType));
+
+ // The random module type itself is known, even though its top-level function members all resolve
+ // to Unknown (see NOTE on IS_PROBLEMATIC_FACTORY). We match `.` syntactically.
+ private static final TypeMatcher IS_RANDOM_MODULE = TypeMatchers.isType("random");
+
+ private static final Set RANDOM_PROBLEMATIC_FUNCTION_NAMES = Set.of(
+ "random",
+ "randint",
+ "randrange",
+ "choice",
+ "choices",
+ "sample",
+ "uniform",
+ "gauss",
+ "normalvariate",
+ "triangular",
+ "betavariate",
+ "expovariate",
+ "gammavariate",
+ "lognormvariate",
+ "paretovariate",
+ "vonmisesvariate",
+ "weibullvariate",
+ "getrandbits",
+ "randbytes");
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CLASSDEF, DataClassFunctionCallDefaultCheck::checkClassDef);
+ }
+
+ private static void checkClassDef(SubscriptionContext ctx) {
+ ClassDef classDef = (ClassDef) ctx.syntaxNode();
+ if (!isDataclass(classDef, ctx)) {
+ return;
+ }
+ classDef.body().statements().stream()
+ .flatMap(TreeUtils.toStreamInstanceOfMapper(AnnotatedAssignment.class))
+ .forEach(annotatedAssignment -> checkField(ctx, annotatedAssignment));
+ }
+
+ private static boolean isDataclass(ClassDef classDef, SubscriptionContext ctx) {
+ for (Decorator decorator : classDef.decorators()) {
+ Expression decoratorExpr = getDecoratorFunctionExpression(decorator);
+ if (IS_DATACLASS_DECORATOR.isTrueFor(decoratorExpr, ctx)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Expression getDecoratorFunctionExpression(Decorator decorator) {
+ Expression expr = decorator.expression();
+ if (expr instanceof CallExpression callExpr) {
+ return callExpr.callee();
+ }
+ return expr;
+ }
+
+ private static void checkField(SubscriptionContext ctx, AnnotatedAssignment annotatedAssignment) {
+ Expression assignedValue = annotatedAssignment.assignedValue();
+ if (assignedValue == null || isClassVar(annotatedAssignment.annotation(), ctx)) {
+ return;
+ }
+ if (isMutableLiteral(assignedValue)) {
+ ctx.addIssue(assignedValue, MESSAGE);
+ return;
+ }
+ if (assignedValue instanceof CallExpression callExpression) {
+ Tree problematic = problematicCall(callExpression, ctx);
+ if (problematic != null) {
+ ctx.addIssue(problematic, MESSAGE);
+ }
+ }
+ }
+
+ private static boolean isMutableLiteral(Expression expression) {
+ return expression.is(Tree.Kind.LIST_LITERAL, Tree.Kind.DICTIONARY_LITERAL, Tree.Kind.SET_LITERAL);
+ }
+
+ // Returns the expression to flag, or null. Handles all of:
+ // x: T = datetime.now() -> the outer call
+ // x: T = field(default=datetime.now()) -> the inner default argument
+ // x: T = field(default=[]) -> the inner mutable literal
+ private static Tree problematicCall(CallExpression callExpression, SubscriptionContext ctx) {
+ if (isProblematicFactoryCall(callExpression, ctx)) {
+ return callExpression;
+ }
+ if (IS_DATACLASSES_FIELD.isTrueFor(callExpression.callee(), ctx)) {
+ RegularArgument defaultArg = TreeUtils.argumentByKeyword(DEFAULT_KEYWORD, callExpression.arguments());
+ if (defaultArg != null) {
+ Expression defaultExpr = defaultArg.expression();
+ if (isMutableLiteral(defaultExpr)) {
+ return defaultExpr;
+ }
+ if (defaultExpr instanceof CallExpression innerCall && isProblematicFactoryCall(innerCall, ctx)) {
+ return innerCall;
+ }
+ }
+ }
+ return null;
+ }
+
+ private static boolean isProblematicFactoryCall(CallExpression callExpression, SubscriptionContext ctx) {
+ Expression callee = callExpression.callee();
+ return IS_PROBLEMATIC_FACTORY.isTrueFor(callee, ctx) || isRandomModuleFunctionCall(callee, ctx);
+ }
+
+ // Workaround for the random.* alias gap in the V2 typeshed serializer (see NOTE on
+ // IS_PROBLEMATIC_FACTORY): the random module type itself is known, so we recognise
+ // a problematic call by checking the qualifier's type and the syntactic name.
+ private static boolean isRandomModuleFunctionCall(Expression callee, SubscriptionContext ctx) {
+ if (!(callee instanceof QualifiedExpression qualifiedExpression)) {
+ return false;
+ }
+ return RANDOM_PROBLEMATIC_FUNCTION_NAMES.contains(qualifiedExpression.name().name())
+ && IS_RANDOM_MODULE.isTrueFor(qualifiedExpression.qualifier(), ctx);
+ }
+
+ private static boolean isClassVar(TypeAnnotation annotation, SubscriptionContext ctx) {
+ Expression annotationExpr = annotation.expression();
+ if (annotationExpr instanceof SubscriptionExpression subscriptionExpr) {
+ return IS_CLASS_VAR.isTrueFor(subscriptionExpr.object(), ctx);
+ }
+ return IS_CLASS_VAR.isTrueFor(annotationExpr, ctx);
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DataClassOnEnumCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DataClassOnEnumCheck.java
new file mode 100644
index 0000000000..45df3120a8
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/DataClassOnEnumCheck.java
@@ -0,0 +1,72 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+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.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.ClassDef;
+import org.sonar.plugins.python.api.tree.Decorator;
+import org.sonar.plugins.python.api.tree.Expression;
+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;
+
+@Rule(key = "S8490")
+public class DataClassOnEnumCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Remove this \"@dataclass\" decorator; it is incompatible with Enum classes.";
+
+ private static final TypeMatcher IS_ENUM_MATCHER = TypeMatchers.any(
+ TypeMatchers.isOrExtendsType("enum.Enum"),
+ TypeMatchers.isOrExtendsType("enum.IntEnum"),
+ TypeMatchers.isOrExtendsType("enum.IntFlag"));
+ private static final TypeMatcher IS_DATACLASS_MATCHER = TypeMatchers.isType("dataclasses.dataclass");
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CLASSDEF, DataClassOnEnumCheck::checkClassDef);
+ }
+
+ private static void checkClassDef(SubscriptionContext ctx) {
+ ClassDef classDef = (ClassDef) ctx.syntaxNode();
+
+ if (classDef.decorators().isEmpty()) {
+ return;
+ }
+
+ if (!IS_ENUM_MATCHER.isTrueFor(classDef.name(), ctx)) {
+ return;
+ }
+
+ for (Decorator decorator : classDef.decorators()) {
+ Expression decoratorExpr = getDecoratorFunctionExpression(decorator);
+ if (IS_DATACLASS_MATCHER.isTrueFor(decoratorExpr, ctx)) {
+ ctx.addIssue(decorator, MESSAGE);
+ }
+ }
+ }
+
+ private static Expression getDecoratorFunctionExpression(Decorator decorator) {
+ Expression expr = decorator.expression();
+ if (expr instanceof CallExpression callExpr) {
+ return callExpr.callee();
+ }
+ return expr;
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DataclassFieldDefinitionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DataclassFieldDefinitionCheck.java
new file mode 100644
index 0000000000..f00de87a2e
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/DataclassFieldDefinitionCheck.java
@@ -0,0 +1,102 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+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.tree.AssignmentStatement;
+import org.sonar.plugins.python.api.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.ClassDef;
+import org.sonar.plugins.python.api.tree.Decorator;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.ExpressionList;
+import org.sonar.plugins.python.api.tree.Name;
+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;
+
+@Rule(key = "S8514")
+public class DataclassFieldDefinitionCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE_MISSING_ANNOTATION = "Add a type annotation to this dataclass attribute.";
+
+ private static final TypeMatcher IS_DATACLASS = TypeMatchers.isType("dataclasses.dataclass");
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CLASSDEF, DataclassFieldDefinitionCheck::checkClassDef);
+ }
+
+ private static void checkClassDef(SubscriptionContext ctx) {
+ ClassDef classDef = (ClassDef) ctx.syntaxNode();
+
+ if (!isDataclass(classDef, ctx)) {
+ return;
+ }
+
+ classDef.body().statements().forEach(statement -> checkStatement(ctx, statement));
+ }
+
+ private static boolean isDataclass(ClassDef classDef, SubscriptionContext ctx) {
+ return classDef.decorators().stream().anyMatch(decorator -> isDataclassDecorator(decorator, ctx));
+ }
+
+ private static boolean isDataclassDecorator(Decorator decorator, SubscriptionContext ctx) {
+ Expression expression = decorator.expression();
+ if (expression instanceof CallExpression callExpr) {
+ return IS_DATACLASS.isTrueFor(callExpr.callee(), ctx);
+ }
+ return IS_DATACLASS.isTrueFor(expression, ctx);
+ }
+
+ private static void checkStatement(SubscriptionContext ctx, Tree statement) {
+ if (statement instanceof AssignmentStatement assignmentStatement && !isLikelyIntentionalClassVar(assignmentStatement)) {
+ ctx.addIssue(assignmentStatement, MESSAGE_MISSING_ANNOTATION);
+ }
+ }
+
+ private static boolean isLikelyIntentionalClassVar(AssignmentStatement assignment) {
+ List lhsList = assignment.lhsExpressions();
+ if (lhsList.size() != 1) {
+ return false;
+ }
+ List expressions = lhsList.get(0).expressions();
+ if (expressions.size() != 1 || !expressions.get(0).is(Tree.Kind.NAME)) {
+ return false;
+ }
+ String name = ((Name) expressions.get(0)).name();
+ return name.startsWith("_") || isAllCaps(name);
+ }
+
+ // ALL_CAPS names follow the Python constant convention — they are intentional class-level attributes.
+ private static boolean isAllCaps(String name) {
+ if (name.isEmpty()) {
+ return false;
+ }
+ boolean hasUpperCase = false;
+ for (char c : name.toCharArray()) {
+ if (Character.isUpperCase(c)) {
+ hasUpperCase = true;
+ } else if (!Character.isDigit(c) && c != '_') {
+ return false;
+ }
+ }
+ return hasUpperCase;
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DbNoPasswordCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DbNoPasswordCheck.java
index 33e2baa3d2..1534471358 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DbNoPasswordCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DbNoPasswordCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -16,14 +16,12 @@
*/
package org.sonar.python.checks;
-import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
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.AssignmentStatement;
import org.sonar.plugins.python.api.tree.CallExpression;
@@ -34,6 +32,8 @@
import org.sonar.plugins.python.api.tree.StringLiteral;
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 static org.sonar.plugins.python.api.tree.Tree.Kind.ASSIGNMENT_STMT;
import static org.sonar.plugins.python.api.tree.Tree.Kind.CALL_EXPR;
@@ -49,15 +49,19 @@ public class DbNoPasswordCheck extends PythonSubscriptionCheck {
private static final String MESSAGE = "Add password protection to this database.";
- private static final List CONNECT_METHODS = Arrays.asList(
- "mysql.connector.connect",
- "mysql.connector.connection.MySQLConnection",
- "pymysql.connections.connect",
- "psycopg2.connect",
- "pgdb.connect.connect",
- "pg.DB",
- "pg.connect"
- );
+ private static final TypeMatcher PG_MATCHER = TypeMatchers.any(
+ TypeMatchers.isType("pg.db.DB"),
+ TypeMatchers.isType("pg.connect"));
+
+ private static final TypeMatcher CONNECT_MATCHER = TypeMatchers.any(
+ TypeMatchers.isType("mysql.connector.connect"),
+ TypeMatchers.isType("mysql.connector.connection.MySQLConnection"),
+ TypeMatchers.isType("pymysql.connections.connect"),
+ TypeMatchers.isType("psycopg2.connect"),
+ // pgdb.connect is a module whose connect function has FQN pgdb.connect.connect.
+ // isType can't resolve it through the type table, so use withFQN to match on the FQN directly.
+ TypeMatchers.withFQN("pgdb.connect.connect"),
+ PG_MATCHER);
private static final Pattern CONNECTION_URI_PATTERN =
Pattern.compile("^(?:postgresql|mysql|oracle|mssql)(?:\\+.+?)?://.+?(:.*)?@.+");
@@ -83,17 +87,17 @@ private static void checkDbUri(SubscriptionContext ctx) {
private static void checkDbApi(SubscriptionContext ctx) {
CallExpression callExpr = (CallExpression) ctx.syntaxNode();
- Symbol symbol = callExpr.calleeSymbol();
- if (symbol != null && CONNECT_METHODS.contains(symbol.fullyQualifiedName())) {
- RegularArgument passwordArgument = getPasswordArgument(symbol.fullyQualifiedName(), callExpr.arguments());
- if (passwordArgument != null && isString(passwordArgument.expression(), "")) {
- ctx.addIssue(passwordArgument, MESSAGE);
- }
+ if (!CONNECT_MATCHER.isTrueFor(callExpr.callee(), ctx)) {
+ return;
+ }
+ boolean isPg = PG_MATCHER.isTrueFor(callExpr.callee(), ctx);
+ RegularArgument passwordArgument = getPasswordArgument(isPg, callExpr.arguments());
+ if (passwordArgument != null && isString(passwordArgument.expression(), "")) {
+ ctx.addIssue(passwordArgument, MESSAGE);
}
}
- private static RegularArgument getPasswordArgument(String method, List arguments) {
- boolean isPg = method.startsWith("pg.");
+ private static RegularArgument getPasswordArgument(boolean isPg, List arguments) {
String argumentKeyword = isPg ? "passwd" : "password";
int passwordIndex = isPg ? 5 : 2;
int positionalIndex = 0;
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 6427b3133a..b6aaa353b4 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -55,55 +55,11 @@
@Rule(key = "S1854")
public class DeadStoreCheck extends PythonSubscriptionCheck {
+ public static final String QUICK_FIX_MESSAGE = "Remove the unused assignment";
private static final String MESSAGE_TEMPLATE = "Remove this assignment to local variable '%s'; the value is never used.";
-
private static final String SECONDARY_MESSAGE_TEMPLATE = "'%s' is reassigned here.";
- public static final String QUICK_FIX_MESSAGE = "Remove the unused assignment";
-
private boolean isTemplateVariablesAccessEnabled = false;
- @Override
- public void initialize(Context context) {
- context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::checkTemplateVariablesAccessEnabled);
- context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, ctx -> {
- FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
- if (TreeUtils.hasDescendant(functionDef, tree -> tree.is(Tree.Kind.TRY_STMT))) {
- return;
- }
- ControlFlowGraph cfg = ControlFlowGraph.build(functionDef, ctx.pythonFile());
- if (cfg == null) {
- return;
- }
- LiveVariablesAnalysis lva = LiveVariablesAnalysis.analyze(cfg);
- cfg.blocks().forEach(block -> verifyBlock(ctx, block, lva.getLiveVariables(block), lva.getReadSymbols(), functionDef));
- });
- }
-
- private void checkTemplateVariablesAccessEnabled(SubscriptionContext ctx) {
- var importedNamesCollector = new ImportedNamesCollector();
- importedNamesCollector.collect(ctx.syntaxNode());
- isTemplateVariablesAccessEnabled = importedNamesCollector.anyMatches("pandas"::equals);
- }
-
- /**
- * Bottom-up approach, keeping track of which variables will be read by successor elements.
- */
- private void verifyBlock(SubscriptionContext ctx, CfgBlock block, LiveVariablesAnalysis.LiveVariables blockLiveVariables,
- Set readSymbols, FunctionDef functionDef) {
-
- var stringLiteralValuesCollector = new StringLiteralValuesCollector();
- if (isTemplateVariablesAccessEnabled) {
- stringLiteralValuesCollector.collect(functionDef);
- }
- DeadStoreUtils.findUnnecessaryAssignments(block, blockLiveVariables, functionDef)
- .stream()
- // symbols should have at least one read usage (otherwise will be reported by S1481)
- .filter(unnecessaryAssignment -> readSymbols.contains(unnecessaryAssignment.symbol))
- .filter((unnecessaryAssignment -> !isException(unnecessaryAssignment.symbol, unnecessaryAssignment.element, functionDef,
- stringLiteralValuesCollector)))
- .forEach(unnecessaryAssignment -> raiseIssue(ctx, unnecessaryAssignment));
- }
-
private static void raiseIssue(SubscriptionContext ctx, DeadStoreUtils.UnnecessaryAssignment unnecessaryAssignment) {
Tree element;
if (unnecessaryAssignment.element instanceof ClassDef classDefElement) {
@@ -247,7 +203,6 @@ private static boolean isFunctionDeclarationSymbol(Symbol symbol) {
return symbol.usages().stream().anyMatch(u -> u.kind() == Usage.Kind.FUNC_DECLARATION);
}
-
private static boolean isExceptionForQuickFix(Statement tree) {
switch (tree.getKind()) {
// foo:str = bar
@@ -265,6 +220,51 @@ private static boolean isExceptionForQuickFix(Statement tree) {
}
}
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::checkTemplateVariablesAccessEnabled);
+ context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, ctx -> {
+ FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
+ if (TreeUtils.hasDescendant(functionDef, tree -> tree.is(Tree.Kind.TRY_STMT))) {
+ return;
+ }
+ ControlFlowGraph cfg = ctx.cfg(functionDef);
+ if (cfg == null) {
+ return;
+ }
+ LiveVariablesAnalysis lva = ctx.lva(functionDef);
+ if (lva == null) {
+ return;
+ }
+ cfg.blocks().forEach(block -> verifyBlock(ctx, block, lva.getLiveVariables(block), lva.getReadSymbols(), functionDef));
+ });
+ }
+
+ private void checkTemplateVariablesAccessEnabled(SubscriptionContext ctx) {
+ var importedNamesCollector = new ImportedNamesCollector();
+ importedNamesCollector.collect(ctx.syntaxNode());
+ isTemplateVariablesAccessEnabled = importedNamesCollector.anyMatches("pandas"::equals);
+ }
+
+ /**
+ * Bottom-up approach, keeping track of which variables will be read by successor elements.
+ */
+ private void verifyBlock(SubscriptionContext ctx, CfgBlock block, LiveVariablesAnalysis.LiveVariables blockLiveVariables,
+ Set readSymbols, FunctionDef functionDef) {
+
+ var stringLiteralValuesCollector = new StringLiteralValuesCollector();
+ if (isTemplateVariablesAccessEnabled) {
+ stringLiteralValuesCollector.collect(functionDef);
+ }
+ DeadStoreUtils.findUnnecessaryAssignments(block, blockLiveVariables, functionDef)
+ .stream()
+ // symbols should have at least one read usage (otherwise will be reported by S1481)
+ .filter(unnecessaryAssignment -> readSymbols.contains(unnecessaryAssignment.symbol))
+ .filter((unnecessaryAssignment -> !isException(unnecessaryAssignment.symbol, unnecessaryAssignment.element, functionDef,
+ stringLiteralValuesCollector)))
+ .forEach(unnecessaryAssignment -> raiseIssue(ctx, unnecessaryAssignment));
+ }
+
private static class SideEffectDetector extends BaseTreeVisitor {
private boolean sideEffect = false;
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DefaultFactoryArgumentCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DefaultFactoryArgumentCheck.java
index d84e151b17..9908472600 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DefaultFactoryArgumentCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DefaultFactoryArgumentCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DeprecatedNumpyTypesCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DeprecatedNumpyTypesCheck.java
index d08041d29c..b4e1aa26bd 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DeprecatedNumpyTypesCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DeprecatedNumpyTypesCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -16,15 +16,15 @@
*/
package org.sonar.python.checks;
-import java.util.Map;
-import java.util.Optional;
+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.quickfix.PythonQuickFix;
-import org.sonar.plugins.python.api.symbols.Symbol;
import org.sonar.plugins.python.api.tree.QualifiedExpression;
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;
@Rule(key = "S6730")
@@ -32,15 +32,17 @@ public class DeprecatedNumpyTypesCheck extends PythonSubscriptionCheck {
private static final String MESSAGE = "Replace this deprecated \"numpy\" type alias with the builtin type \"%s\".";
private static final String QUICK_FIX_MESSAGE = "Replace with %s.";
- private static final Map TYPE_TO_CHECK = Map.of(
- "numpy.bool", "bool",
- "numpy.int", "int",
- "numpy.float", "float",
- "numpy.complex", "complex",
- "numpy.object", "object",
- "numpy.str", "str",
- "numpy.long", "int",
- "numpy.unicode", "str");
+
+ private record DeprecatedType(TypeMatcher matcher, String replacement) {}
+
+ private static final List DEPRECATED_TYPES = List.of(
+ new DeprecatedType(TypeMatchers.withFQN("numpy.int"), "int"),
+ new DeprecatedType(TypeMatchers.withFQN("numpy.float"), "float"),
+ new DeprecatedType(TypeMatchers.withFQN("numpy.complex"), "complex"),
+ new DeprecatedType(TypeMatchers.withFQN("numpy.object"), "object"),
+ new DeprecatedType(TypeMatchers.withFQN("numpy.str"), "str"),
+ new DeprecatedType(TypeMatchers.withFQN("numpy.long"), "int"),
+ new DeprecatedType(TypeMatchers.withFQN("numpy.unicode"), "str"));
@Override
public void initialize(Context context) {
@@ -50,13 +52,15 @@ public void initialize(Context context) {
private static void checkForDeprecatedTypesNames(SubscriptionContext ctx) {
QualifiedExpression expression = (QualifiedExpression) ctx.syntaxNode();
- Optional.ofNullable(expression.symbol())
- .map(Symbol::fullyQualifiedName)
- .map(TYPE_TO_CHECK::get)
- .ifPresent(type -> raiseIssue(expression, type, ctx));
+ for (DeprecatedType deprecatedType : DEPRECATED_TYPES) {
+ if (deprecatedType.matcher().isTrueFor(expression, ctx)) {
+ raiseIssue(expression, deprecatedType.replacement(), ctx);
+ return;
+ }
+ }
}
- private static void raiseIssue(Tree expression, String replacementType, SubscriptionContext ctx) {
+ private static void raiseIssue(QualifiedExpression expression, String replacementType, SubscriptionContext ctx) {
PreciseIssue issue = ctx.addIssue(expression, String.format(MESSAGE, replacementType));
PythonQuickFix quickFix = PythonQuickFix.newQuickFix(
String.format(QUICK_FIX_MESSAGE, replacementType),
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DictKeysMembershipTestCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DictKeysMembershipTestCheck.java
new file mode 100644
index 0000000000..01a6bc9fd8
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/DictKeysMembershipTestCheck.java
@@ -0,0 +1,62 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+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.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.InExpression;
+import org.sonar.plugins.python.api.tree.QualifiedExpression;
+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;
+
+@Rule(key = "S8521")
+public class DictKeysMembershipTestCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Remove this unnecessary \"keys()\" call.";
+ private static final String KEYS_METHOD_NAME = "keys";
+ private static final TypeMatcher DICT_OR_SUBCLASS_KEYS_MATCHER =
+ TypeMatchers.isFunctionOwnerSatisfying(TypeMatchers.isOrExtendsType("builtins.dict"));
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.IN, DictKeysMembershipTestCheck::checkInExpression);
+ }
+
+ private static void checkInExpression(SubscriptionContext ctx) {
+ InExpression inExpression = (InExpression) ctx.syntaxNode();
+ if (!(inExpression.rightOperand() instanceof CallExpression callExpression)) {
+ return;
+ }
+ if (!callExpression.arguments().isEmpty()) {
+ return;
+ }
+ if (!isKeysCall(callExpression)) {
+ return;
+ }
+ if (DICT_OR_SUBCLASS_KEYS_MATCHER.isTrueFor(callExpression.callee(), ctx)) {
+ ctx.addIssue(callExpression, MESSAGE);
+ }
+ }
+
+ private static boolean isKeysCall(CallExpression callExpression) {
+ return callExpression.callee() instanceof QualifiedExpression qualifiedExpression
+ && KEYS_METHOD_NAME.equals(qualifiedExpression.name().name());
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DictionaryDuplicateKeyCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DictionaryDuplicateKeyCheck.java
index 75c2169852..6d622ecc48 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DictionaryDuplicateKeyCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DictionaryDuplicateKeyCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DictionaryStaticKeyCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DictionaryStaticKeyCheck.java
index 2aafc43bfe..a787bae10e 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DictionaryStaticKeyCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DictionaryStaticKeyCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DirectTypeComparisonCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DirectTypeComparisonCheck.java
index deb3a15641..ebea455369 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DirectTypeComparisonCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DirectTypeComparisonCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -20,15 +20,13 @@
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.BinaryExpression;
-import org.sonar.plugins.python.api.tree.CallExpression;
import org.sonar.plugins.python.api.tree.Expression;
import org.sonar.plugins.python.api.tree.Token;
+import org.sonar.python.checks.utils.Expressions;
import org.sonar.python.tree.TreeUtils;
import static org.sonar.plugins.python.api.symbols.Symbol.Kind.CLASS;
-import static org.sonar.plugins.python.api.tree.Tree.Kind.CALL_EXPR;
import static org.sonar.plugins.python.api.tree.Tree.Kind.COMPARISON;
@Rule(key = "S6660")
@@ -53,13 +51,7 @@ private static void checkBinaryExpression(SubscriptionContext ctx, BinaryExpress
}
private static boolean isDirectTypeComparison(Expression lhs, Expression rhs) {
- return (isTypeBuiltinCall(lhs) && TreeUtils.getSymbolFromTree(rhs).filter(s -> s.is(CLASS)).isPresent())
- || (isTypeBuiltinCall(rhs) && TreeUtils.getSymbolFromTree(lhs).filter(s -> s.is(CLASS)).isPresent());
- }
-
- private static boolean isTypeBuiltinCall(Expression expression) {
- if (!expression.is(CALL_EXPR)) return false;
- Symbol calleeSymbol = ((CallExpression) expression).calleeSymbol();
- return calleeSymbol != null && "type".equals(calleeSymbol.fullyQualifiedName());
+ return (Expressions.isBuiltinTypeCall(lhs) && TreeUtils.getSymbolFromTree(rhs).filter(s -> s.is(CLASS)).isPresent())
+ || (Expressions.isBuiltinTypeCall(rhs) && TreeUtils.getSymbolFromTree(lhs).filter(s -> s.is(CLASS)).isPresent());
}
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DjangoNonDictSerializationCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DjangoNonDictSerializationCheck.java
index 1bc5fc7d9d..27254a9cca 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DjangoNonDictSerializationCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DjangoNonDictSerializationCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DjangoRenderContextCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DjangoRenderContextCheck.java
index b78f77e3d4..fc95de644c 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DjangoRenderContextCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DjangoRenderContextCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DoublePrefixOperatorCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DoublePrefixOperatorCheck.java
index 7ea28d62e4..cabf38be9c 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DoublePrefixOperatorCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DoublePrefixOperatorCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -23,7 +23,8 @@
import org.sonar.plugins.python.api.tree.ParenthesizedExpression;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.UnaryExpression;
-import org.sonar.python.types.InferredTypes;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
@Rule(key = "S2761")
@@ -31,6 +32,7 @@ public class DoublePrefixOperatorCheck extends PythonSubscriptionCheck {
private static final String MESSAGE = "Use the \"%s\" operator just once or not at all.";
private static final String MESSAGE_NOT = "Use the \"bool()\" builtin function instead of calling \"not\" twice.";
+ private static final TypeMatcher IS_INT = TypeMatchers.isObjectOfType("builtins.int");
@Override
public void initialize(Context context) {
@@ -52,7 +54,7 @@ private static void doubleInversionCheck(SubscriptionContext ctx, UnaryExpressio
if (invertedExpr.is(Tree.Kind.NOT)) {
ctx.addIssue(original, MESSAGE_NOT);
} else {
- if (((UnaryExpression) invertedExpr).expression().type() == InferredTypes.INT) {
+ if (IS_INT.isTrueFor(((UnaryExpression) invertedExpr).expression(), ctx)) {
ctx.addIssue(original, String.format(MESSAGE, original.operator().value()));
}
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DuplicateArgumentCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DuplicateArgumentCheck.java
index 309d6a421a..bd41c95752 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DuplicateArgumentCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DuplicateArgumentCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DuplicateBaseClassCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DuplicateBaseClassCheck.java
new file mode 100644
index 0000000000..d49223bcb6
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/DuplicateBaseClassCheck.java
@@ -0,0 +1,87 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.CheckForNull;
+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.tree.ArgList;
+import org.sonar.plugins.python.api.tree.Argument;
+import org.sonar.plugins.python.api.tree.ClassDef;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.RegularArgument;
+import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.plugins.python.api.types.v2.FullyQualifiedNameHelper;
+
+@Rule(key = "S8509")
+public class DuplicateBaseClassCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Remove this duplicate base class.";
+ private static final String SECONDARY_MESSAGE = "Already listed here.";
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CLASSDEF, ctx -> checkClassDef((ClassDef) ctx.syntaxNode(), ctx));
+ }
+
+ private static void checkClassDef(ClassDef classDef, SubscriptionContext ctx) {
+ ArgList args = classDef.args();
+ if (args == null) {
+ return;
+ }
+ var duplicates = gatherDuplicates(args);
+ raiseOnDuplicates(duplicates, ctx);
+ }
+
+ private static Map> gatherDuplicates(ArgList args) {
+ Map> groups = new LinkedHashMap<>();
+ for (Argument argument : args.arguments()) {
+ if (argument instanceof RegularArgument regularArgument) {
+ if (regularArgument.keywordArgument() != null) {
+ continue;
+ }
+ String key = expressionKey(regularArgument.expression());
+ if (key != null) {
+ groups.computeIfAbsent(key, k -> new ArrayList<>()).add(regularArgument);
+ }
+ }
+ }
+ return groups;
+ }
+
+ private static void raiseOnDuplicates(Map> duplicates, SubscriptionContext ctx) {
+ for (List group : duplicates.values()) {
+ if (group.size() > 1) {
+ RegularArgument first = group.get(0);
+ PreciseIssue issue = ctx.addIssue(first.expression(), MESSAGE);
+ for (int i = 1; i < group.size(); i++) {
+ issue.secondary(group.get(i).expression(), SECONDARY_MESSAGE);
+ }
+ }
+ }
+ }
+
+ @CheckForNull
+ private static String expressionKey(Expression expression) {
+ return FullyQualifiedNameHelper.getFullyQualifiedName(expression.typeV2()).orElse(null);
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DuplicatedMethodFieldNamesCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DuplicatedMethodFieldNamesCheck.java
index b09a8ab93f..5974290dce 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DuplicatedMethodFieldNamesCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DuplicatedMethodFieldNamesCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/DuplicatedMethodImplementationCheck.java b/python-checks/src/main/java/org/sonar/python/checks/DuplicatedMethodImplementationCheck.java
index da330dda2b..c5e4ea45bb 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/DuplicatedMethodImplementationCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/DuplicatedMethodImplementationCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/EinopsSyntaxCheck.java b/python-checks/src/main/java/org/sonar/python/checks/EinopsSyntaxCheck.java
index baf45b8900..3c0b66e14b 100755
--- a/python-checks/src/main/java/org/sonar/python/checks/EinopsSyntaxCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/EinopsSyntaxCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ElseAfterLoopsWithoutBreakCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ElseAfterLoopsWithoutBreakCheck.java
index be37249f5a..23f6796cf8 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ElseAfterLoopsWithoutBreakCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ElseAfterLoopsWithoutBreakCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/EmptyCollectionConstructorCheck.java b/python-checks/src/main/java/org/sonar/python/checks/EmptyCollectionConstructorCheck.java
index 3cd6ce7f1b..7b754ea312 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/EmptyCollectionConstructorCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/EmptyCollectionConstructorCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/EmptyCollectionMembershipTestCheck.java b/python-checks/src/main/java/org/sonar/python/checks/EmptyCollectionMembershipTestCheck.java
new file mode 100644
index 0000000000..e385b1c6dd
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/EmptyCollectionMembershipTestCheck.java
@@ -0,0 +1,72 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+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.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.DictionaryLiteral;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.InExpression;
+import org.sonar.plugins.python.api.tree.ListLiteral;
+import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.plugins.python.api.tree.Tuple;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
+
+@Rule(key = "S8503")
+public class EmptyCollectionMembershipTestCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Remove this membership test on an empty collection; it will always be the same value.";
+
+ private static final TypeMatcher EMPTY_COLLECTION_CONSTRUCTOR = TypeMatchers.any(
+ TypeMatchers.isType("builtins.set"),
+ TypeMatchers.isType("builtins.tuple"),
+ TypeMatchers.isType("builtins.frozenset")
+ );
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.IN, EmptyCollectionMembershipTestCheck::checkInExpression);
+ }
+
+ private static void checkInExpression(SubscriptionContext ctx) {
+ InExpression inExpression = (InExpression) ctx.syntaxNode();
+ Expression rightOperand = inExpression.rightOperand();
+ if (isEmptyCollection(rightOperand, ctx)) {
+ ctx.addIssue(inExpression, MESSAGE);
+ }
+ }
+
+ private static boolean isEmptyCollection(Expression expression, SubscriptionContext ctx) {
+ if (expression instanceof ListLiteral listLiteral) {
+ return listLiteral.elements().expressions().isEmpty();
+ }
+ if (expression instanceof DictionaryLiteral dictionaryLiteral) {
+ return dictionaryLiteral.elements().isEmpty();
+ }
+ if (expression instanceof Tuple tuple) {
+ return tuple.elements().isEmpty();
+ }
+ if (expression instanceof CallExpression callExpression) {
+ return callExpression.arguments().isEmpty()
+ && EMPTY_COLLECTION_CONSTRUCTOR.isTrueFor(callExpression.callee(), ctx);
+ }
+ return false;
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/EmptyFunctionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/EmptyFunctionCheck.java
index c9a68cb5db..6c03821d10 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/EmptyFunctionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/EmptyFunctionCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/EmptyNestedBlockCheck.java b/python-checks/src/main/java/org/sonar/python/checks/EmptyNestedBlockCheck.java
index 4958ed63fb..ee4f608de2 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/EmptyNestedBlockCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/EmptyNestedBlockCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/EnumerateUnpackingCheck.java b/python-checks/src/main/java/org/sonar/python/checks/EnumerateUnpackingCheck.java
new file mode 100644
index 0000000000..e3a9b17c02
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/EnumerateUnpackingCheck.java
@@ -0,0 +1,143 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.ArrayList;
+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.tree.AnnotatedAssignment;
+import org.sonar.plugins.python.api.tree.AssignmentStatement;
+import org.sonar.plugins.python.api.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.CompoundAssignmentStatement;
+import org.sonar.plugins.python.api.tree.DelStatement;
+import org.sonar.plugins.python.api.tree.ExpressionList;
+import org.sonar.plugins.python.api.tree.ForStatement;
+import org.sonar.plugins.python.api.tree.Name;
+import org.sonar.plugins.python.api.tree.NumericLiteral;
+import org.sonar.plugins.python.api.tree.RegularArgument;
+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.tree.TreeUtils;
+
+@Rule(key = "S8518")
+public class EnumerateUnpackingCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Unpack the value from 'enumerate()' directly instead of using an index lookup.";
+ private static final String SECONDARY_MESSAGE = "Replace this index lookup with the unpacked value.";
+ private static final TypeMatcher ENUMERATE_MATCHER = TypeMatchers.isType("enumerate");
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.FOR_STMT, EnumerateUnpackingCheck::check);
+ }
+
+ private static void check(SubscriptionContext ctx) {
+ ForStatement forStatement = (ForStatement) ctx.syntaxNode();
+
+ if (forStatement.testExpressions().size() != 1) {
+ return;
+ }
+ if (!(forStatement.testExpressions().get(0) instanceof CallExpression call)) {
+ return;
+ }
+ if (!ENUMERATE_MATCHER.isTrueFor(call.callee(), ctx)) {
+ return;
+ }
+ if (forStatement.expressions().size() != 2) {
+ return;
+ }
+ if (!(forStatement.expressions().get(0) instanceof Name indexName)) {
+ return;
+ }
+ SymbolV2 indexSymbol = indexName.symbolV2();
+ if (indexSymbol == null) {
+ return;
+ }
+
+ RegularArgument startArg = TreeUtils.nthArgumentOrKeyword(1, "start", call.arguments());
+ if (startArg != null && !(startArg.expression() instanceof NumericLiteral num && "0".equals(num.valueAsString()))) {
+ return;
+ }
+
+ RegularArgument iterableArg = TreeUtils.nthArgumentOrKeyword(0, "iterable", call.arguments());
+ if (iterableArg == null || !(iterableArg.expression() instanceof Name iterableName)) {
+ return;
+ }
+ SymbolV2 iterableSymbol = iterableName.symbolV2();
+ if (iterableSymbol == null) {
+ return;
+ }
+
+ List matchingSubscripts = new ArrayList<>();
+ collectMatchingSubscripts(forStatement.body(), indexSymbol, iterableSymbol, matchingSubscripts);
+
+ if (matchingSubscripts.isEmpty()) {
+ return;
+ }
+ if (matchingSubscripts.stream().anyMatch(EnumerateUnpackingCheck::isSubscriptWriteTarget)) {
+ return;
+ }
+
+ PreciseIssue issue = ctx.addIssue(call, MESSAGE);
+ matchingSubscripts.forEach(subscript -> issue.secondary(subscript, SECONDARY_MESSAGE));
+ }
+
+ private static void collectMatchingSubscripts(Tree tree, SymbolV2 indexSymbol, SymbolV2 iterableSymbol, List result) {
+ for (Tree child : tree.children()) {
+ if (child instanceof SubscriptionExpression subscription && isMatchingSubscript(subscription, indexSymbol, iterableSymbol)) {
+ result.add(subscription);
+ }
+ collectMatchingSubscripts(child, indexSymbol, iterableSymbol, result);
+ }
+ }
+
+ private static boolean isMatchingSubscript(SubscriptionExpression subscription, SymbolV2 indexSymbol, SymbolV2 iterableSymbol) {
+ if (subscription.subscripts().expressions().size() != 1) {
+ return false;
+ }
+ if (!(subscription.subscripts().expressions().get(0) instanceof Name subscriptName)) {
+ return false;
+ }
+ if (!indexSymbol.equals(subscriptName.symbolV2())) {
+ return false;
+ }
+ if (!(subscription.object() instanceof Name objectName)) {
+ return false;
+ }
+ return iterableSymbol.equals(objectName.symbolV2());
+ }
+
+ private static boolean isSubscriptWriteTarget(SubscriptionExpression subscription) {
+ Tree parent = subscription.parent();
+ if (parent instanceof DelStatement) {
+ return true;
+ }
+ if (parent instanceof CompoundAssignmentStatement compound) {
+ return compound.lhsExpression() == subscription;
+ }
+ if (parent instanceof AnnotatedAssignment annotated) {
+ return annotated.variable() == subscription;
+ }
+ return parent instanceof ExpressionList exprList
+ && exprList.parent() instanceof AssignmentStatement;
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ExceptRethrowingCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ExceptRethrowingCheck.java
index bfa77475dd..88d2dc338a 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ExceptRethrowingCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ExceptRethrowingCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ExceptionCauseTypeCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ExceptionCauseTypeCheck.java
index 209b61a691..e34a290b08 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ExceptionCauseTypeCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ExceptionCauseTypeCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ExceptionGroupCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ExceptionGroupCheck.java
index d346f6dd25..111a36b9a1 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ExceptionGroupCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ExceptionGroupCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ExceptionNotThrownCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ExceptionNotThrownCheck.java
index 3a5b5052df..25d0be4833 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ExceptionNotThrownCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ExceptionNotThrownCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ExceptionSuperClassDeclarationCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ExceptionSuperClassDeclarationCheck.java
index 19b8b2620d..b249ba8ffd 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ExceptionSuperClassDeclarationCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ExceptionSuperClassDeclarationCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ExecStatementUsageCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ExecStatementUsageCheck.java
index 1d31b239b0..777d48541e 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ExecStatementUsageCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ExecStatementUsageCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ExitHasBadArgumentsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ExitHasBadArgumentsCheck.java
index 49db585c41..ac1d1fb030 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ExitHasBadArgumentsCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ExitHasBadArgumentsCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FStringNestingLevelCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FStringNestingLevelCheck.java
index 1534ad301d..e031373cbb 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FStringNestingLevelCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FStringNestingLevelCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FastAPIDependencyAnnotatedCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FastAPIDependencyAnnotatedCheck.java
index a2bc11c3cb..12acb5df04 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FastAPIDependencyAnnotatedCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FastAPIDependencyAnnotatedCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -16,6 +16,8 @@
*/
package org.sonar.python.checks;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import javax.annotation.Nullable;
@@ -26,7 +28,6 @@
import org.sonar.plugins.python.api.tree.Decorator;
import org.sonar.plugins.python.api.tree.Expression;
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.ParameterList;
import org.sonar.plugins.python.api.tree.SubscriptionExpression;
@@ -34,11 +35,17 @@
import org.sonar.plugins.python.api.tree.TypeAnnotation;
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;
@Rule(key = "S8410")
public class FastAPIDependencyAnnotatedCheck extends PythonSubscriptionCheck {
+ enum IssueReportingMode {
+ FILE_LOCAL_HEURISTIC,
+ DISABLE_FILE_LOCAL_SUPPRESSION
+ }
+
private static final String MESSAGE = "Use \"Annotated\" type hints for FastAPI dependency injection";
private static final Set ROUTES = Set.of(
@@ -67,13 +74,46 @@ public class FastAPIDependencyAnnotatedCheck extends PythonSubscriptionCheck {
);
private static final TypeMatcher TYPING_ANNOTATED_MATCHER = TypeMatchers.isType("typing.Annotated");
+ private static final Set ANNOTATED_FULLY_QUALIFIED_NAMES = Set.of(
+ "typing.Annotated",
+ "typing_extensions.Annotated"
+ );
+
+ private final IssueReportingMode issueReportingMode;
+ private final List pendingIssues = new ArrayList<>();
+ private SubscriptionContext fileContext;
+ private int annotatedStyleCount;
+ private int oldStyleCount;
+
+ public FastAPIDependencyAnnotatedCheck() {
+ this(IssueReportingMode.FILE_LOCAL_HEURISTIC);
+ }
+
+ FastAPIDependencyAnnotatedCheck(IssueReportingMode issueReportingMode) {
+ this.issueReportingMode = issueReportingMode;
+ }
@Override
public void initialize(Context context) {
- context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, FastAPIDependencyAnnotatedCheck::checkFunctionDef);
+ context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::resetFileState);
+ context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, this::checkFunctionDef);
}
- private static void checkFunctionDef(SubscriptionContext ctx) {
+ @Override
+ public void leaveFile() {
+ if (fileContext != null && shouldReportCollectedIssues()) {
+ pendingIssues.forEach(param -> fileContext.addIssue(param, MESSAGE));
+ }
+ }
+
+ private void resetFileState(SubscriptionContext ctx) {
+ pendingIssues.clear();
+ fileContext = ctx;
+ annotatedStyleCount = 0;
+ oldStyleCount = 0;
+ }
+
+ private void checkFunctionDef(SubscriptionContext ctx) {
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
if (!hasFastAPIRouteDecorator(functionDef, ctx)) {
@@ -85,9 +125,7 @@ private static void checkFunctionDef(SubscriptionContext ctx) {
return;
}
- parameterList.nonTuple().stream()
- .filter(param -> isParameterUsingOldDependencySyntax(param, ctx))
- .forEach(param -> ctx.addIssue(param, MESSAGE));
+ parameterList.nonTuple().forEach(param -> collectParameterStyle(param, ctx));
}
private static boolean hasFastAPIRouteDecorator(FunctionDef functionDef, SubscriptionContext ctx) {
@@ -97,7 +135,36 @@ private static boolean hasFastAPIRouteDecorator(FunctionDef functionDef, Subscri
.anyMatch(callExpr -> FASTAPI_ROUTE_METHODS_MATCHER.isTrueFor(callExpr.callee(), ctx));
}
- private static boolean isParameterUsingOldDependencySyntax(Parameter param, SubscriptionContext ctx) {
+ private void collectParameterStyle(Parameter param, SubscriptionContext ctx) {
+ TypeAnnotation typeAnnotation = param.typeAnnotation();
+ if (isUsingAnnotatedWithDependency(typeAnnotation, ctx)) {
+ annotatedStyleCount++;
+ return;
+ }
+
+ if (isParameterUsingOldDependencySyntax(param, typeAnnotation, ctx)) {
+ oldStyleCount++;
+ pendingIssues.add(param);
+ }
+ }
+
+ private boolean shouldReportCollectedIssues() {
+ final int minStyleSampleSize = 3;
+ final double oldStyleDominanceRatioThreshold = 0.75;
+ return switch (issueReportingMode) {
+ case FILE_LOCAL_HEURISTIC -> {
+ int totalStyleCount = oldStyleCount + annotatedStyleCount;
+ if (totalStyleCount < minStyleSampleSize) {
+ yield true;
+ }
+ double oldStyleRatio = (double) oldStyleCount / totalStyleCount;
+ yield oldStyleRatio < oldStyleDominanceRatioThreshold;
+ }
+ case DISABLE_FILE_LOCAL_SUPPRESSION -> true;
+ };
+ }
+
+ private static boolean isParameterUsingOldDependencySyntax(Parameter param, @Nullable TypeAnnotation typeAnnotation, SubscriptionContext ctx) {
Expression defaultValue = param.defaultValue();
if (!(defaultValue instanceof CallExpression callExpr)) {
return false;
@@ -107,7 +174,6 @@ private static boolean isParameterUsingOldDependencySyntax(Parameter param, Subs
return false;
}
- TypeAnnotation typeAnnotation = param.typeAnnotation();
return !isUsingAnnotatedWithDependency(typeAnnotation, ctx);
}
@@ -115,19 +181,25 @@ private static boolean isUsingAnnotatedWithDependency(@Nullable TypeAnnotation t
if (typeAnnotation == null) {
return false;
}
- Expression annotationExpr = typeAnnotation.expression();
- if (annotationExpr instanceof SubscriptionExpression subscriptionExpr) {
- Expression object = subscriptionExpr.object();
- if (object instanceof Name name && TYPING_ANNOTATED_MATCHER.isTrueFor(name, ctx)) {
- return subscriptionExpr.subscripts().expressions().stream()
- .anyMatch(expr -> {
- if (expr instanceof CallExpression callExpr) {
- return FASTAPI_DEPENDENCY_FUNCTIONS_MATCHER.isTrueFor(callExpr.callee(), ctx);
- }
- return false;
- });
- }
+ Expression annotationExpr = Expressions.removeParentheses(typeAnnotation.expression());
+ if (!(annotationExpr instanceof SubscriptionExpression subscriptionExpr) || !isAnnotatedObject(subscriptionExpr.object(), ctx)) {
+ return false;
+ }
+ return subscriptionExpr.subscripts().expressions().stream()
+ .anyMatch(expr -> {
+ if (expr instanceof CallExpression callExpr) {
+ return FASTAPI_DEPENDENCY_FUNCTIONS_MATCHER.isTrueFor(callExpr.callee(), ctx);
+ }
+ return false;
+ });
+ }
+
+ private static boolean isAnnotatedObject(Expression expression, SubscriptionContext ctx) {
+ if (TYPING_ANNOTATED_MATCHER.isTrueFor(expression, ctx)) {
+ return true;
}
- return false;
+ return TreeUtils.fullyQualifiedNameFromExpression(expression)
+ .filter(ANNOTATED_FULLY_QUALIFIED_NAMES::contains)
+ .isPresent();
}
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FastAPIFileUploadFormCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FastAPIFileUploadFormCheck.java
index 797a74f312..61aea9cae7 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FastAPIFileUploadFormCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FastAPIFileUploadFormCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FastAPIGenericRouteDecoratorCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FastAPIGenericRouteDecoratorCheck.java
index 52198130a9..49dadb8552 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FastAPIGenericRouteDecoratorCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FastAPIGenericRouteDecoratorCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FastAPIHTTPExceptionDocumentedCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FastAPIHTTPExceptionDocumentedCheck.java
index 718b04c659..1e9cdf6c1f 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FastAPIHTTPExceptionDocumentedCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FastAPIHTTPExceptionDocumentedCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -261,7 +261,11 @@ private static Optional extractStatusCode(Expression statusCodeExpr) {
return extractStatusCode(singleAssignedValue);
}
} else if (statusCodeExpr instanceof NumericLiteral numericLiteral) {
- return Optional.of((int) numericLiteral.valueAsLong());
+ try {
+ return Optional.of((int) numericLiteral.valueAsLong());
+ } catch (NumberFormatException e) {
+ return Optional.empty();
+ }
} else if (statusCodeExpr instanceof StringLiteral stringLiteral) {
try {
return Optional.of(Integer.parseInt(stringLiteral.trimmedQuotesValue()));
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 6dda10e40f..8fc051aa9e 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -18,22 +18,36 @@
import java.util.HashSet;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import java.util.stream.IntStream;
import java.util.stream.Stream;
+import javax.annotation.Nullable;
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.CallExpression;
+import org.sonar.plugins.python.api.tree.ClassDef;
import org.sonar.plugins.python.api.tree.Decorator;
import org.sonar.plugins.python.api.tree.Expression;
import org.sonar.plugins.python.api.tree.FunctionDef;
+import org.sonar.plugins.python.api.tree.LambdaExpression;
+import org.sonar.plugins.python.api.tree.ListLiteral;
+import org.sonar.plugins.python.api.tree.Name;
+import org.sonar.plugins.python.api.tree.Parameter;
+import org.sonar.plugins.python.api.tree.ParameterList;
+import org.sonar.plugins.python.api.tree.QualifiedExpression;
+import org.sonar.plugins.python.api.tree.RegularArgument;
+import org.sonar.plugins.python.api.tree.SubscriptionExpression;
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.tree.Tuple;
+import org.sonar.plugins.python.api.tree.TypeAnnotation;
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;
@@ -48,7 +62,7 @@ public class FastAPIPathParametersCheck extends PythonSubscriptionCheck {
private static final List HTTP_METHODS = List.of(
"get", "post", "put", "delete", "patch", "options", "head", "trace");
- private static final Pattern PATH_PARAM_PATTERN = Pattern.compile("\\{([^}:]+)(?::[^}]*)?\\}");
+ private static final Pattern PATH_PARAM_PATTERN = Pattern.compile("\\{([a-zA-Z_]\\w*)(?::[a-zA-Z_]\\w*)?\\}");
private static final TypeMatcher FASTAPI_ROUTE_MATCHER = TypeMatchers.any(
HTTP_METHODS.stream()
@@ -57,11 +71,23 @@ 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);
- }
- }
+ private static final TypeMatcher FASTAPI_DEPENDS_MATCHER = TypeMatchers.any(
+ TypeMatchers.isType("fastapi.param_functions.Depends"),
+ TypeMatchers.isType("fastapi.param_functions.Security"));
+ private static final TypeMatcher FASTAPI_PATH_MATCHER = TypeMatchers.isType("fastapi.param_functions.Path");
+ private static final TypeMatcher TYPING_ANNOTATED_MATCHER = TypeMatchers.isType("typing.Annotated");
+ private static final TypeMatcher FASTAPI_APPLICATION_OR_ROUTER_MATCHER = TypeMatchers.any(
+ TypeMatchers.isType("fastapi.FastAPI"),
+ TypeMatchers.isType("fastapi.applications.FastAPI"),
+ TypeMatchers.isType("fastapi.APIRouter"),
+ TypeMatchers.isType("fastapi.routing.APIRouter"));
+ private static final Set FASTAPI_APPLICATION_OR_ROUTER_NAMES = Set.of(
+ "FastAPI",
+ "fastapi.FastAPI",
+ "fastapi.applications.FastAPI",
+ "APIRouter",
+ "fastapi.APIRouter",
+ "fastapi.routing.APIRouter");
@Override
public void initialize(Context context) {
@@ -90,8 +116,410 @@ private static void checkDecorator(SubscriptionContext ctx, Decorator decorator,
return;
}
- FunctionParameterInfo paramInfo = extractFunctionParameters(functionDef);
- reportIssues(ctx, functionDef, pathParams, paramInfo);
+ DependencyCollector dependencyCollector = new DependencyCollector(ctx);
+ dependencyCollector.collect(functionDef, callExpr);
+
+ reportIssues(ctx, functionDef, pathParams, dependencyCollector);
+ }
+
+ private static final class DependencyCollector {
+ private final SubscriptionContext ctx;
+ private final Set names = new HashSet<>();
+ private final Set positionalOnlyParams = new HashSet<>();
+ private final Set visitedFunctions = new HashSet<>();
+ // True when a dynamic, unresolved, or unsupported source could still provide route-visible parameters.
+ // In that case missing-parameter issues are not reliable, but positional-only issues are still reported.
+ private boolean hasUnresolvedOrUnsupportedParameterSources;
+
+ DependencyCollector(SubscriptionContext ctx) {
+ this.ctx = ctx;
+ }
+
+ void collect(FunctionDef pathOperationFunction, CallExpression pathOperationDecoratorCall) {
+ visitFunction(pathOperationFunction, false, true);
+ visitDependenciesArgument(pathOperationDecoratorCall);
+ visitApplicationOrRouterDependencies(pathOperationDecoratorCall);
+ }
+
+ private void visitApplicationOrRouterDependencies(CallExpression pathOperationDecoratorCall) {
+ // FastAPI applies dependencies declared on FastAPI(...) / APIRouter(...) to every route
+ // registered on that object. For `router = APIRouter(dependencies=[Depends(dep)])` and
+ // `@router.get(...)`, inspect the receiver (`router`) as another dependency entry point.
+ if (!(pathOperationDecoratorCall.callee() instanceof QualifiedExpression qualifiedExpression)) {
+ return;
+ }
+
+ Expression receiver = resolveLocalAliasChain(qualifiedExpression.qualifier());
+ if (receiver instanceof CallExpression callExpression && isFastApiApplicationOrRouterCall(callExpression)) {
+ visitDependenciesArgument(callExpression);
+ }
+ }
+
+ private void visitDependenciesArgument(CallExpression callExpression) {
+ RegularArgument dependenciesArgument = TreeUtils.argumentByKeyword("dependencies", callExpression.arguments());
+ if (dependenciesArgument == null) {
+ return;
+ }
+
+ Expression dependenciesExpression = resolveLocalAliasChain(dependenciesArgument.expression());
+ if (!(dependenciesExpression instanceof ListLiteral || dependenciesExpression instanceof Tuple)) {
+ if (dependenciesExpression instanceof Name || dependenciesExpression instanceof QualifiedExpression) {
+ markUnresolvedParameterSource();
+ } else {
+ markUnsupportedParameterSource();
+ }
+ return;
+ }
+
+ Expressions.expressionsFromListOrTuple(dependenciesExpression).forEach(this::visitDependencyListElement);
+ }
+
+ private void visitDependencyListElement(Expression element) {
+ Expression dependency = resolveLocalAliasChain(element);
+ if (dependency instanceof CallExpression callExpression && isDependsCall(callExpression)) {
+ visitDependsCall(callExpression, null);
+ } else if (dependency instanceof Name || dependency instanceof QualifiedExpression) {
+ markUnresolvedParameterSource();
+ } else {
+ markUnsupportedParameterSource();
+ }
+ }
+
+ private void visitFunction(FunctionDef functionDef, boolean skipFirstParameter, boolean recordPositionalOnlyParams) {
+ if (!visitedFunctions.add(functionDef)) {
+ return;
+ }
+
+ ParameterList parameterList = functionDef.parameters();
+ if (parameterList == null) {
+ return;
+ }
+
+ visitParameterList(parameterList, skipFirstParameter, recordPositionalOnlyParams);
+ }
+
+ private void visitLambda(LambdaExpression lambdaExpression) {
+ ParameterList parameterList = lambdaExpression.parameters();
+ if (parameterList == null) {
+ return;
+ }
+ visitParameterList(parameterList, false, false);
+ }
+
+ private void visitParameterList(ParameterList parameterList, boolean skipFirstParameter, boolean recordPositionalOnlyParams) {
+ List parameters = parameterList.nonTuple().stream()
+ .skip(skipFirstParameter ? 1 : 0)
+ .toList();
+
+ int slashParameterIndex = IntStream.range(0, parameters.size())
+ .filter(i -> isSlashParameter(parameters.get(i)))
+ .findFirst()
+ .orElse(-1);
+ for (int i = 0; i < parameters.size(); i++) {
+ Parameter parameter = parameters.get(i);
+ if (parameter.name() != null) {
+ visitParameter(parameter, slashParameterIndex != -1 && i < slashParameterIndex, recordPositionalOnlyParams);
+ }
+ }
+ }
+
+ private void visitParameter(Parameter parameter, boolean isPositionalOnly, boolean recordPositionalOnlyParams) {
+ if (parameter.starToken() != null) {
+ if ("**".equals(parameter.starToken().value())) {
+ markUnresolvedParameterSource();
+ }
+ return;
+ }
+
+ Set pathAliases = new HashSet<>();
+ Expression parameterType = visitTypeAnnotation(parameter.typeAnnotation(), pathAliases::add).orElse(null);
+ visitDefaultValue(parameter.defaultValue(), parameterType, pathAliases::add);
+
+ Name parameterName = parameter.name();
+ if (parameterName != null) {
+ if (pathAliases.isEmpty()) {
+ names.add(parameterName.name());
+ if (isPositionalOnly && recordPositionalOnlyParams) {
+ positionalOnlyParams.add(parameterName.name());
+ }
+ } else {
+ names.addAll(pathAliases);
+ if (isPositionalOnly && recordPositionalOnlyParams) {
+ positionalOnlyParams.addAll(pathAliases);
+ }
+ }
+ }
+ }
+
+ private Optional visitTypeAnnotation(@Nullable TypeAnnotation typeAnnotation, Consumer pathAliasConsumer) {
+ if (typeAnnotation == null) {
+ return Optional.empty();
+ }
+ return visitTypeAnnotationExpression(typeAnnotation.expression(), new HashSet<>(), pathAliasConsumer);
+ }
+
+ private Optional visitTypeAnnotationExpression(Expression annotationExpression, Set visitedAliases, Consumer pathAliasConsumer) {
+ // Parenthesized annotations are common in formatted multiline Annotated[...] expressions.
+ Expression expression = Expressions.removeParentheses(annotationExpression);
+
+ if (expression instanceof Name name) {
+ Optional assignedExpression = assignedTypeAliasValue(name, visitedAliases);
+ if (assignedExpression.isPresent()) {
+ return visitTypeAnnotationExpression(assignedExpression.get(), visitedAliases, pathAliasConsumer);
+ }
+ }
+
+ if (expression instanceof SubscriptionExpression subscriptionExpression && isAnnotatedObject(subscriptionExpression.object())) {
+ // Annotated stores the real type first; FastAPI metadata such as Depends and Path follows.
+ List subscripts = subscriptionExpression.subscripts().expressions();
+ if (subscripts.isEmpty()) {
+ return Optional.empty();
+ }
+ Expression baseType = subscripts.get(0);
+ subscripts.stream()
+ .skip(1)
+ .forEach(metadata -> visitAnnotationMetadata(metadata, baseType, pathAliasConsumer));
+ return Optional.of(baseType);
+ }
+
+ if (expression instanceof CallExpression) {
+ markUnsupportedParameterSource();
+ }
+
+ return Optional.of(expression);
+ }
+
+ private void visitAnnotationMetadata(Expression metadata, @Nullable Expression parameterType, Consumer pathAliasConsumer) {
+ visitDependencyMarkerExpression(metadata, parameterType, pathAliasConsumer);
+ }
+
+ private void visitDefaultValue(@Nullable Expression defaultValue, @Nullable Expression parameterType, Consumer pathAliasConsumer) {
+ if (defaultValue != null) {
+ visitDependencyMarkerExpression(defaultValue, parameterType, pathAliasConsumer);
+ }
+ }
+
+ // FastAPI reads dependency/path marker objects from both parameter defaults
+ // and Annotated[...] metadata, so both contexts share the same handling.
+ private void visitDependencyMarkerExpression(Expression expression, @Nullable Expression parameterType, Consumer pathAliasConsumer) {
+ Expression resolvedExpression = resolveLocalAliasChain(expression);
+ if (resolvedExpression instanceof CallExpression callExpression) {
+ if (isDependsCall(callExpression)) {
+ visitDependsCall(callExpression, parameterType);
+ } else if (isPathCall(callExpression)) {
+ visitPathAlias(callExpression, pathAliasConsumer);
+ } else {
+ markUnsupportedParameterSource();
+ }
+ } else if (resolvedExpression instanceof Name || resolvedExpression instanceof QualifiedExpression) {
+ markUnresolvedParameterSource();
+ }
+ }
+
+ private void visitDependsCall(CallExpression dependsCall, @Nullable Expression parameterType) {
+ Optional explicitTarget = TreeUtils.nthArgumentOrKeywordOptional(0, "dependency", dependsCall.arguments())
+ .map(RegularArgument::expression);
+ Expression target = explicitTarget.orElse(parameterType);
+ if (target == null) {
+ // Bare Depends() without a parameter type gives FastAPI no callable to inspect.
+ return;
+ }
+ visitDependencyCallable(target);
+ }
+
+ private void visitPathAlias(CallExpression pathCall, Consumer pathAliasConsumer) {
+ RegularArgument aliasArgument = TreeUtils.argumentByKeyword("alias", pathCall.arguments());
+ if (aliasArgument == null) {
+ return;
+ }
+ Optional alias = extractStringValue(aliasArgument.expression());
+ if (alias.isPresent()) {
+ pathAliasConsumer.accept(alias.get());
+ } else {
+ markUnresolvedParameterSource();
+ }
+ }
+
+ private void visitDependencyCallable(Expression expression) {
+ // FastAPI accepts any callable dependency. We only inspect callable shapes that can be resolved locally.
+ Expression target = resolveLocalAliasChain(expression);
+
+ Optional functionDef = getFunctionDef(target);
+ if (functionDef.isPresent()) {
+ // Depends(get_item) / Depends(SomeClass.get_item): FastAPI inspects the dependency function signature.
+ visitFunction(functionDef.get(), false, false);
+ return;
+ }
+
+ Optional classDef = getClassDef(target);
+ if (classDef.isPresent()) {
+ // Depends(ItemQuery) / Depends() with an Annotated class type: FastAPI inspects the constructor signature.
+ visitClassConstructor(classDef.get());
+ return;
+ }
+
+ if (target instanceof CallExpression callExpression) {
+ // Depends(ItemChecker()) / checker = ItemChecker(); Depends(checker): FastAPI inspects the instance __call__ signature.
+ // FastAPI accepts arbitrary callable results from dependency factory functions, but we cannot inspect them statically.
+ Expression callee = resolveLocalAliasChain(callExpression.callee());
+ Optional calleeClassDef = getClassDef(callee);
+ if (calleeClassDef.isPresent()) {
+ visitCallableInstance(calleeClassDef.get());
+ return;
+ }
+ }
+
+ if (target instanceof LambdaExpression lambdaExpression) {
+ // Depends(lambda item_id: ...): FastAPI accepts any callable, including lambdas.
+ visitLambda(lambdaExpression);
+ return;
+ }
+
+ if (target instanceof Name || target instanceof QualifiedExpression) {
+ markUnresolvedParameterSource();
+ } else {
+ markUnsupportedParameterSource();
+ }
+ }
+
+ private void visitClassConstructor(ClassDef classDef) {
+ Optional initFunction = topLevelMethod(classDef, "__init__");
+ if (initFunction.isPresent()) {
+ visitFunction(initFunction.get(), true, false);
+ } else if (!classDef.decorators().isEmpty() || classDef.args() != null) {
+ // Decorators and base classes can generate or inherit constructor parameters.
+ markUnsupportedParameterSource();
+ }
+ }
+
+ private void visitCallableInstance(ClassDef classDef) {
+ Optional callFunction = topLevelMethod(classDef, "__call__");
+ if (callFunction.isPresent()) {
+ visitFunction(callFunction.get(), true, false);
+ } else if (!classDef.decorators().isEmpty() || classDef.args() != null) {
+ // Decorators and base classes can generate or inherit callable behavior.
+ markUnsupportedParameterSource();
+ }
+ }
+
+ private static Expression resolveLocalAliasChain(Expression expression) {
+ // Follow simple local aliases like `route_deps = [Deps(...)]` while preserving the last expression when resolution stops.
+ Expression target = Expressions.removeParentheses(expression);
+ Set visitedAliases = new HashSet<>();
+ while (target instanceof Name name) {
+ Expression assignedValue = Expressions.singleAssignedValue(name, visitedAliases);
+ if (assignedValue == null) {
+ return target;
+ }
+ target = Expressions.removeParentheses(assignedValue);
+ }
+ return target;
+ }
+
+ private Optional assignedTypeAliasValue(Name name, Set visitedAliases) {
+ Expression assignedValue = Expressions.singleAssignedValue(name);
+ if (assignedValue == null) {
+ return Optional.empty();
+ }
+ SymbolV2 symbol = name.symbolV2();
+ if (symbol != null && !visitedAliases.add(symbol)) {
+ // Stop chasing type aliases such as A = B; B = A. The unresolved alias chain may hide Annotated metadata.
+ markUnresolvedParameterSource();
+ return Optional.empty();
+ }
+ return Optional.of(Expressions.removeParentheses(assignedValue));
+ }
+
+ private static Optional topLevelMethod(ClassDef classDef, String methodName) {
+ return TreeUtils.topLevelFunctionDefs(classDef).stream()
+ .filter(functionDef -> methodName.equals(functionDef.name().name()))
+ .findFirst();
+ }
+
+ private static Optional getFunctionDef(Expression expression) {
+ return findDeclarationAncestor(expression, UsageV2.Kind.FUNC_DECLARATION, FunctionDef.class, Tree.Kind.FUNCDEF);
+ }
+
+ private static Optional getClassDef(Expression expression) {
+ return findDeclarationAncestor(expression, UsageV2.Kind.CLASS_DECLARATION, ClassDef.class, Tree.Kind.CLASSDEF);
+ }
+
+ private static Optional findDeclarationAncestor(Expression expression, UsageV2.Kind usageKind, Class declarationClass, Tree.Kind declarationKind) {
+ // Map a resolved symbol usage back to the local declaration syntax node.
+ Name name;
+ if (expression instanceof Name n) {
+ name = n;
+ } else if (expression instanceof QualifiedExpression qe) {
+ name = qe.name();
+ } else {
+ name = null;
+ }
+ if (name == null) {
+ return Optional.empty();
+ }
+ SymbolV2 symbol = name.symbolV2();
+ if (symbol == null) {
+ return Optional.empty();
+ }
+ return symbol.usages().stream()
+ .filter(u -> u.kind() == usageKind)
+ .map(UsageV2::tree)
+ .map(tree -> TreeUtils.firstAncestorOfKind(tree, declarationKind))
+ .filter(Objects::nonNull)
+ .map(declarationClass::cast)
+ .findFirst();
+ }
+
+ private boolean isAnnotatedObject(Expression expression) {
+ if (TYPING_ANNOTATED_MATCHER.isTrueFor(expression, ctx)) {
+ return true;
+ }
+ return localAliasResolvedName(expression)
+ .filter(name -> "Annotated".equals(name) || "typing.Annotated".equals(name) || "typing_extensions.Annotated".equals(name))
+ .isPresent();
+ }
+
+ private boolean isDependsCall(CallExpression callExpression) {
+ return FASTAPI_DEPENDS_MATCHER.isTrueFor(callExpression.callee(), ctx)
+ || localAliasResolvedName(callExpression.callee()).filter(name -> "Depends".equals(name) || "Security".equals(name)).isPresent();
+ }
+
+ private boolean isPathCall(CallExpression callExpression) {
+ return FASTAPI_PATH_MATCHER.isTrueFor(callExpression.callee(), ctx)
+ || localAliasResolvedName(callExpression.callee()).filter("Path"::equals).isPresent();
+ }
+
+ private boolean isFastApiApplicationOrRouterCall(CallExpression callExpression) {
+ return FASTAPI_APPLICATION_OR_ROUTER_MATCHER.isTrueFor(callExpression.callee(), ctx)
+ || localAliasResolvedName(callExpression.callee())
+ .filter(FASTAPI_APPLICATION_OR_ROUTER_NAMES::contains)
+ .isPresent();
+ }
+
+ private static Optional localAliasResolvedName(Expression expression) {
+ return TreeUtils.stringValueFromNameOrQualifiedExpression(resolveLocalAliasChain(expression));
+ }
+
+ private static boolean isSlashParameter(Parameter parameter) {
+ return parameter.starToken() != null && "/".equals(parameter.starToken().value());
+ }
+
+ private void markUnresolvedParameterSource() {
+ // Use when resolving further would likely require dynamic or intermodule data-flow analysis.
+ hasUnresolvedOrUnsupportedParameterSources = true;
+ }
+
+ private void markUnsupportedParameterSource() {
+ // Use when the expression is not one of the FastAPI shapes we explicitly support. Some such
+ // expressions may still be valid FastAPI usage that we do not model statically yet, such as
+ // unpacking, calls, conditional expressions, or subscriptions producing Depends(...) or
+ // Path(...) values;
+ // others may be plainly invalid API usage and could theoretically be ignored. Proving that
+ // precisely is non-trivial and better left to type checking, so keep the rule conservative
+ // and bail out.
+ hasUnresolvedOrUnsupportedParameterSources = true;
+ }
+
}
private static Set extractPathParameters(CallExpression callExpr) {
@@ -114,54 +542,18 @@ 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) {
+ private static void reportIssues(
+ SubscriptionContext ctx,
+ FunctionDef functionDef,
+ Set pathParams,
+ DependencyCollector dependencyCollector) {
pathParams.stream()
- .filter(param -> isMissingFromSignature(param, paramInfo))
+ .filter(param -> !dependencyCollector.names.contains(param))
+ .filter(param -> !dependencyCollector.hasUnresolvedOrUnsupportedParameterSources)
.forEach(param -> ctx.addIssue(functionDef.name(), String.format(MISSING_PARAM_MESSAGE, param)));
pathParams.stream()
- .filter(paramInfo.positionalOnlyParams::contains)
+ .filter(dependencyCollector.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/FastAPIRedundantResponseModelCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FastAPIRedundantResponseModelCheck.java
index a18b2305ce..264d82c4e4 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FastAPIRedundantResponseModelCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FastAPIRedundantResponseModelCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FastApiImportStringCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FastApiImportStringCheck.java
index ab3858e626..521207e9b1 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FastApiImportStringCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FastApiImportStringCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FieldDuplicatesClassNameCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FieldDuplicatesClassNameCheck.java
index 6e264b317c..91a1816f56 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FieldDuplicatesClassNameCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FieldDuplicatesClassNameCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FieldNameCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FieldNameCheck.java
index 151cf73050..119138d0da 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FieldNameCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FieldNameCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FileComplexityCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FileComplexityCheck.java
index 2fa51e2d13..4921c09a59 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FileComplexityCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FileComplexityCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FileHeaderCopyrightCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FileHeaderCopyrightCheck.java
index c585c5d745..419fa62fef 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FileHeaderCopyrightCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FileHeaderCopyrightCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FilePermissionsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FilePermissionsCheck.java
index e79b2e7464..3bf0d93d34 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FilePermissionsCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FilePermissionsCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -99,7 +99,11 @@ private static boolean isUnsafeExpression(Expression expression, int safeModulo,
}
if (expression.is(Tree.Kind.NUMERIC_LITERAL)) {
NumericLiteral numericLiteral = (NumericLiteral) expression;
- return numericLiteral.valueAsLong() % 8 != safeModulo;
+ try {
+ return numericLiteral.valueAsLong() % 8 != safeModulo;
+ } catch (NumberFormatException nfe) {
+ return false;
+ }
}
if (expression.is(Tree.Kind.NAME)) {
Expression singleAssignedValue = Expressions.singleAssignedValue(((Name) expression));
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FixmeCommentCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FixmeCommentCheck.java
index 0bf4f13df7..23035be23f 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FixmeCommentCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FixmeCommentCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FlaskErrorHandlerStatusCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FlaskErrorHandlerStatusCheck.java
index c74b1e64ff..0af449eb98 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FlaskErrorHandlerStatusCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FlaskErrorHandlerStatusCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedJWTSecretKeyCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedJWTSecretKeyCheck.java
index f7a0b90f23..34015ff5d6 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedJWTSecretKeyCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedJWTSecretKeyCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedSecret.java b/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedSecret.java
index 99bb75615a..d70fffc7cf 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedSecret.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedSecret.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -38,6 +38,7 @@
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.python.checks.utils.Expressions;
import org.sonar.python.tree.TreeUtils;
+import org.sonarsource.analyzer.commons.appsec.SecretClassifier;
public abstract class FlaskHardCodedSecret extends PythonSubscriptionCheck {
@@ -133,20 +134,20 @@ private boolean isIllegalKeyValuePair(KeyValuePair keyValuePair) {
.map(StringLiteral.class::cast)
.map(StringLiteral::trimmedQuotesValue)
.filter(getSecretKeyKeyword()::equals)
- .isPresent() && isStringValue(keyValuePair.value());
+ .isPresent() && isPotentialSecretValue(keyValuePair.value());
}
private Optional getIllegalKeywordArgument(CallExpression callExpression) {
return Optional.ofNullable(TreeUtils.argumentByKeyword(getSecretKeyKeyword(), callExpression.arguments()))
.filter(argument -> Optional.of(argument)
.map(RegularArgument::expression)
- .filter(FlaskHardCodedSecret::isStringValue)
+ .filter(FlaskHardCodedSecret::isPotentialSecretValue)
.isPresent());
}
private void verifyAssignmentStatement(SubscriptionContext ctx) {
AssignmentStatement assignmentStatementTree = (AssignmentStatement) ctx.syntaxNode();
- if (!isStringValue(assignmentStatementTree.assignedValue())) {
+ if (!isPotentialSecretValue(assignmentStatementTree.assignedValue())) {
return;
}
List expressionList = assignmentStatementTree.lhsExpressions().stream()
@@ -181,24 +182,27 @@ protected boolean isSensitiveProperty(Expression expression) {
.isPresent();
}
- private static boolean isStringValue(@Nullable Expression expr) {
- return isStringValue(expr, new HashSet<>());
+ // True when expr resolves to a string literal that SecretClassifier does NOT recognize as a known non-secret placeholder.
+ private static boolean isPotentialSecretValue(@Nullable Expression expr) {
+ return resolveStringValue(expr, new HashSet<>())
+ .filter(value -> !SecretClassifier.isKnownNonSecret(value))
+ .isPresent();
}
-
- private static boolean isStringValue(@Nullable Expression expr, Set visited) {
+ private static Optional resolveStringValue(@Nullable Expression expr, Set visited) {
if (expr == null) {
- return false;
+ return Optional.empty();
}
if (expr.is(Tree.Kind.NAME)) {
if (visited.contains(((Name) expr).name())) {
- return false;
+ return Optional.empty();
}
visited.add(((Name) expr).name());
Expression assignmentValueExpression = Expressions.singleAssignedValue((Name) expr);
- return isStringValue(assignmentValueExpression, visited);
- } else {
- return expr.is(Tree.Kind.STRING_LITERAL);
+ return resolveStringValue(assignmentValueExpression, visited);
+ } else if (expr.is(Tree.Kind.STRING_LITERAL)) {
+ return Optional.of(((StringLiteral) expr).trimmedQuotesValue());
}
+ return Optional.empty();
}
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedSecretKeyCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedSecretKeyCheck.java
index 9d85c2cc4f..cfefc2084f 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedSecretKeyCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FlaskHardCodedSecretKeyCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FlaskHeadersDictAccessCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FlaskHeadersDictAccessCheck.java
index 2eafe15541..e8cad03c89 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FlaskHeadersDictAccessCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FlaskHeadersDictAccessCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FlaskPostWithQueryParameterCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FlaskPostWithQueryParameterCheck.java
index 59d817257f..c2769c6335 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FlaskPostWithQueryParameterCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FlaskPostWithQueryParameterCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FlaskPreprocessRequestCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FlaskPreprocessRequestCheck.java
index b229647765..4d6c83dc7e 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FlaskPreprocessRequestCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FlaskPreprocessRequestCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
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 39d35088fd..1f63affec5 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -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-checks/src/main/java/org/sonar/python/checks/FlaskSendFileMimeTypeCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FlaskSendFileMimeTypeCheck.java
index e77b67d670..a463f1f3ed 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FlaskSendFileMimeTypeCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FlaskSendFileMimeTypeCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FlaskViewDecoratorCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FlaskViewDecoratorCheck.java
index e49deacc24..723c72cdd1 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FlaskViewDecoratorCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FlaskViewDecoratorCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
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 9641caa28a..01f60f8d11 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -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/FunctionComplexityCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FunctionComplexityCheck.java
index dac8b775b4..7041724b76 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FunctionComplexityCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FunctionComplexityCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FunctionNameCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FunctionNameCheck.java
index ce6f370303..c37c98f0b5 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FunctionNameCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FunctionNameCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FunctionReturnTypeCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FunctionReturnTypeCheck.java
index 3a8af277d4..3410cfdd16 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FunctionReturnTypeCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FunctionReturnTypeCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/FunctionUsingLoopVariableCheck.java b/python-checks/src/main/java/org/sonar/python/checks/FunctionUsingLoopVariableCheck.java
index c6991f8418..c1593147c1 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/FunctionUsingLoopVariableCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/FunctionUsingLoopVariableCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/GenericClassTypeParameterCheck.java b/python-checks/src/main/java/org/sonar/python/checks/GenericClassTypeParameterCheck.java
index 70da79648b..cb5bb03caf 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/GenericClassTypeParameterCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/GenericClassTypeParameterCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
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 f206a835e2..a35ec6133b 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -19,11 +19,20 @@
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.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.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,21 +40,72 @@
@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),
+ TypeMatchers.isType(EXCEPTION),
+ 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;
+ }
+ 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;
}
- 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.");
+ Expression argExpr = regArg.expression();
+ if (isObjectOfTypeExceptionOrBaseExceptionMatcher.isTrueFor(argExpr, ctx) && isExceptionFunctionLocal(argExpr, call)) {
+ ctx.addIssue(argExpr, MESSAGE);
}
- });
+ }
+ }
+
+ private static boolean isExceptionFunctionLocal(Expression expression, Tree contextTree) {
+ if (!(expression instanceof Name name)) return true;
+ SymbolV2 symbolV2 = name.symbolV2();
+ return symbolV2 == null || isLocalVariable(symbolV2, contextTree);
+ }
+
+ private static boolean isLocalVariable(SymbolV2 symbol, Tree contextTree) {
+ Tree function = TreeUtils.firstAncestorOfKind(contextTree, 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/main/java/org/sonar/python/checks/GenericFunctionTypeParameterCheck.java b/python-checks/src/main/java/org/sonar/python/checks/GenericFunctionTypeParameterCheck.java
index 8bb0a24e6e..617e6f06e8 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/GenericFunctionTypeParameterCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/GenericFunctionTypeParameterCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/GenericTypeStatementCheck.java b/python-checks/src/main/java/org/sonar/python/checks/GenericTypeStatementCheck.java
index a434a3a48f..4582965299 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/GenericTypeStatementCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/GenericTypeStatementCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/GenericTypeWithoutArgumentCheck.java b/python-checks/src/main/java/org/sonar/python/checks/GenericTypeWithoutArgumentCheck.java
index a3177033dd..eba3c13fb7 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/GenericTypeWithoutArgumentCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/GenericTypeWithoutArgumentCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/GraphQLDenialOfServiceCheck.java b/python-checks/src/main/java/org/sonar/python/checks/GraphQLDenialOfServiceCheck.java
index c40a31559a..0fa3c2f66c 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/GraphQLDenialOfServiceCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/GraphQLDenialOfServiceCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -93,8 +93,8 @@ private static boolean hasSafeMiddlewares(List arguments) {
}
Optional argumentValue = Expressions.ifNameGetSingleAssignedNonNameValue(argument.expression());
- boolean isNotTupleNorListLiteral = argumentValue.filter(a -> a.is(Tree.Kind.LIST_LITERAL, Tree.Kind.TUPLE)).isEmpty();
- return isNotTupleNorListLiteral || Expressions.expressionsFromListOrTuple(argumentValue.get()).stream().anyMatch(GraphQLDenialOfServiceCheck::isSafeMiddlewareName);
+ boolean isNotCollectionLiteral = argumentValue.filter(a -> a.is(Tree.Kind.LIST_LITERAL, Tree.Kind.TUPLE, Tree.Kind.SET_LITERAL)).isEmpty();
+ return isNotCollectionLiteral || Expressions.expressionsFromListOrTuple(argumentValue.get()).stream().anyMatch(GraphQLDenialOfServiceCheck::isSafeMiddlewareName);
}
private static boolean hasSafeValidationRules(List arguments) {
@@ -104,8 +104,8 @@ private static boolean hasSafeValidationRules(List arguments) {
}
Optional argumentValue = Expressions.ifNameGetSingleAssignedNonNameValue(argument.expression());
- boolean isNotTupleNorListLiteral = argumentValue.filter(a -> a.is(Tree.Kind.LIST_LITERAL, Tree.Kind.TUPLE)).isEmpty();
- return isNotTupleNorListLiteral || Expressions.expressionsFromListOrTuple(argumentValue.get()).stream().anyMatch(GraphQLDenialOfServiceCheck::isSafeValidationRule);
+ boolean isNotCollectionLiteral = argumentValue.filter(a -> a.is(Tree.Kind.LIST_LITERAL, Tree.Kind.TUPLE, Tree.Kind.SET_LITERAL)).isEmpty();
+ return isNotCollectionLiteral || Expressions.expressionsFromListOrTuple(argumentValue.get()).stream().anyMatch(GraphQLDenialOfServiceCheck::isSafeValidationRule);
}
private static boolean isSafeValidationRule(Expression value) {
diff --git a/python-checks/src/main/java/org/sonar/python/checks/GroupByIteratorReuseCheck.java b/python-checks/src/main/java/org/sonar/python/checks/GroupByIteratorReuseCheck.java
new file mode 100644
index 0000000000..078897d304
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/GroupByIteratorReuseCheck.java
@@ -0,0 +1,213 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+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.quickfix.PythonQuickFix;
+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.ArgList;
+import org.sonar.plugins.python.api.tree.AssignmentStatement;
+import org.sonar.plugins.python.api.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.ForStatement;
+import org.sonar.plugins.python.api.tree.Name;
+import org.sonar.plugins.python.api.tree.QualifiedExpression;
+import org.sonar.plugins.python.api.tree.RegularArgument;
+import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.plugins.python.api.tree.YieldExpression;
+import org.sonar.plugins.python.api.tree.YieldStatement;
+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;
+
+@Rule(key = "S8516")
+public class GroupByIteratorReuseCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Consume this group iterator inside the loop, or materialize it into a collection.";
+ private static final String QUICK_FIX_MESSAGE = "Wrap with \"list()\"";
+
+ private static final TypeMatcher GROUPBY_MATCHER = TypeMatchers.isType("itertools.groupby");
+
+ private static final TypeMatcher SAFE_CONSUMER_MATCHER = TypeMatchers.any(
+ TypeMatchers.isType("list"),
+ TypeMatchers.isType("tuple"),
+ TypeMatchers.isType("set"),
+ TypeMatchers.isType("frozenset"),
+ TypeMatchers.isType("sorted"),
+ TypeMatchers.isType("sum"),
+ TypeMatchers.isType("max"),
+ TypeMatchers.isType("min"),
+ TypeMatchers.isType("any"),
+ TypeMatchers.isType("all"),
+ TypeMatchers.isType("next"),
+ TypeMatchers.isType("len"),
+ TypeMatchers.isType("str.join"),
+ TypeMatchers.isType("bytes.join")
+ );
+
+ // Matches class objects produced at runtime via `type(...)` (e.g. `Cls = type(obj); Cls(group)`).
+ // Direct class references (`MyClass`) are NOT matched here
+ private static final TypeMatcher RUNTIME_CLASS_OBJECT_MATCHER = TypeMatchers.isObjectOfType("type");
+
+ // Container methods that store their argument *as a single element* without iterating it.
+ private static final Set STORING_METHOD_NAMES = Set.of(
+ "append", "add", "setdefault"
+ );
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.FOR_STMT, GroupByIteratorReuseCheck::checkForStatement);
+ }
+
+ private static void checkForStatement(SubscriptionContext ctx) {
+ ForStatement forStatement = (ForStatement) ctx.syntaxNode();
+ Name groupName = extractGroupByLoopVariable(forStatement, ctx).orElse(null);
+ if (groupName == null) {
+ return;
+ }
+ SymbolV2 groupSymbol = groupName.symbolV2();
+ if (groupSymbol == null) {
+ return;
+ }
+
+ Tree loopBody = forStatement.body();
+
+ // Bail on any rebinding of `group` in the body to avoid requiring a CFG
+ boolean isReboundInLoopBody = namesInLoopBody(groupSymbol, loopBody,
+ usage -> usage.kind() == UsageV2.Kind.ASSIGNMENT_LHS).findAny().isPresent();
+ if (isReboundInLoopBody) {
+ return;
+ }
+
+ List loopBodyReads = namesInLoopBody(groupSymbol, loopBody,
+ usage -> !usage.isBindingUsage()).toList();
+
+ List unsafeReads = loopBodyReads.stream()
+ .filter(nameUsage -> isUnsafeRead(nameUsage, forStatement, ctx))
+ .toList();
+
+ // Quickfix only when there is a single read in the body: wrapping `group` in `list()`
+ // consumes the iterator and would silently break any other read.
+ boolean canOfferQuickFix = loopBodyReads.size() == 1 && unsafeReads.size() == 1;
+ for (Name nameUsage : unsafeReads) {
+ var issue = ctx.addIssue(nameUsage, MESSAGE);
+ if (canOfferQuickFix) {
+ PythonQuickFix quickFix = PythonQuickFix.newQuickFix(QUICK_FIX_MESSAGE)
+ .addTextEdit(TextEditUtils.insertBefore(nameUsage, "list("))
+ .addTextEdit(TextEditUtils.insertAfter(nameUsage, ")"))
+ .build();
+ issue.addQuickFix(quickFix);
+ }
+ }
+ }
+
+ // Matches `for key, group in groupby(...):` and returns the `group` name
+ private static Optional extractGroupByLoopVariable(ForStatement forStatement, SubscriptionContext ctx) {
+ if (forStatement.testExpressions().size() != 1
+ || !(forStatement.testExpressions().get(0) instanceof CallExpression callExpr)
+ || !GROUPBY_MATCHER.isTrueFor(callExpr.callee(), ctx)
+ || forStatement.expressions().size() != 2
+ || !(forStatement.expressions().get(1) instanceof Name groupName)) {
+ return Optional.empty();
+ }
+ return Optional.of(groupName);
+ }
+
+ // Recognized escape sinks: lambda/nested-function capture, assignment rvalue, yield, and
+ // positional argument of a known storing-method. Anything else is treated as safe.
+ private static boolean isUnsafeRead(Name nameUsage, ForStatement enclosingForStatement, SubscriptionContext ctx) {
+ if (isCapturedByNestedFunctionOrLambda(nameUsage, enclosingForStatement)) {
+ return true;
+ }
+ return reachesSink(nameUsage, ctx);
+ }
+
+ private static boolean reachesSink(Expression expression, SubscriptionContext ctx) {
+ Tree parent = expression.parent();
+ if (parent instanceof AssignmentStatement assign && assign.assignedValue() == expression) {
+ return true;
+ }
+ if (parent instanceof YieldExpression || parent instanceof YieldStatement) {
+ return true;
+ }
+ // Keyword arguments are skipped (treated as safe): mapping them to the callee's parameter would
+ // require signature resolution, and iterators are overwhelmingly passed positionally in practice.
+ if (parent instanceof RegularArgument regularArg && regularArg.keywordArgument() == null) {
+ return chainReachesSink(regularArg, ctx);
+ }
+ return false;
+ }
+
+ private static boolean chainReachesSink(RegularArgument arg, SubscriptionContext ctx) {
+ CallExpression call = owningCall(arg).orElse(null);
+ if (call == null) {
+ return false;
+ }
+ if (isSafeConsumerCallee(call.callee(), ctx)) {
+ return false;
+ }
+ if (isStoringMethodCall(call)) {
+ return true;
+ }
+ return reachesSink(call, ctx);
+ }
+
+ private static boolean isSafeConsumerCallee(Expression callee, SubscriptionContext ctx) {
+ return !SAFE_CONSUMER_MATCHER.evaluateFor(callee, ctx).isFalse()
+ || !RUNTIME_CLASS_OBJECT_MATCHER.evaluateFor(callee, ctx).isFalse();
+ }
+
+ // Name-only on purpose: gating on the receiver type would silently miss the case where the
+ // receiver's type cannot be resolved. Middle ground between FP risk and raising actual issues.
+ private static boolean isStoringMethodCall(CallExpression call) {
+ return call.callee() instanceof QualifiedExpression qualified
+ && STORING_METHOD_NAMES.contains(qualified.name().name());
+ }
+
+ private static boolean isCapturedByNestedFunctionOrLambda(Name nameUsage, ForStatement enclosingForStatement) {
+ Tree functionLikeAncestor = TreeUtils.firstAncestorOfKind(nameUsage, Tree.Kind.FUNCDEF, Tree.Kind.LAMBDA);
+ return functionLikeAncestor != null && isInside(functionLikeAncestor, enclosingForStatement.body());
+ }
+
+ private static Stream namesInLoopBody(SymbolV2 symbol, Tree loopBody, Predicate usageFilter) {
+ return symbol.usages().stream()
+ .filter(usageFilter)
+ .map(UsageV2::tree)
+ .flatMap(TreeUtils.toStreamInstanceOfMapper(Name.class))
+ .filter(name -> isInside(name, loopBody));
+ }
+
+ private static boolean isInside(Tree tree, Tree container) {
+ return TreeUtils.firstAncestor(tree, ancestor -> ancestor == container) != null;
+ }
+
+ private static Optional owningCall(RegularArgument regularArg) {
+ if (regularArg.parent() instanceof ArgList argList && argList.parent() instanceof CallExpression callExpr) {
+ return Optional.of(callExpr);
+ }
+ return Optional.empty();
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/HardcodedCredentialsCallCheck.java b/python-checks/src/main/java/org/sonar/python/checks/HardcodedCredentialsCallCheck.java
index 960417fac8..3613567160 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/HardcodedCredentialsCallCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/HardcodedCredentialsCallCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -44,6 +44,7 @@
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.python.checks.utils.Expressions;
import org.sonar.python.tree.TreeUtils;
+import org.sonarsource.analyzer.commons.appsec.SecretClassifier;
@Rule(key = "S6437")
public class HardcodedCredentialsCallCheck extends PythonSubscriptionCheck {
@@ -91,6 +92,7 @@ private static void checkArgument(SubscriptionContext ctx, RegularArgument argum
.map(StringLiteral.class::cast)
.filter(Predicate.not(HardcodedCredentialsCallCheck::containsFormattedExpressions))
.filter(HardcodedCredentialsCallCheck::isNotEmpty)
+ .filter(HardcodedCredentialsCallCheck::isNotKnownNonSecret)
.ifPresent(string -> ctx.addIssue(argument, MESSAGE));
} else if (argExp.is(Tree.Kind.NAME)) {
findAssignment((Name) argExp, 0)
@@ -98,6 +100,7 @@ private static void checkArgument(SubscriptionContext ctx, RegularArgument argum
.map(StringLiteral.class::cast)
.filter(Predicate.not(HardcodedCredentialsCallCheck::containsFormattedExpressions))
.filter(HardcodedCredentialsCallCheck::isNotEmpty)
+ .filter(HardcodedCredentialsCallCheck::isNotKnownNonSecret)
.ifPresent(assignedValue -> ctx.addIssue(argument, MESSAGE).secondary(assignedValue, MESSAGE));
}
}
@@ -106,7 +109,11 @@ private static boolean isNotEmpty(StringLiteral stringLiteral) {
return Optional.of(stringLiteral)
.map(StringLiteral::trimmedQuotesValue)
.filter(Predicate.not(String::isEmpty))
- .isPresent();
+ .isPresent();
+ }
+
+ private static boolean isNotKnownNonSecret(StringLiteral stringLiteral) {
+ return !SecretClassifier.isKnownNonSecret(stringLiteral.trimmedQuotesValue());
}
private static boolean containsFormattedExpressions(StringLiteral stringLiteral) {
diff --git a/python-checks/src/main/java/org/sonar/python/checks/HardcodedIPCheck.java b/python-checks/src/main/java/org/sonar/python/checks/HardcodedIPCheck.java
index 643f055eb9..375948c578 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/HardcodedIPCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/HardcodedIPCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -23,6 +23,12 @@
import javax.annotation.Nullable;
import org.sonar.check.Rule;
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
+import org.sonar.plugins.python.api.tree.AnnotatedAssignment;
+import org.sonar.plugins.python.api.tree.AssignmentStatement;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.ExpressionList;
+import org.sonar.plugins.python.api.tree.Name;
+import org.sonar.plugins.python.api.tree.ParenthesizedExpression;
import org.sonar.plugins.python.api.tree.StringLiteral;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.python.checks.utils.Expressions;
@@ -62,8 +68,11 @@ public class HardcodedIPCheck extends PythonSubscriptionCheck {
@Override
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Tree.Kind.STRING_LITERAL, ctx -> {
+ if (ctx.isLikelyTestFile()) {
+ return;
+ }
StringLiteral stringLiteral = (StringLiteral) ctx.syntaxNode();
- if (isMultilineString(stringLiteral)) {
+ if (isMultilineString(stringLiteral) || isVersionLiteral(stringLiteral)) {
return;
}
String content = Expressions.unescape(stringLiteral);
@@ -88,6 +97,32 @@ public void initialize(Context context) {
});
}
+ private static boolean isVersionLiteral(StringLiteral stringLiteral) {
+ Expression assignedValue = stringLiteral;
+ while (assignedValue.parent() instanceof ParenthesizedExpression parenthesizedExpression) {
+ assignedValue = parenthesizedExpression;
+ }
+ Tree parent = assignedValue.parent();
+ if (parent instanceof AssignmentStatement assignment) {
+ return assignment.assignedValue() == assignedValue && hasVersionName(assignment);
+ }
+ return parent instanceof AnnotatedAssignment assignment
+ && assignment.assignedValue() == assignedValue
+ && isVersionName(assignment.variable());
+ }
+
+ private static boolean hasVersionName(AssignmentStatement assignment) {
+ if (assignment.lhsExpressions().size() != 1) {
+ return false;
+ }
+ ExpressionList lhsExpressions = assignment.lhsExpressions().get(0);
+ return lhsExpressions.expressions().size() == 1 && isVersionName(lhsExpressions.expressions().get(0));
+ }
+
+ private static boolean isVersionName(Expression expression) {
+ return Expressions.removeParentheses(expression) instanceof Name name && "__version__".equals(name.name());
+ }
+
private static boolean isMultilineString(StringLiteral pyStringLiteralTree) {
return pyStringLiteralTree.stringElements().size() > 1;
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/HashMethodCheck.java b/python-checks/src/main/java/org/sonar/python/checks/HashMethodCheck.java
index 647bd4e2d7..da4a1aa05c 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/HashMethodCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/HashMethodCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
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 4cf7486494..f25a6522ff 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -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");
@@ -54,18 +51,14 @@ public class HttpNoContentNonEmptyBodyCheck extends PythonSubscriptionCheck {
TypeMatchers.withFQN("fastapi.applications.FastAPI.patch"),
TypeMatchers.withFQN("fastapi.applications.FastAPI.options"),
TypeMatchers.withFQN("fastapi.applications.FastAPI.head"),
- TypeMatchers.withFQN("fastapi.applications.FastAPI.trace")
- );
+ TypeMatchers.withFQN("fastapi.applications.FastAPI.trace"));
@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);
+ context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, HttpNoContentNonEmptyBodyCheck::checkFunctionDef);
}
- private void checkFunctionDef(SubscriptionContext ctx) {
+ private static void checkFunctionDef(SubscriptionContext ctx) {
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
if (!isFastApiEndpointWithNoContentStatus(ctx, functionDef)) {
@@ -79,9 +72,7 @@ private static boolean isFastApiEndpointWithNoContentStatus(SubscriptionContext
for (Decorator decorator : functionDef.decorators()) {
Expression decoratorExpression = decorator.expression();
- if (decoratorExpression.is(Tree.Kind.CALL_EXPR)) {
- CallExpression callExpr = (CallExpression) decoratorExpression;
-
+ if (decoratorExpression instanceof CallExpression callExpr) {
if (!FASTAPI_METHODS_MATCHER.isTrueFor(callExpr.callee(), ctx)) {
continue;
}
@@ -106,7 +97,7 @@ private static boolean isNoContentStatusValue(Expression expr) {
return false;
}
- private void findProblematicReturns(SubscriptionContext ctx, FunctionDef functionDef) {
+ private static void findProblematicReturns(SubscriptionContext ctx, FunctionDef functionDef) {
List allReturns = new ArrayList<>();
collectReturnStatements(functionDef.body(), allReturns);
@@ -141,7 +132,7 @@ private static void collectReturnStatements(Tree tree, List ret
tree.children().forEach(child -> collectReturnStatements(child, returns));
}
- private ValidationResult isValidReturnStatement(SubscriptionContext ctx, ReturnStatement returnStmt) {
+ private static ValidationResult isValidReturnStatement(SubscriptionContext ctx, ReturnStatement returnStmt) {
List expressions = returnStmt.expressions();
if (expressions.isEmpty()) {
@@ -167,39 +158,31 @@ private ValidationResult isValidReturnStatement(SubscriptionContext ctx, ReturnS
return new ValidationResult(false);
}
- private ValidationResult isValidResponseObject(SubscriptionContext ctx, Expression expr) {
+ private static ValidationResult isValidResponseObject(SubscriptionContext ctx, Expression expr) {
if (!FASTAPI_RESPONSE_INSTANCE.isTrueFor(expr, ctx)) {
return new ValidationResult(false);
}
List secondaryLocations = new ArrayList<>();
- if (expr.is(Tree.Kind.NAME)) {
- Name name = (Name) expr;
- var assignedValues = reachingDefinitionsAnalysis.valuesAtLocation(name);
+ if (expr instanceof Name name) {
+ var assignedValues = ctx.valuesAtLocation(name);
boolean anyInvalid = false;
for (Expression assignedValue : assignedValues) {
- if (assignedValue.is(Tree.Kind.CALL_EXPR)) {
- CallExpression callExpr = (CallExpression) assignedValue;
-
+ if (assignedValue instanceof CallExpression callExpr && isInvalidResponseCall(callExpr)) {
// Check if this Response has invalid arguments
- if (isInvalidResponseCall(callExpr)) {
- anyInvalid = true;
- secondaryLocations.add(assignedValue);
- }
+ anyInvalid = true;
+ secondaryLocations.add(assignedValue);
}
}
if (anyInvalid) {
return new ValidationResult(false, secondaryLocations);
}
- } else if (expr.is(Tree.Kind.CALL_EXPR)) {
+ } else if (expr instanceof CallExpression callExpr && isInvalidResponseCall(callExpr)) {
// Direct Response constructor call
- CallExpression callExpr = (CallExpression) expr;
- if (isInvalidResponseCall(callExpr)) {
- return new ValidationResult(false);
- }
+ return new ValidationResult(false);
}
return new ValidationResult(true);
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IdenticalExpressionOnBinaryOperatorCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IdenticalExpressionOnBinaryOperatorCheck.java
index 796aa7803c..e1ae8db69a 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IdenticalExpressionOnBinaryOperatorCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IdenticalExpressionOnBinaryOperatorCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IdentityComparisonWithCachedTypesCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IdentityComparisonWithCachedTypesCheck.java
index 2d2a0efa42..3dbbc899a3 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IdentityComparisonWithCachedTypesCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IdentityComparisonWithCachedTypesCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -29,9 +29,9 @@
import org.sonar.plugins.python.api.tree.IsExpression;
import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.Tree;
-import org.sonar.plugins.python.api.types.BuiltinTypes;
import org.sonar.python.quickfix.TextEditUtils;
import org.sonar.python.tree.TreeUtils;
+import org.sonar.plugins.python.api.types.BuiltinTypes;
import org.sonar.plugins.python.api.types.v2.PythonType;
import org.sonar.python.types.v2.TypeCheckBuilder;
import org.sonar.python.types.v2.TypeChecker;
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IdentityComparisonWithNewObjectCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IdentityComparisonWithNewObjectCheck.java
index 4f3d8b421e..126b48c502 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IdentityComparisonWithNewObjectCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IdentityComparisonWithNewObjectCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
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 1f0cbb685f..26ced29a41 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -43,11 +43,15 @@ 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;
}
- LiveVariablesAnalysis lva = LiveVariablesAnalysis.analyze(cfg);
+ LiveVariablesAnalysis lva = ctx.lva(functionDef);
+ if (lva == null) {
+ return;
+ }
+
Set unreachableBlocks = CfgUtils.unreachableBlocks(cfg);
cfg.blocks().forEach(block -> {
var unnecessaryAssignments = DeadStoreUtils.findUnnecessaryAssignments(block, lva.getLiveVariables(block), functionDef);
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IgnoredPureOperationsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IgnoredPureOperationsCheck.java
index dec50d4b84..8a616d028f 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IgnoredPureOperationsCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IgnoredPureOperationsCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IgnoredSystemExitCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IgnoredSystemExitCheck.java
index bbb984d2ca..e67d4af39f 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IgnoredSystemExitCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IgnoredSystemExitCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ImplicitStringConcatenationCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ImplicitStringConcatenationCheck.java
index 0792d6af9c..0e1a010b26 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ImplicitStringConcatenationCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ImplicitStringConcatenationCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IncompatibleOperands.java b/python-checks/src/main/java/org/sonar/python/checks/IncompatibleOperands.java
index 7f63caa14c..0f180beff2 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IncompatibleOperands.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IncompatibleOperands.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IncompatibleOperandsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IncompatibleOperandsCheck.java
index f876964838..0c104dd69e 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IncompatibleOperandsCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IncompatibleOperandsCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IncompleteComparisonMethodsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IncompleteComparisonMethodsCheck.java
new file mode 100644
index 0000000000..b8b3d07181
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/IncompleteComparisonMethodsCheck.java
@@ -0,0 +1,128 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+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.tree.AnnotatedAssignment;
+import org.sonar.plugins.python.api.tree.AssignmentStatement;
+import org.sonar.plugins.python.api.tree.BaseTreeVisitor;
+import org.sonar.plugins.python.api.tree.ClassDef;
+import org.sonar.plugins.python.api.tree.Decorator;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.ExpressionList;
+import org.sonar.plugins.python.api.tree.FunctionDef;
+import org.sonar.plugins.python.api.tree.Name;
+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;
+
+@Rule(key = "S8500")
+public class IncompleteComparisonMethodsCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Add the missing comparison methods or use \"functools.total_ordering\".";
+ private static final String SECONDARY_MESSAGE = "\"%s\" is defined here.";
+
+ private static final Set ORDERING_METHODS = Set.of("__lt__", "__le__", "__gt__", "__ge__");
+
+ private static final TypeMatcher TOTAL_ORDERING_MATCHER = TypeMatchers.withFQN("functools.total_ordering");
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CLASSDEF, IncompleteComparisonMethodsCheck::checkClassDef);
+ }
+
+ private static void checkClassDef(SubscriptionContext ctx) {
+ ClassDef classDef = (ClassDef) ctx.syntaxNode();
+
+ CollectOrderingMethodNamesVisitor visitor = new CollectOrderingMethodNamesVisitor();
+ classDef.body().accept(visitor);
+
+ if (visitor.definitions.isEmpty() || visitor.definitions.size() == ORDERING_METHODS.size()) {
+ return;
+ }
+
+ for (Decorator decorator : classDef.decorators()) {
+ if (TOTAL_ORDERING_MATCHER.isTrueFor(decorator.expression(), ctx)) {
+ return;
+ }
+ }
+
+ PreciseIssue issue = ctx.addIssue(classDef.name(), MESSAGE);
+ visitor.definitions.forEach((name, tree) -> issue.secondary(tree, String.format(SECONDARY_MESSAGE, name)));
+ }
+
+ /**
+ * Collects ordering methods defined at the top level of a class body, whether they are
+ * introduced by a {@code def} or by an assignment such as {@code __lt__ = lambda ...}.
+ * The map preserves the first occurrence per name in source order, which keeps secondary
+ * locations stable when a method is shadowed.
+ * Mirrors the recursion-control pattern of {@code TreeUtils.CollectFunctionDefsVisitor}:
+ * does not descend into nested classes or nested functions.
+ */
+ private static class CollectOrderingMethodNamesVisitor extends BaseTreeVisitor {
+ private final Map definitions = new LinkedHashMap<>();
+
+ @Override
+ public void visitClassDef(ClassDef nestedClass) {
+ // Do not descend into nested classes
+ }
+
+ @Override
+ public void visitFunctionDef(FunctionDef functionDef) {
+ Name name = functionDef.name();
+ if (ORDERING_METHODS.contains(name.name())) {
+ definitions.putIfAbsent(name.name(), name);
+ }
+ // Do not descend into nested functions
+ }
+
+ @Override
+ public void visitAssignmentStatement(AssignmentStatement assignment) {
+ assignmentTargetName(assignment)
+ .filter(name -> ORDERING_METHODS.contains(name.name()))
+ .ifPresent(name -> definitions.putIfAbsent(name.name(), name));
+ super.visitAssignmentStatement(assignment);
+ }
+
+ @Override
+ public void visitAnnotatedAssignment(AnnotatedAssignment annotated) {
+ if (annotated.assignedValue() != null && annotated.variable() instanceof Name name && ORDERING_METHODS.contains(name.name())) {
+ definitions.putIfAbsent(name.name(), name);
+ }
+ super.visitAnnotatedAssignment(annotated);
+ }
+
+ private static Optional assignmentTargetName(AssignmentStatement assignment) {
+ List lhsList = assignment.lhsExpressions();
+ if (lhsList.size() != 1) {
+ return Optional.empty();
+ }
+ List expressions = lhsList.get(0).expressions();
+ if (expressions.size() == 1 && expressions.get(0) instanceof Name name) {
+ return Optional.of(name);
+ }
+ return Optional.empty();
+ }
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/InconsistentTupleReturnCheck.java b/python-checks/src/main/java/org/sonar/python/checks/InconsistentTupleReturnCheck.java
new file mode 100644
index 0000000000..d09ff83d78
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/InconsistentTupleReturnCheck.java
@@ -0,0 +1,114 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.OptionalInt;
+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.tree.Expression;
+import org.sonar.plugins.python.api.tree.FunctionDef;
+import org.sonar.plugins.python.api.tree.ReturnStatement;
+import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.plugins.python.api.tree.Tuple;
+
+@Rule(key = "S8495")
+public class InconsistentTupleReturnCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Refactor this function to always return tuples of the same length.";
+ private static final String SECONDARY_MESSAGE = "Returns a %d-tuple.";
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, InconsistentTupleReturnCheck::checkFunction);
+ }
+
+ private static void checkFunction(SubscriptionContext ctx) {
+ FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
+
+ ReturnCheckUtils.ReturnStmtCollector collector = ReturnCheckUtils.ReturnStmtCollector.collect(functionDef);
+
+ if (collector.containsYield()) {
+ return;
+ }
+
+ List returnStmts = collector.getReturnStmts();
+
+ List tupleReturns = new ArrayList<>();
+ for (ReturnStatement returnStmt : returnStmts) {
+ OptionalInt length = getTupleLengthIfTupleReturn(returnStmt);
+ if (length.isPresent()) {
+ tupleReturns.add(new ReturnWithLength(returnStmt, length.getAsInt()));
+ }
+ }
+
+ if (tupleReturns.size() < 2) {
+ return;
+ }
+
+ int firstLength = tupleReturns.get(0).length;
+ boolean allSame = tupleReturns.stream().allMatch(r -> r.length == firstLength);
+ if (allSame) {
+ return;
+ }
+
+ PreciseIssue issue = ctx.addIssue(functionDef.name(), MESSAGE);
+ for (ReturnWithLength tupleReturn : tupleReturns) {
+ issue.secondary(tupleReturn.returnStmt, String.format(SECONDARY_MESSAGE, tupleReturn.length));
+ }
+ }
+
+ private static boolean containsUnpacking(List exprs) {
+ return exprs.stream().anyMatch(e -> e.is(Tree.Kind.UNPACKING_EXPR));
+ }
+
+ private static OptionalInt getTupleLengthIfTupleReturn(ReturnStatement returnStmt) {
+ List expressions = returnStmt.expressions();
+ if (expressions.isEmpty()) {
+ return OptionalInt.empty();
+ }
+ if (expressions.size() > 1) {
+ // Implicit tuple: return a, b — skip if any element is a star expression
+ if (containsUnpacking(expressions)) {
+ return OptionalInt.empty();
+ }
+ return OptionalInt.of(expressions.size());
+ }
+ // Single expression - check if it's an explicit tuple literal
+ Expression expr = expressions.get(0);
+ if (expr.is(Tree.Kind.TUPLE)) {
+ Tuple tuple = (Tuple) expr;
+ if (containsUnpacking(tuple.elements())) {
+ return OptionalInt.empty();
+ }
+ return OptionalInt.of(tuple.elements().size());
+ }
+ return OptionalInt.empty();
+ }
+
+ private static class ReturnWithLength {
+ final ReturnStatement returnStmt;
+ final int length;
+
+ ReturnWithLength(ReturnStatement returnStmt, int length) {
+ this.returnStmt = returnStmt;
+ this.length = length;
+ }
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/InconsistentTypeHintCheck.java b/python-checks/src/main/java/org/sonar/python/checks/InconsistentTypeHintCheck.java
index 909211ad9d..aff40d0814 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/InconsistentTypeHintCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/InconsistentTypeHintCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IncorrectExceptionTypeCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IncorrectExceptionTypeCheck.java
index 7616904918..84f3899ea4 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IncorrectExceptionTypeCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IncorrectExceptionTypeCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IncorrectParameterDatetimeConstructorsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IncorrectParameterDatetimeConstructorsCheck.java
index fd89aea51c..f4022b6a01 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IncorrectParameterDatetimeConstructorsCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IncorrectParameterDatetimeConstructorsCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -19,7 +19,6 @@
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.CallExpression;
import org.sonar.plugins.python.api.tree.Expression;
import org.sonar.plugins.python.api.tree.Name;
@@ -27,6 +26,8 @@
import org.sonar.plugins.python.api.tree.RegularArgument;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.UnaryExpression;
+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;
@@ -36,6 +37,9 @@ public class IncorrectParameterDatetimeConstructorsCheck extends PythonSubscript
private static final int MAX_YEAR = 9999;
private static final String MESSAGE = "Provide a correct value for the `%s` parameter.";
private static final String MESSAGE_SECONDARY_LOCATION = "An invalid value is assigned here.";
+ private static final TypeMatcher DATE_MATCHER = TypeMatchers.isType("datetime.date");
+ private static final TypeMatcher TIME_MATCHER = TypeMatchers.isType("datetime.time");
+ private static final TypeMatcher DATETIME_MATCHER = TypeMatchers.isType("datetime.datetime");
@Override
public void initialize(Context context) {
@@ -44,15 +48,12 @@ public void initialize(Context context) {
private static void checkCallExpr(SubscriptionContext context) {
CallExpression callExpression = (CallExpression) context.syntaxNode();
- Symbol calleeSymbol = callExpression.calleeSymbol();
- if (calleeSymbol == null) {
- return;
- }
- if ("datetime.date".equals(calleeSymbol.fullyQualifiedName())) {
+ Expression callee = callExpression.callee();
+ if (DATE_MATCHER.isTrueFor(callee, context)) {
checkDate(context, callExpression);
- } else if ("datetime.time".equals(calleeSymbol.fullyQualifiedName())) {
+ } else if (TIME_MATCHER.isTrueFor(callee, context)) {
checkTime(context, callExpression);
- } else if ("datetime.datetime".equals(calleeSymbol.fullyQualifiedName())) {
+ } else if (DATETIME_MATCHER.isTrueFor(callee, context)) {
checkDate(context, callExpression);
checkTime(context, callExpression, 3);
}
@@ -101,16 +102,20 @@ public Tree expression() {
}
private static ValueWithExpression getValue(Expression expression) {
- if (expression.is(Tree.Kind.NUMERIC_LITERAL)) {
- return new ValueWithExpression(((NumericLiteral) expression).valueAsLong(), expression);
- } else if (expression.is(Tree.Kind.UNARY_MINUS)) {
- UnaryExpression unaryExpression = (UnaryExpression) expression;
- if (!unaryExpression.expression().is(Tree.Kind.NUMERIC_LITERAL)) {
- return null;
+ try {
+ if (expression.is(Tree.Kind.NUMERIC_LITERAL)) {
+ return new ValueWithExpression(((NumericLiteral) expression).valueAsLong(), expression);
+ } else if (expression.is(Tree.Kind.UNARY_MINUS)) {
+ UnaryExpression unaryExpression = (UnaryExpression) expression;
+ if (!unaryExpression.expression().is(Tree.Kind.NUMERIC_LITERAL)) {
+ return null;
+ }
+ return new ValueWithExpression(-((NumericLiteral) unaryExpression.expression()).valueAsLong(), unaryExpression);
+ } else if (expression.is(Tree.Kind.NAME)) {
+ return Expressions.singleAssignedNonNameValue((Name) expression).map(IncorrectParameterDatetimeConstructorsCheck::getValue).orElse(null);
}
- return new ValueWithExpression(-((NumericLiteral) unaryExpression.expression()).valueAsLong(), unaryExpression);
- } else if (expression.is(Tree.Kind.NAME)) {
- return Expressions.singleAssignedNonNameValue((Name) expression).map(IncorrectParameterDatetimeConstructorsCheck::getValue).orElse(null);
+ } catch (NumberFormatException nfe) {
+ return null;
}
return null;
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IndexMethodCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IndexMethodCheck.java
index a924246350..f1ef7d4ff1 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IndexMethodCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IndexMethodCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/InefficientDictIterationCheck.java b/python-checks/src/main/java/org/sonar/python/checks/InefficientDictIterationCheck.java
index b3b116adb9..bc87602380 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/InefficientDictIterationCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/InefficientDictIterationCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/InequalityUsageCheck.java b/python-checks/src/main/java/org/sonar/python/checks/InequalityUsageCheck.java
index 9b35cf7b58..1e2b980a0b 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/InequalityUsageCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/InequalityUsageCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
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 29e6d8d4bf..aaef73b437 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -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/InitReturnsValueCheck.java b/python-checks/src/main/java/org/sonar/python/checks/InitReturnsValueCheck.java
index 4c56232d29..5ca3fd7499 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/InitReturnsValueCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/InitReturnsValueCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/InputInAsyncCheck.java b/python-checks/src/main/java/org/sonar/python/checks/InputInAsyncCheck.java
index be5c56cd24..aec5d1c8bc 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/InputInAsyncCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/InputInAsyncCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/InstanceAndClassMethodsAtLeastOnePositionalCheck.java b/python-checks/src/main/java/org/sonar/python/checks/InstanceAndClassMethodsAtLeastOnePositionalCheck.java
index 586cf56075..d1651a0973 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/InstanceAndClassMethodsAtLeastOnePositionalCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/InstanceAndClassMethodsAtLeastOnePositionalCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/InstanceMethodSelfAsFirstCheck.java b/python-checks/src/main/java/org/sonar/python/checks/InstanceMethodSelfAsFirstCheck.java
index c0b056e5dc..22ff1a3e64 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/InstanceMethodSelfAsFirstCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/InstanceMethodSelfAsFirstCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -93,6 +93,11 @@ private boolean isRelevantMethod(ClassDef classDef, ClassSymbol classSymbol, Fun
return false;
}
+ // Skip _generate_next_value_ in Enum subclasses: it is a static protocol method per the Python docs
+ if ("_generate_next_value_".equals(functionDef.name().name()) && classSymbol.isOrExtends("enum.Enum")) {
+ return false;
+ }
+
// Skip if the class has a ignored decorator
if (functionDef.decorators().stream().anyMatch(this::isNonInstanceMethodDecorator)) {
return false;
diff --git a/python-checks/src/main/java/org/sonar/python/checks/InvalidOpenModeCheck.java b/python-checks/src/main/java/org/sonar/python/checks/InvalidOpenModeCheck.java
index a693717534..9537e96da5 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/InvalidOpenModeCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/InvalidOpenModeCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -23,7 +23,6 @@
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;
import org.sonar.plugins.python.api.tree.Expression;
@@ -32,6 +31,8 @@
import org.sonar.plugins.python.api.tree.StringElement;
import org.sonar.plugins.python.api.tree.StringLiteral;
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;
@@ -41,13 +42,13 @@ public class InvalidOpenModeCheck extends PythonSubscriptionCheck {
private static final String VALID_MODES = "rwatb+Ux";
private static final Pattern INVALID_CHARACTERS = Pattern.compile("[^" + VALID_MODES + "]");
private static final String MESSAGE = "Fix this invalid mode string.";
+ private static final TypeMatcher OPEN_MATCHER = TypeMatchers.isType("open");
@Override
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, ctx -> {
CallExpression callExpression = (CallExpression) ctx.syntaxNode();
- Symbol calleeSymbol = callExpression.calleeSymbol();
- if (calleeSymbol == null || !"open".equals(calleeSymbol.fullyQualifiedName())) {
+ if (!OPEN_MATCHER.isTrueFor(callExpression.callee(), ctx)) {
return;
}
List arguments = callExpression.arguments();
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 480969b36b..d254f00f4d 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -42,6 +42,7 @@
import org.sonar.plugins.python.api.tree.Tree.Kind;
import org.sonar.plugins.python.api.tree.TryStatement;
import org.sonar.plugins.python.api.tree.UnaryExpression;
+import org.sonar.python.cfg.CfgUtils;
import org.sonar.python.cfg.PythonCfgBranchingBlock;
import org.sonar.python.tree.TreeUtils;
@@ -62,7 +63,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);
@@ -80,17 +81,21 @@ public void initialize(Context context) {
private static List collectLatestExecutedBlocks(ControlFlowGraph cfg) {
List collectedBlocks = new ArrayList<>();
+ Set reachableBlocks = CfgUtils.reachableBlocks(cfg);
for (CfgBlock predecessor : cfg.end().predecessors()) {
+ if (!reachableBlocks.contains(predecessor)) {
+ continue;
+ }
if (predecessor instanceof PythonCfgBranchingBlock pythonCfgBranchingBlock) {
- collectBranchingBlock(collectedBlocks, pythonCfgBranchingBlock);
- } else if (!endsWithElementKind(predecessor, Kind.RAISE_STMT)) {
+ collectBranchingBlock(collectedBlocks, pythonCfgBranchingBlock, reachableBlocks);
+ } else {
collectedBlocks.add(new LatestExecutedBlock(predecessor));
}
}
return collectedBlocks;
}
- private static void collectBranchingBlock(List collectedBlocks, PythonCfgBranchingBlock branchingBlock) {
+ private static void collectBranchingBlock(List collectedBlocks, PythonCfgBranchingBlock branchingBlock, Set reachableBlocks) {
Tree branchingTree = branchingBlock.branchingTree();
if (branchingTree.is(Kind.TRY_STMT)) {
TryStatement tryStatement = (TryStatement) branchingTree;
@@ -100,16 +105,20 @@ private static void collectBranchingBlock(List collectedBlo
} else if (branchingTree.is(Kind.IF_STMT) || branchingTree instanceof Pattern) {
collectedBlocks.add(new LatestExecutedBlock(branchingBlock));
} else {
- collectBlocksHavingReturnBeforeExceptOrFinallyBlock(collectedBlocks, branchingBlock);
+ collectBlocksHavingReturnBeforeExceptOrFinallyBlock(collectedBlocks, branchingBlock, reachableBlocks);
}
}
- private static void collectBlocksHavingReturnBeforeExceptOrFinallyBlock(List collectedBlocks, PythonCfgBranchingBlock branchingBlock) {
+ private static void collectBlocksHavingReturnBeforeExceptOrFinallyBlock(List collectedBlocks, PythonCfgBranchingBlock branchingBlock,
+ Set reachableBlocks) {
if (branchingBlock.branchingTree().is(Kind.EXCEPT_CLAUSE, Kind.FINALLY_CLAUSE)) {
for (CfgBlock predecessor : branchingBlock.predecessors()) {
+ if (!reachableBlocks.contains(predecessor)) {
+ continue;
+ }
if (predecessor instanceof PythonCfgBranchingBlock pythonCfgBranchingBlock) {
- collectBlocksHavingReturnBeforeExceptOrFinallyBlock(collectedBlocks, pythonCfgBranchingBlock);
- } else if (endsWithElementKind(predecessor, Kind.RETURN_STMT)) {
+ collectBlocksHavingReturnBeforeExceptOrFinallyBlock(collectedBlocks, pythonCfgBranchingBlock, reachableBlocks);
+ } else if (endsWithElementKind(predecessor, Kind.RETURN_STMT) || endsWithElementKind(predecessor, Kind.RAISE_STMT)) {
collectedBlocks.add(new LatestExecutedBlock(predecessor));
}
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IsCloseAbsTolCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IsCloseAbsTolCheck.java
index 7370f1f801..b0544b1d68 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IsCloseAbsTolCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IsCloseAbsTolCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -22,13 +22,14 @@
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
import org.sonar.plugins.python.api.SubscriptionContext;
import org.sonar.plugins.python.api.quickfix.PythonQuickFix;
-import org.sonar.plugins.python.api.symbols.Symbol;
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.NumericLiteral;
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.quickfix.TextEditUtils;
import org.sonar.python.tree.TreeUtils;
@@ -39,6 +40,7 @@ public class IsCloseAbsTolCheck extends PythonSubscriptionCheck {
private static final String MESSAGE = "Provide the \"abs_tol\" parameter when using \"math.isclose\" to compare a value to 0.";
private static final String SECONDARY_LOCATION_MESSAGE = "This argument evaluates to zero.";
private static final String QUICK_FIX_MESSAGE = "Add the \"abs_tol\" parameter.";
+ private static final TypeMatcher MATH_ISCLOSE = TypeMatchers.isType("math.isclose");
@Override
public void initialize(Context context) {
@@ -47,8 +49,7 @@ public void initialize(Context context) {
}
private static void checkForIsCloseAbsTolArgument(SubscriptionContext ctx, CallExpression call) {
- Symbol symbol = call.calleeSymbol();
- if (symbol != null && "math.isclose".equals(symbol.fullyQualifiedName())
+ if (MATH_ISCLOSE.isTrueFor(call.callee(), ctx)
&& TreeUtils.argumentByKeyword("abs_tol", call.arguments()) == null) {
RegularArgument firstArg = TreeUtils.nthArgumentOrKeyword(0, "a", call.arguments());
RegularArgument secondArg = TreeUtils.nthArgumentOrKeyword(1, "b", call.arguments());
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ItemOperationsType.java b/python-checks/src/main/java/org/sonar/python/checks/ItemOperationsType.java
index 57a4f9c59f..9382ee1646 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ItemOperationsType.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ItemOperationsType.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ItemOperationsTypeCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ItemOperationsTypeCheck.java
index 8c348632fd..b7b0abff8c 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ItemOperationsTypeCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ItemOperationsTypeCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IterMethodReturnTypeCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IterMethodReturnTypeCheck.java
index 2ebc629766..24a43eec90 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IterMethodReturnTypeCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IterMethodReturnTypeCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IterationOnNonIterable.java b/python-checks/src/main/java/org/sonar/python/checks/IterationOnNonIterable.java
index e447be0e8a..8de74d3155 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IterationOnNonIterable.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IterationOnNonIterable.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/IterationOnNonIterableCheck.java b/python-checks/src/main/java/org/sonar/python/checks/IterationOnNonIterableCheck.java
index c1d704bf02..f511d4aa0a 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/IterationOnNonIterableCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/IterationOnNonIterableCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/JumpInFinallyCheck.java b/python-checks/src/main/java/org/sonar/python/checks/JumpInFinallyCheck.java
index 007a281e84..dcae33a537 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/JumpInFinallyCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/JumpInFinallyCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/JwtVerificationCheck.java b/python-checks/src/main/java/org/sonar/python/checks/JwtVerificationCheck.java
index 9314dc64f8..ac851f9acb 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/JwtVerificationCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/JwtVerificationCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -20,6 +20,7 @@
import java.util.List;
import java.util.Optional;
import java.util.Set;
+import java.util.function.Predicate;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import org.sonar.check.Rule;
@@ -27,22 +28,26 @@
import org.sonar.plugins.python.api.SubscriptionContext;
import org.sonar.plugins.python.api.symbols.Symbol;
import org.sonar.plugins.python.api.symbols.Usage;
-import org.sonar.plugins.python.api.tree.Argument;
import org.sonar.plugins.python.api.tree.AssignmentStatement;
+import org.sonar.plugins.python.api.tree.BinaryExpression;
import org.sonar.plugins.python.api.tree.CallExpression;
import org.sonar.plugins.python.api.tree.DictionaryLiteral;
import org.sonar.plugins.python.api.tree.Expression;
import org.sonar.plugins.python.api.tree.ExpressionList;
+import org.sonar.plugins.python.api.tree.InExpression;
import org.sonar.plugins.python.api.tree.KeyValuePair;
import org.sonar.plugins.python.api.tree.ListLiteral;
import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.QualifiedExpression;
import org.sonar.plugins.python.api.tree.RegularArgument;
+import org.sonar.plugins.python.api.tree.StringElement;
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.tree.Tree.Kind;
import org.sonar.plugins.python.api.tree.Tuple;
+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;
@@ -80,6 +85,14 @@ public class JwtVerificationCheck extends PythonSubscriptionCheck {
public static final Set VERIFY_SIGNATURE_OPTION_SUPPORTING_FUNCTION_FQNS = Set.of("jose.jwt.decode", "jwt.decode");
+ private static final Set EQUALITY_COMPARATORS = Set.of("==", "!=");
+
+ private static final String ALGORITHMS_KEYWORD = "algorithms";
+
+ private static final Set ISSUER_CLAIM_KEY = Set.of("iss");
+
+ private static final TypeMatcher IS_ENUM_MATCHER = TypeMatchers.isOrExtendsType("enum.Enum");
+
@Override
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Kind.CALL_EXPR, JwtVerificationCheck::verifyCallExpression);
@@ -96,7 +109,7 @@ private static void verifyCallExpression(SubscriptionContext ctx) {
String calleeFqn = calleeSymbol.fullyQualifiedName();
if (WHERE_VERIFY_KWARG_SHOULD_BE_TRUE_FQNS.contains(calleeFqn)) {
RegularArgument verifyArg = TreeUtils.argumentByKeyword("verify", call.arguments());
- if (verifyArg != null && Expressions.isFalsy(verifyArg.expression())) {
+ if (verifyArg != null && Expressions.isFalsy(verifyArg.expression()) && !isVerifiedElsewhere(call) && !isIssuerRoutingPattern(call, ctx)) {
ctx.addIssue(verifyArg, MESSAGE);
return;
}
@@ -114,10 +127,214 @@ private static void verifyCallExpression(SubscriptionContext ctx) {
Optional.ofNullable(TreeUtils.argumentByKeyword("options", call.arguments()))
.map(RegularArgument::expression)
.filter(JwtVerificationCheck::isListOrDictWithSensitiveEntry)
+ .filter(expression -> !isVerifiedElsewhere(call) && !isIssuerRoutingPattern(call, ctx))
.ifPresent(expression -> ctx.addIssue(expression, MESSAGE));
}
}
+ /**
+ * "Peek then verify" pattern (multi-tenant JWT key discovery): an unverified decode of a token is
+ * compliant if the same token is decoded again elsewhere in the same function/module with real
+ * signature verification. If the token argument can't be resolved to a symbol, we can't disprove
+ * such a call exists, so we assume compliance rather than raise a false positive.
+ */
+ private static boolean isVerifiedElsewhere(CallExpression unverifiedCall) {
+ Symbol tokenSymbol = tokenArgumentSymbol(unverifiedCall);
+ if (tokenSymbol == null) {
+ return true;
+ }
+ Tree scope = TreeUtils.firstAncestorOfKind(unverifiedCall, Kind.FILE_INPUT, Kind.FUNCDEF);
+ return scope != null && TreeUtils.hasDescendant(scope, tree -> isVerifyingCallOnToken(tree, unverifiedCall, tokenSymbol));
+ }
+
+ private static boolean isVerifyingCallOnToken(Tree tree, CallExpression unverifiedCall, Symbol tokenSymbol) {
+ return TreeUtils.toOptionalInstanceOf(CallExpression.class, tree)
+ .filter(call -> call != unverifiedCall)
+ .filter(JwtVerificationCheck::isDecodeOrVerifyCall)
+ .filter(call -> tokenSymbol.equals(tokenArgumentSymbol(call)))
+ .filter(call -> !isUnverifiedShape(call))
+ .filter(JwtVerificationCheck::hasKeyArgument)
+ .isPresent();
+ }
+
+ private static boolean hasKeyArgument(CallExpression call) {
+ RegularArgument keyArg = TreeUtils.nthArgumentOrKeyword(1, "key", call.arguments());
+ return keyArg != null && !Expressions.isFalsy(keyArg.expression());
+ }
+
+ private static boolean isDecodeOrVerifyCall(CallExpression call) {
+ return Optional.ofNullable(call.calleeSymbol())
+ .map(Symbol::fullyQualifiedName)
+ .filter(fqn -> WHERE_VERIFY_KWARG_SHOULD_BE_TRUE_FQNS.contains(fqn) || VERIFY_SIGNATURE_OPTION_SUPPORTING_FUNCTION_FQNS.contains(fqn))
+ .isPresent();
+ }
+
+ private static boolean isUnverifiedShape(CallExpression call) {
+ RegularArgument verifyArg = TreeUtils.argumentByKeyword("verify", call.arguments());
+ if (verifyArg != null && Expressions.isFalsy(verifyArg.expression())) {
+ return true;
+ }
+ return Optional.ofNullable(TreeUtils.argumentByKeyword("options", call.arguments()))
+ .map(RegularArgument::expression)
+ .filter(JwtVerificationCheck::isListOrDictWithSensitiveEntry)
+ .isPresent();
+ }
+
+ @Nullable
+ private static Symbol tokenArgumentSymbol(CallExpression call) {
+ return Optional.ofNullable(TreeUtils.nthArgumentOrKeyword(0, "jwt", call.arguments()))
+ .map(RegularArgument::expression)
+ .filter(expression -> expression.is(Kind.NAME))
+ .map(expression -> ((Name) expression).symbol())
+ .orElse(null);
+ }
+
+ /**
+ * "Issuer routing" pattern: an unverified decode is compliant if its payload's ONLY use is reading the
+ * {@code iss} claim, and that issuer is then checked against a static whitelist (enum conversion, set/list/tuple
+ * membership, or an all-literal-keys dict lookup) - the real signature verification typically happens in the
+ * caller, using a key resolved from the whitelisted issuer, so unlike {@link #isVerifiedElsewhere} this doesn't
+ * require the same token to be re-decoded in this function. No ordering is enforced between the payload access
+ * and the whitelist check (unlike {@link #isValidatedBeforeAlgorithmsUse}) - there's no sink this rule can see
+ * the issuer flow into (the resolved key is typically returned or passed to a caller-supplied parameter), so
+ * there's nothing to protect by requiring the whitelist check to come first.
+ */
+ private static boolean isIssuerRoutingPattern(CallExpression unverifiedCall, SubscriptionContext ctx) {
+ Tree assignment = TreeUtils.firstAncestorOfKind(unverifiedCall, Tree.Kind.ASSIGNMENT_STMT);
+ if (assignment == null) {
+ return false;
+ }
+ List lhsExpressions = ((AssignmentStatement) assignment).lhsExpressions().stream()
+ .map(ExpressionList::expressions)
+ .flatMap(Collection::stream).toList();
+ if (lhsExpressions.size() != 1 || !lhsExpressions.get(0).is(Tree.Kind.NAME)) {
+ return false;
+ }
+ Symbol payloadSymbol = ((Name) lhsExpressions.get(0)).symbol();
+ if (payloadSymbol == null) {
+ return false;
+ }
+ List usages = getForwardUsages(payloadSymbol, unverifiedCall).toList();
+ if (usages.isEmpty()) {
+ return false;
+ }
+ Tree scope = TreeUtils.firstAncestorOfKind(unverifiedCall, Kind.FILE_INPUT, Kind.FUNCDEF);
+ return scope != null && usages.stream().allMatch(usage -> isIssuerClaimAccessWhitelistedElsewhere(usage, scope, ctx));
+ }
+
+ /**
+ * Whether one usage of the payload Name is a `.get("iss")`/`["iss"]` access whose extracted value (the issuer)
+ * is validated against a static whitelist somewhere in {@code scope} - either via the Name it's assigned to
+ * ({@link #isIssuerWhitelisted}), or, when never assigned at all, by being a direct operand of an inline
+ * `in`/`not in` membership check against a literal collection ({@link #isInlineLiteralMembershipOperand}).
+ * The inline case mirrors the same "no trackable symbol" limitation {@link #extractedValueSymbol} already
+ * has for the algorithm-validation pattern elsewhere in this file - see review discussion on PR #1294.
+ */
+ private static boolean isIssuerClaimAccessWhitelistedElsewhere(Usage usage, Tree scope, SubscriptionContext ctx) {
+ Tree usageParent = usage.tree().parent();
+ Optional getCall = getCallExprWhereDictIsAccessedWithGet(Stream.of(usageParent)).findFirst();
+ if (getCall.isPresent()) {
+ Stream keys = getStringLiteralKeyArgument(getCall.get());
+ return isIssuerClaimKey(keys) && (isIssuerWhitelisted(getCall.get(), scope, ctx) || isInlineLiteralMembershipOperand(getCall.get()));
+ }
+ Optional subscription = getSubscriptions(Stream.of(usageParent)).findFirst();
+ if (subscription.isPresent()) {
+ Stream keys = getSubscriptsStringLiteral(Stream.of(subscription.get()));
+ return isIssuerClaimKey(keys) && (isIssuerWhitelisted(subscription.get(), scope, ctx) || isInlineLiteralMembershipOperand(subscription.get()));
+ }
+ return false;
+ }
+
+ /**
+ * Whether {@code extractionSite} (e.g. {@code payload.get("iss")}) is itself a direct operand of an
+ * `in`/`not in` check against a literal string collection, e.g. {@code payload.get("iss") not in {"a", "b"}}.
+ * Unlike {@link #isLiteralMembershipGuardOnSymbol}, this needs no assignment/symbol - the extraction site is
+ * checked directly, covering the case where the claim is compared without ever being bound to a variable.
+ */
+ private static boolean isInlineLiteralMembershipOperand(Tree extractionSite) {
+ return TreeUtils.toOptionalInstanceOf(InExpression.class, extractionSite.parent())
+ .filter(inExpr -> inExpr.leftOperand() == extractionSite)
+ .map(InExpression::rightOperand)
+ .flatMap(Expressions::ifNameGetSingleAssignedNonNameValue)
+ .map(Expressions::expressionsFromListOrTuple)
+ .filter(elements -> !elements.isEmpty() && elements.stream().allMatch(JwtVerificationCheck::isNonInterpolatedStringLiteral))
+ .isPresent();
+ }
+
+ private static boolean isNonInterpolatedStringLiteral(Expression element) {
+ return TreeUtils.toOptionalInstanceOf(StringLiteral.class, element)
+ .filter(literal -> literal.stringElements().stream().noneMatch(StringElement::isInterpolated))
+ .isPresent();
+ }
+
+ private static boolean isIssuerClaimKey(Stream keyLiterals) {
+ List keys = keyLiterals.toList();
+ return !keys.isEmpty() && keys.stream().allMatch(str -> ISSUER_CLAIM_KEY.contains(str.trimmedQuotesValue()));
+ }
+
+ private static boolean isIssuerWhitelisted(Tree issuerExtractionSite, Tree scope, SubscriptionContext ctx) {
+ Optional issuerSymbol = extractedValueSymbol(issuerExtractionSite);
+ if (issuerSymbol.isEmpty()) {
+ return false;
+ }
+ Symbol symbol = issuerSymbol.get();
+ return TreeUtils.hasDescendant(scope, tree -> isEnumConversionOfSymbol(tree, symbol, ctx))
+ || TreeUtils.hasDescendant(scope, tree -> isLiteralMembershipGuardOnSymbol(tree, symbol))
+ || TreeUtils.hasDescendant(scope, tree -> isDictLookupOnSymbolWithLiteralKeys(tree, symbol));
+ }
+
+ /**
+ * Whether {@code tree} is `symbol in [...]`/`symbol not in [...]` against a list/tuple/set literal (or a Name
+ * bound to one) whose elements are ALL string literals. Stricter than {@link #isAllowlistGuardOnSymbol} - that
+ * one only requires the guard to exist (enough to prevent algorithm confusion, since any real check beats none),
+ * but the ticket's "static whitelist" requirement here means a guard containing even one dynamic element
+ * (`issuer in {"a", dynamic_value}`) doesn't qualify: the whitelist isn't actually static.
+ */
+ private static boolean isLiteralMembershipGuardOnSymbol(Tree tree, Symbol symbol) {
+ return TreeUtils.toOptionalInstanceOf(InExpression.class, tree)
+ .filter(inExpr -> isNameOfSymbol(inExpr.leftOperand(), symbol))
+ .map(InExpression::rightOperand)
+ .flatMap(Expressions::ifNameGetSingleAssignedNonNameValue)
+ .map(Expressions::expressionsFromListOrTuple)
+ .filter(elements -> !elements.isEmpty() && elements.stream().allMatch(JwtVerificationCheck::isNonInterpolatedStringLiteral))
+ .isPresent();
+ }
+
+ /** Whether {@code tree} is a call passing {@code symbol} to a callee whose type is a declared Enum subclass, e.g. {@code AuthIssuer(issuer)}. */
+ private static boolean isEnumConversionOfSymbol(Tree tree, Symbol symbol, SubscriptionContext ctx) {
+ return TreeUtils.toOptionalInstanceOf(CallExpression.class, tree)
+ .filter(call -> call.arguments().stream()
+ .flatMap(TreeUtils.toStreamInstanceOfMapper(RegularArgument.class))
+ .map(RegularArgument::expression)
+ .anyMatch(expression -> isNameOfSymbol(expression, symbol)))
+ .map(CallExpression::callee)
+ .filter(callee -> IS_ENUM_MATCHER.isTrueFor(callee, ctx))
+ .isPresent();
+ }
+
+ /** Whether {@code tree} is `symbol[key]` (or `key = symbol[...]`'s RHS) where the subscripted base is a dict literal with only string-literal keys. */
+ private static boolean isDictLookupOnSymbolWithLiteralKeys(Tree tree, Symbol symbol) {
+ return TreeUtils.toOptionalInstanceOf(SubscriptionExpression.class, tree)
+ .filter(subscription -> subscription.subscripts().expressions().stream().anyMatch(expression -> isNameOfSymbol(expression, symbol)))
+ .map(SubscriptionExpression::object)
+ .flatMap(Expressions::ifNameGetSingleAssignedNonNameValue)
+ .flatMap(TreeUtils.toOptionalInstanceOfMapper(DictionaryLiteral.class))
+ .filter(JwtVerificationCheck::hasOnlyStringLiteralKeys)
+ .isPresent();
+ }
+
+ private static boolean hasOnlyStringLiteralKeys(DictionaryLiteral dictionaryLiteral) {
+ if (dictionaryLiteral.elements().isEmpty()) {
+ return false;
+ }
+ List pairs = dictionaryLiteral.elements().stream()
+ .filter(KeyValuePair.class::isInstance)
+ .map(KeyValuePair.class::cast)
+ .toList();
+ return pairs.size() == dictionaryLiteral.elements().size()
+ && pairs.stream().allMatch(pair -> pair.key().is(Kind.STRING_LITERAL));
+ }
+
private static boolean isListOrDictWithSensitiveEntry(@Nullable Expression expression) {
if (expression == null) {
return false;
@@ -182,56 +399,228 @@ private static boolean isCallToVerifyJwt(Tree t) {
.isPresent();
}
+ /**
+ * Fail-open: a call is compliant unless a concrete unsafe usage of the header/claims value (or a value
+ * extracted from it) is found. A usage shape this check doesn't recognize is not, by itself, grounds to raise -
+ * only known-unsafe sinks (bare pass-through, return, disallowed key access) do, unless that same usage also
+ * matches one of the safe patterns (allowed key, comparison-only, or validated-then-used-as-algorithm).
+ */
private static boolean accessOnlyAllowedHeaderKeys(CallExpression call) {
+ // the call's own result can itself be a comparison operand, e.g. `jwt.get_unverified_header(token) == expected`
+ // - here `call` is the operand, not `call.parent()` (which is already the COMPARISON node), unlike the
+ // `.get(...)`/`[...]` chained-access shapes below where the extra level of nesting means the parent is
+ // the right thing to inspect.
+ if (isComparisonOperand(call)) {
+ return true;
+ }
+ // direct chained access on the call's own result, e.g. `jwt.get_unverified_header(token).get("x5u")`,
+ // is always checked - independent of whether the whole chained expression is itself assigned to some
+ // unrelated name (`x5u = jwt.get_unverified_header(token).get("x5u")`).
+ if (isSafeUsageSite(call.parent())) {
+ return true;
+ }
Tree assignment = TreeUtils.firstAncestorOfKind(call, Tree.Kind.ASSIGNMENT_STMT);
- Stream headerKeysAccessedDirectly = accessToHeaderKeyWithoutAssignment(call);
if (assignment == null) {
- return areStringLiteralsPartOfAllowedKeys(headerKeysAccessedDirectly);
- } else {
- List lhsExpressions = ((AssignmentStatement) assignment).lhsExpressions().stream()
- .map(ExpressionList::expressions)
- .flatMap(Collection::stream).toList();
- if (lhsExpressions.size() == 1 && lhsExpressions.get(0).is(Tree.Kind.NAME)) {
- Name name = (Name) lhsExpressions.get(0);
- Symbol symbol = name.symbol();
- if (symbol != null) {
- Stream argumentsOfGet = usagesAccessedByGet(symbol, call);
- Stream argumentsOfSubscription = usagesAccessedBySubscription(symbol, call);
- Stream headerKeysAccessFromAssignedValues = Stream.concat(argumentsOfGet, argumentsOfSubscription);
- return areStringLiteralsPartOfAllowedKeys(Stream.concat(headerKeysAccessFromAssignedValues, headerKeysAccessedDirectly));
- }
+ return false;
+ }
+ List lhsExpressions = ((AssignmentStatement) assignment).lhsExpressions().stream()
+ .map(ExpressionList::expressions)
+ .flatMap(Collection::stream).toList();
+ if (lhsExpressions.size() == 1 && lhsExpressions.get(0).is(Tree.Kind.NAME)) {
+ Name name = (Name) lhsExpressions.get(0);
+ Symbol symbol = name.symbol();
+ if (symbol != null) {
+ Tree scope = TreeUtils.firstAncestorOfKind(call, Kind.FILE_INPUT, Kind.FUNCDEF);
+ List usages = getForwardUsages(symbol, call).toList();
+ return !usages.isEmpty() && usages.stream().allMatch(usage -> isSafeUsage(usage, scope));
}
}
return false;
}
- private static boolean areStringLiteralsPartOfAllowedKeys(Stream literals) {
- var literalList = literals.toList();
- return !literalList.isEmpty() && literalList.stream().allMatch(str -> ALLOWED_KEYS_ACCESS.contains(str.trimmedQuotesValue()));
+ /**
+ * Classifies one forward usage of the header/claims Name (e.g. the `header` in `header = jwt.get_unverified_header(token)`).
+ * Two things can make a usage safe, checked at two different tree depths:
+ * - the bare Name itself is compared (`header == expected`) - checked directly on {@code usage.tree()};
+ * - a value extracted from it via `.get(key)`/`[key]` is either an allowed key, compared, or validated-then-used
+ * as an algorithm - checked on {@code usageParent}, which is the `.get(...)` call or `[...]` subscription
+ * sitting immediately on top of this usage.
+ * Anything else (bare pass-through, `return header`, disallowed key) falls through to `false` - this is the
+ * pre-existing sink detection carried over unchanged from before this ticket, not a new way to raise issues.
+ */
+ private static boolean isSafeUsage(Usage usage, @Nullable Tree scope) {
+ Tree usageParent = usage.tree().parent();
+ if (isComparisonOperand(usage.tree())) {
+ return true;
+ }
+ Optional getCall = getCallExprWhereDictIsAccessedWithGet(Stream.of(usageParent)).findFirst();
+ if (getCall.isPresent()) {
+ Stream keys = getStringLiteralKeyArgument(getCall.get());
+ return isSafeExtractedValueSite(getCall.get(), keys, scope);
+ }
+ Optional subscription = getSubscriptions(Stream.of(usageParent)).findFirst();
+ if (subscription.isPresent()) {
+ Stream keys = getSubscriptsStringLiteral(Stream.of(subscription.get()));
+ return isSafeExtractedValueSite(subscription.get(), keys, scope);
+ }
+ return false;
+ }
+
+ /**
+ * Whether a value extracted from the header (e.g. the `.get("alg")` call itself, or a `["alg"]` subscription)
+ * is safe to use unrestricted: it's directly compared, its key is in the {@link #ALLOWED_KEYS_ACCESS} allowlist,
+ * or (algorithm pattern only) it's validated against an allowlist before being used as `algorithms=`.
+ */
+ private static boolean isSafeExtractedValueSite(Tree extractionSite, Stream keyLiterals, @Nullable Tree scope) {
+ if (isComparisonOperand(extractionSite)) {
+ return true;
+ }
+ List keys = keyLiterals.toList();
+ if (!keys.isEmpty() && keys.stream().allMatch(str -> ALLOWED_KEYS_ACCESS.contains(str.trimmedQuotesValue()))) {
+ return true;
+ }
+ return scope != null && isValidatedBeforeAlgorithmsUse(extractionSite, scope);
+ }
+
+ /** Whether {@code tree}'s parent is a `==`/`!=` comparison with {@code tree} as one of its two operands. */
+ private static boolean isComparisonOperand(Tree extractionSiteOrName) {
+ Tree parent = extractionSiteOrName.parent();
+ if (!parent.is(Kind.COMPARISON)) {
+ return false;
+ }
+ return EQUALITY_COMPARATORS.contains(((BinaryExpression) parent).operator().value());
+ }
+
+ /**
+ * Whether {@code extractionSite} (e.g. {@code header.get("alg")}) flows, via the Name it's assigned to, into an
+ * {@code in}/{@code not in} check against a literal list/tuple allowlist that appears strictly before the
+ * value is passed as the {@code algorithms=} argument to a decode/verify call, anywhere in {@code scope}.
+ * Line position is used as a lightweight ordering proxy rather than full control-flow dominance, matching
+ * this file's existing line-number heuristics (e.g. {@link #getForwardUsages}) - the guard must be found
+ * on an earlier line than the algorithms= use, otherwise the algorithm-confusion attack this rule targets
+ * (using an unvalidated alg to decode, then validating too late or in unreachable code) would go undetected.
+ */
+ private static boolean isValidatedBeforeAlgorithmsUse(Tree extractionSite, Tree scope) {
+ Optional symbol = extractedValueSymbol(extractionSite);
+ if (symbol.isEmpty()) {
+ return false;
+ }
+ Optional guardLine = firstDescendantLine(scope, tree -> isAllowlistGuardOnSymbol(tree, symbol.get()));
+ Optional algorithmsUseLine = firstDescendantLine(scope, tree -> isAlgorithmsArgumentUsingSymbol(tree, symbol.get()));
+ return guardLine.isPresent() && algorithmsUseLine.isPresent() && guardLine.get() < algorithmsUseLine.get();
+ }
+
+ private static Optional firstDescendantLine(Tree tree, Predicate predicate) {
+ for (Tree child : tree.children()) {
+ if (predicate.test(child)) {
+ return Optional.of(child.firstToken().line());
+ }
+ Optional nested = firstDescendantLine(child, predicate);
+ if (nested.isPresent()) {
+ return nested;
+ }
+ }
+ return Optional.empty();
}
- private static Stream accessToHeaderKeyWithoutAssignment(CallExpression call) {
- Stream callExpressionFromGetUnverifiedHeaders = getCallExprWhereDictIsAccessedWithGet(Stream.of(call.parent()));
- Stream argumentsOfCallExpr = getArgumentsFromCallExpr(callExpressionFromGetUnverifiedHeaders);
- Stream stringLiteralArgumentsFromGet = getStringLiteralArguments(argumentsOfCallExpr);
- Stream subscriptionFromGetUnverifiedHeaders = getSubscriptions(Stream.of(call.parent()));
- Stream stringLiteralArgumentFromSubscription = getSubscriptsStringLiteral(subscriptionFromGetUnverifiedHeaders);
- return Stream.concat(stringLiteralArgumentsFromGet, stringLiteralArgumentFromSubscription);
+ /**
+ * The symbol a `.get(key)`/`[key]` extraction result is bound to, e.g. `alg` in `alg = header.get("alg")`.
+ * Only handles the direct single-Name-LHS shape; if the extraction is used inline (e.g.
+ * `algorithms=[header.get("alg")]`) or the target isn't a plain Name, there's no symbol to track the
+ * value's later validation/use through, so this - and therefore the algorithm-validation pattern - doesn't
+ * apply. That's intentional: without a trackable symbol we can't confirm the value was validated, so the
+ * usage falls through to the pre-existing sink detection in {@link #isSafeUsage} instead of being exempted.
+ */
+ private static Optional extractedValueSymbol(Tree extractionSite) {
+ Tree assignment = TreeUtils.firstAncestorOfKind(extractionSite, Kind.ASSIGNMENT_STMT);
+ return Optional.ofNullable(assignment)
+ .map(AssignmentStatement.class::cast)
+ .map(AssignmentStatement::lhsExpressions)
+ .filter(list -> list.size() == 1)
+ .map(list -> list.get(0).expressions())
+ .filter(list -> list.size() == 1 && list.get(0).is(Kind.NAME))
+ .map(list -> ((Name) list.get(0)).symbol());
}
- private static Stream usagesAccessedByGet(Symbol symbol, CallExpression call) {
- var usages = getForwardUsages(symbol, call);
- var parentOfUsages = usages.map(Usage::tree).map(Tree::parent);
- var callExpressionsFromUsages = getCallExprWhereDictIsAccessedWithGet(parentOfUsages);
- return getStringLiteralArguments(getArgumentsFromCallExpr(callExpressionsFromUsages));
+ /** Whether {@code tree} is `symbol in [...]`/`symbol not in [...]` against a literal list/tuple (or a Name bound to one). */
+ private static boolean isAllowlistGuardOnSymbol(Tree tree, Symbol symbol) {
+ return TreeUtils.toOptionalInstanceOf(InExpression.class, tree)
+ .filter(inExpr -> isNameOfSymbol(inExpr.leftOperand(), symbol))
+ .map(InExpression::rightOperand)
+ .flatMap(Expressions::ifNameGetSingleAssignedNonNameValue)
+ .map(Expressions::expressionsFromListOrTuple)
+ .filter(elements -> !elements.isEmpty())
+ .isPresent();
}
- private static Stream getArgumentsFromCallExpr(Stream callExprs) {
- return callExprs.map(CallExpression::arguments).flatMap(Collection::stream);
+ /** Whether {@code tree} is a `jwt.decode`/`jose.jwt.decode` call whose `algorithms=` argument uses {@code symbol}. */
+ private static boolean isAlgorithmsArgumentUsingSymbol(Tree tree, Symbol symbol) {
+ return TreeUtils.toOptionalInstanceOf(CallExpression.class, tree)
+ .map(CallExpression::calleeSymbol)
+ .map(Symbol::fullyQualifiedName)
+ .filter(VERIFY_SIGNATURE_OPTION_SUPPORTING_FUNCTION_FQNS::contains)
+ .map(fqn -> ((CallExpression) tree).arguments())
+ .map(arguments -> TreeUtils.argumentByKeyword(ALGORITHMS_KEYWORD, arguments))
+ .map(RegularArgument::expression)
+ .filter(algorithms -> algorithmsExpressionUsesSymbol(algorithms, symbol))
+ .isPresent();
+ }
+
+ private static boolean algorithmsExpressionUsesSymbol(Expression algorithms, Symbol symbol) {
+ if (isNameOfSymbol(algorithms, symbol)) {
+ return true;
+ }
+ return Expressions.expressionsFromListOrTuple(algorithms).stream().anyMatch(element -> isNameOfSymbol(element, symbol));
}
+ private static boolean isNameOfSymbol(Expression expression, Symbol symbol) {
+ return expression.is(Kind.NAME) && symbol.equals(((Name) expression).symbol());
+ }
+
+ /**
+ * Same safety check as {@link #isSafeExtractedValueSite}, but for the "no assignment" path where the header
+ * result is chain-accessed directly (`jwt.get_unverified_header(token).get("x5u")`) instead of first bound
+ * to a Name. There's no extracted-value symbol to track here, so only comparison and allowed-key access
+ * apply - the algorithm-validation pattern needs a Name to check the `in`/`not in` guard against, see
+ * {@link #extractedValueSymbol}. {@code usageSite} is the node directly above the call - i.e. the
+ * `QualifiedExpression` for `.get(...)` chains or the `SubscriptionExpression` for `[...]` - so the
+ * comparison check must run on the resolved `.get(...)` call / subscription itself, not on {@code usageSite}:
+ * for a `.get(...)` chain, {@code usageSite} is the intermediate `.get` qualified expression, which is never
+ * itself a comparison operand (its parent is always the enclosing call).
+ */
+ private static boolean isSafeUsageSite(Tree usageSite) {
+ Optional getCall = getCallExprWhereDictIsAccessedWithGet(Stream.of(usageSite)).findFirst();
+ if (getCall.isPresent()) {
+ if (isComparisonOperand(getCall.get())) {
+ return true;
+ }
+ return isAllowedKeyAccess(getStringLiteralKeyArgument(getCall.get()));
+ }
+ Optional subscription = getSubscriptions(Stream.of(usageSite)).findFirst();
+ if (subscription.isPresent()) {
+ if (isComparisonOperand(subscription.get())) {
+ return true;
+ }
+ return isAllowedKeyAccess(getSubscriptsStringLiteral(Stream.of(subscription.get())));
+ }
+ return false;
+ }
+
+ private static boolean isAllowedKeyAccess(Stream keyLiterals) {
+ List keys = keyLiterals.toList();
+ return !keys.isEmpty() && keys.stream().allMatch(str -> ALLOWED_KEYS_ACCESS.contains(str.trimmedQuotesValue()));
+ }
+
+ /**
+ * All reads of {@code symbol} after {@code call}, using line number as a cheap proxy for "later" (this file
+ * doesn't do real control-flow analysis). Binding usages are excluded: if the same variable name is rebound
+ * later in the function (`header = jwt.get_unverified_header(token)` again), that's a fresh, independently
+ * checked call, not a use of *this* call's result - counting it here would attribute a later call's usages
+ * to this one and could wrongly flag (or wrongly clear) either call based on the other's usages.
+ */
private static Stream getForwardUsages(Symbol symbol, CallExpression call) {
return symbol.usages().stream()
+ .filter(usage -> !usage.isBindingUsage())
.filter(usage -> usage.tree().firstToken().line() > call.callee().firstToken().line());
}
@@ -245,18 +634,17 @@ private static Stream getCallExprWhereDictIsAccessedWithGet(Stre
.flatMap(TreeUtils.toStreamInstanceOfMapper(CallExpression.class));
}
- private static Stream getStringLiteralArguments(Stream arguments) {
- return arguments.filter(arg -> arg.is(Tree.Kind.REGULAR_ARGUMENT))
- .flatMap(TreeUtils.toStreamInstanceOfMapper(RegularArgument.class))
+ /**
+ * The `.get(key)` call's key argument, as a StringLiteral if it is one. Only the first positional/keyword
+ * argument is considered - `.get(key, default)`'s second argument is a fallback value, not part of the key,
+ * and must not be treated as an additional accessed key (e.g. `header.get("kid", "fallback")` must still be
+ * recognized as accessing only `"kid"`, not `{"kid", "fallback"}`).
+ */
+ private static Stream getStringLiteralKeyArgument(CallExpression getCall) {
+ return Optional.ofNullable(TreeUtils.nthArgumentOrKeyword(0, "key", getCall.arguments()))
.map(RegularArgument::expression)
- .flatMap(TreeUtils.toStreamInstanceOfMapper(StringLiteral.class));
- }
-
- private static Stream usagesAccessedBySubscription(Symbol symbol, CallExpression call) {
- var usages = getForwardUsages(symbol, call);
- var parentFromUsages = usages.map(Usage::tree).map(Tree::parent);
- var subscriptionsFromUsages = getSubscriptions(parentFromUsages);
- return getSubscriptsStringLiteral(subscriptionsFromUsages);
+ .flatMap(TreeUtils.toOptionalInstanceOfMapper(StringLiteral.class))
+ .stream();
}
private static Stream getSubscriptions(Stream subscriptions) {
diff --git a/python-checks/src/main/java/org/sonar/python/checks/LambdaAssignmentCheck.java b/python-checks/src/main/java/org/sonar/python/checks/LambdaAssignmentCheck.java
index d33d273555..f6827c8e31 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/LambdaAssignmentCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/LambdaAssignmentCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/LdapAuthenticationCheck.java b/python-checks/src/main/java/org/sonar/python/checks/LdapAuthenticationCheck.java
index e66b99e080..192b697fe7 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/LdapAuthenticationCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/LdapAuthenticationCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/LineLengthCheck.java b/python-checks/src/main/java/org/sonar/python/checks/LineLengthCheck.java
index 958ebcd2f2..63aa019dfd 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/LineLengthCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/LineLengthCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ListIterableFirstElementCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ListIterableFirstElementCheck.java
new file mode 100644
index 0000000000..9476c1f795
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/ListIterableFirstElementCheck.java
@@ -0,0 +1,98 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+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.quickfix.PythonQuickFix;
+import org.sonar.plugins.python.api.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.NumericLiteral;
+import org.sonar.plugins.python.api.tree.RegularArgument;
+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.quickfix.TextEditUtils;
+import org.sonar.python.tree.NumericLiteralImpl;
+import org.sonar.python.tree.TreeUtils;
+
+@Rule(key = "S8519")
+public class ListIterableFirstElementCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Replace \"list(...)[0]\" with \"next(iter(...))\" to avoid materializing the entire iterable.";
+ private static final TypeMatcher IS_LIST = TypeMatchers.isType("list");
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.SUBSCRIPTION, ListIterableFirstElementCheck::checkSubscription);
+ }
+
+ private static void checkSubscription(SubscriptionContext ctx) {
+ SubscriptionExpression subscriptionExpression = (SubscriptionExpression) ctx.syntaxNode();
+
+ var subscripts = subscriptionExpression.subscripts();
+ if (!subscripts.commas().isEmpty() || subscripts.expressions().size() != 1) {
+ return;
+ }
+
+ var subscript = subscripts.expressions().get(0);
+ if (!(subscript instanceof NumericLiteral numericLiteral)) {
+ return;
+ }
+ if (!isIntegerLiteralSubscript(numericLiteral)) {
+ return;
+ }
+ long indexValue;
+ try {
+ indexValue = numericLiteral.valueAsLong();
+ } catch (NumberFormatException e) {
+ return;
+ }
+ if (indexValue != 0L) {
+ return;
+ }
+
+ if (!(subscriptionExpression.object() instanceof CallExpression listCall)) {
+ return;
+ }
+
+ if (!IS_LIST.isTrueFor(listCall.callee(), ctx)) {
+ return;
+ }
+
+ if (listCall.arguments().size() != 1 || !(listCall.arguments().get(0) instanceof RegularArgument regularArg)) {
+ return;
+ }
+
+ PreciseIssue issue = ctx.addIssue(listCall.callee(), MESSAGE);
+
+ String argText = TreeUtils.treeToString(regularArg.expression(), false);
+ if (argText != null && !argText.contains("\n")) {
+ PythonQuickFix quickFix = PythonQuickFix.newQuickFix("Replace with \"next(iter(...))\"",
+ TextEditUtils.replace(subscriptionExpression, "next(iter(" + argText + "))"));
+ issue.addQuickFix(quickFix);
+ }
+ }
+
+ private static boolean isIntegerLiteralSubscript(NumericLiteral literal) {
+ if (literal instanceof NumericLiteralImpl impl) {
+ return impl.numericKind() == NumericLiteralImpl.NumericKind.INT;
+ }
+ return false;
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/LocalVariableAndParameterNameConventionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/LocalVariableAndParameterNameConventionCheck.java
index 7d00d3cce2..24cd555219 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/LocalVariableAndParameterNameConventionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/LocalVariableAndParameterNameConventionCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -32,6 +32,8 @@
import org.sonar.plugins.python.api.symbols.Usage;
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.AnnotatedAssignment;
+import org.sonar.plugins.python.api.tree.AssignmentExpression;
import org.sonar.plugins.python.api.tree.AssignmentStatement;
import org.sonar.plugins.python.api.tree.Expression;
import org.sonar.plugins.python.api.tree.FunctionDef;
@@ -40,6 +42,8 @@
import org.sonar.plugins.python.api.tree.SubscriptionExpression;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.types.v2.PythonType;
+import org.sonar.python.checks.utils.Expressions;
+import org.sonar.python.checks.utils.MarimoUtils;
import org.sonar.python.semantic.SymbolUtils;
import org.sonar.python.tree.TreeUtils;
import org.sonar.python.types.v2.TypeCheckBuilder;
@@ -91,22 +95,32 @@ private void checkName(SymbolV2 symbol, SubscriptionContext ctx) {
return;
}
if (!pattern.matcher(name).matches()) {
- if (isType(symbol)) {
+ if (hasTypeVariableType(symbol)) {
// Type variables generally adhere to class naming conventions rather than regular variable naming conventions
return;
}
+ boolean assignedFromType = isAssignedFromType(symbol);
symbol.usages().stream()
.filter(usage -> USAGES.contains(usage.kind()))
.sorted(Comparator.comparingInt(u -> u.tree().firstToken().line()))
.limit(1)
- .forEach(usage -> raiseIssueForNameAndUsage(ctx, name, usage));
+ .forEach(usage -> {
+ if (assignedFromType && usage.kind() == UsageV2.Kind.ASSIGNMENT_LHS) {
+ return;
+ }
+ raiseIssueForNameAndUsage(ctx, name, usage);
+ });
}
}
- private boolean isType(SymbolV2 symbolV2) {
+ private boolean hasTypeVariableType(SymbolV2 symbolV2) {
// TypeV1 and TypeV2 can detect different cases and work complementary to find more issues
Symbol symbolV1 = SymbolUtils.symbolV2ToSymbolV1(symbolV2).orElse(null);
- return symbolV1 != null && (isExtendingType(symbolV1) || isAssignedFromTyping(symbolV2) || isPythonTypeAClassType(symbolV2));
+ return (symbolV1 != null && isExtendingType(symbolV1)) || isPythonTypeAClassType(symbolV2);
+ }
+
+ private static boolean isAssignedFromType(SymbolV2 symbolV2) {
+ return isAssignedFromTyping(symbolV2) || isAssignedFromBuiltinType(symbolV2);
}
private static boolean isExtendingType(Symbol symbol) {
@@ -121,18 +135,13 @@ private static boolean isExtendingType(Symbol symbol) {
}
private static boolean isAssignedFromTyping(SymbolV2 symbol) {
- List assignedValues = symbol.usages().stream()
- .filter(u -> u.kind() == UsageV2.Kind.ASSIGNMENT_LHS)
- .flatMap(usage -> getAssignedValue(usage.tree()))
- .toList();
+ return assignedValuesOf(symbol)
+ .map(LocalVariableAndParameterNameConventionCheck::getTypingSymbol)
+ .anyMatch(assignedSymbol -> assignedSymbol != null && isExtendingType(assignedSymbol));
+ }
- for (Expression assignedValue : assignedValues) {
- Symbol assignedSymbol = getTypingSymbol(assignedValue);
- if (assignedSymbol != null && isExtendingType(assignedSymbol)) {
- return true;
- }
- }
- return false;
+ private static boolean isAssignedFromBuiltinType(SymbolV2 symbol) {
+ return assignedValuesOf(symbol).anyMatch(Expressions::isBuiltinTypeAssignment);
}
private boolean isPythonTypeAClassType(SymbolV2 symbol) {
@@ -140,13 +149,24 @@ private boolean isPythonTypeAClassType(SymbolV2 symbol) {
return isDjangoModelTypeCheck.check(type).isTrue();
}
+ private static Stream assignedValuesOf(SymbolV2 symbol) {
+ return symbol.usages().stream()
+ .filter(u -> u.kind() == UsageV2.Kind.ASSIGNMENT_LHS)
+ .flatMap(usage -> getAssignedValue(usage.tree()));
+ }
+
private static Stream getAssignedValue(Tree assignmentName) {
- var assignmentStmt = TreeUtils.firstAncestorOfClass(assignmentName, AssignmentStatement.class);
- if (assignmentStmt != null) {
- return Stream.of(assignmentStmt.assignedValue());
- } else {
- return Stream.empty();
+ Tree assignment = TreeUtils.firstAncestor(assignmentName,
+ t -> t.is(Tree.Kind.ASSIGNMENT_STMT, Tree.Kind.ANNOTATED_ASSIGNMENT, Tree.Kind.ASSIGNMENT_EXPRESSION));
+ Expression assignedValue = null;
+ if (assignment instanceof AssignmentStatement assignmentStatement) {
+ assignedValue = assignmentStatement.assignedValue();
+ } else if (assignment instanceof AnnotatedAssignment annotatedAssignment) {
+ assignedValue = annotatedAssignment.assignedValue();
+ } else if (assignment instanceof AssignmentExpression assignmentExpression) {
+ assignedValue = assignmentExpression.expression();
}
+ return assignedValue != null ? Stream.of(assignedValue) : Stream.empty();
}
private static @Nullable Symbol getTypingSymbol(Expression expr) {
@@ -170,7 +190,7 @@ private void raiseIssueForNameAndUsage(SubscriptionContext ctx, String name, Usa
if (name.length() <= 1) {
return;
}
- } else if (kind == UsageV2.Kind.PARAMETER && isParameterNameFromOverriddenMethod(usage, name)) {
+ } else if (kind == UsageV2.Kind.PARAMETER && (isParameterNameFromOverriddenMethod(usage, name) || MarimoUtils.isTreeInMarimoDecoratedFunction(usage.tree(), ctx))) {
return;
}
ctx.addIssue(usage.tree(), String.format(MESSAGE, type, name, format));
diff --git a/python-checks/src/main/java/org/sonar/python/checks/LoggingBestPracticesCheck.java b/python-checks/src/main/java/org/sonar/python/checks/LoggingBestPracticesCheck.java
new file mode 100644
index 0000000000..034a002fb0
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/LoggingBestPracticesCheck.java
@@ -0,0 +1,148 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+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.tree.BinaryExpression;
+import org.sonar.plugins.python.api.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.DictionaryLiteral;
+import org.sonar.plugins.python.api.tree.DictionaryLiteralElement;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.KeyValuePair;
+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.matchers.TypeMatcher;
+import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
+import org.sonar.python.tree.TreeUtils;
+
+@Rule(key = "S8554")
+public class LoggingBestPracticesCheck extends PythonSubscriptionCheck {
+
+ private static final TypeMatcher LOGGING_CALL_MATCHER = TypeMatchers.any(
+ TypeMatchers.isType("logging.debug"),
+ TypeMatchers.isType("logging.info"),
+ TypeMatchers.isType("logging.warning"),
+ TypeMatchers.isType("logging.warn"),
+ TypeMatchers.isType("logging.error"),
+ TypeMatchers.isType("logging.exception"),
+ TypeMatchers.isType("logging.critical"),
+ TypeMatchers.isType("logging.Logger.debug"),
+ TypeMatchers.isType("logging.Logger.info"),
+ TypeMatchers.isType("logging.Logger.warning"),
+ TypeMatchers.isType("logging.Logger.warn"),
+ TypeMatchers.isType("logging.Logger.error"),
+ TypeMatchers.isType("logging.Logger.exception"),
+ TypeMatchers.isType("logging.Logger.critical")
+ );
+
+ private static final TypeMatcher DEPRECATED_WARN_MATCHER = TypeMatchers.any(
+ TypeMatchers.isType("logging.warn"),
+ TypeMatchers.isType("logging.Logger.warn")
+ );
+
+ private static final TypeMatcher STR_FORMAT_MATCHER = TypeMatchers.isType("str.format");
+
+ private static final Set LOG_RECORD_ATTRIBUTES = new HashSet<>(Arrays.asList(
+ "name", "msg", "args", "created", "filename", "funcName", "levelname", "levelno",
+ "lineno", "module", "msecs", "message", "pathname", "process", "processName",
+ "relativeCreated", "thread", "threadName", "exc_info", "exc_text", "stack_info",
+ "taskName", "asctime"
+ ));
+
+ private static final String EAGER_FORMAT_MESSAGE =
+ "Pass formatting arguments to the logging call instead of pre-formatting the message string.";
+ private static final String DEPRECATED_WARN_MESSAGE =
+ "Use \"warning\" instead of the deprecated \"warn\" method.";
+ private static final String EXTRA_COLLISION_MESSAGE =
+ "Remove or rename this key; it overrides a built-in LogRecord attribute.";
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, LoggingBestPracticesCheck::checkLoggingCall);
+ }
+
+ private static void checkLoggingCall(SubscriptionContext ctx) {
+ CallExpression call = (CallExpression) ctx.syntaxNode();
+ if (!LOGGING_CALL_MATCHER.isTrueFor(call.callee(), ctx)) {
+ return;
+ }
+
+ if (DEPRECATED_WARN_MATCHER.isTrueFor(call.callee(), ctx)) {
+ ctx.addIssue(call.callee(), DEPRECATED_WARN_MESSAGE);
+ }
+
+ List positionalArgs = call.arguments().stream()
+ .flatMap(TreeUtils.toStreamInstanceOfMapper(RegularArgument.class))
+ .filter(arg -> arg.keywordArgument() == null)
+ .toList();
+
+ if (!positionalArgs.isEmpty()) {
+ checkEagerFormatting(ctx, positionalArgs.get(0).expression());
+ }
+
+ checkExtraAttributeCollision(ctx, call);
+ }
+
+ private static void checkEagerFormatting(SubscriptionContext ctx, Expression expr) {
+ if (expr instanceof StringLiteral literal) {
+ boolean isFString = literal.stringElements().stream()
+ .anyMatch(e -> e.prefix().toLowerCase(Locale.ENGLISH).contains("f") && !e.formattedExpressions().isEmpty());
+ if (isFString) {
+ ctx.addIssue(expr, EAGER_FORMAT_MESSAGE);
+ }
+ } else if (expr instanceof CallExpression innerCall && STR_FORMAT_MATCHER.isTrueFor(innerCall.callee(), ctx)) {
+ ctx.addIssue(expr, EAGER_FORMAT_MESSAGE);
+ } else if (expr.is(Tree.Kind.MODULO) && containsStringLiteral(((BinaryExpression) expr).leftOperand())) {
+ ctx.addIssue(expr, EAGER_FORMAT_MESSAGE);
+ } else if (expr.is(Tree.Kind.PLUS) && containsStringLiteral(expr)) {
+ ctx.addIssue(expr, EAGER_FORMAT_MESSAGE);
+ }
+ }
+
+ private static boolean containsStringLiteral(Expression expr) {
+ if (expr instanceof StringLiteral) {
+ return true;
+ }
+ if (expr.is(Tree.Kind.PLUS)) {
+ BinaryExpression plus = (BinaryExpression) expr;
+ return containsStringLiteral(plus.leftOperand()) || containsStringLiteral(plus.rightOperand());
+ }
+ return false;
+ }
+
+ private static void checkExtraAttributeCollision(SubscriptionContext ctx, CallExpression call) {
+ RegularArgument extraArg = TreeUtils.argumentByKeyword("extra", call.arguments());
+ if (extraArg == null || !(extraArg.expression() instanceof DictionaryLiteral dict)) {
+ return;
+ }
+ for (DictionaryLiteralElement element : dict.elements()) {
+ if (element instanceof KeyValuePair kvp
+ && kvp.key() instanceof StringLiteral key
+ && LOG_RECORD_ATTRIBUTES.contains(key.trimmedQuotesValue())) {
+ ctx.addIssue(key, EXTRA_COLLISION_MESSAGE);
+ }
+ }
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/LoggingExceptionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/LoggingExceptionCheck.java
new file mode 100644
index 0000000000..dc78f5e72c
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/LoggingExceptionCheck.java
@@ -0,0 +1,110 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import javax.annotation.Nullable;
+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.tree.CallExpression;
+import org.sonar.plugins.python.api.tree.ExceptClause;
+import org.sonar.plugins.python.api.tree.FunctionDef;
+import org.sonar.plugins.python.api.tree.LambdaExpression;
+import org.sonar.plugins.python.api.tree.Name;
+import org.sonar.plugins.python.api.tree.QualifiedExpression;
+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;
+
+@Rule(key = "S8572")
+public class LoggingExceptionCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Use \"logging.exception()\" instead.";
+
+ private static final TypeMatcher LOGGING_ERROR_MATCHER = TypeMatchers.any(
+ TypeMatchers.isType("logging.error"),
+ TypeMatchers.isType("logging.Logger.error"),
+ TypeMatchers.isType("logging.LoggerAdapter.error")
+ );
+
+ private static final TypeMatcher TRACEBACK_FORMAT_EXC_MATCHER = TypeMatchers.isType("traceback.format_exc");
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, LoggingExceptionCheck::checkCall);
+ }
+
+ private static void checkCall(SubscriptionContext ctx) {
+ CallExpression callExpr = (CallExpression) ctx.syntaxNode();
+ if (!(callExpr.callee() instanceof QualifiedExpression qualifiedCallee)) {
+ return;
+ }
+ if (!LOGGING_ERROR_MATCHER.isTrueFor(qualifiedCallee, ctx)) {
+ return;
+ }
+ ExceptClause exceptClause = findEnclosingExceptClause(callExpr);
+ if (exceptClause == null) {
+ return;
+ }
+ RegularArgument excInfoArg = TreeUtils.argumentByKeyword("exc_info", callExpr.arguments());
+ if (excInfoArg != null) {
+ if (!Expressions.isTruthy(excInfoArg.expression())) {
+ return;
+ }
+ } else if (!isExceptionLoggedInArgs(callExpr, exceptClause, ctx)) {
+ return;
+ }
+ Name errorName = qualifiedCallee.name();
+ ctx.addIssue(errorName, MESSAGE);
+ }
+
+ @Nullable
+ private static ExceptClause findEnclosingExceptClause(Tree tree) {
+ Tree parent = tree.parent();
+ while (parent != null) {
+ if (parent instanceof FunctionDef || parent instanceof LambdaExpression) {
+ return null;
+ }
+ if (parent instanceof ExceptClause exceptClause) {
+ return exceptClause;
+ }
+ parent = parent.parent();
+ }
+ return null;
+ }
+
+ private static boolean isExceptionLoggedInArgs(CallExpression callExpr, ExceptClause exceptClause, SubscriptionContext ctx) {
+ SymbolV2 exceptionSymbol = exceptClause.exceptionInstance() instanceof Name name ? name.symbolV2() : null;
+ return callExpr.arguments().stream().anyMatch(arg -> logsException(arg, exceptionSymbol, ctx));
+ }
+
+ private static boolean logsException(Tree tree, @Nullable SymbolV2 exceptionSymbol, SubscriptionContext ctx) {
+ return matchesExceptionLogging(tree, exceptionSymbol, ctx)
+ || TreeUtils.hasDescendant(tree, t -> matchesExceptionLogging(t, exceptionSymbol, ctx));
+ }
+
+ private static boolean matchesExceptionLogging(Tree tree, @Nullable SymbolV2 exceptionSymbol, SubscriptionContext ctx) {
+ if (exceptionSymbol != null && tree instanceof Name name && exceptionSymbol.equals(name.symbolV2())) {
+ return true;
+ }
+ return tree instanceof CallExpression call && TRACEBACK_FORMAT_EXC_MATCHER.isTrueFor(call.callee(), ctx);
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/LongIntegerWithLowercaseSuffixUsageCheck.java b/python-checks/src/main/java/org/sonar/python/checks/LongIntegerWithLowercaseSuffixUsageCheck.java
index 36bbe68952..0d678c1fc2 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/LongIntegerWithLowercaseSuffixUsageCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/LongIntegerWithLowercaseSuffixUsageCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
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 098a0da852..d0eae3db37 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
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -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/LoopOverDictKeyValuesCheck.java b/python-checks/src/main/java/org/sonar/python/checks/LoopOverDictKeyValuesCheck.java
index 6151ed1d44..69253e72d6 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/LoopOverDictKeyValuesCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/LoopOverDictKeyValuesCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/MandatoryFunctionParameterTypeHintCheck.java b/python-checks/src/main/java/org/sonar/python/checks/MandatoryFunctionParameterTypeHintCheck.java
index facec6d695..cf9879bfe0 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/MandatoryFunctionParameterTypeHintCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/MandatoryFunctionParameterTypeHintCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/MandatoryFunctionReturnTypeHintCheck.java b/python-checks/src/main/java/org/sonar/python/checks/MandatoryFunctionReturnTypeHintCheck.java
index 72a749c4e1..590f30fa27 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/MandatoryFunctionReturnTypeHintCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/MandatoryFunctionReturnTypeHintCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -17,11 +17,10 @@
package org.sonar.python.checks;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
-import java.util.Objects;
import java.util.Optional;
import java.util.Set;
-import java.util.stream.Collectors;
import org.sonar.check.Rule;
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
import org.sonar.plugins.python.api.SubscriptionContext;
@@ -34,22 +33,23 @@
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.YieldStatement;
import org.sonar.plugins.python.api.types.BuiltinTypes;
+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.FunctionDefImpl;
-import org.sonar.python.types.InferredTypes;
@Rule(key = "S6538")
public class MandatoryFunctionReturnTypeHintCheck extends PythonSubscriptionCheck {
public static final String MESSAGE = "Add a return type hint to this function declaration.";
public static final String CONSTRUCTOR_MESSAGE = "Annotate the return type of this constructor with `None`.";
- private static final List SUPPORTED_TYPES = List.of(
- BuiltinTypes.STR,
- BuiltinTypes.NONE_TYPE,
- BuiltinTypes.BOOL,
- BuiltinTypes.COMPLEX,
- BuiltinTypes.FLOAT,
- BuiltinTypes.INT);
+ private static final List SUPPORTED_TYPES = List.of(
+ new SupportedReturnType(TypeMatchers.isObjectOfType("builtins.str"), BuiltinTypes.STR),
+ new SupportedReturnType(TypeMatchers.isObjectOfType("NoneType"), "None"),
+ new SupportedReturnType(TypeMatchers.isObjectOfType("builtins.bool"), BuiltinTypes.BOOL),
+ new SupportedReturnType(TypeMatchers.isObjectOfType("builtins.complex"), BuiltinTypes.COMPLEX),
+ new SupportedReturnType(TypeMatchers.isObjectOfType("builtins.float"), BuiltinTypes.FLOAT),
+ new SupportedReturnType(TypeMatchers.isObjectOfType("builtins.int"), BuiltinTypes.INT));
@Override
public void initialize(Context context) {
@@ -78,7 +78,7 @@ private static void raiseIssueForReturnType(SubscriptionContext ctx, Name functi
ReturnStatementVisitor returnStatementVisitor = new ReturnStatementVisitor();
functionDef.body().accept(returnStatementVisitor);
if (!returnStatementVisitor.returnStatements.isEmpty()) {
- addQuickFixForReturnType(issue, functionDef, returnStatementVisitor.returnStatements);
+ addQuickFixForReturnType(ctx, issue, functionDef, returnStatementVisitor.returnStatements);
} else if (returnStatementVisitor.yieldStatements.isEmpty()) {
addQuickFixForNoneType(issue, functionDef);
}
@@ -91,26 +91,39 @@ private static void addQuickFixForNoneType(PreciseIssue issue, FunctionDef funct
issue.addQuickFix(quickFix);
}
- private static void addQuickFixForReturnType(PreciseIssue issue, FunctionDef functionDef, List statements) {
- Set returnTypes = statements.stream()
- .flatMap(stmts -> stmts.expressions().stream())
- .map(Expression::type)
- .map(InferredTypes::typeName)
- .filter(Objects::nonNull)
- .collect(Collectors.toSet());
+ private static void addQuickFixForReturnType(SubscriptionContext ctx, PreciseIssue issue, FunctionDef functionDef, List statements) {
+ Set returnTypes = collectSupportedReturnTypeAnnotations(statements, ctx);
if (returnTypes.size() == 1) {
- String typeName = returnTypes.stream().iterator().next();
- if (SUPPORTED_TYPES.contains(typeName)) {
- PythonQuickFix quickFix = PythonQuickFix.newQuickFix(MandatoryFunctionReturnTypeHintCheck.MESSAGE)
- .addTextEdit(TextEditUtils.insertAfter(functionDef.rightPar(), String.format(" -> %s", fixTypeName(typeName))))
- .build();
- issue.addQuickFix(quickFix);
+ String annotation = returnTypes.iterator().next();
+ PythonQuickFix quickFix = PythonQuickFix.newQuickFix(MandatoryFunctionReturnTypeHintCheck.MESSAGE)
+ .addTextEdit(TextEditUtils.insertAfter(functionDef.rightPar(), String.format(" -> %s", annotation)))
+ .build();
+ issue.addQuickFix(quickFix);
+ }
+ }
+
+ private static Set collectSupportedReturnTypeAnnotations(List statements, SubscriptionContext ctx) {
+ Set returnTypes = new HashSet<>();
+ for (ReturnStatement stmt : statements) {
+ for (Expression expression : stmt.expressions()) {
+ Optional annotation = supportedReturnTypeAnnotation(expression, ctx);
+ if (annotation.isEmpty()) {
+ return Set.of();
+ }
+ returnTypes.add(annotation.get());
}
}
+ return returnTypes;
+ }
+
+ private static Optional supportedReturnTypeAnnotation(Expression expression, SubscriptionContext ctx) {
+ return SUPPORTED_TYPES.stream()
+ .filter(supportedType -> supportedType.matcher().isTrueFor(expression, ctx))
+ .map(SupportedReturnType::annotation)
+ .findFirst();
}
- private static String fixTypeName(String typeName) {
- return typeName.equals(BuiltinTypes.NONE_TYPE) ? "None" : typeName;
+ private record SupportedReturnType(TypeMatcher matcher, String annotation) {
}
private static class ReturnStatementVisitor extends BaseTreeVisitor {
diff --git a/python-checks/src/main/java/org/sonar/python/checks/MembershipTestSupportCheck.java b/python-checks/src/main/java/org/sonar/python/checks/MembershipTestSupportCheck.java
index 07a48e4efc..8c9878d8f6 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/MembershipTestSupportCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/MembershipTestSupportCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/MethodNameCheck.java b/python-checks/src/main/java/org/sonar/python/checks/MethodNameCheck.java
index a0c7f1cd11..c9a5b77c5d 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/MethodNameCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/MethodNameCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -16,6 +16,7 @@
*/
package org.sonar.python.checks;
+import java.util.Set;
import org.sonar.check.Rule;
import org.sonar.plugins.python.api.tree.FunctionDef;
@@ -26,6 +27,8 @@
public class MethodNameCheck extends AbstractFunctionNameCheck {
public static final String CHECK_KEY = "S100";
+ private static final Set WHITELIST = Set.of("setUp", "tearDown", "setUpClass", "tearDownClass", "setUpTestData");
+
@Override
public String typeName() {
return "method";
@@ -33,6 +36,8 @@ public String typeName() {
@Override
public boolean shouldCheckFunctionDeclaration(FunctionDef pyFunctionDefTree) {
- return pyFunctionDefTree.isMethodDefinition() && !classHasInheritance(getParentClassDef(pyFunctionDefTree));
+ return pyFunctionDefTree.isMethodDefinition()
+ && !classHasInheritance(getParentClassDef(pyFunctionDefTree))
+ && !WHITELIST.contains(pyFunctionDefTree.name().name());
}
}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/MethodShouldBeStaticCheck.java b/python-checks/src/main/java/org/sonar/python/checks/MethodShouldBeStaticCheck.java
index 93710c836b..37fc66e8d8 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/MethodShouldBeStaticCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/MethodShouldBeStaticCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/MissingDocstringCheck.java b/python-checks/src/main/java/org/sonar/python/checks/MissingDocstringCheck.java
index 85f499bfc2..aec7483191 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/MissingDocstringCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/MissingDocstringCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/MissingHyperParameterCheck.java b/python-checks/src/main/java/org/sonar/python/checks/MissingHyperParameterCheck.java
index 9ec7f34ad7..c0fd33c2d1 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/MissingHyperParameterCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/MissingHyperParameterCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/MissingNewlineAtEndOfFileCheck.java b/python-checks/src/main/java/org/sonar/python/checks/MissingNewlineAtEndOfFileCheck.java
index 3b870169e0..d10a82336b 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/MissingNewlineAtEndOfFileCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/MissingNewlineAtEndOfFileCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ModifiedParameterValueCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ModifiedParameterValueCheck.java
index e2795b6d90..c380e7de1d 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ModifiedParameterValueCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ModifiedParameterValueCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/ModuleNameCheck.java b/python-checks/src/main/java/org/sonar/python/checks/ModuleNameCheck.java
index 3a2970d6dd..ef33d749c4 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/ModuleNameCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/ModuleNameCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/MultipleInheritanceMROConflictCheck.java b/python-checks/src/main/java/org/sonar/python/checks/MultipleInheritanceMROConflictCheck.java
new file mode 100644
index 0000000000..ef729fe4dc
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/MultipleInheritanceMROConflictCheck.java
@@ -0,0 +1,178 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import javax.annotation.CheckForNull;
+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.tree.ArgList;
+import org.sonar.plugins.python.api.tree.Argument;
+import org.sonar.plugins.python.api.tree.ClassDef;
+import org.sonar.plugins.python.api.tree.Expression;
+import org.sonar.plugins.python.api.tree.RegularArgument;
+import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.plugins.python.api.types.v2.ClassType;
+import org.sonar.plugins.python.api.types.v2.PythonType;
+import org.sonar.plugins.python.api.types.v2.TypeWrapper;
+
+@Rule(key = "S8511")
+public class MultipleInheritanceMROConflictCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Reorder or remove base classes to fix this MRO conflict.";
+ private static final String SECONDARY_MESSAGE = "This base class is an ancestor of another listed base class appearing after it.";
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CLASSDEF, MultipleInheritanceMROConflictCheck::checkClassDef);
+ }
+
+ private static void checkClassDef(SubscriptionContext ctx) {
+ ClassDef classDef = (ClassDef) ctx.syntaxNode();
+ ArgList argList = classDef.args();
+ if (argList == null) {
+ return;
+ }
+
+ List bases = collectPositionalBases(argList);
+ if (bases.size() < 2) {
+ return;
+ }
+
+ List types = new ArrayList<>();
+ for (Expression base : bases) {
+ types.add(resolveClassType(base));
+ }
+
+ int conflictIndex = findAncestorConflictIndex(types);
+ if (hasMroConflict(types, conflictIndex)) {
+ PreciseIssue issue = ctx.addIssue(classDef.name(), MESSAGE);
+ if (conflictIndex >= 0) {
+ issue.secondary(bases.get(conflictIndex), SECONDARY_MESSAGE);
+ }
+ }
+ }
+
+ private static List collectPositionalBases(ArgList argList) {
+ List bases = new ArrayList<>();
+ for (Argument argument : argList.arguments()) {
+ if (argument instanceof RegularArgument regularArgument && regularArgument.keywordArgument() == null) {
+ bases.add(regularArgument.expression());
+ }
+ }
+ return bases;
+ }
+
+ /**
+ * Detects an MRO conflict: either via C3 when every base is fully resolved, or otherwise only
+ * when {@link #findAncestorConflictIndex} finds an earlier base that is a strict superclass of a
+ * later base. Both paths use a runtime-faithful view of built-in containers — see
+ * {@link ClassType#wouldHaveValidMro(List)}.
+ */
+ private static boolean hasMroConflict(List types, int conflictIndex) {
+ if (isFullyResolved(types)) {
+ return !ClassType.wouldHaveValidMro(types);
+ }
+ return conflictIndex >= 0;
+ }
+
+ /**
+ * Returns {@code true} if every base class is fully resolved (non-null, with a fully known type
+ * hierarchy). Only when this is true can we run the complete C3 algorithm safely.
+ */
+ private static boolean isFullyResolved(List types) {
+ for (ClassType type : types) {
+ if (type == null || type.hasUnresolvedHierarchy()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Returns the index {@code i} of the first base class that is an ancestor of some later base
+ * class at index {@code j > i}, or {@code -1} if no such pair exists. Paths from any later base
+ * that pass through a virtual-ABC-subclassing builtin are cut off, so typeshed-only ABC edges
+ * (e.g. {@code dict → MutableMapping}) are not treated as real ancestry — see
+ * {@link #isOrExtendsClassAtRuntime(ClassType, ClassType)}.
+ */
+ private static int findAncestorConflictIndex(List types) {
+ for (int i = 0; i < types.size() - 1; i++) {
+ if (laterBaseExtendsEarlier(types, i)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static boolean laterBaseExtendsEarlier(List types, int i) {
+ ClassType typeI = types.get(i);
+ if (typeI == null) {
+ return false;
+ }
+ for (int j = i + 1; j < types.size(); j++) {
+ ClassType typeJ = types.get(j);
+ if (typeJ != null && isOrExtendsClassAtRuntime(typeJ, typeI)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns {@code true} if {@code ancestor} appears in {@code candidate}'s runtime type hierarchy.
+ * Traversal stops at virtual-ABC-subclassing builtins so their typeshed-only ABC parents are not
+ * reachable; see {@link ClassType#wouldHaveValidMro(List)}. Uses reference equality
+ * since {@link ClassType} instances are canonical within a single analysis.
+ */
+ private static boolean isOrExtendsClassAtRuntime(ClassType candidate, ClassType ancestor) {
+ Set visited = new HashSet<>();
+ Deque queue = new ArrayDeque<>();
+ queue.add(candidate);
+ while (!queue.isEmpty()) {
+ PythonType current = queue.poll();
+ if (!visited.add(current)) {
+ continue;
+ }
+ if (current == ancestor) {
+ return true;
+ }
+ if (current instanceof ClassType ct && !ct.isVirtualAbcSubclassingBuiltin()) {
+ enqueueDirectSuperclasses(ct, queue);
+ }
+ }
+ return false;
+ }
+
+ private static void enqueueDirectSuperclasses(ClassType ct, Deque queue) {
+ for (TypeWrapper sw : ct.superClasses()) {
+ queue.add(sw.type());
+ }
+ }
+
+ @CheckForNull
+ private static ClassType resolveClassType(Expression expression) {
+ PythonType type = expression.typeV2();
+ return (type instanceof ClassType classType) ? classType : null;
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/MutableDefaultValueCheck.java b/python-checks/src/main/java/org/sonar/python/checks/MutableDefaultValueCheck.java
new file mode 100644
index 0000000000..c521dfb3cb
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/MutableDefaultValueCheck.java
@@ -0,0 +1,111 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.List;
+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.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.QualifiedExpression;
+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.tree.TreeUtils;
+
+@Rule(key = "S8508")
+public class MutableDefaultValueCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Replace this mutable value with an immutable default to avoid shared state.";
+
+ private static final TypeMatcher IS_DICT_TYPE = TypeMatchers.isType("builtins.dict");
+ private static final TypeMatcher IS_CONTEXT_VAR = TypeMatchers.isType("contextvars.ContextVar");
+ private static final TypeMatcher IS_MUTABLE_CONSTRUCTOR = TypeMatchers.any(
+ TypeMatchers.isType("builtins.list"),
+ TypeMatchers.isType("builtins.dict"),
+ TypeMatchers.isType("builtins.set"),
+ TypeMatchers.isType("builtins.bytearray")
+ );
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, MutableDefaultValueCheck::checkCall);
+ }
+
+ private static void checkCall(SubscriptionContext ctx) {
+ CallExpression callExpression = (CallExpression) ctx.syntaxNode();
+ Expression callee = callExpression.callee();
+ if (isDictFromkeys(callee, ctx)) {
+ checkDictFromkeys(ctx, callExpression);
+ } else if (IS_CONTEXT_VAR.isTrueFor(callee, ctx)) {
+ checkContextVar(ctx, callExpression);
+ }
+ }
+
+ private static boolean isDictFromkeys(Expression callee, SubscriptionContext ctx) {
+ if (!(callee instanceof QualifiedExpression qualifiedExpr)) {
+ return false;
+ }
+ return "fromkeys".equals(qualifiedExpr.name().name())
+ && IS_DICT_TYPE.isTrueFor(qualifiedExpr.qualifier(), ctx);
+ }
+
+ private static void checkDictFromkeys(SubscriptionContext ctx, CallExpression callExpression) {
+ List arguments = callExpression.arguments();
+ if (arguments.size() < 2) {
+ return;
+ }
+ Argument secondArg = arguments.get(1);
+ if (secondArg instanceof RegularArgument regularArg && regularArg.keywordArgument() == null) {
+ checkAndReportIfMutable(regularArg.expression(), ctx);
+ }
+ }
+
+ private static void checkContextVar(SubscriptionContext ctx, CallExpression callExpression) {
+ RegularArgument defaultArg = TreeUtils.argumentByKeyword("default", callExpression.arguments());
+ if (defaultArg != null) {
+ checkAndReportIfMutable(defaultArg.expression(), ctx);
+ }
+ }
+
+ private static void checkAndReportIfMutable(Expression value, SubscriptionContext ctx) {
+ if (isMutableValue(value, ctx)) {
+ ctx.addIssue(value, MESSAGE);
+ }
+ }
+
+ private static boolean isMutableValue(Expression expression, SubscriptionContext ctx) {
+ if (expression.is(Tree.Kind.LIST_LITERAL)
+ || expression.is(Tree.Kind.DICTIONARY_LITERAL)
+ || expression.is(Tree.Kind.SET_LITERAL)) {
+ return true;
+ }
+ if (expression instanceof CallExpression callExpr) {
+ return IS_MUTABLE_CONSTRUCTOR.isTrueFor(callExpr.callee(), ctx);
+ }
+ if (expression instanceof Name name) {
+ Set values = ctx.valuesAtLocation(name);
+ return !values.isEmpty() && values.stream().allMatch(v -> isMutableValue(v, ctx));
+ }
+ return false;
+ }
+}
diff --git a/python-checks/src/main/java/org/sonar/python/checks/NeedlessPassCheck.java b/python-checks/src/main/java/org/sonar/python/checks/NeedlessPassCheck.java
index 1f7d272fe8..01e52840a4 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/NeedlessPassCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/NeedlessPassCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/NestedCollectionsCreationCheck.java b/python-checks/src/main/java/org/sonar/python/checks/NestedCollectionsCreationCheck.java
index 0dfbc94baf..555437d673 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/NestedCollectionsCreationCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/NestedCollectionsCreationCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/NestedConditionalExpressionCheck.java b/python-checks/src/main/java/org/sonar/python/checks/NestedConditionalExpressionCheck.java
index c23d88598d..c0418b382d 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/NestedConditionalExpressionCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/NestedConditionalExpressionCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/NestedControlFlowDepthCheck.java b/python-checks/src/main/java/org/sonar/python/checks/NestedControlFlowDepthCheck.java
index 4183f97ee2..a2b39f3c28 100644
--- a/python-checks/src/main/java/org/sonar/python/checks/NestedControlFlowDepthCheck.java
+++ b/python-checks/src/main/java/org/sonar/python/checks/NestedControlFlowDepthCheck.java
@@ -1,10 +1,10 @@
/*
* SonarQube Python Plugin
- * Copyright (C) 2011-2025 SonarSource Sàrl
+ * Copyright (C) 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.
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
diff --git a/python-checks/src/main/java/org/sonar/python/checks/NestedLoopVariableReuseCheck.java b/python-checks/src/main/java/org/sonar/python/checks/NestedLoopVariableReuseCheck.java
new file mode 100644
index 0000000000..73783ca8b2
--- /dev/null
+++ b/python-checks/src/main/java/org/sonar/python/checks/NestedLoopVariableReuseCheck.java
@@ -0,0 +1,117 @@
+/*
+ * SonarQube Python Plugin
+ * Copyright (C) SonarSource Sàrl
+ * mailto:info AT sonarsource DOT com
+ *
+ * You can redistribute and/or modify this program under the terms of
+ * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
+ *
+ * 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;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+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.tree.Expression;
+import org.sonar.plugins.python.api.tree.ForStatement;
+import org.sonar.plugins.python.api.tree.Name;
+import org.sonar.plugins.python.api.tree.Tree;
+import org.sonar.plugins.python.api.tree.Tuple;
+
+@Rule(key = "S8510")
+public class NestedLoopVariableReuseCheck extends PythonSubscriptionCheck {
+
+ private static final String MESSAGE = "Rename this loop variable; it shadows the outer loop variable \"%s\".";
+ private static final String SECONDARY_MESSAGE = "Outer loop variable.";
+
+ @Override
+ public void initialize(Context context) {
+ context.registerSyntaxNodeConsumer(Tree.Kind.FOR_STMT, NestedLoopVariableReuseCheck::checkForStatement);
+ }
+
+ private static void checkForStatement(SubscriptionContext ctx) {
+ ForStatement forStatement = (ForStatement) ctx.syntaxNode();
+ List innerVarNames = extractLoopVarNames(forStatement);
+ if (innerVarNames.isEmpty()) {
+ return;
+ }
+ Map> shadowedOuterNames = collectShadowedNames(forStatement, innerVarNames);
+ reportIssues(ctx, shadowedOuterNames);
+ }
+
+ private static Map> collectShadowedNames(ForStatement forStatement, List innerVarNames) {
+ Map> shadowedOuterNames = new LinkedHashMap<>();
+ for (Name innerName : innerVarNames) {
+ shadowedOuterNames.put(innerName, new ArrayList<>());
+ }
+ Tree current = forStatement.parent();
+ while (current != null) {
+ if (current.is(Tree.Kind.FUNCDEF, Tree.Kind.CLASSDEF, Tree.Kind.LAMBDA,
+ Tree.Kind.LIST_COMPREHENSION, Tree.Kind.SET_COMPREHENSION,
+ Tree.Kind.DICT_COMPREHENSION, Tree.Kind.GENERATOR_EXPR)) {
+ break;
+ }
+ if (current.is(Tree.Kind.FOR_STMT)) {
+ collectMatchingNames((ForStatement) current, innerVarNames, shadowedOuterNames);
+ }
+ current = current.parent();
+ }
+ return shadowedOuterNames;
+ }
+
+ private static void collectMatchingNames(ForStatement outerLoop, List innerVarNames, Map> shadowedOuterNames) {
+ List