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 ADDED
@@ -0,0 +1,3 @@
1
+ """idemcheck: find HTTP handlers that will double-charge on a retry."""
2
+
3
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ idemcheck = idemcheck.cli:main
@@ -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