idemcheck 0.1.0__py3-none-any.whl
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.
- idemcheck/__init__.py +3 -0
- idemcheck/analyze.py +251 -0
- idemcheck/cli.py +88 -0
- idemcheck-0.1.0.dist-info/METADATA +120 -0
- idemcheck-0.1.0.dist-info/RECORD +9 -0
- idemcheck-0.1.0.dist-info/WHEEL +5 -0
- idemcheck-0.1.0.dist-info/entry_points.txt +2 -0
- idemcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- idemcheck-0.1.0.dist-info/top_level.txt +1 -0
idemcheck/__init__.py
ADDED
idemcheck/analyze.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Find mutating routes whose writes have no protection against a second delivery.
|
|
2
|
+
|
|
3
|
+
A client retries because the network lied. If the handler charges, inserts, or sends
|
|
4
|
+
before it can answer, the retry charges twice. Libraries that solve this exist per
|
|
5
|
+
framework; nothing tells you which of your routes is missing the guard.
|
|
6
|
+
|
|
7
|
+
This is static analysis over the AST, so it can only see what is written down. A guard
|
|
8
|
+
built by a decorator defined in another module, or an ORM method that commits for you,
|
|
9
|
+
is invisible. The README says so instead of implying the tool proves safety.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable, List, Optional, Sequence, Tuple
|
|
18
|
+
|
|
19
|
+
MUTATING = ("POST", "PUT", "PATCH", "DELETE")
|
|
20
|
+
|
|
21
|
+
#: Methods that end in a write, anywhere in the codebase.
|
|
22
|
+
WRITE_METHODS = frozenset({
|
|
23
|
+
"commit", "flush", "save", "insert", "insert_one", "insert_many", "update",
|
|
24
|
+
"update_one", "upsert", "delete", "delete_one", "execute", "create", "add",
|
|
25
|
+
"charge", "capture", "transfer", "refund", "enqueue", "delay", "send_message",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
#: Outbound calls that move money or send something a retry would duplicate.
|
|
29
|
+
OUTBOUND = ("requests.post", "requests.put", "httpx.post", "httpx.put", "aiohttp.",
|
|
30
|
+
"stripe.", "session.post", "client.post", "urlopen")
|
|
31
|
+
|
|
32
|
+
#: Any of these in the handler means somebody thought about the retry.
|
|
33
|
+
PROTECTION = ("idempotency", "idempotent", "idem_key", "idemkey", "request_id",
|
|
34
|
+
"requestid", "dedupe", "dedup_key", "nonce", "client_reference",
|
|
35
|
+
"get_or_create", "update_or_create", "on_conflict", "unique_together")
|
|
36
|
+
|
|
37
|
+
ROUTE_DECORATORS = frozenset({"post", "put", "patch", "delete", "route", "api_route"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Route:
|
|
42
|
+
"""A handler and the mutating methods it answers."""
|
|
43
|
+
|
|
44
|
+
line: int
|
|
45
|
+
methods: Tuple[str, ...]
|
|
46
|
+
name: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Finding:
|
|
51
|
+
path: str
|
|
52
|
+
line: int
|
|
53
|
+
rule: str
|
|
54
|
+
severity: str
|
|
55
|
+
route: str
|
|
56
|
+
detail: str
|
|
57
|
+
writes: List[str] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict:
|
|
60
|
+
return {"path": self.path, "line": self.line, "rule": self.rule,
|
|
61
|
+
"severity": self.severity, "route": self.route,
|
|
62
|
+
"detail": self.detail, "writes": self.writes}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Report:
|
|
67
|
+
files: int = 0
|
|
68
|
+
routes: int = 0
|
|
69
|
+
findings: List[Finding] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _dotted(node: ast.AST) -> str:
|
|
73
|
+
"""The dotted name of a call target, or '' when it is not a plain name."""
|
|
74
|
+
parts = []
|
|
75
|
+
while isinstance(node, ast.Attribute):
|
|
76
|
+
parts.append(node.attr)
|
|
77
|
+
node = node.value
|
|
78
|
+
if isinstance(node, ast.Name):
|
|
79
|
+
parts.append(node.id)
|
|
80
|
+
return ".".join(reversed(parts))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _methods_from_decorator(node: ast.AST) -> Optional[Tuple[str, ...]]:
|
|
84
|
+
"""Return the mutating methods a decorator registers, or None if it is not a route."""
|
|
85
|
+
if not isinstance(node, ast.Call):
|
|
86
|
+
return None
|
|
87
|
+
target = _dotted(node.func).rsplit(".", 1)[-1]
|
|
88
|
+
if target not in ROUTE_DECORATORS:
|
|
89
|
+
return None
|
|
90
|
+
if target in ("route", "api_route"):
|
|
91
|
+
for kw in node.keywords:
|
|
92
|
+
if kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
|
|
93
|
+
names = [e.value.upper() for e in kw.value.elts
|
|
94
|
+
if isinstance(e, ast.Constant) and isinstance(e.value, str)]
|
|
95
|
+
picked = tuple(m for m in names if m in MUTATING)
|
|
96
|
+
return picked or None
|
|
97
|
+
return None
|
|
98
|
+
method = target.upper()
|
|
99
|
+
return (method,) if method in MUTATING else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _strings_and_names(func: ast.AST) -> str:
|
|
103
|
+
"""Every identifier and string literal in the handler, lowercased, for guard hunting."""
|
|
104
|
+
chunks: List[str] = []
|
|
105
|
+
for node in ast.walk(func):
|
|
106
|
+
if isinstance(node, ast.Name):
|
|
107
|
+
chunks.append(node.id)
|
|
108
|
+
elif isinstance(node, ast.Attribute):
|
|
109
|
+
chunks.append(node.attr)
|
|
110
|
+
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
111
|
+
chunks.append(node.value)
|
|
112
|
+
elif isinstance(node, ast.keyword) and node.arg:
|
|
113
|
+
chunks.append(node.arg)
|
|
114
|
+
return " ".join(chunks).lower()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _writes(func: ast.AST) -> List[str]:
|
|
118
|
+
"""Dotted names of calls that look like writes or outbound sends."""
|
|
119
|
+
found: List[str] = []
|
|
120
|
+
for node in ast.walk(func):
|
|
121
|
+
if not isinstance(node, ast.Call):
|
|
122
|
+
continue
|
|
123
|
+
name = _dotted(node.func)
|
|
124
|
+
short = name.rsplit(".", 1)[-1]
|
|
125
|
+
low = name.lower()
|
|
126
|
+
if short in WRITE_METHODS:
|
|
127
|
+
found.append(name or short)
|
|
128
|
+
elif any(marker in low for marker in OUTBOUND):
|
|
129
|
+
found.append(name or low)
|
|
130
|
+
elif short == "execute" and any(
|
|
131
|
+
kw in low for kw in ("insert", "update", "delete")
|
|
132
|
+
):
|
|
133
|
+
found.append(name)
|
|
134
|
+
# dedupe, keep first-seen order
|
|
135
|
+
seen: List[str] = []
|
|
136
|
+
for name in found:
|
|
137
|
+
if name not in seen:
|
|
138
|
+
seen.append(name)
|
|
139
|
+
return seen
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _docstring_node(func: ast.AST) -> Optional[ast.AST]:
|
|
143
|
+
body = getattr(func, "body", [])
|
|
144
|
+
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
|
|
145
|
+
and isinstance(body[0].value.value, str):
|
|
146
|
+
return body[0]
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _has_guard(func: ast.AST) -> bool:
|
|
151
|
+
"""Is there any sign that a second delivery was considered?
|
|
152
|
+
|
|
153
|
+
The handler's own docstring is excluded: a docstring that says "TODO: idempotency"
|
|
154
|
+
is evidence of the bug, not of the fix.
|
|
155
|
+
"""
|
|
156
|
+
doc = _docstring_node(func)
|
|
157
|
+
doc_text = doc.value if isinstance(doc, ast.Expr) else None
|
|
158
|
+
skip = {id(doc), id(doc_text)}
|
|
159
|
+
chunks: List[str] = []
|
|
160
|
+
for node in ast.walk(func):
|
|
161
|
+
if id(node) in skip:
|
|
162
|
+
continue
|
|
163
|
+
if isinstance(node, ast.Name):
|
|
164
|
+
chunks.append(node.id)
|
|
165
|
+
elif isinstance(node, ast.Attribute):
|
|
166
|
+
chunks.append(node.attr)
|
|
167
|
+
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
168
|
+
chunks.append(node.value)
|
|
169
|
+
elif isinstance(node, ast.keyword) and node.arg:
|
|
170
|
+
chunks.append(node.arg)
|
|
171
|
+
text = " ".join(chunks).lower()
|
|
172
|
+
return any(token in text for token in PROTECTION)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _route_path(dec: ast.AST) -> str:
|
|
176
|
+
if isinstance(dec, ast.Call) and dec.args:
|
|
177
|
+
first = dec.args[0]
|
|
178
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
179
|
+
return first.value
|
|
180
|
+
return ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def analyze_source(source: str, path: str = "<src>") -> Tuple[int, List[Finding]]:
|
|
184
|
+
"""Return (mutating routes seen, findings) for one source file."""
|
|
185
|
+
try:
|
|
186
|
+
tree = ast.parse(source)
|
|
187
|
+
except SyntaxError:
|
|
188
|
+
return 0, []
|
|
189
|
+
routes = 0
|
|
190
|
+
findings: List[Finding] = []
|
|
191
|
+
for node in ast.walk(tree):
|
|
192
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
193
|
+
continue
|
|
194
|
+
methods: List[str] = []
|
|
195
|
+
label = ""
|
|
196
|
+
for dec in node.decorator_list:
|
|
197
|
+
picked = _methods_from_decorator(dec)
|
|
198
|
+
if picked:
|
|
199
|
+
methods.extend(picked)
|
|
200
|
+
label = label or _route_path(dec)
|
|
201
|
+
if not methods:
|
|
202
|
+
continue
|
|
203
|
+
routes += 1
|
|
204
|
+
writes = _writes(node)
|
|
205
|
+
if not writes or _has_guard(node):
|
|
206
|
+
continue
|
|
207
|
+
outbound = any(any(m in w.lower() for m in OUTBOUND) for w in writes)
|
|
208
|
+
severity = "high" if (outbound or "POST" in methods) else "medium"
|
|
209
|
+
route = "%s %s" % ("|".join(sorted(set(methods))), label or node.name)
|
|
210
|
+
findings.append(Finding(
|
|
211
|
+
path=path, line=node.lineno, rule="missing-idempotency", severity=severity,
|
|
212
|
+
route=route,
|
|
213
|
+
detail="%s with no idempotency key, dedupe store or unique constraint"
|
|
214
|
+
% node.name,
|
|
215
|
+
writes=writes,
|
|
216
|
+
))
|
|
217
|
+
return routes, findings
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _looks_like_tests(path: Path) -> bool:
|
|
221
|
+
parts = set(path.parts)
|
|
222
|
+
return "tests" in parts or path.name.startswith("test_") or path.name == "conftest.py"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def analyze_files(paths: Iterable[Path], include_tests: bool = False) -> Report:
|
|
226
|
+
report = Report()
|
|
227
|
+
seen: set = set()
|
|
228
|
+
for base in paths:
|
|
229
|
+
files: Sequence[Path]
|
|
230
|
+
if base.is_dir():
|
|
231
|
+
files = sorted(p for p in base.rglob("*.py")
|
|
232
|
+
if not any(skip in p.parts for skip in
|
|
233
|
+
(".git", "build", "dist", "node_modules", "__pycache__")))
|
|
234
|
+
else:
|
|
235
|
+
files = [base]
|
|
236
|
+
for path in files:
|
|
237
|
+
key = str(path.resolve())
|
|
238
|
+
if key in seen or (not include_tests and _looks_like_tests(path)):
|
|
239
|
+
continue
|
|
240
|
+
seen.add(key)
|
|
241
|
+
try:
|
|
242
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
243
|
+
except OSError:
|
|
244
|
+
continue
|
|
245
|
+
routes, findings = analyze_source(source, str(path))
|
|
246
|
+
report.files += 1
|
|
247
|
+
report.routes += routes
|
|
248
|
+
report.findings.extend(findings)
|
|
249
|
+
report.findings.sort(key=lambda f: ({"high": 0, "medium": 1, "low": 2}[f.severity],
|
|
250
|
+
f.path, f.line))
|
|
251
|
+
return report
|
idemcheck/cli.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The command line: read paths, print findings, and answer with an exit code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional, Sequence
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .analyze import analyze_files
|
|
13
|
+
|
|
14
|
+
EXIT_OK = 0
|
|
15
|
+
EXIT_FINDINGS = 1
|
|
16
|
+
EXIT_USAGE = 2
|
|
17
|
+
|
|
18
|
+
SEVERITIES = ("high", "medium", "low")
|
|
19
|
+
SEVERITY_RANK = {name: index for index, name in enumerate(SEVERITIES)}
|
|
20
|
+
|
|
21
|
+
FIX_HINT = (
|
|
22
|
+
"read an Idempotency-Key and store it before the first write, or make the write\n"
|
|
23
|
+
" itself unique so a second delivery cannot land twice"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render(findings: List[dict], files: int, routes: int) -> str:
|
|
28
|
+
if not findings:
|
|
29
|
+
return ("No issues found by idemcheck v%s across %d mutating route(s) in %d file(s).\n"
|
|
30
|
+
"That means no unguarded write was recognised, not that every handler is safe."
|
|
31
|
+
% (__version__, routes, files))
|
|
32
|
+
|
|
33
|
+
worst = min((f["severity"] for f in findings), key=lambda s: SEVERITY_RANK[s])
|
|
34
|
+
lines = ["idemcheck: %d finding(s) across %d mutating route(s) in %d file(s), worst %s"
|
|
35
|
+
% (len(findings), routes, files, worst), ""]
|
|
36
|
+
for f in findings:
|
|
37
|
+
lines.append("[%s] %s:%d (%s)" % (f["severity"].upper(), f["path"], f["line"], f["rule"]))
|
|
38
|
+
lines.append(" route: %s" % f["route"])
|
|
39
|
+
lines.append(" why: %s" % f["detail"])
|
|
40
|
+
if f["writes"]:
|
|
41
|
+
lines.append(" calls: %s" % ", ".join(f["writes"]))
|
|
42
|
+
lines.append(" fix: %s" % FIX_HINT)
|
|
43
|
+
lines.append("")
|
|
44
|
+
return "\n".join(lines).rstrip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="idemcheck",
|
|
50
|
+
description="Find the HTTP handlers that will double-charge when a client retries.",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument("paths", nargs="*", default=["."],
|
|
53
|
+
help="files or directories to scan (default: .)")
|
|
54
|
+
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
55
|
+
parser.add_argument("--fail-on", choices=("high", "medium", "low", "none"), default="high",
|
|
56
|
+
help="lowest severity that fails the run (default: high)")
|
|
57
|
+
parser.add_argument("--include-tests", action="store_true",
|
|
58
|
+
help="also scan tests/ and test_*.py")
|
|
59
|
+
parser.add_argument("--version", action="version", version="idemcheck %s" % __version__)
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
|
|
62
|
+
targets = [Path(p) for p in args.paths]
|
|
63
|
+
missing = [str(p) for p in targets if not p.exists()]
|
|
64
|
+
if missing:
|
|
65
|
+
print("no such path: %s" % ", ".join(missing), file=sys.stderr)
|
|
66
|
+
return EXIT_USAGE
|
|
67
|
+
|
|
68
|
+
report = analyze_files(targets, include_tests=args.include_tests)
|
|
69
|
+
findings = [f.to_dict() for f in report.findings]
|
|
70
|
+
|
|
71
|
+
if args.json:
|
|
72
|
+
print(json.dumps({
|
|
73
|
+
"findings": findings,
|
|
74
|
+
"summary": {"files": report.files, "routes": report.routes,
|
|
75
|
+
"findings": len(findings), "fail_on": args.fail_on},
|
|
76
|
+
}, indent=2))
|
|
77
|
+
else:
|
|
78
|
+
print(render(findings, report.files, report.routes))
|
|
79
|
+
|
|
80
|
+
if args.fail_on == "none" or not findings:
|
|
81
|
+
return EXIT_OK
|
|
82
|
+
limit = SEVERITY_RANK[args.fail_on]
|
|
83
|
+
return EXIT_FINDINGS if any(SEVERITY_RANK[f["severity"]] <= limit for f in findings) \
|
|
84
|
+
else EXIT_OK
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__": # so `python -m idemcheck.cli` works too
|
|
88
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: idemcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find the HTTP handlers that will double-charge when a client retries.
|
|
5
|
+
Author: mgkvibez
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: idempotency,api,retries,payments,fastapi,flask,static-analysis
|
|
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: Topic :: Software Development :: Quality Assurance
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# idemcheck
|
|
22
|
+
|
|
23
|
+
Find the HTTP handlers that will double-charge when a client retries.
|
|
24
|
+
|
|
25
|
+
A client retries because the network lied. If your handler writes before it can answer,
|
|
26
|
+
the retry writes again. Idempotency libraries exist for every framework, and nothing tells
|
|
27
|
+
you which of your routes is missing the guard.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
$ idemcheck examples/
|
|
31
|
+
idemcheck: 1 finding(s) across 2 mutating route(s) in 1 file(s), worst high
|
|
32
|
+
|
|
33
|
+
[HIGH] examples/app.py:12 (missing-idempotency)
|
|
34
|
+
route: POST /charge
|
|
35
|
+
why: charge with no idempotency key, dedupe store or unique constraint
|
|
36
|
+
calls: db.orders.insert_one, db.session.commit
|
|
37
|
+
fix: read an Idempotency-Key and store it before the first write, or make the write
|
|
38
|
+
itself unique so a second delivery cannot land twice
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The example file contains two POST handlers: `/charge` writes and is flagged, `/charge-safe`
|
|
42
|
+
reads an `Idempotency-Key` first and is not.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pipx install idemcheck # once it is on PyPI
|
|
48
|
+
# or straight from the repo:
|
|
49
|
+
pipx install git+https://github.com/mgkvibez/idemcheck
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Python 3.9 or newer. No runtime dependencies.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
idemcheck # scan the current directory
|
|
58
|
+
idemcheck src/ app.py # scan specific paths
|
|
59
|
+
idemcheck --json | jq . # machine-readable
|
|
60
|
+
idemcheck --fail-on medium # stricter gate, for a payments service
|
|
61
|
+
idemcheck --include-tests # include tests/ (skipped by default)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Exit codes: `0` nothing at or above the threshold, `1` findings, `2` usage error.
|
|
65
|
+
|
|
66
|
+
`--fail-on` defaults to `high`, so a `PUT` that writes without a guard reports and exits 0
|
|
67
|
+
until you ask otherwise.
|
|
68
|
+
|
|
69
|
+
## What it looks for
|
|
70
|
+
|
|
71
|
+
A handler is a finding when it answers a mutating method **and** calls something that
|
|
72
|
+
writes **and** nothing in it mentions a guard.
|
|
73
|
+
|
|
74
|
+
| Severity | When |
|
|
75
|
+
|---|---|
|
|
76
|
+
| high | `POST`, or any outbound money/HTTP send, with unguarded writes |
|
|
77
|
+
| medium | `PUT`/`PATCH`/`DELETE` with unguarded writes (they should already be idempotent) |
|
|
78
|
+
|
|
79
|
+
Writes are recognised by method name: `commit`, `flush`, `insert*`, `update*`, `delete*`,
|
|
80
|
+
`save`, `upsert`, `execute`, `add`, plus money movement like `charge`, `capture`,
|
|
81
|
+
`transfer`, `refund`, and queue sends like `delay` and `enqueue`.
|
|
82
|
+
|
|
83
|
+
A guard is recognised by any of: `idempotency`, `idempotent`, `idem_key`, `request_id`,
|
|
84
|
+
`dedupe`, `dedup_key`, `nonce`, `client_reference`, `get_or_create`, `update_or_create`,
|
|
85
|
+
`on_conflict`, `unique_together`.
|
|
86
|
+
|
|
87
|
+
Routes are read from the decorator: `@app.post`, `@router.put`, and the
|
|
88
|
+
`methods=[...]` keyword on `@app.route` and `@app.api_route` (Flask and FastAPI).
|
|
89
|
+
|
|
90
|
+
## What this deliberately does not do
|
|
91
|
+
|
|
92
|
+
Static analysis cannot prove a handler is safe, and this tool does not claim to.
|
|
93
|
+
|
|
94
|
+
- A guard installed by a decorator defined in another module is invisible here.
|
|
95
|
+
- An ORM that commits on your behalf, or a write hidden inside a helper function, is
|
|
96
|
+
invisible here.
|
|
97
|
+
- Django class-based views, generic routers and dynamically constructed handlers are not
|
|
98
|
+
detected at all.
|
|
99
|
+
- A handler that mentions `request_id` incidentally is treated as guarded, even when it is
|
|
100
|
+
not. That is a false negative, and it is the direction this tool errs on purpose: a
|
|
101
|
+
checker that cries wolf gets uninstalled after one afternoon.
|
|
102
|
+
|
|
103
|
+
The clean line is exactly what it says: no unguarded write was **recognised**.
|
|
104
|
+
|
|
105
|
+
## Tests
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python -m pytest -q # 19 tests
|
|
109
|
+
bash scripts/verify.sh # the release gate
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Contributing
|
|
113
|
+
|
|
114
|
+
Adding a framework means teaching the decorator reader one more name in `analyze.py`, and
|
|
115
|
+
adding a fixture that proves both the flagged and the guarded case. A rule that cannot show
|
|
116
|
+
both is not ready.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
idemcheck/__init__.py,sha256=GbStGxrquBtIDKnWCwLiF9a06cYSx60eRWbBew2qDzs,95
|
|
2
|
+
idemcheck/analyze.py,sha256=JI_JVUxiTampFpMmGkAauSYz879crZmavyMDLlaK-q8,9201
|
|
3
|
+
idemcheck/cli.py,sha256=YzKrq9DWwQ6LDYvqqdNw3eYCoXEIaJ0IoeW2cQUdgSY,3466
|
|
4
|
+
idemcheck-0.1.0.dist-info/licenses/LICENSE,sha256=e-0Bks6hOWW2m30Xgh8w6CENzPJoV246vIq2lz_9Ing,1065
|
|
5
|
+
idemcheck-0.1.0.dist-info/METADATA,sha256=SMpSxxoIQlu6ccNGl4l3DSMTDTjwt5A4uaSj8vGHCIA,4355
|
|
6
|
+
idemcheck-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
idemcheck-0.1.0.dist-info/entry_points.txt,sha256=ABmFiCHP31Yq003bmI1c-vGHKGK9Hb9xlLeA-1QtI2A,49
|
|
8
|
+
idemcheck-0.1.0.dist-info/top_level.txt,sha256=wtTQujSCAF4LpjXC361rBbCxou0DfPp5BOex_4wWbsk,10
|
|
9
|
+
idemcheck-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mgkvibez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
idemcheck
|