wildlint 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.
- wildlint-0.1.0/LICENSE +21 -0
- wildlint-0.1.0/PKG-INFO +96 -0
- wildlint-0.1.0/README.md +80 -0
- wildlint-0.1.0/pyproject.toml +27 -0
- wildlint-0.1.0/setup.cfg +4 -0
- wildlint-0.1.0/src/wildlint/__init__.py +15 -0
- wildlint-0.1.0/src/wildlint/__main__.py +6 -0
- wildlint-0.1.0/src/wildlint/checkers.py +245 -0
- wildlint-0.1.0/src/wildlint/cli.py +85 -0
- wildlint-0.1.0/src/wildlint.egg-info/PKG-INFO +96 -0
- wildlint-0.1.0/src/wildlint.egg-info/SOURCES.txt +13 -0
- wildlint-0.1.0/src/wildlint.egg-info/dependency_links.txt +1 -0
- wildlint-0.1.0/src/wildlint.egg-info/entry_points.txt +2 -0
- wildlint-0.1.0/src/wildlint.egg-info/top_level.txt +1 -0
- wildlint-0.1.0/tests/test_wildlint.py +134 -0
wildlint-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 patchwright
|
|
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.
|
wildlint-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wildlint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static checks distilled from real upstream bugs that off-the-shelf linters miss.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/patchwright/wildlint
|
|
7
|
+
Project-URL: Issues, https://github.com/patchwright/wildlint/issues
|
|
8
|
+
Keywords: lint,linter,ast,static-analysis,bugs,removeprefix
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# wildlint
|
|
18
|
+
|
|
19
|
+
[](https://github.com/patchwright/wildlint/actions/workflows/ci.yml)
|
|
20
|
+
[](https://pypi.org/project/wildlint/)
|
|
21
|
+
|
|
22
|
+
Static checks distilled from **real upstream bugs** — the kind off-the-shelf
|
|
23
|
+
linters miss because they look like ordinary, working code.
|
|
24
|
+
|
|
25
|
+
Every rule here was born from a concrete bug that was found and fixed in a
|
|
26
|
+
public project, then generalized to the smallest static check that still catches
|
|
27
|
+
the *class* without flooding you with false positives. If a bug could not be
|
|
28
|
+
turned into a low-noise rule, it is documented as not-shipped rather than added
|
|
29
|
+
as noise (see [Not shipped](#bugs-considered-but-not-shipped)).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install wildlint
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
wildlint path/to/code # scan a file or directory (default: .)
|
|
41
|
+
wildlint --select WL001,WL002 src/
|
|
42
|
+
wildlint --pedantic src/ # also run opt-in, higher-false-positive rules
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Exits non-zero when anything is found, so it drops straight into CI or a
|
|
46
|
+
pre-commit hook.
|
|
47
|
+
|
|
48
|
+
### pre-commit
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
# .pre-commit-config.yaml
|
|
52
|
+
repos:
|
|
53
|
+
- repo: https://github.com/patchwright/wildlint
|
|
54
|
+
rev: v0.1.0
|
|
55
|
+
hooks:
|
|
56
|
+
- id: wildlint
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### CI (GitHub Actions)
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
- run: pip install wildlint
|
|
63
|
+
- run: wildlint src/
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Rules
|
|
67
|
+
|
|
68
|
+
| Code | Tier | Catches | Distilled from |
|
|
69
|
+
|-------|----------|---------|----------------|
|
|
70
|
+
| WL001 | default | `x.replace(P, "")` guarded by `x.startswith(P)`/`endswith(P)` — removes *every* occurrence, silently corrupting values that contain the marker twice. Meant `str.removeprefix`/`removesuffix`. | [nephila/giturlparse#149](https://github.com/nephila/giturlparse/pull/149) |
|
|
71
|
+
| WL002 | default | `s.split(' ')` where `s.split()` was meant — keeps empty tokens and skips whitespace collapsing/trimming, leaking blanks downstream. Advisory: only an exact single-space literal fires. | [derek73/python-nameparser#164](https://github.com/derek73/python-nameparser/pull/164) |
|
|
72
|
+
| WL003 | pedantic | `x[-k]` with `k >= 2` — `IndexError` when the sequence is shorter than `k`. Opt-in because deep negative indexing is often provably safe from context the checker can't see. | [savoirfairelinux/num2words#661](https://github.com/savoirfairelinux/num2words/pull/661) |
|
|
73
|
+
|
|
74
|
+
Each rule is verified against the *actual pre-fix source* of the project it came
|
|
75
|
+
from — see the tests, and the rule docstrings in `src/wildlint/checkers.py`.
|
|
76
|
+
|
|
77
|
+
## Bugs considered but not shipped
|
|
78
|
+
|
|
79
|
+
Some real bugs do not generalize into a low-false-positive static rule. They are
|
|
80
|
+
recorded in `NON_GENERALIZED` in `checkers.py` so the reasoning is preserved:
|
|
81
|
+
|
|
82
|
+
- **break-vs-continue** ([mnamer#371](https://github.com/jkwill87/mnamer/pull/371)) — whether `break` should be `continue` is entirely loop-intent dependent.
|
|
83
|
+
- **sign-doubling** ([humanize#326](https://github.com/python-humanize/humanize/pull/326)) — a numeric-formatting concern, not a syntactic pattern.
|
|
84
|
+
- **validation-branch-order** ([validators#463](https://github.com/python-validators/validators/pull/463)) — specific to one parser's control flow.
|
|
85
|
+
- **radix-from-ignored-param** ([shortuuid#115](https://github.com/skorokithakis/shortuuid/pull/115)) — requires matching a docstring contract to the implementation.
|
|
86
|
+
|
|
87
|
+
## Adding a rule
|
|
88
|
+
|
|
89
|
+
A checker is any object with `code`, `name`, `tier`, and
|
|
90
|
+
`check(tree, path) -> list[Finding]`. Append an instance to `CHECKERS` in
|
|
91
|
+
`checkers.py` and add positive/negative tests mirroring the wild bug. That's the
|
|
92
|
+
whole extension surface — the suite grows one real bug at a time.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT.
|
wildlint-0.1.0/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# wildlint
|
|
2
|
+
|
|
3
|
+
[](https://github.com/patchwright/wildlint/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/wildlint/)
|
|
5
|
+
|
|
6
|
+
Static checks distilled from **real upstream bugs** — the kind off-the-shelf
|
|
7
|
+
linters miss because they look like ordinary, working code.
|
|
8
|
+
|
|
9
|
+
Every rule here was born from a concrete bug that was found and fixed in a
|
|
10
|
+
public project, then generalized to the smallest static check that still catches
|
|
11
|
+
the *class* without flooding you with false positives. If a bug could not be
|
|
12
|
+
turned into a low-noise rule, it is documented as not-shipped rather than added
|
|
13
|
+
as noise (see [Not shipped](#bugs-considered-but-not-shipped)).
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install wildlint
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
wildlint path/to/code # scan a file or directory (default: .)
|
|
25
|
+
wildlint --select WL001,WL002 src/
|
|
26
|
+
wildlint --pedantic src/ # also run opt-in, higher-false-positive rules
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Exits non-zero when anything is found, so it drops straight into CI or a
|
|
30
|
+
pre-commit hook.
|
|
31
|
+
|
|
32
|
+
### pre-commit
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
# .pre-commit-config.yaml
|
|
36
|
+
repos:
|
|
37
|
+
- repo: https://github.com/patchwright/wildlint
|
|
38
|
+
rev: v0.1.0
|
|
39
|
+
hooks:
|
|
40
|
+
- id: wildlint
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### CI (GitHub Actions)
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
- run: pip install wildlint
|
|
47
|
+
- run: wildlint src/
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Rules
|
|
51
|
+
|
|
52
|
+
| Code | Tier | Catches | Distilled from |
|
|
53
|
+
|-------|----------|---------|----------------|
|
|
54
|
+
| WL001 | default | `x.replace(P, "")` guarded by `x.startswith(P)`/`endswith(P)` — removes *every* occurrence, silently corrupting values that contain the marker twice. Meant `str.removeprefix`/`removesuffix`. | [nephila/giturlparse#149](https://github.com/nephila/giturlparse/pull/149) |
|
|
55
|
+
| WL002 | default | `s.split(' ')` where `s.split()` was meant — keeps empty tokens and skips whitespace collapsing/trimming, leaking blanks downstream. Advisory: only an exact single-space literal fires. | [derek73/python-nameparser#164](https://github.com/derek73/python-nameparser/pull/164) |
|
|
56
|
+
| WL003 | pedantic | `x[-k]` with `k >= 2` — `IndexError` when the sequence is shorter than `k`. Opt-in because deep negative indexing is often provably safe from context the checker can't see. | [savoirfairelinux/num2words#661](https://github.com/savoirfairelinux/num2words/pull/661) |
|
|
57
|
+
|
|
58
|
+
Each rule is verified against the *actual pre-fix source* of the project it came
|
|
59
|
+
from — see the tests, and the rule docstrings in `src/wildlint/checkers.py`.
|
|
60
|
+
|
|
61
|
+
## Bugs considered but not shipped
|
|
62
|
+
|
|
63
|
+
Some real bugs do not generalize into a low-false-positive static rule. They are
|
|
64
|
+
recorded in `NON_GENERALIZED` in `checkers.py` so the reasoning is preserved:
|
|
65
|
+
|
|
66
|
+
- **break-vs-continue** ([mnamer#371](https://github.com/jkwill87/mnamer/pull/371)) — whether `break` should be `continue` is entirely loop-intent dependent.
|
|
67
|
+
- **sign-doubling** ([humanize#326](https://github.com/python-humanize/humanize/pull/326)) — a numeric-formatting concern, not a syntactic pattern.
|
|
68
|
+
- **validation-branch-order** ([validators#463](https://github.com/python-validators/validators/pull/463)) — specific to one parser's control flow.
|
|
69
|
+
- **radix-from-ignored-param** ([shortuuid#115](https://github.com/skorokithakis/shortuuid/pull/115)) — requires matching a docstring contract to the implementation.
|
|
70
|
+
|
|
71
|
+
## Adding a rule
|
|
72
|
+
|
|
73
|
+
A checker is any object with `code`, `name`, `tier`, and
|
|
74
|
+
`check(tree, path) -> list[Finding]`. Append an instance to `CHECKERS` in
|
|
75
|
+
`checkers.py` and add positive/negative tests mirroring the wild bug. That's the
|
|
76
|
+
whole extension surface — the suite grows one real bug at a time.
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wildlint"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Static checks distilled from real upstream bugs that off-the-shelf linters miss."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["lint", "linter", "ast", "static-analysis", "bugs", "removeprefix"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/patchwright/wildlint"
|
|
21
|
+
Issues = "https://github.com/patchwright/wildlint/issues"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
wildlint = "wildlint:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
wildlint-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""wildlint — static checks distilled from real upstream bugs.
|
|
2
|
+
|
|
3
|
+
Each rule was born from a concrete bug fixed in a public project, generalized to
|
|
4
|
+
the smallest form that catches the class without flooding you with false
|
|
5
|
+
positives. See ``checkers.py`` for the rule provenance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .checkers import CHECKERS, Finding, check_source
|
|
11
|
+
from .cli import main
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["CHECKERS", "Finding", "check_source", "main", "__version__"]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Detector registry for wildlint.
|
|
2
|
+
|
|
3
|
+
Each checker is distilled from a *real* bug found in the wild (a public upstream
|
|
4
|
+
PR), generalized into the smallest static rule that still catches the class
|
|
5
|
+
without drowning the user in false positives. Checkers that could only be made
|
|
6
|
+
to fire with an unacceptable false-positive rate are documented in
|
|
7
|
+
``NON_GENERALIZED`` rather than shipped.
|
|
8
|
+
|
|
9
|
+
A checker is any object exposing ``code``, ``name``, ``tier`` and a
|
|
10
|
+
``check(tree, path) -> list[Finding]`` method. Register one by appending an
|
|
11
|
+
instance to ``CHECKERS``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
DEFAULT = "default" # low false-positive; on unless deselected
|
|
20
|
+
PEDANTIC = "pedantic" # higher false-positive; opt-in via --pedantic
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Finding:
|
|
25
|
+
path: str
|
|
26
|
+
line: int
|
|
27
|
+
col: int
|
|
28
|
+
code: str
|
|
29
|
+
message: str
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return f"{self.path}:{self.line}:{self.col}: {self.code} {self.message}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _str_const(node: ast.expr) -> str | None:
|
|
36
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
37
|
+
return node.value
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------- #
|
|
42
|
+
# WL001 — replace-to-empty used as a prefix/suffix strip
|
|
43
|
+
# Origin: nephila/giturlparse PR #149
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
class ReplaceToEmptyPrefix:
|
|
46
|
+
"""``x.replace(P, "")`` guarded by ``x.startswith(P)`` / ``x.endswith(P)``.
|
|
47
|
+
|
|
48
|
+
``str.replace`` removes *every* occurrence, so a value that contains the
|
|
49
|
+
marker twice is silently corrupted (``"/blob/x/blob/y" -> "x/y"``). The
|
|
50
|
+
author meant ``str.removeprefix`` / ``str.removesuffix``. Narrow by design:
|
|
51
|
+
fires only when the *same receiver* is guarded by the *same* literal.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
code = "WL001"
|
|
55
|
+
name = "replace-to-empty-prefix"
|
|
56
|
+
tier = DEFAULT
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _guard(test: ast.expr) -> tuple[str, str, str] | None:
|
|
60
|
+
if not (isinstance(test, ast.Call) and isinstance(test.func, ast.Attribute)):
|
|
61
|
+
return None
|
|
62
|
+
method = test.func.attr
|
|
63
|
+
if method not in ("startswith", "endswith"):
|
|
64
|
+
return None
|
|
65
|
+
if len(test.args) != 1 or test.keywords:
|
|
66
|
+
return None
|
|
67
|
+
literal = _str_const(test.args[0])
|
|
68
|
+
if literal is None:
|
|
69
|
+
return None
|
|
70
|
+
suggestion = "removeprefix" if method == "startswith" else "removesuffix"
|
|
71
|
+
return ast.unparse(test.func.value), literal, suggestion
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _is_replace_to_empty(node: ast.Call, receiver_src: str, literal: str) -> bool:
|
|
75
|
+
if not (isinstance(node.func, ast.Attribute) and node.func.attr == "replace"):
|
|
76
|
+
return False
|
|
77
|
+
if len(node.args) != 2 or node.keywords:
|
|
78
|
+
return False
|
|
79
|
+
if _str_const(node.args[0]) != literal or _str_const(node.args[1]) != "":
|
|
80
|
+
return False
|
|
81
|
+
return ast.unparse(node.func.value) == receiver_src
|
|
82
|
+
|
|
83
|
+
def check(self, tree: ast.AST, path: str) -> list[Finding]:
|
|
84
|
+
out: list[Finding] = []
|
|
85
|
+
for node in ast.walk(tree):
|
|
86
|
+
if not isinstance(node, ast.If):
|
|
87
|
+
continue
|
|
88
|
+
guard = self._guard(node.test)
|
|
89
|
+
if guard is None:
|
|
90
|
+
continue
|
|
91
|
+
receiver_src, literal, suggestion = guard
|
|
92
|
+
for inner in ast.walk(node):
|
|
93
|
+
if isinstance(inner, ast.Call) and self._is_replace_to_empty(
|
|
94
|
+
inner, receiver_src, literal
|
|
95
|
+
):
|
|
96
|
+
out.append(
|
|
97
|
+
Finding(
|
|
98
|
+
path,
|
|
99
|
+
inner.lineno,
|
|
100
|
+
inner.col_offset,
|
|
101
|
+
self.code,
|
|
102
|
+
f'.replace({literal!r}, "") guarded by '
|
|
103
|
+
f"{'startswith' if suggestion == 'removeprefix' else 'endswith'}"
|
|
104
|
+
f"({literal!r}) removes every occurrence; "
|
|
105
|
+
f"use str.{suggestion}({literal!r})",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --------------------------------------------------------------------------- #
|
|
112
|
+
# WL002 — str.split(' ') instead of str.split()
|
|
113
|
+
# Origin: derek73/python-nameparser PR #164
|
|
114
|
+
# --------------------------------------------------------------------------- #
|
|
115
|
+
class SplitSingleSpace:
|
|
116
|
+
"""``s.split(' ')`` where ``s.split()`` was almost certainly meant.
|
|
117
|
+
|
|
118
|
+
``"a b ".split(' ')`` -> ``['a', '', 'b', '']`` keeps empty tokens and does
|
|
119
|
+
not collapse runs or trim ends, while ``.split()`` does both. The single
|
|
120
|
+
blanks then leak downstream (``['']`` where ``[]`` was expected, a leading
|
|
121
|
+
space on a field). Only an *exact single space* literal fires — ``' '`` or
|
|
122
|
+
``','`` are treated as deliberate delimiters and left alone.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
code = "WL002"
|
|
126
|
+
name = "split-single-space"
|
|
127
|
+
tier = DEFAULT
|
|
128
|
+
|
|
129
|
+
def check(self, tree: ast.AST, path: str) -> list[Finding]:
|
|
130
|
+
out: list[Finding] = []
|
|
131
|
+
for node in ast.walk(tree):
|
|
132
|
+
if not (
|
|
133
|
+
isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
134
|
+
):
|
|
135
|
+
continue
|
|
136
|
+
if node.func.attr not in ("split", "rsplit"):
|
|
137
|
+
continue
|
|
138
|
+
if not node.args:
|
|
139
|
+
continue
|
|
140
|
+
if _str_const(node.args[0]) != " ":
|
|
141
|
+
continue
|
|
142
|
+
out.append(
|
|
143
|
+
Finding(
|
|
144
|
+
path,
|
|
145
|
+
node.lineno,
|
|
146
|
+
node.col_offset,
|
|
147
|
+
self.code,
|
|
148
|
+
f".{node.func.attr}(' ') keeps empty tokens and will not "
|
|
149
|
+
"collapse/trim whitespace; use "
|
|
150
|
+
f".{node.func.attr}() unless single-space splitting is intended",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------- #
|
|
157
|
+
# WL003 — deep negative index without a length guard (PEDANTIC)
|
|
158
|
+
# Origin: savoirfairelinux/num2words PR #661
|
|
159
|
+
# --------------------------------------------------------------------------- #
|
|
160
|
+
class NegativeIndexNoGuard:
|
|
161
|
+
"""``x[-k]`` with ``k >= 2`` — IndexError if the sequence is shorter than k.
|
|
162
|
+
|
|
163
|
+
The num2words bug indexed ``number_str[-2]`` unconditionally; ``"0"`` has
|
|
164
|
+
length 1 and crashed. Pedantic because ``x[-2]`` is frequently safe (the
|
|
165
|
+
length is known from context the checker cannot see), so this is opt-in.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
code = "WL003"
|
|
169
|
+
name = "negative-index-no-guard"
|
|
170
|
+
tier = PEDANTIC
|
|
171
|
+
|
|
172
|
+
def check(self, tree: ast.AST, path: str) -> list[Finding]:
|
|
173
|
+
out: list[Finding] = []
|
|
174
|
+
for node in ast.walk(tree):
|
|
175
|
+
if not isinstance(node, ast.Subscript):
|
|
176
|
+
continue
|
|
177
|
+
idx = node.slice
|
|
178
|
+
if (
|
|
179
|
+
isinstance(idx, ast.UnaryOp)
|
|
180
|
+
and isinstance(idx.op, ast.USub)
|
|
181
|
+
and isinstance(idx.operand, ast.Constant)
|
|
182
|
+
and isinstance(idx.operand.value, int)
|
|
183
|
+
and idx.operand.value >= 2
|
|
184
|
+
):
|
|
185
|
+
target = ast.unparse(node.value)
|
|
186
|
+
out.append(
|
|
187
|
+
Finding(
|
|
188
|
+
path,
|
|
189
|
+
node.lineno,
|
|
190
|
+
node.col_offset,
|
|
191
|
+
self.code,
|
|
192
|
+
f"{target}[-{idx.operand.value}] raises IndexError if "
|
|
193
|
+
f"len({target}) < {idx.operand.value}; add a length guard",
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
CHECKERS = [
|
|
200
|
+
ReplaceToEmptyPrefix(),
|
|
201
|
+
SplitSingleSpace(),
|
|
202
|
+
NegativeIndexNoGuard(),
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def select_checkers(*, pedantic: bool = False, codes: set[str] | None = None) -> list:
|
|
207
|
+
"""Return the active checkers.
|
|
208
|
+
|
|
209
|
+
``pedantic`` includes the opt-in tier. ``codes`` (e.g. ``{"WL001"}``)
|
|
210
|
+
restricts to those rules and, when given, overrides the tier filter.
|
|
211
|
+
"""
|
|
212
|
+
if codes is not None:
|
|
213
|
+
return [c for c in CHECKERS if c.code in codes]
|
|
214
|
+
return [c for c in CHECKERS if c.tier == DEFAULT or pedantic]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def check_source(
|
|
218
|
+
source: str,
|
|
219
|
+
path: str = "<unknown>",
|
|
220
|
+
*,
|
|
221
|
+
pedantic: bool = False,
|
|
222
|
+
codes: set[str] | None = None,
|
|
223
|
+
) -> list[Finding]:
|
|
224
|
+
"""Run the selected checkers over one source string; sorted findings."""
|
|
225
|
+
tree = ast.parse(source)
|
|
226
|
+
findings: list[Finding] = []
|
|
227
|
+
for checker in select_checkers(pedantic=pedantic, codes=codes):
|
|
228
|
+
findings.extend(checker.check(tree, path))
|
|
229
|
+
findings.sort(key=lambda f: (f.line, f.col, f.code))
|
|
230
|
+
return findings
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# Bug classes considered but NOT shipped — each would only fire with an
|
|
234
|
+
# unacceptable false-positive rate as a purely-static rule. Kept here so the
|
|
235
|
+
# reasoning is not lost and a future, smarter implementation can revisit.
|
|
236
|
+
NON_GENERALIZED = {
|
|
237
|
+
"break-vs-continue": "jkwill87/mnamer #371 — whether `break` should be "
|
|
238
|
+
"`continue` is entirely loop-intent dependent; both are usually correct.",
|
|
239
|
+
"sign-doubling": "python-humanize/humanize #326 — negative whole+fraction "
|
|
240
|
+
"double-sign is a numeric-formatting specific, not a syntactic, pattern.",
|
|
241
|
+
"validation-branch-order": "python-validators/validators #463 — the unsafe "
|
|
242
|
+
"ordering of `,` vs `-`/`/` handling is specific to that parser's structure.",
|
|
243
|
+
"radix-from-ignored-param": "skorokithakis/shortuuid #115 — requires reading "
|
|
244
|
+
"the docstring contract ('alphabet is ignored') and matching it to impl.",
|
|
245
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Command-line entry point for wildlint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .checkers import CHECKERS, Finding, check_source
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _iter_python_files(paths: list[str]):
|
|
13
|
+
for raw in paths:
|
|
14
|
+
p = Path(raw)
|
|
15
|
+
if p.is_dir():
|
|
16
|
+
yield from sorted(p.rglob("*.py"))
|
|
17
|
+
elif p.suffix == ".py":
|
|
18
|
+
yield p
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check_file(
|
|
22
|
+
path: Path, *, pedantic: bool = False, codes: set[str] | None = None
|
|
23
|
+
) -> list[Finding]:
|
|
24
|
+
try:
|
|
25
|
+
source = path.read_text(encoding="utf-8")
|
|
26
|
+
except (OSError, UnicodeDecodeError):
|
|
27
|
+
return []
|
|
28
|
+
try:
|
|
29
|
+
return check_source(source, str(path), pedantic=pedantic, codes=codes)
|
|
30
|
+
except SyntaxError:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
from . import __version__
|
|
36
|
+
|
|
37
|
+
rules = ", ".join(f"{c.code} ({c.name}, {c.tier})" for c in CHECKERS)
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
prog="wildlint",
|
|
40
|
+
description="Static checks distilled from real upstream bugs. "
|
|
41
|
+
f"Rules: {rules}.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"paths", nargs="*", default=["."], help="files or dirs (default: .)"
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--pedantic",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="also run opt-in higher-false-positive rules",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--select",
|
|
53
|
+
metavar="CODES",
|
|
54
|
+
help="comma-separated rule codes to run exclusively, e.g. WL001,WL002",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
58
|
+
)
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: list[str] | None = None) -> int:
|
|
63
|
+
args = _build_parser().parse_args(argv)
|
|
64
|
+
codes = (
|
|
65
|
+
{c.strip().upper() for c in args.select.split(",") if c.strip()}
|
|
66
|
+
if args.select
|
|
67
|
+
else None
|
|
68
|
+
)
|
|
69
|
+
paths = args.paths or ["."]
|
|
70
|
+
|
|
71
|
+
findings: list[Finding] = []
|
|
72
|
+
for file in _iter_python_files(paths):
|
|
73
|
+
findings.extend(check_file(file, pedantic=args.pedantic, codes=codes))
|
|
74
|
+
|
|
75
|
+
for f in findings:
|
|
76
|
+
print(f)
|
|
77
|
+
|
|
78
|
+
if findings:
|
|
79
|
+
print(f"\n{len(findings)} finding(s).", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wildlint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static checks distilled from real upstream bugs that off-the-shelf linters miss.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/patchwright/wildlint
|
|
7
|
+
Project-URL: Issues, https://github.com/patchwright/wildlint/issues
|
|
8
|
+
Keywords: lint,linter,ast,static-analysis,bugs,removeprefix
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# wildlint
|
|
18
|
+
|
|
19
|
+
[](https://github.com/patchwright/wildlint/actions/workflows/ci.yml)
|
|
20
|
+
[](https://pypi.org/project/wildlint/)
|
|
21
|
+
|
|
22
|
+
Static checks distilled from **real upstream bugs** — the kind off-the-shelf
|
|
23
|
+
linters miss because they look like ordinary, working code.
|
|
24
|
+
|
|
25
|
+
Every rule here was born from a concrete bug that was found and fixed in a
|
|
26
|
+
public project, then generalized to the smallest static check that still catches
|
|
27
|
+
the *class* without flooding you with false positives. If a bug could not be
|
|
28
|
+
turned into a low-noise rule, it is documented as not-shipped rather than added
|
|
29
|
+
as noise (see [Not shipped](#bugs-considered-but-not-shipped)).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install wildlint
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
wildlint path/to/code # scan a file or directory (default: .)
|
|
41
|
+
wildlint --select WL001,WL002 src/
|
|
42
|
+
wildlint --pedantic src/ # also run opt-in, higher-false-positive rules
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Exits non-zero when anything is found, so it drops straight into CI or a
|
|
46
|
+
pre-commit hook.
|
|
47
|
+
|
|
48
|
+
### pre-commit
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
# .pre-commit-config.yaml
|
|
52
|
+
repos:
|
|
53
|
+
- repo: https://github.com/patchwright/wildlint
|
|
54
|
+
rev: v0.1.0
|
|
55
|
+
hooks:
|
|
56
|
+
- id: wildlint
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### CI (GitHub Actions)
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
- run: pip install wildlint
|
|
63
|
+
- run: wildlint src/
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Rules
|
|
67
|
+
|
|
68
|
+
| Code | Tier | Catches | Distilled from |
|
|
69
|
+
|-------|----------|---------|----------------|
|
|
70
|
+
| WL001 | default | `x.replace(P, "")` guarded by `x.startswith(P)`/`endswith(P)` — removes *every* occurrence, silently corrupting values that contain the marker twice. Meant `str.removeprefix`/`removesuffix`. | [nephila/giturlparse#149](https://github.com/nephila/giturlparse/pull/149) |
|
|
71
|
+
| WL002 | default | `s.split(' ')` where `s.split()` was meant — keeps empty tokens and skips whitespace collapsing/trimming, leaking blanks downstream. Advisory: only an exact single-space literal fires. | [derek73/python-nameparser#164](https://github.com/derek73/python-nameparser/pull/164) |
|
|
72
|
+
| WL003 | pedantic | `x[-k]` with `k >= 2` — `IndexError` when the sequence is shorter than `k`. Opt-in because deep negative indexing is often provably safe from context the checker can't see. | [savoirfairelinux/num2words#661](https://github.com/savoirfairelinux/num2words/pull/661) |
|
|
73
|
+
|
|
74
|
+
Each rule is verified against the *actual pre-fix source* of the project it came
|
|
75
|
+
from — see the tests, and the rule docstrings in `src/wildlint/checkers.py`.
|
|
76
|
+
|
|
77
|
+
## Bugs considered but not shipped
|
|
78
|
+
|
|
79
|
+
Some real bugs do not generalize into a low-false-positive static rule. They are
|
|
80
|
+
recorded in `NON_GENERALIZED` in `checkers.py` so the reasoning is preserved:
|
|
81
|
+
|
|
82
|
+
- **break-vs-continue** ([mnamer#371](https://github.com/jkwill87/mnamer/pull/371)) — whether `break` should be `continue` is entirely loop-intent dependent.
|
|
83
|
+
- **sign-doubling** ([humanize#326](https://github.com/python-humanize/humanize/pull/326)) — a numeric-formatting concern, not a syntactic pattern.
|
|
84
|
+
- **validation-branch-order** ([validators#463](https://github.com/python-validators/validators/pull/463)) — specific to one parser's control flow.
|
|
85
|
+
- **radix-from-ignored-param** ([shortuuid#115](https://github.com/skorokithakis/shortuuid/pull/115)) — requires matching a docstring contract to the implementation.
|
|
86
|
+
|
|
87
|
+
## Adding a rule
|
|
88
|
+
|
|
89
|
+
A checker is any object with `code`, `name`, `tier`, and
|
|
90
|
+
`check(tree, path) -> list[Finding]`. Append an instance to `CHECKERS` in
|
|
91
|
+
`checkers.py` and add positive/negative tests mirroring the wild bug. That's the
|
|
92
|
+
whole extension surface — the suite grows one real bug at a time.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/wildlint/__init__.py
|
|
5
|
+
src/wildlint/__main__.py
|
|
6
|
+
src/wildlint/checkers.py
|
|
7
|
+
src/wildlint/cli.py
|
|
8
|
+
src/wildlint.egg-info/PKG-INFO
|
|
9
|
+
src/wildlint.egg-info/SOURCES.txt
|
|
10
|
+
src/wildlint.egg-info/dependency_links.txt
|
|
11
|
+
src/wildlint.egg-info/entry_points.txt
|
|
12
|
+
src/wildlint.egg-info/top_level.txt
|
|
13
|
+
tests/test_wildlint.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wildlint
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Tests for wildlint detectors.
|
|
2
|
+
|
|
3
|
+
Positive cases mirror the real upstream bug each rule was distilled from;
|
|
4
|
+
negative cases are the legitimate code the rule must stay silent on.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from wildlint.checkers import check_source
|
|
10
|
+
|
|
11
|
+
# --------------------------------------------------------------------------- #
|
|
12
|
+
# WL001 — replace-to-empty prefix/suffix (giturlparse #149)
|
|
13
|
+
# --------------------------------------------------------------------------- #
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _codes(src: str, **kw) -> list[str]:
|
|
17
|
+
return [f.code for f in check_source(src, "t.py", **kw)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_wl001_fires_on_startswith_replace_empty():
|
|
21
|
+
src = (
|
|
22
|
+
"def f(path):\n"
|
|
23
|
+
" if path.startswith('/blob/'):\n"
|
|
24
|
+
" return path.replace('/blob/', '')\n"
|
|
25
|
+
)
|
|
26
|
+
assert _codes(src) == ["WL001"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_wl001_fires_on_endswith_suffix():
|
|
30
|
+
src = (
|
|
31
|
+
"def f(name):\n"
|
|
32
|
+
" if name.endswith('.py'):\n"
|
|
33
|
+
" return name.replace('.py', '')\n"
|
|
34
|
+
)
|
|
35
|
+
assert _codes(src) == ["WL001"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_wl001_silent_when_replacement_not_empty():
|
|
39
|
+
src = (
|
|
40
|
+
"def f(p):\n"
|
|
41
|
+
" if p.startswith('/x/'):\n"
|
|
42
|
+
" return p.replace('/x/', '/y/')\n"
|
|
43
|
+
)
|
|
44
|
+
assert "WL001" not in _codes(src)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_wl001_silent_when_literals_differ():
|
|
48
|
+
src = (
|
|
49
|
+
"def f(p):\n"
|
|
50
|
+
" if p.startswith('/a/'):\n"
|
|
51
|
+
" return p.replace('/b/', '')\n"
|
|
52
|
+
)
|
|
53
|
+
assert "WL001" not in _codes(src)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_wl001_silent_without_guard():
|
|
57
|
+
# No startswith/endswith guard -> not our pattern (could be intentional).
|
|
58
|
+
assert "WL001" not in _codes("def f(p):\n return p.replace('/x/', '')\n")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------- #
|
|
62
|
+
# WL002 — split single space (nameparser #164)
|
|
63
|
+
# --------------------------------------------------------------------------- #
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_wl002_fires_on_split_single_space():
|
|
67
|
+
assert _codes("x = name.split(' ')\n") == ["WL002"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_wl002_fires_on_rsplit_single_space():
|
|
71
|
+
assert _codes("x = name.rsplit(' ')\n") == ["WL002"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_wl002_silent_on_bare_split():
|
|
75
|
+
assert "WL002" not in _codes("x = name.split()\n")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_wl002_silent_on_double_space():
|
|
79
|
+
# Two spaces is a deliberate delimiter, not the bug.
|
|
80
|
+
assert "WL002" not in _codes("x = name.split(' ')\n")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_wl002_silent_on_comma():
|
|
84
|
+
assert "WL002" not in _codes("x = name.split(',')\n")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --------------------------------------------------------------------------- #
|
|
88
|
+
# WL003 — deep negative index, PEDANTIC (num2words #661)
|
|
89
|
+
# --------------------------------------------------------------------------- #
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_wl003_off_by_default():
|
|
93
|
+
assert _codes("x = s[-2]\n") == []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_wl003_fires_when_pedantic():
|
|
97
|
+
assert _codes("x = s[-2]\n", pedantic=True) == ["WL003"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_wl003_silent_on_last_element():
|
|
101
|
+
# x[-1] is the idiom for "last item" and never out of bounds on non-empty.
|
|
102
|
+
assert _codes("x = s[-1]\n", pedantic=True) == []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_wl003_fires_on_deeper_index_when_pedantic():
|
|
106
|
+
assert _codes("x = s[-3]\n", pedantic=True) == ["WL003"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
# selection / tier behavior
|
|
111
|
+
# --------------------------------------------------------------------------- #
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_select_restricts_to_codes():
|
|
115
|
+
src = "a = name.split(' ')\nif p.startswith('/x/'):\n p = p.replace('/x/', '')\n"
|
|
116
|
+
assert _codes(src) == ["WL001", "WL002"] or _codes(src) == ["WL002", "WL001"]
|
|
117
|
+
assert _codes(src, codes={"WL002"}) == ["WL002"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_select_can_pick_pedantic_rule_without_flag():
|
|
121
|
+
# Explicit --select overrides the tier filter.
|
|
122
|
+
assert _codes("x = s[-2]\n", codes={"WL003"}) == ["WL003"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_clean_source_has_no_findings():
|
|
126
|
+
src = "def f(p):\n return p.removeprefix('/x/').split()\n"
|
|
127
|
+
assert check_source(src, "t.py", pedantic=True) == []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_syntax_error_does_not_crash_check_source():
|
|
131
|
+
import pytest
|
|
132
|
+
|
|
133
|
+
with pytest.raises(SyntaxError):
|
|
134
|
+
check_source("def (:\n", "t.py")
|