idemcheck 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.
@@ -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,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,100 @@
1
+ # idemcheck
2
+
3
+ Find the HTTP handlers that will double-charge when a client retries.
4
+
5
+ A client retries because the network lied. If your handler writes before it can answer,
6
+ the retry writes again. Idempotency libraries exist for every framework, and nothing tells
7
+ you which of your routes is missing the guard.
8
+
9
+ ```
10
+ $ idemcheck examples/
11
+ idemcheck: 1 finding(s) across 2 mutating route(s) in 1 file(s), worst high
12
+
13
+ [HIGH] examples/app.py:12 (missing-idempotency)
14
+ route: POST /charge
15
+ why: charge with no idempotency key, dedupe store or unique constraint
16
+ calls: db.orders.insert_one, db.session.commit
17
+ fix: read an Idempotency-Key and store it before the first write, or make the write
18
+ itself unique so a second delivery cannot land twice
19
+ ```
20
+
21
+ The example file contains two POST handlers: `/charge` writes and is flagged, `/charge-safe`
22
+ reads an `Idempotency-Key` first and is not.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pipx install idemcheck # once it is on PyPI
28
+ # or straight from the repo:
29
+ pipx install git+https://github.com/mgkvibez/idemcheck
30
+ ```
31
+
32
+ Python 3.9 or newer. No runtime dependencies.
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ idemcheck # scan the current directory
38
+ idemcheck src/ app.py # scan specific paths
39
+ idemcheck --json | jq . # machine-readable
40
+ idemcheck --fail-on medium # stricter gate, for a payments service
41
+ idemcheck --include-tests # include tests/ (skipped by default)
42
+ ```
43
+
44
+ Exit codes: `0` nothing at or above the threshold, `1` findings, `2` usage error.
45
+
46
+ `--fail-on` defaults to `high`, so a `PUT` that writes without a guard reports and exits 0
47
+ until you ask otherwise.
48
+
49
+ ## What it looks for
50
+
51
+ A handler is a finding when it answers a mutating method **and** calls something that
52
+ writes **and** nothing in it mentions a guard.
53
+
54
+ | Severity | When |
55
+ |---|---|
56
+ | high | `POST`, or any outbound money/HTTP send, with unguarded writes |
57
+ | medium | `PUT`/`PATCH`/`DELETE` with unguarded writes (they should already be idempotent) |
58
+
59
+ Writes are recognised by method name: `commit`, `flush`, `insert*`, `update*`, `delete*`,
60
+ `save`, `upsert`, `execute`, `add`, plus money movement like `charge`, `capture`,
61
+ `transfer`, `refund`, and queue sends like `delay` and `enqueue`.
62
+
63
+ A guard is recognised by any of: `idempotency`, `idempotent`, `idem_key`, `request_id`,
64
+ `dedupe`, `dedup_key`, `nonce`, `client_reference`, `get_or_create`, `update_or_create`,
65
+ `on_conflict`, `unique_together`.
66
+
67
+ Routes are read from the decorator: `@app.post`, `@router.put`, and the
68
+ `methods=[...]` keyword on `@app.route` and `@app.api_route` (Flask and FastAPI).
69
+
70
+ ## What this deliberately does not do
71
+
72
+ Static analysis cannot prove a handler is safe, and this tool does not claim to.
73
+
74
+ - A guard installed by a decorator defined in another module is invisible here.
75
+ - An ORM that commits on your behalf, or a write hidden inside a helper function, is
76
+ invisible here.
77
+ - Django class-based views, generic routers and dynamically constructed handlers are not
78
+ detected at all.
79
+ - A handler that mentions `request_id` incidentally is treated as guarded, even when it is
80
+ not. That is a false negative, and it is the direction this tool errs on purpose: a
81
+ checker that cries wolf gets uninstalled after one afternoon.
82
+
83
+ The clean line is exactly what it says: no unguarded write was **recognised**.
84
+
85
+ ## Tests
86
+
87
+ ```bash
88
+ python -m pytest -q # 19 tests
89
+ bash scripts/verify.sh # the release gate
90
+ ```
91
+
92
+ ## Contributing
93
+
94
+ Adding a framework means teaching the decorator reader one more name in `analyze.py`, and
95
+ adding a fixture that proves both the flagged and the guarded case. A rule that cannot show
96
+ both is not ready.
97
+
98
+ ## License
99
+
100
+ MIT.
@@ -0,0 +1,3 @@
1
+ """idemcheck: find HTTP handlers that will double-charge on a retry."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
@@ -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,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ idemcheck/__init__.py
5
+ idemcheck/analyze.py
6
+ idemcheck/cli.py
7
+ idemcheck.egg-info/PKG-INFO
8
+ idemcheck.egg-info/SOURCES.txt
9
+ idemcheck.egg-info/dependency_links.txt
10
+ idemcheck.egg-info/entry_points.txt
11
+ idemcheck.egg-info/requires.txt
12
+ idemcheck.egg-info/top_level.txt
13
+ tests/test_idemcheck.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ idemcheck = idemcheck.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=8
@@ -0,0 +1 @@
1
+ idemcheck
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "idemcheck"
7
+ version = "0.1.0"
8
+ description = "Find the HTTP handlers that will double-charge when a client retries."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "mgkvibez" }]
13
+ keywords = ["idempotency", "api", "retries", "payments", "fastapi", "flask", "static-analysis"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Quality Assurance",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=8"]
26
+
27
+ [project.scripts]
28
+ idemcheck = "idemcheck.cli:main"
29
+
30
+ [tool.setuptools]
31
+ packages = ["idemcheck"]
32
+
33
+ [tool.ruff]
34
+ target-version = "py39"
35
+ line-length = 100
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "W", "I"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,180 @@
1
+ """The analyser and the CLI contract."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from idemcheck.analyze import analyze_files, analyze_source
8
+ from idemcheck.cli import EXIT_FINDINGS, EXIT_OK, EXIT_USAGE, main
9
+
10
+ CHARGE = '''
11
+ @app.post("/charge")
12
+ def charge():
13
+ db.session.add(order)
14
+ db.session.commit()
15
+ '''
16
+
17
+
18
+ def test_a_post_that_writes_with_no_guard_is_high():
19
+ routes, findings = analyze_source(CHARGE)
20
+ assert routes == 1
21
+ assert [f.severity for f in findings] == ["high"]
22
+ assert findings[0].rule == "missing-idempotency"
23
+ assert "db.session.commit" in findings[0].writes
24
+
25
+
26
+ def test_a_guard_anywhere_in_the_handler_clears_it():
27
+ src = '''
28
+ @app.post("/charge")
29
+ def charge():
30
+ key = request.headers.get("Idempotency-Key")
31
+ db.session.commit()
32
+ '''
33
+ assert analyze_source(src)[1] == []
34
+
35
+
36
+ def test_a_docstring_saying_todo_idempotency_is_the_bug_not_the_fix():
37
+ """The first cut skipped the docstring node but not its string constant."""
38
+ src = '''
39
+ @app.post("/charge")
40
+ def charge():
41
+ """Charge the card. TODO: idempotency."""
42
+ db.session.commit()
43
+ '''
44
+ findings = analyze_source(src)[1]
45
+ assert [f.severity for f in findings] == ["high"]
46
+
47
+
48
+ def test_put_and_patch_are_medium_because_they_should_already_be_idempotent():
49
+ src = '''
50
+ @app.put("/order")
51
+ def update_order():
52
+ db.session.commit()
53
+ '''
54
+ findings = analyze_source(src)[1]
55
+ assert [f.severity for f in findings] == ["medium"]
56
+
57
+
58
+ def test_a_read_only_route_is_not_a_finding():
59
+ src = '''
60
+ @app.get("/orders")
61
+ def orders():
62
+ return db.orders.find({})
63
+ '''
64
+ routes, findings = analyze_source(src)
65
+ assert routes == 0 # GET is not a mutating route
66
+ assert findings == []
67
+
68
+
69
+ def test_a_mutating_route_that_only_reads_is_not_a_finding():
70
+ src = '''
71
+ @app.post("/preview")
72
+ def preview():
73
+ return db.orders.find_one({"id": 1})
74
+ '''
75
+ routes, findings = analyze_source(src)
76
+ assert routes == 1
77
+ assert findings == []
78
+
79
+
80
+ def test_api_route_methods_keyword_is_understood():
81
+ src = '''
82
+ @app.api_route("/charge", methods=["POST", "GET"])
83
+ def charge():
84
+ db.session.commit()
85
+ '''
86
+ findings = analyze_source(src)[1]
87
+ assert len(findings) == 1 and findings[0].severity == "high"
88
+
89
+
90
+ def test_flask_route_methods_keyword_is_understood():
91
+ src = '''
92
+ @app.route("/charge", methods=["POST"])
93
+ def charge():
94
+ db.session.commit()
95
+ '''
96
+ assert len(analyze_source(src)[1]) == 1
97
+
98
+
99
+ def test_flask_route_defaulting_to_get_is_not_a_finding():
100
+ src = '''
101
+ @app.route("/health")
102
+ def health():
103
+ db.session.commit()
104
+ '''
105
+ assert analyze_source(src)[1] == []
106
+
107
+
108
+ def test_one_finding_per_handler_however_many_writes():
109
+ src = '''
110
+ @app.post("/checkout")
111
+ def checkout():
112
+ db.session.add(a)
113
+ db.session.commit()
114
+ payments.charge_card(a)
115
+ queue.delay(a)
116
+ '''
117
+ findings = analyze_source(src)[1]
118
+ assert len(findings) == 1
119
+ assert len(findings[0].writes) >= 3
120
+
121
+
122
+ def test_a_syntax_error_file_is_skipped_not_fatal():
123
+ assert analyze_source("def broken(:\n") == (0, [])
124
+
125
+
126
+ def test_tests_are_excluded_by_default(tmp_path):
127
+ (tmp_path / "tests").mkdir()
128
+ (tmp_path / "tests" / "test_api.py").write_text(CHARGE)
129
+ (tmp_path / "app.py").write_text(CHARGE)
130
+
131
+ assert analyze_files([tmp_path]).files == 1
132
+ assert len(analyze_files([tmp_path]).findings) == 1
133
+ assert analyze_files([tmp_path], include_tests=True).files == 2
134
+
135
+
136
+ # --- CLI contract -------------------------------------------------------------
137
+
138
+
139
+ def test_cli_exits_1_on_a_high_finding(tmp_path, capsys):
140
+ (tmp_path / "app.py").write_text(CHARGE)
141
+ assert main([str(tmp_path)]) == EXIT_FINDINGS
142
+ assert "missing-idempotency" in capsys.readouterr().out
143
+
144
+
145
+ def test_cli_exits_0_when_nothing_is_unguarded(tmp_path, capsys):
146
+ (tmp_path / "app.py").write_text('@app.get("/x")\ndef x():\n return 1\n')
147
+ assert main([str(tmp_path)]) == EXIT_OK
148
+ out = capsys.readouterr().out
149
+ assert "No issues found by idemcheck v" in out
150
+
151
+
152
+ def test_fail_on_none_never_fails_the_run(tmp_path):
153
+ (tmp_path / "app.py").write_text(CHARGE)
154
+ assert main([str(tmp_path), "--fail-on", "none"]) == EXIT_OK
155
+
156
+
157
+ def test_a_medium_finding_does_not_fail_the_default_threshold(tmp_path):
158
+ (tmp_path / "app.py").write_text('@app.put("/x")\ndef x():\n db.session.commit()\n')
159
+ assert main([str(tmp_path)]) == EXIT_OK
160
+ assert main([str(tmp_path), "--fail-on", "medium"]) == EXIT_FINDINGS
161
+
162
+
163
+ def test_json_output_is_parseable(tmp_path, capsys):
164
+ (tmp_path / "app.py").write_text(CHARGE)
165
+ main([str(tmp_path), "--json"])
166
+ payload = json.loads(capsys.readouterr().out)
167
+ assert payload["summary"]["findings"] == 1
168
+ assert payload["findings"][0]["severity"] == "high"
169
+
170
+
171
+ def test_a_missing_path_is_a_usage_error(capsys):
172
+ assert main(["/no/such/path/here"]) == EXIT_USAGE
173
+ assert "no such path" in capsys.readouterr().err
174
+
175
+
176
+ def test_version_exits_cleanly(capsys):
177
+ with pytest.raises(SystemExit) as exc:
178
+ main(["--version"])
179
+ assert exc.value.code == 0
180
+ assert capsys.readouterr().out.startswith("idemcheck ")