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.
- fastapi_therapist-0.1.0/.gitignore +10 -0
- fastapi_therapist-0.1.0/PKG-INFO +71 -0
- fastapi_therapist-0.1.0/README.md +53 -0
- fastapi_therapist-0.1.0/pyproject.toml +34 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/__init__.py +0 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/cli.py +53 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/models.py +39 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/reporter.py +64 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/__init__.py +9 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/architecture/__init__.py +0 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/__init__.py +15 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt001_sync_blocking_io.py +80 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt002_db_session_in_async.py +158 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt003_no_await_in_async.py +148 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt004_nested_event_loop.py +112 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt005_blocking_file_io.py +114 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/async_sync/fastt006_sync_subprocess.py +121 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/base.py +171 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/correctness/__init__.py +3 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/correctness/fastt011_missing_status_code.py +62 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/dead_code/__init__.py +0 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/dependency/__init__.py +0 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/performance/__init__.py +0 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/pydantic/__init__.py +0 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/rules/security/__init__.py +0 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/scanner.py +172 -0
- fastapi_therapist-0.1.0/src/fastapi_doctor/scoring.py +34 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt001/bad.py +41 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt001/good.py +48 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt002/bad.py +48 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt002/good.py +57 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt002_crud/crud/users.py +15 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt002_crud/routers.py +19 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt003/bad.py +20 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt003/good.py +31 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt004/bad.py +27 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt004/good.py +21 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt005/bad.py +25 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt005/good.py +34 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt006/bad.py +30 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt006/good.py +40 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt011/bad.py +27 -0
- fastapi_therapist-0.1.0/tests/fixtures/fastt011/good.py +33 -0
- fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt001.py +106 -0
- fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt002.py +109 -0
- fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt002_crud.py +59 -0
- fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt003.py +217 -0
- fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt004.py +156 -0
- fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt005.py +107 -0
- fastapi_therapist-0.1.0/tests/rules/async_sync/test_fastt006.py +111 -0
- fastapi_therapist-0.1.0/tests/rules/correctness/test_fastt011.py +50 -0
|
@@ -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
|
+
]
|
|
File without changes
|
|
@@ -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
|