fastapi-therapist 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. fastapi_therapist-0.1.0/.gitignore +10 -0
  2. fastapi_therapist-0.1.0/PKG-INFO +71 -0
  3. fastapi_therapist-0.1.0/README.md +53 -0
  4. fastapi_therapist-0.1.0/pyproject.toml +34 -0
  5. fastapi_therapist-0.1.0/src/fastapi_doctor/__init__.py +0 -0
  6. fastapi_therapist-0.1.0/src/fastapi_doctor/cli.py +53 -0
  7. fastapi_therapist-0.1.0/src/fastapi_doctor/models.py +39 -0
  8. fastapi_therapist-0.1.0/src/fastapi_doctor/reporter.py +64 -0
  9. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/__init__.py +9 -0
  10. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/architecture/__init__.py +0 -0
  11. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/__init__.py +15 -0
  12. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt001_sync_blocking_io.py +80 -0
  13. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt002_db_session_in_async.py +158 -0
  14. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt003_no_await_in_async.py +148 -0
  15. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt004_nested_event_loop.py +112 -0
  16. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt005_blocking_file_io.py +114 -0
  17. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt006_sync_subprocess.py +121 -0
  18. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/base.py +171 -0
  19. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/correctness/__init__.py +3 -0
  20. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/correctness/fastt011_missing_status_code.py +62 -0
  21. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/dead_code/__init__.py +0 -0
  22. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/dependency/__init__.py +0 -0
  23. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/performance/__init__.py +0 -0
  24. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/pydantic/__init__.py +0 -0
  25. fastapi_therapist-0.1.0/src/fastapi_doctor/rules/security/__init__.py +0 -0
  26. fastapi_therapist-0.1.0/src/fastapi_doctor/scanner.py +172 -0
  27. fastapi_therapist-0.1.0/src/fastapi_doctor/scoring.py +34 -0
  28. fastapi_therapist-0.1.0/tests/fixtures/fastt001/bad.py +41 -0
  29. fastapi_therapist-0.1.0/tests/fixtures/fastt001/good.py +48 -0
  30. fastapi_therapist-0.1.0/tests/fixtures/fastt002/bad.py +48 -0
  31. fastapi_therapist-0.1.0/tests/fixtures/fastt002/good.py +57 -0
  32. fastapi_therapist-0.1.0/tests/fixtures/fastt002_crud/crud/users.py +15 -0
  33. fastapi_therapist-0.1.0/tests/fixtures/fastt002_crud/routers.py +19 -0
  34. fastapi_therapist-0.1.0/tests/fixtures/fastt003/bad.py +20 -0
  35. fastapi_therapist-0.1.0/tests/fixtures/fastt003/good.py +31 -0
  36. fastapi_therapist-0.1.0/tests/fixtures/fastt004/bad.py +27 -0
  37. fastapi_therapist-0.1.0/tests/fixtures/fastt004/good.py +21 -0
  38. fastapi_therapist-0.1.0/tests/fixtures/fastt005/bad.py +25 -0
  39. fastapi_therapist-0.1.0/tests/fixtures/fastt005/good.py +34 -0
  40. fastapi_therapist-0.1.0/tests/fixtures/fastt006/bad.py +30 -0
  41. fastapi_therapist-0.1.0/tests/fixtures/fastt006/good.py +40 -0
  42. fastapi_therapist-0.1.0/tests/fixtures/fastt011/bad.py +27 -0
  43. fastapi_therapist-0.1.0/tests/fixtures/fastt011/good.py +33 -0
  44. fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt001.py +106 -0
  45. fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt002.py +109 -0
  46. fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt002_crud.py +59 -0
  47. fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt003.py +217 -0
  48. fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt004.py +156 -0
  49. fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt005.py +107 -0
  50. fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt006.py +111 -0
  51. fastapi_therapist-0.1.0/tests/rules/correctness/test_fastt011.py +50 -0
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-therapist
3
+ Version: 0.1.0
4
+ Summary: Diagnose FastAPI codebases for best practices
5
+ Project-URL: Homepage, https://github.com/sahil-code-19/FastAPI-doctor
6
+ Project-URL: Repository, https://github.com/sahil-code-19/FastAPI-doctor
7
+ Author-email: Sahil Koshti <koshtisahil02@gmail.com>
8
+ License: MIT
9
+ Classifier: Framework :: FastAPI
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Software Development :: Quality Assurance
15
+ Classifier: Topic :: Software Development :: Testing
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+
19
+ # fastapi-therapist
20
+
21
+ Diagnose FastAPI codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install fastapi-therapist
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ # Scan current directory with verbose output
33
+ fastapi-therapist . --verbose
34
+
35
+ # Scan a specific project
36
+ fastapi-therapist /path/to/fastapi/project --verbose
37
+
38
+ # Output only the score (useful for CI)
39
+ fastapi-therapist . --score
40
+ ```
41
+
42
+ ## Rules
43
+
44
+ ### Async/Sync Correctness
45
+
46
+ | Rule | Severity | Detects |
47
+ |---|---|---|
48
+ | FASTT001 | ERROR | Sync blocking IO (`requests.get`, `time.sleep`) in async endpoint |
49
+ | FASTT002 | ERROR | Sync SQLAlchemy calls in async endpoint |
50
+ | FASTT003 | WARN/ERROR | `async def` endpoint with no await |
51
+ | FASTT004 | ERROR | `asyncio.run()` inside async context — nested event loop |
52
+ | FASTT005 | ERROR | `open()` blocking file I/O in async endpoint |
53
+ | FASTT006 | WARNING | `subprocess.run()` / `os.system()` in async endpoint |
54
+
55
+ ### Correctness
56
+
57
+ | Rule | Severity | Detects |
58
+ |---|---|---|
59
+ | FASTT011 | WARNING | POST/PUT/PATCH/DELETE missing explicit `status_code` |
60
+
61
+ ## Score
62
+
63
+ The health score formula:
64
+
65
+ ```
66
+ 100 - (unique error rules × 1.5) - (unique warning rules × 0.75)
67
+ ```
68
+
69
+ - **75–100** Great
70
+ - **50–74** Needs work
71
+ - **0–49** Critical
@@ -0,0 +1,53 @@
1
+ # fastapi-therapist
2
+
3
+ Diagnose FastAPI codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install fastapi-therapist
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ # Scan current directory with verbose output
15
+ fastapi-therapist . --verbose
16
+
17
+ # Scan a specific project
18
+ fastapi-therapist /path/to/fastapi/project --verbose
19
+
20
+ # Output only the score (useful for CI)
21
+ fastapi-therapist . --score
22
+ ```
23
+
24
+ ## Rules
25
+
26
+ ### Async/Sync Correctness
27
+
28
+ | Rule | Severity | Detects |
29
+ |---|---|---|
30
+ | FASTT001 | ERROR | Sync blocking IO (`requests.get`, `time.sleep`) in async endpoint |
31
+ | FASTT002 | ERROR | Sync SQLAlchemy calls in async endpoint |
32
+ | FASTT003 | WARN/ERROR | `async def` endpoint with no await |
33
+ | FASTT004 | ERROR | `asyncio.run()` inside async context — nested event loop |
34
+ | FASTT005 | ERROR | `open()` blocking file I/O in async endpoint |
35
+ | FASTT006 | WARNING | `subprocess.run()` / `os.system()` in async endpoint |
36
+
37
+ ### Correctness
38
+
39
+ | Rule | Severity | Detects |
40
+ |---|---|---|
41
+ | FASTT011 | WARNING | POST/PUT/PATCH/DELETE missing explicit `status_code` |
42
+
43
+ ## Score
44
+
45
+ The health score formula:
46
+
47
+ ```
48
+ 100 - (unique error rules × 1.5) - (unique warning rules × 0.75)
49
+ ```
50
+
51
+ - **75–100** Great
52
+ - **50–74** Needs work
53
+ - **0–49** Critical
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "fastapi-therapist"
3
+ version = "0.1.0"
4
+ description = "Diagnose FastAPI codebases for best practices"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.12"
8
+ dependencies = []
9
+ authors = [
10
+ { name = "Sahil Koshti", email = "koshtisahil02@gmail.com" },
11
+ ]
12
+ classifiers = [
13
+ "Intended Audience :: Developers",
14
+ "Topic :: Software Development :: Quality Assurance",
15
+ "Topic :: Software Development :: Testing",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Framework :: FastAPI",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/sahil-code-19/FastAPI-doctor"
24
+ Repository = "https://github.com/sahil-code-19/FastAPI-doctor"
25
+
26
+ [project.scripts]
27
+ fastapi-therapist = "fastapi_doctor.cli:main"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/fastapi_doctor"]
File without changes
@@ -0,0 +1,53 @@
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+ from .scanner import scan_directory
5
+ from .scoring import calculate_score
6
+ from .reporter import print_header, print_diagnostics, print_score, print_summary
7
+
8
+ VERSION = "0.1.0"
9
+
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(
13
+ prog="fastapi-therapist",
14
+ description="Diagnose FastAPI codebases for best practices",
15
+ )
16
+ parser.add_argument("directory", nargs="?", default=".", help="Directory to scan")
17
+ parser.add_argument(
18
+ "-v", "--version", action="version", version=f"%(prog)s {VERSION}"
19
+ )
20
+ parser.add_argument(
21
+ "--verbose", action="store_true", help="Show all file locations"
22
+ )
23
+ parser.add_argument("--score", action="store_true", help="Output only the score")
24
+
25
+ args = parser.parse_args()
26
+
27
+ directory = Path(args.directory).resolve()
28
+ if not directory.is_dir():
29
+ print(f"Error: {directory} is not a directory", file=sys.stderr)
30
+ sys.exit(1)
31
+
32
+ if not args.score:
33
+ print_header(VERSION)
34
+
35
+ result = scan_directory(directory)
36
+ score_result = calculate_score(result.diagnostics)
37
+
38
+ if args.score:
39
+ print(score_result.score)
40
+ return
41
+
42
+ print_diagnostics(result.diagnostics, verbose=args.verbose)
43
+ print()
44
+ print_score(score_result)
45
+ print_summary(result.diagnostics, result.files_scanned, result.elapsed_ms)
46
+
47
+ # Exit with error if there are errors
48
+ if any(d.severity.value == "error" for d in result.diagnostics):
49
+ sys.exit(1)
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1,39 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+
4
+
5
+ class Severity(Enum):
6
+ ERROR = "error"
7
+ WARNING = "warning"
8
+
9
+
10
+ @dataclass
11
+ class Diagnostic:
12
+ file_path: str
13
+ rule: str
14
+ severity: Severity
15
+ message: str
16
+ line: int
17
+ column: int
18
+ help: str = ""
19
+
20
+
21
+ @dataclass
22
+ class RuleDefinition:
23
+ id: str
24
+ severity: Severity
25
+ description: str
26
+ recommendation: str
27
+
28
+
29
+ @dataclass
30
+ class ScanResult:
31
+ diagnostics: list[Diagnostic]
32
+ files_scanned: int
33
+ elapsed_ms: float
34
+
35
+
36
+ @dataclass
37
+ class ScoreResult:
38
+ score: int
39
+ label: str
@@ -0,0 +1,64 @@
1
+ from .models import Diagnostic, ScoreResult, Severity
2
+
3
+ # ANSI colors
4
+ RED = "\033[91m"
5
+ YELLOW = "\033[93m"
6
+ GREEN = "\033[92m"
7
+ GRAY = "\033[90m"
8
+ BOLD = "\033[1m"
9
+ RESET = "\033[0m"
10
+
11
+
12
+ def print_header(version: str):
13
+ print(f"{BOLD}FastAPI Therapist{RESET} v{version}")
14
+ print()
15
+
16
+
17
+ def print_diagnostics(diagnostics: list[Diagnostic], verbose: bool = False):
18
+ if not diagnostics:
19
+ print(f"{GREEN}No issues found!{RESET}")
20
+ return
21
+
22
+ # Group by rule + severity so mixed-severity rules get separate headers
23
+ by_rule_severity: dict[tuple[str, str], list[Diagnostic]] = {}
24
+ for diag in diagnostics:
25
+ key = (diag.rule, diag.severity.value)
26
+ by_rule_severity.setdefault(key, []).append(diag)
27
+
28
+ for (rule, severity_value), rule_diags in by_rule_severity.items():
29
+ is_error = severity_value == Severity.ERROR.value
30
+ severity_icon = "X" if is_error else "!"
31
+ color = RED if is_error else YELLOW
32
+
33
+ print(f" {color}{severity_icon} {rule}{RESET} ({len(rule_diags)} issues)")
34
+
35
+ for diag in rule_diags:
36
+ print(
37
+ f" {color}→{RESET} {GRAY}{diag.file_path}:{diag.line}{RESET} {diag.message}"
38
+ )
39
+
40
+ if rule_diags[0].help:
41
+ print(f" {GRAY}-> {rule_diags[0].help}{RESET}")
42
+
43
+ print()
44
+
45
+
46
+ def print_score(score_result: ScoreResult):
47
+ color = (
48
+ GREEN
49
+ if score_result.score >= 75
50
+ else YELLOW
51
+ if score_result.score >= 50
52
+ else RED
53
+ )
54
+ print(
55
+ f"{BOLD}Score:{RESET} {color}{score_result.score}/100{RESET} ({score_result.label})"
56
+ )
57
+
58
+
59
+ def print_summary(diagnostics: list[Diagnostic], files_scanned: int, elapsed_ms: float):
60
+ errors = sum(1 for d in diagnostics if d.severity == Severity.ERROR)
61
+ warnings = sum(1 for d in diagnostics if d.severity == Severity.WARNING)
62
+ print(f"{GRAY}Scanned {files_scanned} files in {elapsed_ms:.0f}ms{RESET}")
63
+ if diagnostics:
64
+ print(f"{GRAY}{errors} errors, {warnings} warnings{RESET}")
@@ -0,0 +1,9 @@
1
+ # Import all rule modules here to trigger @register_rule decorator
2
+ from .async_sync import DbSessionInAsyncRule, SyncBlockingIORule
3
+ from .correctness import MissingStatusCodeRule
4
+
5
+ __all__ = [
6
+ "DbSessionInAsyncRule",
7
+ "MissingStatusCodeRule",
8
+ "SyncBlockingIORule",
9
+ ]
@@ -0,0 +1,15 @@
1
+ from .fastt001_sync_blocking_io import SyncBlockingIORule
2
+ from .fastt002_db_session_in_async import DbSessionInAsyncRule
3
+ from .fastt003_no_await_in_async import NoAwaitInAsyncRule
4
+ from .fastt004_nested_event_loop import NestedEventLoopRule
5
+ from .fastt005_blocking_file_io import BlockingFileIORule
6
+ from .fastt006_sync_subprocess import SyncSubprocessRule
7
+
8
+ __all__ = [
9
+ "BlockingFileIORule",
10
+ "DbSessionInAsyncRule",
11
+ "NestedEventLoopRule",
12
+ "NoAwaitInAsyncRule",
13
+ "SyncBlockingIORule",
14
+ "SyncSubprocessRule",
15
+ ]
@@ -0,0 +1,80 @@
1
+ import ast
2
+
3
+ from fastapi_doctor.rules.base import (
4
+ Rule,
5
+ register_rule,
6
+ is_fastapi_endpoint,
7
+ get_call_sig,
8
+ collect_threadpool_wrappers,
9
+ is_inside_wrapper,
10
+ )
11
+ from fastapi_doctor.models import Diagnostic, RuleDefinition, Severity
12
+
13
+ BLOCKING_CALLS = {
14
+ ("requests", "get"),
15
+ ("requests", "post"),
16
+ ("requests", "put"),
17
+ ("requests", "patch"),
18
+ ("requests", "delete"),
19
+ ("requests", "head"),
20
+ ("requests", "request"),
21
+ ("requests", "Session"),
22
+ ("urllib.request", "urlopen"),
23
+ ("time", "sleep"),
24
+ ("httpx", "get"),
25
+ ("httpx", "post"),
26
+ ("httpx", "put"),
27
+ ("httpx", "delete"),
28
+ ("httpx", "Client"),
29
+ }
30
+
31
+
32
+ @register_rule
33
+ class SyncBlockingIORule(Rule):
34
+ """Detect synchronous blocking IO in async FastAPI endpoints (FASTT001)."""
35
+
36
+ @property
37
+ def definition(self) -> RuleDefinition:
38
+ return RuleDefinition(
39
+ id="fastapi-doctor/FASTT001",
40
+ severity=Severity.ERROR,
41
+ description="async def endpoint calling synchronous blocking IO (requests.get, urllib, time.sleep, sync httpx) without run_in_threadpool",
42
+ recommendation="Wrap blocking IO in asyncio.to_thread() or use async alternatives (httpx.AsyncClient, asyncio.sleep, etc.)",
43
+ )
44
+
45
+ def check(self, tree: ast.Module, file_path: str, source: str) -> list[Diagnostic]:
46
+ diagnostics = []
47
+
48
+ for node in ast.walk(tree):
49
+ if not isinstance(node, ast.AsyncFunctionDef):
50
+ continue
51
+
52
+ method_name = is_fastapi_endpoint(node)
53
+ if method_name is None:
54
+ continue
55
+
56
+ wrappers = collect_threadpool_wrappers(node)
57
+
58
+ for child in ast.walk(node):
59
+ if not isinstance(child, ast.Call):
60
+ continue
61
+
62
+ call_sig = get_call_sig(child)
63
+ if call_sig is None:
64
+ continue
65
+
66
+ if call_sig in BLOCKING_CALLS:
67
+ if not is_inside_wrapper(child, wrappers):
68
+ diagnostics.append(
69
+ Diagnostic(
70
+ severity=self.definition.severity,
71
+ file_path=file_path,
72
+ rule=self.definition.id,
73
+ message=f"Blocking call '{call_sig[0]}.{call_sig[1]}()' inside async endpoint '{node.name}' without asyncio.to_thread()",
74
+ line=child.lineno,
75
+ column=child.col_offset,
76
+ help=self.definition.recommendation,
77
+ )
78
+ )
79
+
80
+ return diagnostics
@@ -0,0 +1,158 @@
1
+ import ast
2
+
3
+ from fastapi_doctor.rules.base import (
4
+ Rule,
5
+ register_rule,
6
+ is_fastapi_endpoint,
7
+ collect_threadpool_wrappers,
8
+ is_inside_wrapper,
9
+ )
10
+ from fastapi_doctor.models import Diagnostic, RuleDefinition, Severity
11
+
12
+ DB_SESSION_METHODS = {
13
+ "execute",
14
+ "commit",
15
+ "rollback",
16
+ "query",
17
+ "flush",
18
+ "refresh",
19
+ "merge",
20
+ "delete",
21
+ "bulk_save_objects",
22
+ "bulk_insert_mappings",
23
+ "scalars",
24
+ "scalar",
25
+ }
26
+
27
+ ASYNC_RESULT_METHODS = {
28
+ "scalars",
29
+ "scalar",
30
+ "fetchone",
31
+ "fetchmany",
32
+ "fetchall",
33
+ "all",
34
+ "first",
35
+ "one",
36
+ "one_or_none",
37
+ }
38
+
39
+
40
+ @register_rule
41
+ class DbSessionInAsyncRule(Rule):
42
+ """Detect synchronous SQLAlchemy session calls in async FastAPI endpoints (FASTT002)."""
43
+
44
+ @property
45
+ def definition(self) -> RuleDefinition:
46
+ return RuleDefinition(
47
+ id="fastapi-doctor/FASTT002",
48
+ severity=Severity.ERROR,
49
+ description="Synchronous SQLAlchemy session call inside async endpoint — use AsyncSession instead",
50
+ recommendation="Use AsyncSession with await (e.g. await session.execute()) or wrap in asyncio.to_thread()",
51
+ )
52
+
53
+ def check(self, tree: ast.Module, file_path: str, source: str) -> list[Diagnostic]:
54
+ diagnostics = []
55
+ for node in ast.walk(tree):
56
+ if not isinstance(node, ast.AsyncFunctionDef):
57
+ continue
58
+ if is_fastapi_endpoint(node) is None:
59
+ continue
60
+ diagnostics.extend(self._check_single(node, file_path))
61
+ return diagnostics
62
+
63
+ def check_function(
64
+ self, func_node: ast.FunctionDef | ast.AsyncFunctionDef, file_path: str
65
+ ) -> list[Diagnostic]:
66
+ """Check a resolved function body (used by import trace pass)."""
67
+ return self._check_single(func_node, file_path)
68
+
69
+ def _check_single(
70
+ self, func_node: ast.FunctionDef | ast.AsyncFunctionDef, file_path: str
71
+ ) -> list[Diagnostic]:
72
+ """Core check: find sync DB calls in a function body."""
73
+ diagnostics = []
74
+ wrappers = collect_threadpool_wrappers(func_node)
75
+
76
+ decorator_descendants = self._collect_decorator_descendants(func_node)
77
+ awaited_descendants = self._collect_awaited_descendants(func_node)
78
+ inner_func_descendants = self._collect_inner_func_descendants(func_node)
79
+
80
+ for child in ast.walk(func_node):
81
+ if not isinstance(child, ast.Call):
82
+ continue
83
+ if not isinstance(child.func, ast.Attribute):
84
+ continue
85
+ if (
86
+ child in decorator_descendants
87
+ or child in awaited_descendants
88
+ or child in inner_func_descendants
89
+ ):
90
+ continue
91
+
92
+ method = child.func.attr
93
+ if method not in DB_SESSION_METHODS:
94
+ continue
95
+ if method in ASYNC_RESULT_METHODS:
96
+ continue
97
+ if self._is_on_async_session(child):
98
+ continue
99
+ if is_inside_wrapper(child, wrappers):
100
+ continue
101
+
102
+ diagnostics.append(
103
+ Diagnostic(
104
+ severity=self.definition.severity,
105
+ file_path=file_path,
106
+ rule=self.definition.id,
107
+ message=f"Synchronous DB call '{method}()' inside async endpoint '{func_node.name}' — use await with AsyncSession",
108
+ line=child.lineno,
109
+ column=child.col_offset,
110
+ help=self.definition.recommendation,
111
+ )
112
+ )
113
+
114
+ return diagnostics
115
+
116
+ KNOWN_ASYNC_VARS = {"async_session", "async_db", "asession", "async_conn"}
117
+
118
+ def _is_on_async_session(self, node: ast.Call) -> bool:
119
+ if isinstance(node.func, ast.Attribute):
120
+ if isinstance(node.func.value, ast.Name):
121
+ base_name = node.func.value.id.lower()
122
+ if any(name in base_name for name in self.KNOWN_ASYNC_VARS):
123
+ return True
124
+ if base_name.startswith("async_"):
125
+ return True
126
+ return False
127
+
128
+ def _collect_decorator_descendants(
129
+ self, func_node: ast.FunctionDef | ast.AsyncFunctionDef
130
+ ) -> set[ast.AST]:
131
+ descendants: set[ast.AST] = set()
132
+ for decorator in func_node.decorator_list:
133
+ for desc in ast.walk(decorator):
134
+ descendants.add(desc)
135
+ return descendants
136
+
137
+ def _collect_awaited_descendants(
138
+ self, func_node: ast.FunctionDef | ast.AsyncFunctionDef
139
+ ) -> set[ast.AST]:
140
+ descendants: set[ast.AST] = set()
141
+ for node in ast.walk(func_node):
142
+ if isinstance(node, ast.Await):
143
+ for desc in ast.walk(node):
144
+ descendants.add(desc)
145
+ return descendants
146
+
147
+ def _collect_inner_func_descendants(
148
+ self, func_node: ast.FunctionDef | ast.AsyncFunctionDef
149
+ ) -> set[ast.AST]:
150
+ descendants: set[ast.AST] = set()
151
+ for node in ast.walk(func_node):
152
+ if (
153
+ isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
154
+ and node is not func_node
155
+ ):
156
+ for desc in ast.walk(node):
157
+ descendants.add(desc)
158
+ return descendants