pythaw 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.
- pythaw-0.1.0/PKG-INFO +188 -0
- pythaw-0.1.0/README.md +163 -0
- pythaw-0.1.0/pyproject.toml +75 -0
- pythaw-0.1.0/pythaw/__init__.py +0 -0
- pythaw-0.1.0/pythaw/__main__.py +5 -0
- pythaw-0.1.0/pythaw/checker.py +264 -0
- pythaw-0.1.0/pythaw/cli.py +145 -0
- pythaw-0.1.0/pythaw/config.py +117 -0
- pythaw-0.1.0/pythaw/finder.py +131 -0
- pythaw-0.1.0/pythaw/formatters/__init__.py +28 -0
- pythaw-0.1.0/pythaw/formatters/_base.py +25 -0
- pythaw-0.1.0/pythaw/formatters/concise.py +46 -0
- pythaw-0.1.0/pythaw/formatters/github.py +22 -0
- pythaw-0.1.0/pythaw/formatters/json.py +26 -0
- pythaw-0.1.0/pythaw/formatters/sarif.py +57 -0
- pythaw-0.1.0/pythaw/py.typed +0 -0
- pythaw-0.1.0/pythaw/rendering.py +98 -0
- pythaw-0.1.0/pythaw/resolver.py +273 -0
- pythaw-0.1.0/pythaw/rules/__init__.py +47 -0
- pythaw-0.1.0/pythaw/rules/_base.py +46 -0
- pythaw-0.1.0/pythaw/rules/pw001.py +47 -0
- pythaw-0.1.0/pythaw/rules/pw002.py +47 -0
- pythaw-0.1.0/pythaw/rules/pw003.py +47 -0
- pythaw-0.1.0/pythaw/rules/pw004.py +45 -0
- pythaw-0.1.0/pythaw/rules/pw005.py +45 -0
- pythaw-0.1.0/pythaw/rules/pw006.py +45 -0
- pythaw-0.1.0/pythaw/rules/pw007.py +45 -0
- pythaw-0.1.0/pythaw/rules/pw008.py +45 -0
- pythaw-0.1.0/pythaw/rules/pw009.py +45 -0
- pythaw-0.1.0/pythaw/violation.py +42 -0
pythaw-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pythaw
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python static analysis tool that detects heavy initialization inside AWS Lambda handlers that should be moved to module scope for faster warm starts.
|
|
5
|
+
Keywords: aws,lambda,static-analysis,boto3,linter
|
|
6
|
+
Author: MiuraToya
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Dist: rich>=14.0.0
|
|
21
|
+
Requires-Dist: tomli>=2.0.0 ; python_full_version < '3.11'
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Project-URL: Repository, https://github.com/MiuraToya/pythaw
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# pythaw
|
|
27
|
+
|
|
28
|
+
[日本語ドキュメント](README.ja.md)
|
|
29
|
+
|
|
30
|
+
A Python static analysis tool that detects heavy initialization inside AWS Lambda handlers that should be moved to module scope for faster warm starts.
|
|
31
|
+
|
|
32
|
+
Recursively follows function calls—including across imported files—to catch indirect violations.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
Python 3.10 - 3.14 — matching the actively supported AWS Lambda Python runtimes.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# pip
|
|
42
|
+
pip install pythaw
|
|
43
|
+
|
|
44
|
+
# uv
|
|
45
|
+
uv add pythaw
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
# handler.py
|
|
52
|
+
|
|
53
|
+
def lambda_handler(event, context):
|
|
54
|
+
# BAD: Creating a boto3 client inside the handler
|
|
55
|
+
# runs initialization on every invocation,
|
|
56
|
+
# losing the benefit of warm starts.
|
|
57
|
+
client = boto3.client("s3")
|
|
58
|
+
return client.get_object(Bucket="my-bucket", Key=event["key"])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
$ pythaw check handler.py
|
|
63
|
+
infra/aws.py:7:15: PW001 boto3.client() should be called at module scope
|
|
64
|
+
→ handler.py:5:11 process()
|
|
65
|
+
→ service.py:5:13 S3Provider.get_client()
|
|
66
|
+
|
|
67
|
+
Found 1 violation in 1 file.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Move the client to module scope so Lambda container reuse skips the initialization:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
# handler.py (fixed)
|
|
74
|
+
|
|
75
|
+
client = boto3.client("s3")
|
|
76
|
+
|
|
77
|
+
def lambda_handler(event, context):
|
|
78
|
+
return client.get_object(Bucket="my-bucket", Key=event["key"])
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
$ pythaw check handler.py
|
|
83
|
+
All checks passed!
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Usage
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pythaw check <path> # Check a file or directory
|
|
90
|
+
pythaw check . --format json # JSON output
|
|
91
|
+
pythaw check . --format github # GitHub Actions annotation format
|
|
92
|
+
pythaw check . --format sarif # SARIF format (Code Scanning integration)
|
|
93
|
+
pythaw check . --select PW001,PW002 # Enable only specific rules
|
|
94
|
+
pythaw check . --ignore PW003 # Disable specific rules
|
|
95
|
+
pythaw check . --exit-zero # Always exit with code 0
|
|
96
|
+
pythaw check . --statistics # Show per-rule violation counts
|
|
97
|
+
pythaw rules # List built-in rules
|
|
98
|
+
pythaw rule PW001 # Show rule details
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Exit Codes
|
|
102
|
+
|
|
103
|
+
| Code | Meaning |
|
|
104
|
+
|------|---------|
|
|
105
|
+
| 0 | No violations found |
|
|
106
|
+
| 1 | Violations found |
|
|
107
|
+
| 2 | Tool error (invalid config, etc.) |
|
|
108
|
+
|
|
109
|
+
## Rules
|
|
110
|
+
|
|
111
|
+
| ID | Detects |
|
|
112
|
+
|-------|---------|
|
|
113
|
+
| PW001 | `boto3.client()` |
|
|
114
|
+
| PW002 | `boto3.resource()` |
|
|
115
|
+
| PW003 | `boto3.Session()` |
|
|
116
|
+
| PW004 | `pymysql.connect()` |
|
|
117
|
+
| PW005 | `psycopg2.connect()` |
|
|
118
|
+
| PW006 | `redis.Redis()` |
|
|
119
|
+
| PW007 | `redis.StrictRedis()` |
|
|
120
|
+
| PW008 | `httpx.Client()` |
|
|
121
|
+
| PW009 | `requests.Session()` |
|
|
122
|
+
|
|
123
|
+
## Call Graph Traversal
|
|
124
|
+
|
|
125
|
+
pythaw recursively follows local function calls and imported modules from the handler, detecting indirect violations across files.
|
|
126
|
+
|
|
127
|
+
### Supported patterns
|
|
128
|
+
|
|
129
|
+
| Pattern | Example |
|
|
130
|
+
|---------|---------|
|
|
131
|
+
| Same-file function call | `helper()` |
|
|
132
|
+
| Module-qualified function call | `infra.get_client()` |
|
|
133
|
+
| Class method call | `AwsProvider.get_client()` |
|
|
134
|
+
| Class instantiation (`__init__`) | `S3Client()` |
|
|
135
|
+
| Cross-file import tracking | `from infra import get_client` |
|
|
136
|
+
|
|
137
|
+
> **Note:** Instance method calls via variables (e.g. `obj = Cls(); obj.method()`) are not tracked — this would require data-flow analysis beyond the current scope.
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
infra/aws.py:4:15: PW001 boto3.client() should be called at module scope
|
|
141
|
+
→ handler.py:2:10 get_client()
|
|
142
|
+
|
|
143
|
+
Found 1 violation in 1 file.
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Suppression
|
|
147
|
+
|
|
148
|
+
### Inline suppression
|
|
149
|
+
|
|
150
|
+
Append `# nopw: <code>` to a line to suppress that violation. Multiple codes can be comma-separated.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
client = boto3.client("s3") # nopw: PW001
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### File-level suppression
|
|
157
|
+
|
|
158
|
+
Add `# pythaw: nocheck` in the leading comment block to skip the entire file.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
# pythaw: nocheck
|
|
162
|
+
import boto3
|
|
163
|
+
|
|
164
|
+
def handler(event, context):
|
|
165
|
+
boto3.client("s3") # not checked
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Configuration
|
|
169
|
+
|
|
170
|
+
Configure via the `[tool.pythaw]` section in `pyproject.toml`.
|
|
171
|
+
|
|
172
|
+
```toml
|
|
173
|
+
[tool.pythaw]
|
|
174
|
+
# Function name patterns recognized as handlers (fnmatch syntax)
|
|
175
|
+
handler_patterns = ["handler", "lambda_handler", "*_handler"]
|
|
176
|
+
|
|
177
|
+
# Patterns to exclude from scanning
|
|
178
|
+
exclude = [".venv", "tests"]
|
|
179
|
+
|
|
180
|
+
# Disable specific rules per file pattern
|
|
181
|
+
[tool.pythaw.per-file-ignores]
|
|
182
|
+
"tests/*" = ["PW001", "PW002"]
|
|
183
|
+
"scripts/*" = ["PW001"]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
[MIT](LICENSE)
|
pythaw-0.1.0/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# pythaw
|
|
2
|
+
|
|
3
|
+
[日本語ドキュメント](README.ja.md)
|
|
4
|
+
|
|
5
|
+
A Python static analysis tool that detects heavy initialization inside AWS Lambda handlers that should be moved to module scope for faster warm starts.
|
|
6
|
+
|
|
7
|
+
Recursively follows function calls—including across imported files—to catch indirect violations.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
Python 3.10 - 3.14 — matching the actively supported AWS Lambda Python runtimes.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# pip
|
|
17
|
+
pip install pythaw
|
|
18
|
+
|
|
19
|
+
# uv
|
|
20
|
+
uv add pythaw
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
# handler.py
|
|
27
|
+
|
|
28
|
+
def lambda_handler(event, context):
|
|
29
|
+
# BAD: Creating a boto3 client inside the handler
|
|
30
|
+
# runs initialization on every invocation,
|
|
31
|
+
# losing the benefit of warm starts.
|
|
32
|
+
client = boto3.client("s3")
|
|
33
|
+
return client.get_object(Bucket="my-bucket", Key=event["key"])
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
$ pythaw check handler.py
|
|
38
|
+
infra/aws.py:7:15: PW001 boto3.client() should be called at module scope
|
|
39
|
+
→ handler.py:5:11 process()
|
|
40
|
+
→ service.py:5:13 S3Provider.get_client()
|
|
41
|
+
|
|
42
|
+
Found 1 violation in 1 file.
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Move the client to module scope so Lambda container reuse skips the initialization:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
# handler.py (fixed)
|
|
49
|
+
|
|
50
|
+
client = boto3.client("s3")
|
|
51
|
+
|
|
52
|
+
def lambda_handler(event, context):
|
|
53
|
+
return client.get_object(Bucket="my-bucket", Key=event["key"])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
$ pythaw check handler.py
|
|
58
|
+
All checks passed!
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pythaw check <path> # Check a file or directory
|
|
65
|
+
pythaw check . --format json # JSON output
|
|
66
|
+
pythaw check . --format github # GitHub Actions annotation format
|
|
67
|
+
pythaw check . --format sarif # SARIF format (Code Scanning integration)
|
|
68
|
+
pythaw check . --select PW001,PW002 # Enable only specific rules
|
|
69
|
+
pythaw check . --ignore PW003 # Disable specific rules
|
|
70
|
+
pythaw check . --exit-zero # Always exit with code 0
|
|
71
|
+
pythaw check . --statistics # Show per-rule violation counts
|
|
72
|
+
pythaw rules # List built-in rules
|
|
73
|
+
pythaw rule PW001 # Show rule details
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Exit Codes
|
|
77
|
+
|
|
78
|
+
| Code | Meaning |
|
|
79
|
+
|------|---------|
|
|
80
|
+
| 0 | No violations found |
|
|
81
|
+
| 1 | Violations found |
|
|
82
|
+
| 2 | Tool error (invalid config, etc.) |
|
|
83
|
+
|
|
84
|
+
## Rules
|
|
85
|
+
|
|
86
|
+
| ID | Detects |
|
|
87
|
+
|-------|---------|
|
|
88
|
+
| PW001 | `boto3.client()` |
|
|
89
|
+
| PW002 | `boto3.resource()` |
|
|
90
|
+
| PW003 | `boto3.Session()` |
|
|
91
|
+
| PW004 | `pymysql.connect()` |
|
|
92
|
+
| PW005 | `psycopg2.connect()` |
|
|
93
|
+
| PW006 | `redis.Redis()` |
|
|
94
|
+
| PW007 | `redis.StrictRedis()` |
|
|
95
|
+
| PW008 | `httpx.Client()` |
|
|
96
|
+
| PW009 | `requests.Session()` |
|
|
97
|
+
|
|
98
|
+
## Call Graph Traversal
|
|
99
|
+
|
|
100
|
+
pythaw recursively follows local function calls and imported modules from the handler, detecting indirect violations across files.
|
|
101
|
+
|
|
102
|
+
### Supported patterns
|
|
103
|
+
|
|
104
|
+
| Pattern | Example |
|
|
105
|
+
|---------|---------|
|
|
106
|
+
| Same-file function call | `helper()` |
|
|
107
|
+
| Module-qualified function call | `infra.get_client()` |
|
|
108
|
+
| Class method call | `AwsProvider.get_client()` |
|
|
109
|
+
| Class instantiation (`__init__`) | `S3Client()` |
|
|
110
|
+
| Cross-file import tracking | `from infra import get_client` |
|
|
111
|
+
|
|
112
|
+
> **Note:** Instance method calls via variables (e.g. `obj = Cls(); obj.method()`) are not tracked — this would require data-flow analysis beyond the current scope.
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
infra/aws.py:4:15: PW001 boto3.client() should be called at module scope
|
|
116
|
+
→ handler.py:2:10 get_client()
|
|
117
|
+
|
|
118
|
+
Found 1 violation in 1 file.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Suppression
|
|
122
|
+
|
|
123
|
+
### Inline suppression
|
|
124
|
+
|
|
125
|
+
Append `# nopw: <code>` to a line to suppress that violation. Multiple codes can be comma-separated.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
client = boto3.client("s3") # nopw: PW001
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### File-level suppression
|
|
132
|
+
|
|
133
|
+
Add `# pythaw: nocheck` in the leading comment block to skip the entire file.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
# pythaw: nocheck
|
|
137
|
+
import boto3
|
|
138
|
+
|
|
139
|
+
def handler(event, context):
|
|
140
|
+
boto3.client("s3") # not checked
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Configuration
|
|
144
|
+
|
|
145
|
+
Configure via the `[tool.pythaw]` section in `pyproject.toml`.
|
|
146
|
+
|
|
147
|
+
```toml
|
|
148
|
+
[tool.pythaw]
|
|
149
|
+
# Function name patterns recognized as handlers (fnmatch syntax)
|
|
150
|
+
handler_patterns = ["handler", "lambda_handler", "*_handler"]
|
|
151
|
+
|
|
152
|
+
# Patterns to exclude from scanning
|
|
153
|
+
exclude = [".venv", "tests"]
|
|
154
|
+
|
|
155
|
+
# Disable specific rules per file pattern
|
|
156
|
+
[tool.pythaw.per-file-ignores]
|
|
157
|
+
"tests/*" = ["PW001", "PW002"]
|
|
158
|
+
"scripts/*" = ["PW001"]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pythaw"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python static analysis tool that detects heavy initialization inside AWS Lambda handlers that should be moved to module scope for faster warm starts."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "MiuraToya" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
keywords = ["aws", "lambda", "static-analysis", "boto3", "linter"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"rich>=14.0.0",
|
|
28
|
+
"tomli>=2.0.0; python_version < '3.11'",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/MiuraToya/pythaw"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
pythaw = "pythaw.cli:main"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"mypy>=1.19.1",
|
|
40
|
+
"pytest>=9.0.2",
|
|
41
|
+
"pytest-cov>=6.2.1",
|
|
42
|
+
|
|
43
|
+
"ruff>=0.15.4",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["uv_build>=0.9.8,<0.11.0"]
|
|
48
|
+
build-backend = "uv_build"
|
|
49
|
+
|
|
50
|
+
[tool.uv.build-backend]
|
|
51
|
+
module-root = "."
|
|
52
|
+
|
|
53
|
+
[tool.mypy]
|
|
54
|
+
python_version = "3.10"
|
|
55
|
+
strict = true
|
|
56
|
+
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
target-version = "py310"
|
|
59
|
+
extend-exclude = ["tests/e2e/scenarios", "tests/scenarios"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint]
|
|
62
|
+
select = ["ALL"]
|
|
63
|
+
ignore = [
|
|
64
|
+
"D", # pydocstyle — ドキュメント規約は別途決める
|
|
65
|
+
"ANN", # flake8-annotations — mypy strict でカバー
|
|
66
|
+
"COM812", # trailing comma — formatter と競合
|
|
67
|
+
"ISC001", # implicit string concat — formatter と競合
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint.per-file-ignores]
|
|
71
|
+
"tests/*" = ["S101", "PLR2004"] # assert・マジックナンバーの使用を許可
|
|
72
|
+
"pythaw/cli.py" = ["T201", "TC003"] # CLI は print で出力し、Path を実行時に使用する
|
|
73
|
+
|
|
74
|
+
[tool.pytest.ini_options]
|
|
75
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from fnmatch import fnmatch
|
|
7
|
+
from typing import TYPE_CHECKING, TypeAlias
|
|
8
|
+
|
|
9
|
+
from pythaw.finder import find_files
|
|
10
|
+
from pythaw.resolver import Resolver
|
|
11
|
+
from pythaw.rules import get_all_rules
|
|
12
|
+
from pythaw.violation import CallSite, Violation
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pythaw.config import Config
|
|
18
|
+
from pythaw.rules._base import Rule
|
|
19
|
+
|
|
20
|
+
FunctionNode: TypeAlias = ast.FunctionDef | ast.AsyncFunctionDef
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check(
|
|
24
|
+
path: Path,
|
|
25
|
+
config: Config,
|
|
26
|
+
*,
|
|
27
|
+
select: frozenset[str] = frozenset(),
|
|
28
|
+
ignore: frozenset[str] = frozenset(),
|
|
29
|
+
) -> list[Violation]:
|
|
30
|
+
"""Run all rules against handler functions found under *path*.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
path: File or directory to check.
|
|
34
|
+
config: Project configuration (handler patterns, excludes, etc.).
|
|
35
|
+
select: If non-empty, only run rules whose codes are in this set.
|
|
36
|
+
ignore: Rule codes to skip.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A list of violations found across all handler functions.
|
|
40
|
+
"""
|
|
41
|
+
files = find_files(path, config)
|
|
42
|
+
rules = _filter_rules(get_all_rules(), select=select, ignore=ignore)
|
|
43
|
+
violations: list[Violation] = []
|
|
44
|
+
|
|
45
|
+
base = path if path.is_dir() else path.parent
|
|
46
|
+
resolver = Resolver(base)
|
|
47
|
+
for file in files:
|
|
48
|
+
source = _read_source(file)
|
|
49
|
+
if source is None:
|
|
50
|
+
continue
|
|
51
|
+
if _has_nocheck(source):
|
|
52
|
+
continue
|
|
53
|
+
tree = _parse_source(source, file)
|
|
54
|
+
if tree is None:
|
|
55
|
+
continue
|
|
56
|
+
file_rules = _apply_per_file_ignores(rules, file, base, config.per_file_ignores)
|
|
57
|
+
suppressed = _parse_nopw_comments(source)
|
|
58
|
+
for func_node in _extract_handlers(tree, config.handler_patterns):
|
|
59
|
+
violations.extend(
|
|
60
|
+
_check_function(
|
|
61
|
+
file,
|
|
62
|
+
func_node,
|
|
63
|
+
file_rules,
|
|
64
|
+
suppressed,
|
|
65
|
+
resolver,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return violations
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _apply_per_file_ignores(
|
|
73
|
+
rules: tuple[Rule, ...],
|
|
74
|
+
file: Path,
|
|
75
|
+
base: Path,
|
|
76
|
+
per_file_ignores: tuple[tuple[str, tuple[str, ...]], ...],
|
|
77
|
+
) -> tuple[Rule, ...]:
|
|
78
|
+
"""Remove rules that match per-file-ignores patterns for *file*."""
|
|
79
|
+
if not per_file_ignores:
|
|
80
|
+
return rules
|
|
81
|
+
ignored_codes: set[str] = set()
|
|
82
|
+
try:
|
|
83
|
+
rel = str(file.resolve().relative_to(base.resolve()))
|
|
84
|
+
except ValueError:
|
|
85
|
+
rel = os.path.relpath(file)
|
|
86
|
+
for pattern, codes in per_file_ignores:
|
|
87
|
+
if fnmatch(rel, pattern):
|
|
88
|
+
ignored_codes.update(codes)
|
|
89
|
+
if not ignored_codes:
|
|
90
|
+
return rules
|
|
91
|
+
return tuple(r for r in rules if r.code not in ignored_codes)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _filter_rules(
|
|
95
|
+
rules: tuple[Rule, ...],
|
|
96
|
+
*,
|
|
97
|
+
select: frozenset[str],
|
|
98
|
+
ignore: frozenset[str],
|
|
99
|
+
) -> tuple[Rule, ...]:
|
|
100
|
+
"""Filter rules by *select* and *ignore* code sets."""
|
|
101
|
+
filtered = rules
|
|
102
|
+
if select:
|
|
103
|
+
filtered = tuple(r for r in filtered if r.code in select)
|
|
104
|
+
if ignore:
|
|
105
|
+
filtered = tuple(r for r in filtered if r.code not in ignore)
|
|
106
|
+
return filtered
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_NOPW_RE = re.compile(r"#\s*nopw:\s*(PW\d+(?:\s*,\s*PW\d+)*)")
|
|
110
|
+
_NOCHECK_RE = re.compile(r"^\s*#\s*pythaw:\s*nocheck\b")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _read_source(file: Path) -> str | None:
|
|
114
|
+
"""Read *file* and return its contents, or ``None`` on failure."""
|
|
115
|
+
try:
|
|
116
|
+
return file.read_text(encoding="utf-8")
|
|
117
|
+
except (UnicodeDecodeError, OSError):
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_source(source: str, file: Path) -> ast.Module | None:
|
|
122
|
+
"""Parse *source* and return the AST, or ``None`` on failure."""
|
|
123
|
+
try:
|
|
124
|
+
return ast.parse(source, filename=str(file))
|
|
125
|
+
except SyntaxError:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _has_nocheck(source: str) -> bool:
|
|
130
|
+
"""Return ``True`` if *source* contains a ``# pythaw: nocheck`` directive."""
|
|
131
|
+
for line in source.splitlines():
|
|
132
|
+
stripped = line.strip()
|
|
133
|
+
if not stripped or stripped.startswith("#"):
|
|
134
|
+
if _NOCHECK_RE.match(stripped):
|
|
135
|
+
return True
|
|
136
|
+
continue
|
|
137
|
+
break
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_nopw_comments(source: str) -> dict[int, frozenset[str]]:
|
|
142
|
+
"""Extract per-line ``# nopw: PWXXX`` suppression directives.
|
|
143
|
+
|
|
144
|
+
Returns a mapping of line number to the set of suppressed rule codes.
|
|
145
|
+
"""
|
|
146
|
+
suppressed: dict[int, frozenset[str]] = {}
|
|
147
|
+
for lineno, line in enumerate(source.splitlines(), start=1):
|
|
148
|
+
m = _NOPW_RE.search(line)
|
|
149
|
+
if m:
|
|
150
|
+
codes = frozenset(c.strip() for c in m.group(1).split(","))
|
|
151
|
+
suppressed[lineno] = codes
|
|
152
|
+
return suppressed
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _extract_handlers(
|
|
156
|
+
tree: ast.Module,
|
|
157
|
+
patterns: tuple[str, ...],
|
|
158
|
+
) -> list[FunctionNode]:
|
|
159
|
+
"""Return top-level function nodes whose name matches *patterns*."""
|
|
160
|
+
# Only inspect top-level nodes (iter_child_nodes does not recurse)
|
|
161
|
+
# so that nested functions and class methods are excluded.
|
|
162
|
+
return [
|
|
163
|
+
node
|
|
164
|
+
for node in ast.iter_child_nodes(tree)
|
|
165
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
166
|
+
and any(fnmatch(node.name, p) for p in patterns)
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _check_function( # noqa: PLR0913
|
|
171
|
+
file: Path,
|
|
172
|
+
func_node: FunctionNode,
|
|
173
|
+
rules: tuple[Rule, ...],
|
|
174
|
+
suppressed: dict[int, frozenset[str]],
|
|
175
|
+
resolver: Resolver,
|
|
176
|
+
*,
|
|
177
|
+
chain: tuple[CallSite, ...] = (),
|
|
178
|
+
visited: set[tuple[str, str]] | None = None,
|
|
179
|
+
) -> list[Violation]:
|
|
180
|
+
"""Walk *func_node* and return violations, following local calls."""
|
|
181
|
+
if visited is None:
|
|
182
|
+
visited = set()
|
|
183
|
+
|
|
184
|
+
violations: list[Violation] = []
|
|
185
|
+
for node in ast.walk(func_node):
|
|
186
|
+
if not isinstance(node, ast.Call):
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
# Check rule violations
|
|
190
|
+
suppressed_codes = suppressed.get(node.lineno, frozenset())
|
|
191
|
+
violations.extend(
|
|
192
|
+
Violation(
|
|
193
|
+
file=os.path.relpath(file),
|
|
194
|
+
line=node.lineno,
|
|
195
|
+
col=node.col_offset,
|
|
196
|
+
code=rule.code,
|
|
197
|
+
message=rule.message,
|
|
198
|
+
call_chain=chain,
|
|
199
|
+
)
|
|
200
|
+
for rule in rules
|
|
201
|
+
if rule.check(node) and rule.code not in suppressed_codes
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Follow resolved local calls
|
|
205
|
+
_follow_call(
|
|
206
|
+
file,
|
|
207
|
+
node,
|
|
208
|
+
rules,
|
|
209
|
+
resolver,
|
|
210
|
+
chain,
|
|
211
|
+
visited,
|
|
212
|
+
violations,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return violations
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _follow_call( # noqa: PLR0913
|
|
219
|
+
file: Path,
|
|
220
|
+
node: ast.Call,
|
|
221
|
+
rules: tuple[Rule, ...],
|
|
222
|
+
resolver: Resolver,
|
|
223
|
+
chain: tuple[CallSite, ...],
|
|
224
|
+
visited: set[tuple[str, str]],
|
|
225
|
+
violations: list[Violation],
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Resolve *node* and recursively check the target definition."""
|
|
228
|
+
target = resolver.resolve_call(file, node)
|
|
229
|
+
if target is None:
|
|
230
|
+
return
|
|
231
|
+
target_file, target_defn = target
|
|
232
|
+
|
|
233
|
+
walkable: FunctionNode | None
|
|
234
|
+
if isinstance(target_defn, ast.ClassDef):
|
|
235
|
+
walkable = resolver.get_init(target_defn)
|
|
236
|
+
else:
|
|
237
|
+
walkable = target_defn
|
|
238
|
+
if walkable is None:
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
key = (str(target_file.resolve()), target_defn.name)
|
|
242
|
+
if key in visited:
|
|
243
|
+
return
|
|
244
|
+
visited.add(key)
|
|
245
|
+
|
|
246
|
+
site = CallSite(
|
|
247
|
+
file=os.path.relpath(file),
|
|
248
|
+
line=node.lineno,
|
|
249
|
+
col=node.col_offset,
|
|
250
|
+
name=resolver.call_display_name(node),
|
|
251
|
+
)
|
|
252
|
+
target_source = resolver.read_source(target_file)
|
|
253
|
+
target_suppressed = _parse_nopw_comments(target_source) if target_source else {}
|
|
254
|
+
violations.extend(
|
|
255
|
+
_check_function(
|
|
256
|
+
target_file,
|
|
257
|
+
walkable,
|
|
258
|
+
rules,
|
|
259
|
+
target_suppressed,
|
|
260
|
+
resolver,
|
|
261
|
+
chain=(*chain, site),
|
|
262
|
+
visited=visited,
|
|
263
|
+
)
|
|
264
|
+
)
|