pycheck-neuron 1.1.2__tar.gz → 1.2.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.
- {pycheck_neuron-1.1.2/pycheck_neuron.egg-info → pycheck_neuron-1.2.0}/PKG-INFO +27 -5
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/README.md +23 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/__init__.py +1 -1
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/analyzer.py +12 -4
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/cli.py +70 -5
- pycheck_neuron-1.2.0/pycheck/config.py +132 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/flow.py +35 -1
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/rules.py +102 -30
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0/pycheck_neuron.egg-info}/PKG-INFO +27 -5
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck_neuron.egg-info/SOURCES.txt +2 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pyproject.toml +4 -5
- pycheck_neuron-1.2.0/tests/test_config.py +98 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/tests/test_rules.py +106 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/LICENSE +0 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/__main__.py +0 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/audit.py +0 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/server.py +0 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck_neuron.egg-info/dependency_links.txt +0 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck_neuron.egg-info/entry_points.txt +0 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck_neuron.egg-info/requires.txt +0 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck_neuron.egg-info/top_level.txt +0 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/setup.cfg +0 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/tests/test_audit.py +0 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/tests/test_cli.py +0 -0
- {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/tests/test_regression.py +0 -0
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycheck-neuron
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: 37-rule static AST analyzer catching temporal lookahead bias, causal confusion, and uncertainty bugs in Python ML code. Zero dependencies.
|
|
5
5
|
Author-email: Fayo Ibrahim <contact@neuron-lab.org>
|
|
6
|
-
License
|
|
6
|
+
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/neuronlabs-ai/pycheck
|
|
8
8
|
Project-URL: Documentation, https://github.com/neuronlabs-ai/pycheck#readme
|
|
9
9
|
Project-URL: Repository, https://github.com/neuronlabs-ai/pycheck
|
|
10
10
|
Project-URL: Bug Tracker, https://github.com/neuronlabs-ai/pycheck/issues
|
|
11
11
|
Keywords: machine-learning,data-leakage,temporal-bias,lookahead-bias,backtesting,quantitative-finance,algorithmic-trading,linter,static-analysis,causal-inference,time-series,pandas,sklearn,scikit-learn,uncertainty,neuron,ml-safety,data-science,feature-engineering
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.7
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
15
13
|
Classifier: Programming Language :: Python :: 3.9
|
|
16
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
17
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
18
|
Classifier: Operating System :: OS Independent
|
|
20
19
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
20
|
Classifier: Topic :: Software Development :: Testing
|
|
@@ -26,7 +25,7 @@ Classifier: Intended Audience :: Financial and Insurance Industry
|
|
|
26
25
|
Classifier: Intended Audience :: Developers
|
|
27
26
|
Classifier: Development Status :: 5 - Production/Stable
|
|
28
27
|
Classifier: Environment :: Console
|
|
29
|
-
Requires-Python: >=3.
|
|
28
|
+
Requires-Python: >=3.9
|
|
30
29
|
Description-Content-Type: text/markdown
|
|
31
30
|
License-File: LICENSE
|
|
32
31
|
Provides-Extra: test
|
|
@@ -239,6 +238,29 @@ Returns structured output for automated pipelines:
|
|
|
239
238
|
]
|
|
240
239
|
```
|
|
241
240
|
|
|
241
|
+
### Project Configuration (`pyproject.toml` or `.pycheck.toml`)
|
|
242
|
+
|
|
243
|
+
PyCheck automatically detects and respects project-level configuration in `pyproject.toml` or `.pycheck.toml`:
|
|
244
|
+
|
|
245
|
+
```toml
|
|
246
|
+
[tool.pycheck]
|
|
247
|
+
ignore = ["C001", "U002"]
|
|
248
|
+
exclude = ["tests", "legacy"]
|
|
249
|
+
info = false
|
|
250
|
+
quiet = false
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Or override dynamically via CLI flags:
|
|
254
|
+
```bash
|
|
255
|
+
pycheck . --ignore C001,U002 --select T001,T010 -c pyproject.toml
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Modern Quant Stack Support (Polars, PyTorch, JAX)
|
|
259
|
+
|
|
260
|
+
PyCheck natively guards both Pandas and modern high-performance quantitative computing pipelines:
|
|
261
|
+
- **Polars Expression Engine**: Detects lookaheads in `pl.col("close").shift(n=-1)`, `pct_change(n=-1)`, `diff(n=-1)`, `backward_fill()`, `fill_null(strategy="backward")`, `rolling_mean(center=True)`, and `join_asof(strategy="forward" | "nearest")`.
|
|
262
|
+
- **Deep Learning Time Series**: Detects future tensor index rotation via `torch.roll(tensor, shifts=-N)` and `jnp.roll(arr, -N)`.
|
|
263
|
+
|
|
242
264
|
### Exit Codes
|
|
243
265
|
|
|
244
266
|
- `0` — Verified clean / no errors found
|
|
@@ -200,6 +200,29 @@ Returns structured output for automated pipelines:
|
|
|
200
200
|
]
|
|
201
201
|
```
|
|
202
202
|
|
|
203
|
+
### Project Configuration (`pyproject.toml` or `.pycheck.toml`)
|
|
204
|
+
|
|
205
|
+
PyCheck automatically detects and respects project-level configuration in `pyproject.toml` or `.pycheck.toml`:
|
|
206
|
+
|
|
207
|
+
```toml
|
|
208
|
+
[tool.pycheck]
|
|
209
|
+
ignore = ["C001", "U002"]
|
|
210
|
+
exclude = ["tests", "legacy"]
|
|
211
|
+
info = false
|
|
212
|
+
quiet = false
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Or override dynamically via CLI flags:
|
|
216
|
+
```bash
|
|
217
|
+
pycheck . --ignore C001,U002 --select T001,T010 -c pyproject.toml
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Modern Quant Stack Support (Polars, PyTorch, JAX)
|
|
221
|
+
|
|
222
|
+
PyCheck natively guards both Pandas and modern high-performance quantitative computing pipelines:
|
|
223
|
+
- **Polars Expression Engine**: Detects lookaheads in `pl.col("close").shift(n=-1)`, `pct_change(n=-1)`, `diff(n=-1)`, `backward_fill()`, `fill_null(strategy="backward")`, `rolling_mean(center=True)`, and `join_asof(strategy="forward" | "nearest")`.
|
|
224
|
+
- **Deep Learning Time Series**: Detects future tensor index rotation via `torch.roll(tensor, shifts=-N)` and `jnp.roll(arr, -N)`.
|
|
225
|
+
|
|
203
226
|
### Exit Codes
|
|
204
227
|
|
|
205
228
|
- `0` — Verified clean / no errors found
|
|
@@ -3,7 +3,7 @@ PyCheck — NEURON ML Safety Analyzer
|
|
|
3
3
|
Catches temporal leaks, causal confusion, and uncertainty bugs in Python ML scripts.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
__version__ = "1.
|
|
6
|
+
__version__ = "1.2.0"
|
|
7
7
|
|
|
8
8
|
from pycheck.analyzer import analyze_file, analyze_code, format_output
|
|
9
9
|
from pycheck.rules import ALL_RULES, RULES_BY_CODE
|
|
@@ -7,7 +7,7 @@ causal confusion, and uncertainty bugs in Python ML scripts.
|
|
|
7
7
|
import ast
|
|
8
8
|
import sys
|
|
9
9
|
import json
|
|
10
|
-
from typing import List, Tuple
|
|
10
|
+
from typing import List, Tuple, Optional, Set
|
|
11
11
|
|
|
12
12
|
from pycheck.rules import ALL_RULES, RULES_BY_CODE, AnalysisContext, Diagnostic
|
|
13
13
|
from pycheck.flow import TaintTracker
|
|
@@ -105,7 +105,9 @@ def parse_suppressions(source_lines: List[str]) -> dict:
|
|
|
105
105
|
suppressions.setdefault(lineno + 1, set()).update(codes)
|
|
106
106
|
return suppressions
|
|
107
107
|
|
|
108
|
-
def analyze_code(source: str, filepath: str = "<string>"
|
|
108
|
+
def analyze_code(source: str, filepath: str = "<string>",
|
|
109
|
+
ignore: Optional[Set[str]] = None,
|
|
110
|
+
select: Optional[Set[str]] = None) -> Tuple[List[dict], List[str]]:
|
|
109
111
|
"""
|
|
110
112
|
Analyze Python source code for ML safety issues.
|
|
111
113
|
Returns (diagnostics_list, source_lines).
|
|
@@ -144,6 +146,10 @@ def analyze_code(source: str, filepath: str = "<string>") -> Tuple[List[dict], L
|
|
|
144
146
|
seen.add(key)
|
|
145
147
|
line_suppressed = suppressed_map.get(d.line, set())
|
|
146
148
|
if 'ALL' not in line_suppressed and d.code not in line_suppressed:
|
|
149
|
+
if ignore and d.code in ignore:
|
|
150
|
+
continue
|
|
151
|
+
if select is not None and d.code not in select:
|
|
152
|
+
continue
|
|
147
153
|
unique.append(d)
|
|
148
154
|
|
|
149
155
|
# Sort by line number
|
|
@@ -152,7 +158,9 @@ def analyze_code(source: str, filepath: str = "<string>") -> Tuple[List[dict], L
|
|
|
152
158
|
return [d.to_dict() for d in unique], source_lines
|
|
153
159
|
|
|
154
160
|
|
|
155
|
-
def analyze_file(filepath: str
|
|
161
|
+
def analyze_file(filepath: str,
|
|
162
|
+
ignore: Optional[Set[str]] = None,
|
|
163
|
+
select: Optional[Set[str]] = None) -> Tuple[List[dict], List[str]]:
|
|
156
164
|
"""
|
|
157
165
|
Analyze a Python (.py) or Jupyter notebook (.ipynb) file for ML safety issues.
|
|
158
166
|
Returns (diagnostics_list, source_lines).
|
|
@@ -178,7 +186,7 @@ def analyze_file(filepath: str) -> Tuple[List[dict], List[str]]:
|
|
|
178
186
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|
179
187
|
source = f.read()
|
|
180
188
|
|
|
181
|
-
return analyze_code(source, filepath=filepath)
|
|
189
|
+
return analyze_code(source, filepath=filepath, ignore=ignore, select=select)
|
|
182
190
|
|
|
183
191
|
|
|
184
192
|
def format_output(filepath: str, diagnostics: List[dict], source_lines: List[str],
|
|
@@ -5,21 +5,31 @@ import hashlib
|
|
|
5
5
|
from pycheck.analyzer import analyze_file, format_output
|
|
6
6
|
from pycheck.rules import ALL_RULES
|
|
7
7
|
from pycheck.audit import generate_proof_of_causality_certificate, format_certificate_text
|
|
8
|
+
from pycheck.config import load_config
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def main():
|
|
11
12
|
args = sys.argv[1:]
|
|
13
|
+
|
|
14
|
+
if '--version' in args or '-V' in args:
|
|
15
|
+
from pycheck import __version__
|
|
16
|
+
print(f"pycheck {__version__}")
|
|
17
|
+
sys.exit(0)
|
|
12
18
|
|
|
13
19
|
if not args or '--help' in args or '-h' in args:
|
|
14
20
|
print("PyCheck — NEURON ML Safety Analyzer")
|
|
15
21
|
print(f" {len(ALL_RULES)} rules for temporal leaks, causal confusion, and uncertainty bugs\n")
|
|
16
22
|
print("Usage: pycheck <path> [options]\n")
|
|
17
23
|
print("Options:")
|
|
24
|
+
print(" --version, -V Show version and exit")
|
|
18
25
|
print(" --json Output diagnostics as JSON")
|
|
19
26
|
print(" --gitlab Output diagnostics in GitLab Code Quality JSON format")
|
|
20
27
|
print(" --audit, -a Generate a verifiable Proof-of-Causality Audit Certificate")
|
|
21
28
|
print(" Exit codes: 0 = verified pass, 1 = leakage detected, 3 = unverifiable (dynamic code or unscanned imports)")
|
|
22
29
|
print(" -o, --output <file> Write JSON / GitLab report directly to specified file")
|
|
30
|
+
print(" -c, --config <file> Path to configuration file (.pycheck.toml or pyproject.toml)")
|
|
31
|
+
print(" --ignore <codes> Comma-separated rule codes to ignore (e.g. C001,U002)")
|
|
32
|
+
print(" --select <codes> Comma-separated rule codes to exclusively run (e.g. T001,T010)")
|
|
23
33
|
print(" --info Include info-level diagnostics")
|
|
24
34
|
print(" --quiet Only show errors (no warnings)")
|
|
25
35
|
print(" --list List all available rules")
|
|
@@ -37,8 +47,6 @@ def main():
|
|
|
37
47
|
print()
|
|
38
48
|
sys.exit(0)
|
|
39
49
|
|
|
40
|
-
show_info = '--info' in args
|
|
41
|
-
is_quiet = '--quiet' in args
|
|
42
50
|
is_json = '--json' in args
|
|
43
51
|
is_gitlab = '--gitlab' in args
|
|
44
52
|
is_audit = '--audit' in args or '-a' in args or (len(args) > 0 and args[0] == 'audit')
|
|
@@ -53,6 +61,28 @@ def main():
|
|
|
53
61
|
if idx + 1 < len(args):
|
|
54
62
|
output_file = args[idx + 1]
|
|
55
63
|
|
|
64
|
+
cli_config = None
|
|
65
|
+
if '--config' in args:
|
|
66
|
+
idx = args.index('--config')
|
|
67
|
+
if idx + 1 < len(args):
|
|
68
|
+
cli_config = args[idx + 1]
|
|
69
|
+
elif '-c' in args:
|
|
70
|
+
idx = args.index('-c')
|
|
71
|
+
if idx + 1 < len(args):
|
|
72
|
+
cli_config = args[idx + 1]
|
|
73
|
+
|
|
74
|
+
cli_ignore = set()
|
|
75
|
+
if '--ignore' in args:
|
|
76
|
+
idx = args.index('--ignore')
|
|
77
|
+
if idx + 1 < len(args):
|
|
78
|
+
cli_ignore = {c.strip().upper() for c in args[idx + 1].split(',') if c.strip()}
|
|
79
|
+
|
|
80
|
+
cli_select = None
|
|
81
|
+
if '--select' in args:
|
|
82
|
+
idx = args.index('--select')
|
|
83
|
+
if idx + 1 < len(args):
|
|
84
|
+
cli_select = {c.strip().upper() for c in args[idx + 1].split(',') if c.strip()}
|
|
85
|
+
|
|
56
86
|
# Get target path (first non-flag argument not consumed by an option)
|
|
57
87
|
filepath = None
|
|
58
88
|
skip_next = False
|
|
@@ -60,7 +90,7 @@ def main():
|
|
|
60
90
|
if skip_next:
|
|
61
91
|
skip_next = False
|
|
62
92
|
continue
|
|
63
|
-
if a in ('--output', '-o'):
|
|
93
|
+
if a in ('--output', '-o', '--config', '-c', '--ignore', '--select'):
|
|
64
94
|
skip_next = True
|
|
65
95
|
continue
|
|
66
96
|
if a == 'audit':
|
|
@@ -76,6 +106,21 @@ def main():
|
|
|
76
106
|
print(f"Error: Path not found: {filepath}")
|
|
77
107
|
sys.exit(1)
|
|
78
108
|
|
|
109
|
+
abs_path = os.path.abspath(filepath)
|
|
110
|
+
# Prevent accidental recursive scan of entire root filesystem
|
|
111
|
+
if abs_path in ('/', 'C:\\', 'c:\\', 'D:\\', 'd:\\'):
|
|
112
|
+
print(f"Error: Refusing to scan root filesystem '{filepath}'. Please specify a project directory or file.")
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
# Load configuration
|
|
116
|
+
cfg = load_config(filepath, explicit_config_path=cli_config)
|
|
117
|
+
if cli_ignore:
|
|
118
|
+
cfg.ignore.update(cli_ignore)
|
|
119
|
+
if cli_select is not None:
|
|
120
|
+
cfg.select = cli_select
|
|
121
|
+
show_info = '--info' in args or cfg.show_info
|
|
122
|
+
is_quiet = '--quiet' in args or cfg.quiet
|
|
123
|
+
|
|
79
124
|
# Collect target files
|
|
80
125
|
files_to_scan = []
|
|
81
126
|
if os.path.isdir(filepath):
|
|
@@ -88,8 +133,28 @@ def main():
|
|
|
88
133
|
else:
|
|
89
134
|
files_to_scan = [filepath]
|
|
90
135
|
|
|
136
|
+
if cfg.exclude:
|
|
137
|
+
filtered = []
|
|
138
|
+
for f in files_to_scan:
|
|
139
|
+
rel = os.path.relpath(f, filepath).replace('\\', '/')
|
|
140
|
+
if not any(ex in rel or rel.startswith(ex.rstrip('/')) for ex in cfg.exclude):
|
|
141
|
+
filtered.append(f)
|
|
142
|
+
files_to_scan = filtered
|
|
143
|
+
|
|
91
144
|
if not files_to_scan:
|
|
92
|
-
if
|
|
145
|
+
if is_json:
|
|
146
|
+
if output_file:
|
|
147
|
+
with open(output_file, 'w', encoding='utf-8') as f:
|
|
148
|
+
f.write("{}\n")
|
|
149
|
+
else:
|
|
150
|
+
print("{}")
|
|
151
|
+
elif is_gitlab:
|
|
152
|
+
if output_file:
|
|
153
|
+
with open(output_file, 'w', encoding='utf-8') as f:
|
|
154
|
+
f.write("[]\n")
|
|
155
|
+
else:
|
|
156
|
+
print("[]")
|
|
157
|
+
else:
|
|
93
158
|
print(f"No Python (.py) or notebook (.ipynb) files found in {filepath}")
|
|
94
159
|
sys.exit(0)
|
|
95
160
|
|
|
@@ -102,7 +167,7 @@ def main():
|
|
|
102
167
|
|
|
103
168
|
for f_path in files_to_scan:
|
|
104
169
|
try:
|
|
105
|
-
diagnostics, source_lines = analyze_file(f_path)
|
|
170
|
+
diagnostics, source_lines = analyze_file(f_path, ignore=cfg.ignore, select=cfg.select)
|
|
106
171
|
except SyntaxError as e:
|
|
107
172
|
if not is_json and (not is_gitlab or output_file):
|
|
108
173
|
print(f"Syntax error in {f_path}: {e}")
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyCheck Configuration Loader
|
|
3
|
+
Supports pyproject.toml ([tool.pycheck]) and .pycheck.toml.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Optional, Set, List
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import tomllib
|
|
13
|
+
except ImportError:
|
|
14
|
+
try:
|
|
15
|
+
import tomli as tomllib
|
|
16
|
+
except ImportError:
|
|
17
|
+
tomllib = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class PyCheckConfig:
|
|
22
|
+
ignore: Set[str] = field(default_factory=set)
|
|
23
|
+
select: Optional[Set[str]] = None
|
|
24
|
+
exclude: List[str] = field(default_factory=list)
|
|
25
|
+
show_info: bool = False
|
|
26
|
+
quiet: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_simple_toml_section(content: str, section_name: str) -> dict:
|
|
30
|
+
"""Lightweight pure-Python fallback parser for basic TOML sections."""
|
|
31
|
+
lines = content.splitlines()
|
|
32
|
+
in_section = False
|
|
33
|
+
data = {}
|
|
34
|
+
for line in lines:
|
|
35
|
+
line = line.strip()
|
|
36
|
+
if not line or line.startswith('#'):
|
|
37
|
+
continue
|
|
38
|
+
if line.startswith('[') and line.endswith(']'):
|
|
39
|
+
sec = line[1:-1].strip()
|
|
40
|
+
in_section = (sec == section_name)
|
|
41
|
+
continue
|
|
42
|
+
if in_section and '=' in line:
|
|
43
|
+
k, v = line.split('=', 1)
|
|
44
|
+
k = k.strip()
|
|
45
|
+
v = v.strip()
|
|
46
|
+
if v.lower() == 'true':
|
|
47
|
+
data[k] = True
|
|
48
|
+
elif v.lower() == 'false':
|
|
49
|
+
data[k] = False
|
|
50
|
+
elif v.startswith('[') and v.endswith(']'):
|
|
51
|
+
items = re.findall(r'["\']([^"\']+)["\']', v)
|
|
52
|
+
data[k] = items
|
|
53
|
+
elif (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
|
|
54
|
+
data[k] = v[1:-1]
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_from_file(filepath: str, is_pyproject: bool) -> Optional[PyCheckConfig]:
|
|
59
|
+
try:
|
|
60
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
61
|
+
content = f.read()
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
raw_cfg = {}
|
|
66
|
+
if tomllib is not None:
|
|
67
|
+
try:
|
|
68
|
+
parsed = tomllib.loads(content)
|
|
69
|
+
if is_pyproject:
|
|
70
|
+
raw_cfg = parsed.get("tool", {}).get("pycheck", {})
|
|
71
|
+
else:
|
|
72
|
+
raw_cfg = parsed.get("pycheck", parsed)
|
|
73
|
+
except Exception:
|
|
74
|
+
raw_cfg = {}
|
|
75
|
+
else:
|
|
76
|
+
section = "tool.pycheck" if is_pyproject else "pycheck"
|
|
77
|
+
raw_cfg = _parse_simple_toml_section(content, section)
|
|
78
|
+
|
|
79
|
+
if not raw_cfg and is_pyproject:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
ignore_list = raw_cfg.get("ignore", [])
|
|
83
|
+
select_list = raw_cfg.get("select", None)
|
|
84
|
+
exclude_list = raw_cfg.get("exclude", [])
|
|
85
|
+
|
|
86
|
+
return PyCheckConfig(
|
|
87
|
+
ignore={c.strip().upper() for c in ignore_list},
|
|
88
|
+
select={c.strip().upper() for c in select_list} if select_list is not None else None,
|
|
89
|
+
exclude=list(exclude_list),
|
|
90
|
+
show_info=bool(raw_cfg.get("info", False)),
|
|
91
|
+
quiet=bool(raw_cfg.get("quiet", False))
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_config(start_path: Optional[str] = None, explicit_config_path: Optional[str] = None) -> PyCheckConfig:
|
|
96
|
+
"""Find and load configuration from explicit path, .pycheck.toml, or pyproject.toml."""
|
|
97
|
+
if explicit_config_path:
|
|
98
|
+
if os.path.isfile(explicit_config_path):
|
|
99
|
+
is_pyproject = explicit_config_path.endswith('pyproject.toml')
|
|
100
|
+
cfg = _load_from_file(explicit_config_path, is_pyproject=is_pyproject)
|
|
101
|
+
if cfg is not None:
|
|
102
|
+
return cfg
|
|
103
|
+
return PyCheckConfig()
|
|
104
|
+
|
|
105
|
+
if start_path is None:
|
|
106
|
+
start_dir = os.getcwd()
|
|
107
|
+
elif os.path.isfile(start_path):
|
|
108
|
+
start_dir = os.path.dirname(os.path.abspath(start_path))
|
|
109
|
+
else:
|
|
110
|
+
start_dir = os.path.abspath(start_path)
|
|
111
|
+
|
|
112
|
+
search_dirs = [start_dir]
|
|
113
|
+
parent = os.path.dirname(start_dir)
|
|
114
|
+
if parent and parent != start_dir:
|
|
115
|
+
search_dirs.append(parent)
|
|
116
|
+
|
|
117
|
+
for d in search_dirs:
|
|
118
|
+
# 1. .pycheck.toml
|
|
119
|
+
pycheck_toml = os.path.join(d, ".pycheck.toml")
|
|
120
|
+
if os.path.isfile(pycheck_toml):
|
|
121
|
+
cfg = _load_from_file(pycheck_toml, is_pyproject=False)
|
|
122
|
+
if cfg is not None:
|
|
123
|
+
return cfg
|
|
124
|
+
|
|
125
|
+
# 2. pyproject.toml
|
|
126
|
+
pyproject_toml = os.path.join(d, "pyproject.toml")
|
|
127
|
+
if os.path.isfile(pyproject_toml):
|
|
128
|
+
cfg = _load_from_file(pyproject_toml, is_pyproject=True)
|
|
129
|
+
if cfg is not None:
|
|
130
|
+
return cfg
|
|
131
|
+
|
|
132
|
+
return PyCheckConfig()
|
|
@@ -157,13 +157,47 @@ class TaintTracker:
|
|
|
157
157
|
self.train_vars.add(t_name)
|
|
158
158
|
elif sl.id in self.test_vars or any(m in sl.id.lower() for m in self.test_markers):
|
|
159
159
|
self.test_vars.add(t_name)
|
|
160
|
+
elif isinstance(sl, ast.Compare):
|
|
161
|
+
# Detect semantic filtering: df[df['date'] > cutoff] vs df[df['date'] < cutoff]
|
|
162
|
+
if len(sl.ops) == 1:
|
|
163
|
+
op = sl.ops[0]
|
|
164
|
+
temporal_hints = ('date', 'time', 'timestamp', 'year', 'day', 'dt', 'ts', 'cutoff', 'split')
|
|
165
|
+
comp_str = ast.dump(sl).lower()
|
|
166
|
+
if any(h in comp_str for h in temporal_hints):
|
|
167
|
+
for target in stmt.targets:
|
|
168
|
+
t_name = self._get_var_name(target)
|
|
169
|
+
if t_name:
|
|
170
|
+
if isinstance(op, (ast.Gt, ast.GtE)):
|
|
171
|
+
self.test_vars.add(t_name)
|
|
172
|
+
self.train_vars.discard(t_name)
|
|
173
|
+
self.test_contaminated_vars[t_name] = "partition filtered with future condition (date > cutoff)"
|
|
174
|
+
elif isinstance(op, (ast.Lt, ast.LtE)):
|
|
175
|
+
self.train_vars.add(t_name)
|
|
176
|
+
self.test_vars.discard(t_name)
|
|
177
|
+
self.test_contaminated_vars.pop(t_name, None)
|
|
178
|
+
elif isinstance(stmt.value, ast.Call) and isinstance(stmt.value.func, ast.Attribute) and stmt.value.func.attr == 'query':
|
|
179
|
+
if len(stmt.value.args) >= 1 and isinstance(stmt.value.args[0], ast.Constant) and isinstance(stmt.value.args[0].value, str):
|
|
180
|
+
q_str = stmt.value.args[0].value.lower()
|
|
181
|
+
temporal_hints = ('date', 'time', 'timestamp', 'year', 'day', 'dt', 'ts', 'cutoff', 'split')
|
|
182
|
+
if any(h in q_str for h in temporal_hints):
|
|
183
|
+
for target in stmt.targets:
|
|
184
|
+
t_name = self._get_var_name(target)
|
|
185
|
+
if t_name:
|
|
186
|
+
if '>' in q_str:
|
|
187
|
+
self.test_vars.add(t_name)
|
|
188
|
+
self.train_vars.discard(t_name)
|
|
189
|
+
self.test_contaminated_vars[t_name] = "partition filtered via .query() with future condition"
|
|
190
|
+
elif '<' in q_str:
|
|
191
|
+
self.train_vars.add(t_name)
|
|
192
|
+
self.test_vars.discard(t_name)
|
|
193
|
+
self.test_contaminated_vars.pop(t_name, None)
|
|
160
194
|
|
|
161
195
|
# 3. Register naming evidence
|
|
162
196
|
for target in stmt.targets:
|
|
163
197
|
t_name = self._get_var_name(target)
|
|
164
198
|
if t_name:
|
|
165
199
|
t_lower = t_name.lower()
|
|
166
|
-
if any(m in t_lower for m in self.train_markers) and t_name not in self.test_contaminated_vars:
|
|
200
|
+
if any(m in t_lower for m in self.train_markers) and t_name not in self.test_contaminated_vars and t_name not in self.test_vars:
|
|
167
201
|
self.train_vars.add(t_name)
|
|
168
202
|
elif any(m in t_lower for m in self.test_markers):
|
|
169
203
|
self.test_vars.add(t_name)
|
|
@@ -74,10 +74,12 @@ class Rule:
|
|
|
74
74
|
|
|
75
75
|
temporal_call_methods = {
|
|
76
76
|
'rolling', 'expanding', 'shift', 'pct_change', 'diff',
|
|
77
|
-
'resample', 'merge_asof', 'to_datetime', 'date_range'
|
|
77
|
+
'resample', 'merge_asof', 'to_datetime', 'date_range',
|
|
78
|
+
'join_asof', 'backward_fill', 'rolling_mean', 'rolling_std'
|
|
78
79
|
}
|
|
79
80
|
temporal_call_names = {'timeseriessplit', 'to_datetime', 'date_range',
|
|
80
|
-
'fromisoformat', 'strptime', 'strftime'
|
|
81
|
+
'fromisoformat', 'strptime', 'strftime',
|
|
82
|
+
'join_asof', 'backward_fill'}
|
|
81
83
|
temporal_columns = {
|
|
82
84
|
'date', 'datetime', 'timestamp', 'time', 'day', 'dt',
|
|
83
85
|
'close', 'open', 'high', 'low', 'volume', 'vwap', 'ohlc',
|
|
@@ -292,6 +294,18 @@ class T001_ShiftNegative(Rule):
|
|
|
292
294
|
f"Manual lead via np.append(arr[{arg0.slice.lower.value}:], nan) accesses future values",
|
|
293
295
|
"Use backward lag features .shift(1)")]
|
|
294
296
|
|
|
297
|
+
# B18: index-offset lead via .set_index(key - k)
|
|
298
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'set_index':
|
|
299
|
+
if len(node.args) >= 1:
|
|
300
|
+
arg = node.args[0]
|
|
301
|
+
if isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Sub):
|
|
302
|
+
if isinstance(arg.right, ast.Constant) and isinstance(arg.right.value, (int, float)) and arg.right.value > 0:
|
|
303
|
+
val = arg.right.value
|
|
304
|
+
return [Diagnostic(
|
|
305
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
306
|
+
f".set_index(key - {val}) shifts lookup keys to access future records {val} steps ahead (lookahead bias)",
|
|
307
|
+
"Use .shift(1) for past lookups or avoid backward index arithmetic on lookup/merge keys")]
|
|
308
|
+
|
|
295
309
|
if not (is_shift or is_getattr_shift or is_alias_shift):
|
|
296
310
|
return []
|
|
297
311
|
results = []
|
|
@@ -308,15 +322,15 @@ class T001_ShiftNegative(Rule):
|
|
|
308
322
|
# Taint the assignment target
|
|
309
323
|
self._taint_parent(node, ctx, f"shift(-{val}) future data")
|
|
310
324
|
for kw in node.keywords:
|
|
311
|
-
if kw.arg
|
|
325
|
+
if kw.arg in ('periods', 'n'):
|
|
312
326
|
val = self._get_negative_value(kw.value, ctx)
|
|
313
327
|
if val is not None and val > 0:
|
|
314
328
|
if self._is_target_construction(node):
|
|
315
329
|
return []
|
|
316
330
|
results.append(Diagnostic(
|
|
317
331
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
318
|
-
f".shift(
|
|
319
|
-
f"Use .shift(
|
|
332
|
+
f".shift({kw.arg}=-{val}) accesses data {val} rows INTO THE FUTURE",
|
|
333
|
+
f"Use .shift({kw.arg}={val}) to access past data instead"))
|
|
320
334
|
return results
|
|
321
335
|
|
|
322
336
|
def _get_negative_value(self, arg, ctx=None):
|
|
@@ -493,20 +507,20 @@ class T005_PctChangeNegative(Rule):
|
|
|
493
507
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
494
508
|
f"pct_change({int(arg.value)}) accesses future data",
|
|
495
509
|
"Use positive periods to look backwards in time")]
|
|
496
|
-
# Also check keyword arg: pct_change(periods=-1)
|
|
510
|
+
# Also check keyword arg: pct_change(periods=-1) or Polars pct_change(n=-1)
|
|
497
511
|
for kw in node.keywords:
|
|
498
|
-
if kw.arg
|
|
512
|
+
if kw.arg in ('periods', 'n'):
|
|
499
513
|
if isinstance(kw.value, ast.UnaryOp) and isinstance(kw.value.op, ast.USub):
|
|
500
514
|
if isinstance(kw.value.operand, ast.Constant):
|
|
501
515
|
val = kw.value.operand.value
|
|
502
516
|
return [Diagnostic(
|
|
503
517
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
504
|
-
f"pct_change(
|
|
518
|
+
f"pct_change({kw.arg}=-{val}) accesses future data",
|
|
505
519
|
"Use positive periods to look backwards in time")]
|
|
506
520
|
if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, (int, float)) and kw.value.value < 0:
|
|
507
521
|
return [Diagnostic(
|
|
508
522
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
509
|
-
f"pct_change(
|
|
523
|
+
f"pct_change({kw.arg}={int(kw.value.value)}) accesses future data",
|
|
510
524
|
"Use positive periods to look backwards in time")]
|
|
511
525
|
# Resolve variable reference
|
|
512
526
|
if isinstance(kw.value, ast.Name) and ctx is not None and hasattr(ctx, 'constants'):
|
|
@@ -514,7 +528,7 @@ class T005_PctChangeNegative(Rule):
|
|
|
514
528
|
if val is not None and isinstance(val, (int, float)) and val < 0:
|
|
515
529
|
return [Diagnostic(
|
|
516
530
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
517
|
-
f"pct_change(
|
|
531
|
+
f"pct_change({kw.arg}={int(val)}) accesses future data",
|
|
518
532
|
"Use positive periods to look backwards in time")]
|
|
519
533
|
return []
|
|
520
534
|
|
|
@@ -589,7 +603,7 @@ class T007_BackfillLeak(Rule):
|
|
|
589
603
|
def check_node(self, node, ctx):
|
|
590
604
|
if not isinstance(node, ast.Call):
|
|
591
605
|
return []
|
|
592
|
-
if isinstance(node.func, ast.Attribute) and node.func.attr in ('fillna', 'bfill', 'backfill', 'ffill'):
|
|
606
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in ('fillna', 'bfill', 'backfill', 'ffill', 'backward_fill', 'fill_null'):
|
|
593
607
|
# B14: ffill on reversed frame is equivalent to bfill
|
|
594
608
|
if node.func.attr == 'ffill':
|
|
595
609
|
for child in ast.walk(node.func.value):
|
|
@@ -599,11 +613,11 @@ class T007_BackfillLeak(Rule):
|
|
|
599
613
|
".ffill() on reversed slice [::-1] is equivalent to .bfill() and fills with FUTURE data",
|
|
600
614
|
"Use forward fill on chronological data")]
|
|
601
615
|
|
|
602
|
-
if node.func.attr in ('bfill', 'backfill'):
|
|
616
|
+
if node.func.attr in ('bfill', 'backfill', 'backward_fill'):
|
|
603
617
|
return [Diagnostic(
|
|
604
618
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
605
619
|
f".{node.func.attr}() fills missing values with FUTURE data",
|
|
606
|
-
"Use .ffill() (
|
|
620
|
+
"Use forward fill (e.g. .ffill() or .forward_fill()) or .fillna(0) instead")]
|
|
607
621
|
# Check fillna(method='bfill') or fillna(method='backfill')
|
|
608
622
|
for kw in node.keywords:
|
|
609
623
|
if kw.arg == 'method' and isinstance(kw.value, ast.Constant):
|
|
@@ -612,6 +626,19 @@ class T007_BackfillLeak(Rule):
|
|
|
612
626
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
613
627
|
f".fillna(method='{kw.value.value}') fills missing values with FUTURE data",
|
|
614
628
|
"Use .fillna(method='ffill') or .fillna(0) instead")]
|
|
629
|
+
# Polars fill_null(strategy='backward')
|
|
630
|
+
if kw.arg == 'strategy' and isinstance(kw.value, ast.Constant):
|
|
631
|
+
if kw.value.value in ('backward', 'bfill'):
|
|
632
|
+
return [Diagnostic(
|
|
633
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
634
|
+
f".{node.func.attr}(strategy='{kw.value.value}') fills missing values with FUTURE data",
|
|
635
|
+
"Use strategy='forward' or fill with a constant value instead")]
|
|
636
|
+
for arg in node.args:
|
|
637
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and arg.value in ('backward', 'bfill'):
|
|
638
|
+
return [Diagnostic(
|
|
639
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
640
|
+
f".{node.func.attr}('{arg.value}') fills missing values with FUTURE data",
|
|
641
|
+
"Use strategy='forward' or fill with a constant value instead")]
|
|
615
642
|
return []
|
|
616
643
|
|
|
617
644
|
|
|
@@ -652,13 +679,13 @@ class T009_CenteredRolling(Rule):
|
|
|
652
679
|
def check_node(self, node, ctx):
|
|
653
680
|
if not isinstance(node, ast.Call):
|
|
654
681
|
return []
|
|
655
|
-
if isinstance(node.func, ast.Attribute) and node.func.attr == 'rolling':
|
|
682
|
+
if isinstance(node.func, ast.Attribute) and (node.func.attr == 'rolling' or node.func.attr.startswith('rolling_')):
|
|
656
683
|
for kw in node.keywords:
|
|
657
684
|
if kw.arg == 'center' and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
658
685
|
return [Diagnostic(
|
|
659
686
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
660
|
-
".
|
|
661
|
-
"Use .
|
|
687
|
+
f".{node.func.attr}(center=True) uses future data on both sides of the window",
|
|
688
|
+
f"Use .{node.func.attr}(center=False) for causal (backward-looking) windows")]
|
|
662
689
|
return []
|
|
663
690
|
|
|
664
691
|
|
|
@@ -758,6 +785,40 @@ class T010_FitBeforeSplit(Rule):
|
|
|
758
785
|
return self._is_train_data(node.value, ctx)
|
|
759
786
|
return False
|
|
760
787
|
|
|
788
|
+
def _is_performance_metric(self, node):
|
|
789
|
+
"""Check if this division (mean/std) is part of a portfolio performance metric
|
|
790
|
+
(e.g., Sharpe, Sortino, Information Ratio) rather than an ML feature standardizer."""
|
|
791
|
+
cur = node
|
|
792
|
+
metric_markers = {'sharpe', 'sortino', 'information_ratio', 'info_ratio', 'treynor', 'calmar', 'omega', 'pnl', 'returns'}
|
|
793
|
+
while cur is not None:
|
|
794
|
+
p = getattr(cur, 'parent', None)
|
|
795
|
+
if p is None:
|
|
796
|
+
break
|
|
797
|
+
# 1. Multiplied by annualization factor (sqrt(252), sqrt(365), 252**0.5, etc.)
|
|
798
|
+
if isinstance(p, ast.BinOp) and isinstance(p.op, ast.Mult):
|
|
799
|
+
dumped = ast.dump(p).lower()
|
|
800
|
+
if any(k in dumped for k in ('252', '365', 'sqrt', 'annualize', 'annualized', 'sharpe', 'sortino')):
|
|
801
|
+
return True
|
|
802
|
+
# 2. Assigned to a metric variable (e.g. sharpe = ..., res['sharpe'] = ...)
|
|
803
|
+
if isinstance(p, ast.Assign):
|
|
804
|
+
for tgt in p.targets:
|
|
805
|
+
if isinstance(tgt, ast.Name):
|
|
806
|
+
tgt_lower = tgt.id.lower()
|
|
807
|
+
if any(m in tgt_lower for m in metric_markers):
|
|
808
|
+
return True
|
|
809
|
+
elif isinstance(tgt, ast.Subscript) and isinstance(tgt.slice, ast.Constant):
|
|
810
|
+
if isinstance(tgt.slice.value, str):
|
|
811
|
+
slice_lower = tgt.slice.value.lower()
|
|
812
|
+
if any(m in slice_lower for m in metric_markers):
|
|
813
|
+
return True
|
|
814
|
+
# 3. Inside a function explicitly calculating metrics
|
|
815
|
+
if isinstance(p, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
816
|
+
f_lower = p.name.lower()
|
|
817
|
+
if any(m in f_lower for m in ('sharpe', 'sortino')):
|
|
818
|
+
return True
|
|
819
|
+
cur = p
|
|
820
|
+
return False
|
|
821
|
+
|
|
761
822
|
def check_node(self, node, ctx):
|
|
762
823
|
if not isinstance(node, ast.Call):
|
|
763
824
|
return []
|
|
@@ -779,6 +840,8 @@ class T010_FitBeforeSplit(Rule):
|
|
|
779
840
|
if parent is not None and isinstance(parent, ast.BinOp):
|
|
780
841
|
# Z-score / variance scaling: / std()
|
|
781
842
|
if isinstance(parent.op, ast.Div) and node.func.attr in ('std', 'var'):
|
|
843
|
+
if self._is_performance_metric(node):
|
|
844
|
+
return []
|
|
782
845
|
return [Diagnostic(
|
|
783
846
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
784
847
|
"Manual full-sample z-score (.std() / .mean()) computed on un-split dataset leaks test statistics",
|
|
@@ -1078,13 +1141,13 @@ class T014_DiffNegative(Rule):
|
|
|
1078
1141
|
f".diff(-{val}) computes difference using future data",
|
|
1079
1142
|
f"Use .diff({val}) to compute backward differences"))
|
|
1080
1143
|
for kw in node.keywords:
|
|
1081
|
-
if kw.arg
|
|
1144
|
+
if kw.arg in ('periods', 'n'):
|
|
1082
1145
|
val = self._get_negative_value(kw.value, ctx)
|
|
1083
1146
|
if val is not None and val > 0:
|
|
1084
1147
|
results.append(Diagnostic(
|
|
1085
1148
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
1086
|
-
f".diff(
|
|
1087
|
-
f"Use .diff(
|
|
1149
|
+
f".diff({kw.arg}=-{val}) computes difference using future data",
|
|
1150
|
+
f"Use .diff({kw.arg}={val}) to compute backward differences"))
|
|
1088
1151
|
return results
|
|
1089
1152
|
|
|
1090
1153
|
|
|
@@ -1122,23 +1185,26 @@ class T016_MergeAsofDirection(Rule):
|
|
|
1122
1185
|
if not isinstance(node, ast.Call):
|
|
1123
1186
|
return []
|
|
1124
1187
|
is_merge_asof = False
|
|
1125
|
-
|
|
1188
|
+
call_name = 'merge_asof'
|
|
1189
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in ('merge_asof', 'join_asof'):
|
|
1126
1190
|
is_merge_asof = True
|
|
1127
|
-
|
|
1191
|
+
call_name = node.func.attr
|
|
1192
|
+
elif isinstance(node.func, ast.Name) and node.func.id in ('merge_asof', 'join_asof'):
|
|
1128
1193
|
is_merge_asof = True
|
|
1194
|
+
call_name = node.func.id
|
|
1129
1195
|
|
|
1130
1196
|
if not is_merge_asof:
|
|
1131
1197
|
return []
|
|
1132
1198
|
|
|
1133
1199
|
for kw in node.keywords:
|
|
1134
|
-
if kw.arg
|
|
1200
|
+
if kw.arg in ('direction', 'strategy'):
|
|
1135
1201
|
if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
1136
1202
|
val = kw.value.value.lower()
|
|
1137
1203
|
if val in ('nearest', 'forward'):
|
|
1138
1204
|
return [Diagnostic(
|
|
1139
1205
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
1140
|
-
f"
|
|
1141
|
-
"Use
|
|
1206
|
+
f"{call_name}() with {kw.arg}='{kw.value.value}' matches future timestamps (lookahead bias)",
|
|
1207
|
+
f"Use {kw.arg}='backward' for point-in-time causal event joins")]
|
|
1142
1208
|
return []
|
|
1143
1209
|
|
|
1144
1210
|
|
|
@@ -1336,25 +1402,31 @@ class T019_NpRollNegative(Rule):
|
|
|
1336
1402
|
def check_node(self, node, ctx):
|
|
1337
1403
|
if not isinstance(node, ast.Call):
|
|
1338
1404
|
return []
|
|
1339
|
-
# Detect np.roll(arr, -N)
|
|
1405
|
+
# Detect np.roll(arr, -N), numpy.roll(arr, -N), torch.roll(arr, -N), jnp.roll(arr, -N)
|
|
1340
1406
|
if isinstance(node.func, ast.Attribute) and node.func.attr == 'roll':
|
|
1341
1407
|
obj = node.func.value
|
|
1342
|
-
|
|
1408
|
+
caller_name = ""
|
|
1409
|
+
if isinstance(obj, ast.Name):
|
|
1410
|
+
caller_name = obj.id
|
|
1411
|
+
elif isinstance(obj, ast.Attribute) and isinstance(obj.value, ast.Name) and obj.value.id == 'jax':
|
|
1412
|
+
caller_name = f"jax.{obj.attr}"
|
|
1413
|
+
|
|
1414
|
+
if caller_name in ('np', 'numpy', 'torch', 'jnp', 'jax', 'jax.numpy'):
|
|
1343
1415
|
if len(node.args) >= 2:
|
|
1344
1416
|
val = self._get_negative_value(node.args[1], ctx)
|
|
1345
1417
|
if val is not None and val > 0:
|
|
1346
1418
|
return [Diagnostic(
|
|
1347
1419
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
1348
|
-
f"
|
|
1420
|
+
f"{caller_name}.roll(array, -{val}) rotates data {val} positions from the FUTURE (no NaN boundary)",
|
|
1349
1421
|
f"Use pd.Series.shift({val}) which inserts NaN at boundaries, or slice manually")]
|
|
1350
|
-
# Also check keyword: shift=... (numpy
|
|
1422
|
+
# Also check keyword: shift=... (numpy) or shifts=... (torch)
|
|
1351
1423
|
for kw in node.keywords:
|
|
1352
|
-
if kw.arg
|
|
1424
|
+
if kw.arg in ('shift', 'shifts'):
|
|
1353
1425
|
val = self._get_negative_value(kw.value, ctx)
|
|
1354
1426
|
if val is not None and val > 0:
|
|
1355
1427
|
return [Diagnostic(
|
|
1356
1428
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
1357
|
-
f"
|
|
1429
|
+
f"{caller_name}.roll({kw.arg}=-{val}) rotates data {val} positions from the FUTURE (no NaN boundary)",
|
|
1358
1430
|
f"Use pd.Series.shift({val}) which inserts NaN at boundaries")]
|
|
1359
1431
|
return []
|
|
1360
1432
|
|
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycheck-neuron
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: 37-rule static AST analyzer catching temporal lookahead bias, causal confusion, and uncertainty bugs in Python ML code. Zero dependencies.
|
|
5
5
|
Author-email: Fayo Ibrahim <contact@neuron-lab.org>
|
|
6
|
-
License
|
|
6
|
+
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/neuronlabs-ai/pycheck
|
|
8
8
|
Project-URL: Documentation, https://github.com/neuronlabs-ai/pycheck#readme
|
|
9
9
|
Project-URL: Repository, https://github.com/neuronlabs-ai/pycheck
|
|
10
10
|
Project-URL: Bug Tracker, https://github.com/neuronlabs-ai/pycheck/issues
|
|
11
11
|
Keywords: machine-learning,data-leakage,temporal-bias,lookahead-bias,backtesting,quantitative-finance,algorithmic-trading,linter,static-analysis,causal-inference,time-series,pandas,sklearn,scikit-learn,uncertainty,neuron,ml-safety,data-science,feature-engineering
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.7
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
15
13
|
Classifier: Programming Language :: Python :: 3.9
|
|
16
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
17
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
18
|
Classifier: Operating System :: OS Independent
|
|
20
19
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
20
|
Classifier: Topic :: Software Development :: Testing
|
|
@@ -26,7 +25,7 @@ Classifier: Intended Audience :: Financial and Insurance Industry
|
|
|
26
25
|
Classifier: Intended Audience :: Developers
|
|
27
26
|
Classifier: Development Status :: 5 - Production/Stable
|
|
28
27
|
Classifier: Environment :: Console
|
|
29
|
-
Requires-Python: >=3.
|
|
28
|
+
Requires-Python: >=3.9
|
|
30
29
|
Description-Content-Type: text/markdown
|
|
31
30
|
License-File: LICENSE
|
|
32
31
|
Provides-Extra: test
|
|
@@ -239,6 +238,29 @@ Returns structured output for automated pipelines:
|
|
|
239
238
|
]
|
|
240
239
|
```
|
|
241
240
|
|
|
241
|
+
### Project Configuration (`pyproject.toml` or `.pycheck.toml`)
|
|
242
|
+
|
|
243
|
+
PyCheck automatically detects and respects project-level configuration in `pyproject.toml` or `.pycheck.toml`:
|
|
244
|
+
|
|
245
|
+
```toml
|
|
246
|
+
[tool.pycheck]
|
|
247
|
+
ignore = ["C001", "U002"]
|
|
248
|
+
exclude = ["tests", "legacy"]
|
|
249
|
+
info = false
|
|
250
|
+
quiet = false
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Or override dynamically via CLI flags:
|
|
254
|
+
```bash
|
|
255
|
+
pycheck . --ignore C001,U002 --select T001,T010 -c pyproject.toml
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Modern Quant Stack Support (Polars, PyTorch, JAX)
|
|
259
|
+
|
|
260
|
+
PyCheck natively guards both Pandas and modern high-performance quantitative computing pipelines:
|
|
261
|
+
- **Polars Expression Engine**: Detects lookaheads in `pl.col("close").shift(n=-1)`, `pct_change(n=-1)`, `diff(n=-1)`, `backward_fill()`, `fill_null(strategy="backward")`, `rolling_mean(center=True)`, and `join_asof(strategy="forward" | "nearest")`.
|
|
262
|
+
- **Deep Learning Time Series**: Detects future tensor index rotation via `torch.roll(tensor, shifts=-N)` and `jnp.roll(arr, -N)`.
|
|
263
|
+
|
|
242
264
|
### Exit Codes
|
|
243
265
|
|
|
244
266
|
- `0` — Verified clean / no errors found
|
|
@@ -6,6 +6,7 @@ pycheck/__main__.py
|
|
|
6
6
|
pycheck/analyzer.py
|
|
7
7
|
pycheck/audit.py
|
|
8
8
|
pycheck/cli.py
|
|
9
|
+
pycheck/config.py
|
|
9
10
|
pycheck/flow.py
|
|
10
11
|
pycheck/rules.py
|
|
11
12
|
pycheck/server.py
|
|
@@ -17,5 +18,6 @@ pycheck_neuron.egg-info/requires.txt
|
|
|
17
18
|
pycheck_neuron.egg-info/top_level.txt
|
|
18
19
|
tests/test_audit.py
|
|
19
20
|
tests/test_cli.py
|
|
21
|
+
tests/test_config.py
|
|
20
22
|
tests/test_regression.py
|
|
21
23
|
tests/test_rules.py
|
|
@@ -4,21 +4,20 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycheck-neuron"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "37-rule static AST analyzer catching temporal lookahead bias, causal confusion, and uncertainty bugs in Python ML code. Zero dependencies."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
11
11
|
{ name = "Fayo Ibrahim", email = "contact@neuron-lab.org" }
|
|
12
12
|
]
|
|
13
|
-
license = "MIT"
|
|
13
|
+
license = { text = "MIT" }
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
|
-
"Programming Language :: Python :: 3.7",
|
|
17
|
-
"Programming Language :: Python :: 3.8",
|
|
18
16
|
"Programming Language :: Python :: 3.9",
|
|
19
17
|
"Programming Language :: Python :: 3.10",
|
|
20
18
|
"Programming Language :: Python :: 3.11",
|
|
21
19
|
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
22
21
|
"Operating System :: OS Independent",
|
|
23
22
|
"Topic :: Software Development :: Quality Assurance",
|
|
24
23
|
"Topic :: Software Development :: Testing",
|
|
@@ -37,7 +36,7 @@ keywords = [
|
|
|
37
36
|
"pandas", "sklearn", "scikit-learn", "uncertainty", "neuron",
|
|
38
37
|
"ml-safety", "data-science", "feature-engineering"
|
|
39
38
|
]
|
|
40
|
-
requires-python = ">=3.
|
|
39
|
+
requires-python = ">=3.9"
|
|
41
40
|
dependencies = []
|
|
42
41
|
|
|
43
42
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for PyCheck configuration file support (.pycheck.toml and pyproject.toml)
|
|
3
|
+
and CLI --ignore, --select, --config options.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import pytest
|
|
9
|
+
from pycheck.config import load_config, PyCheckConfig
|
|
10
|
+
from pycheck.analyzer import analyze_code
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_load_config_from_pyproject_toml(tmp_path):
|
|
14
|
+
pyproject = tmp_path / "pyproject.toml"
|
|
15
|
+
pyproject.write_text("""
|
|
16
|
+
[tool.pycheck]
|
|
17
|
+
ignore = ["C001", "U002"]
|
|
18
|
+
exclude = ["legacy/", "tests/"]
|
|
19
|
+
info = true
|
|
20
|
+
quiet = false
|
|
21
|
+
""")
|
|
22
|
+
cfg = load_config(str(tmp_path))
|
|
23
|
+
assert cfg.ignore == {"C001", "U002"}
|
|
24
|
+
assert cfg.exclude == ["legacy/", "tests/"]
|
|
25
|
+
assert cfg.show_info is True
|
|
26
|
+
assert cfg.quiet is False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_load_config_from_pycheck_toml(tmp_path):
|
|
30
|
+
cfg_file = tmp_path / ".pycheck.toml"
|
|
31
|
+
cfg_file.write_text("""
|
|
32
|
+
[pycheck]
|
|
33
|
+
ignore = ["T001"]
|
|
34
|
+
select = ["T001", "T002"]
|
|
35
|
+
quiet = true
|
|
36
|
+
""")
|
|
37
|
+
cfg = load_config(str(tmp_path))
|
|
38
|
+
assert cfg.ignore == {"T001"}
|
|
39
|
+
assert cfg.select == {"T001", "T002"}
|
|
40
|
+
assert cfg.quiet is True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_analyze_code_with_ignore_and_select():
|
|
44
|
+
code = """
|
|
45
|
+
import pandas as pd
|
|
46
|
+
df['y'] = df['close'].shift(-1)
|
|
47
|
+
corr = df['close'].corr()
|
|
48
|
+
"""
|
|
49
|
+
# Without filter: both T001 and C001
|
|
50
|
+
diags, _ = analyze_code(code)
|
|
51
|
+
codes = {d['code'] for d in diags}
|
|
52
|
+
assert 'T001' in codes
|
|
53
|
+
assert 'C001' in codes
|
|
54
|
+
|
|
55
|
+
# With ignore T001
|
|
56
|
+
diags_ignored, _ = analyze_code(code, ignore={"T001"})
|
|
57
|
+
codes_ignored = {d['code'] for d in diags_ignored}
|
|
58
|
+
assert 'T001' not in codes_ignored
|
|
59
|
+
assert 'C001' in codes_ignored
|
|
60
|
+
|
|
61
|
+
# With select only T001
|
|
62
|
+
diags_selected, _ = analyze_code(code, select={"T001"})
|
|
63
|
+
codes_selected = {d['code'] for d in diags_selected}
|
|
64
|
+
assert 'T001' in codes_selected
|
|
65
|
+
assert 'C001' not in codes_selected
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_cli_ignore_flag(tmp_path):
|
|
69
|
+
f = tmp_path / "test_leak.py"
|
|
70
|
+
f.write_text("import pandas as pd\ndf['feat'] = df['close'].shift(-1)\n")
|
|
71
|
+
|
|
72
|
+
# Run without ignore -> exits 1
|
|
73
|
+
r1 = subprocess.run(["pycheck", str(f), "--json"], capture_output=True, text=True)
|
|
74
|
+
assert r1.returncode == 1
|
|
75
|
+
assert "T001" in r1.stdout
|
|
76
|
+
|
|
77
|
+
# Run with --ignore T001 -> exits 0
|
|
78
|
+
r2 = subprocess.run(["pycheck", str(f), "--ignore", "T001", "--json"], capture_output=True, text=True)
|
|
79
|
+
assert r2.returncode == 0
|
|
80
|
+
data = json.loads(r2.stdout)
|
|
81
|
+
assert len(data) == 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_cli_exclude_config(tmp_path):
|
|
85
|
+
subdir = tmp_path / "legacy"
|
|
86
|
+
subdir.mkdir()
|
|
87
|
+
f = subdir / "old_model.py"
|
|
88
|
+
f.write_text("df['feat'] = df['close'].shift(-1)\n")
|
|
89
|
+
|
|
90
|
+
pyproj = tmp_path / "pyproject.toml"
|
|
91
|
+
pyproj.write_text("""
|
|
92
|
+
[tool.pycheck]
|
|
93
|
+
exclude = ["legacy"]
|
|
94
|
+
""")
|
|
95
|
+
r = subprocess.run(["pycheck", str(tmp_path), "--config", str(pyproj), "--json"], capture_output=True, text=True)
|
|
96
|
+
assert r.returncode == 0
|
|
97
|
+
data = json.loads(r.stdout)
|
|
98
|
+
assert len(data) == 0
|
|
@@ -1134,3 +1134,109 @@ model.fit(X, df['target'])
|
|
|
1134
1134
|
code_train_perc = "df['clipped'] = df['x'].clip(upper=np.percentile(train['x'], 99))"
|
|
1135
1135
|
diags_train, _ = analyze_code(code_train_perc, "test.py")
|
|
1136
1136
|
assert not [d for d in diags_train if d['code'] == 'T010'], "np.percentile on train data must be clean"
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
class TestModernQuantStackSupport:
|
|
1140
|
+
"""Verify lookahead detection across Polars, PyTorch, and JAX time-series pipelines."""
|
|
1141
|
+
|
|
1142
|
+
def test_polars_shift_negative_n_fires_t001(self):
|
|
1143
|
+
code = """
|
|
1144
|
+
import polars as pl
|
|
1145
|
+
df = pl.read_csv("prices.csv")
|
|
1146
|
+
df = df.with_columns(next_close=pl.col("close").shift(n=-1))
|
|
1147
|
+
"""
|
|
1148
|
+
diags, _ = analyze_code(code)
|
|
1149
|
+
assert any(d['code'] == 'T001' for d in diags)
|
|
1150
|
+
|
|
1151
|
+
def test_polars_pct_change_negative_n_fires_t005(self):
|
|
1152
|
+
code = """
|
|
1153
|
+
import polars as pl
|
|
1154
|
+
df = pl.read_csv("prices.csv")
|
|
1155
|
+
df = df.with_columns(fwd_ret=pl.col("close").pct_change(n=-1))
|
|
1156
|
+
"""
|
|
1157
|
+
diags, _ = analyze_code(code)
|
|
1158
|
+
assert any(d['code'] == 'T005' for d in diags)
|
|
1159
|
+
|
|
1160
|
+
def test_polars_diff_negative_n_fires_t014(self):
|
|
1161
|
+
code = """
|
|
1162
|
+
import polars as pl
|
|
1163
|
+
df = pl.read_csv("prices.csv")
|
|
1164
|
+
df = df.with_columns(fwd_diff=pl.col("close").diff(n=-1))
|
|
1165
|
+
"""
|
|
1166
|
+
diags, _ = analyze_code(code)
|
|
1167
|
+
assert any(d['code'] == 'T014' for d in diags)
|
|
1168
|
+
|
|
1169
|
+
def test_polars_backward_fill_fires_t007(self):
|
|
1170
|
+
code = """
|
|
1171
|
+
import polars as pl
|
|
1172
|
+
df = pl.read_csv("prices.csv")
|
|
1173
|
+
df = df.with_columns(filled=pl.col("close").backward_fill())
|
|
1174
|
+
"""
|
|
1175
|
+
diags, _ = analyze_code(code)
|
|
1176
|
+
assert any(d['code'] == 'T007' for d in diags)
|
|
1177
|
+
|
|
1178
|
+
def test_polars_fill_null_backward_fires_t007(self):
|
|
1179
|
+
code = """
|
|
1180
|
+
import polars as pl
|
|
1181
|
+
df = pl.read_csv("prices.csv")
|
|
1182
|
+
df = df.with_columns(filled=pl.col("close").fill_null(strategy="backward"))
|
|
1183
|
+
"""
|
|
1184
|
+
diags, _ = analyze_code(code)
|
|
1185
|
+
assert any(d['code'] == 'T007' for d in diags)
|
|
1186
|
+
|
|
1187
|
+
def test_polars_rolling_mean_centered_fires_t009(self):
|
|
1188
|
+
code = """
|
|
1189
|
+
import polars as pl
|
|
1190
|
+
df = pl.read_csv("prices.csv")
|
|
1191
|
+
df = df.with_columns(centered=pl.col("close").rolling_mean(window_size=10, center=True))
|
|
1192
|
+
"""
|
|
1193
|
+
diags, _ = analyze_code(code)
|
|
1194
|
+
assert any(d['code'] == 'T009' for d in diags)
|
|
1195
|
+
|
|
1196
|
+
def test_polars_join_asof_forward_fires_t016(self):
|
|
1197
|
+
code = """
|
|
1198
|
+
import polars as pl
|
|
1199
|
+
trades = pl.read_csv("trades.csv")
|
|
1200
|
+
quotes = pl.read_csv("quotes.csv")
|
|
1201
|
+
merged = trades.join_asof(quotes, on="timestamp", strategy="forward")
|
|
1202
|
+
"""
|
|
1203
|
+
diags, _ = analyze_code(code)
|
|
1204
|
+
assert any(d['code'] == 'T016' for d in diags)
|
|
1205
|
+
|
|
1206
|
+
def test_torch_roll_negative_fires_t019(self):
|
|
1207
|
+
code = """
|
|
1208
|
+
import torch
|
|
1209
|
+
seq = torch.randn(100, 10)
|
|
1210
|
+
future = torch.roll(seq, -1, dims=0)
|
|
1211
|
+
"""
|
|
1212
|
+
diags, _ = analyze_code(code)
|
|
1213
|
+
assert any(d['code'] == 'T019' for d in diags)
|
|
1214
|
+
|
|
1215
|
+
def test_torch_roll_keyword_shifts_negative_fires_t019(self):
|
|
1216
|
+
code = """
|
|
1217
|
+
import torch
|
|
1218
|
+
seq = torch.randn(100, 10)
|
|
1219
|
+
future = torch.roll(seq, shifts=-1, dims=0)
|
|
1220
|
+
"""
|
|
1221
|
+
diags, _ = analyze_code(code)
|
|
1222
|
+
assert any(d['code'] == 'T019' for d in diags)
|
|
1223
|
+
|
|
1224
|
+
def test_jax_roll_negative_fires_t019(self):
|
|
1225
|
+
code = """
|
|
1226
|
+
import jax.numpy as jnp
|
|
1227
|
+
arr = jnp.arange(100)
|
|
1228
|
+
fwd = jnp.roll(arr, -1)
|
|
1229
|
+
"""
|
|
1230
|
+
diags, _ = analyze_code(code)
|
|
1231
|
+
assert any(d['code'] == 'T019' for d in diags)
|
|
1232
|
+
|
|
1233
|
+
def test_sharpe_ratio_calculation_is_exempt_from_t010(self):
|
|
1234
|
+
code = """
|
|
1235
|
+
import numpy as np
|
|
1236
|
+
daily_pnls = [10.0, -5.0, 15.0, -2.0]
|
|
1237
|
+
sharpe = (np.mean(daily_pnls) / np.std(daily_pnls)) * np.sqrt(252)
|
|
1238
|
+
"""
|
|
1239
|
+
diags, _ = analyze_code(code)
|
|
1240
|
+
assert not any(d['code'] == 'T010' for d in diags), "Sharpe ratio formula must not be flagged as T010"
|
|
1241
|
+
|
|
1242
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|