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.
Files changed (25) hide show
  1. {pycheck_neuron-1.1.2/pycheck_neuron.egg-info → pycheck_neuron-1.2.0}/PKG-INFO +27 -5
  2. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/README.md +23 -0
  3. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/__init__.py +1 -1
  4. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/analyzer.py +12 -4
  5. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/cli.py +70 -5
  6. pycheck_neuron-1.2.0/pycheck/config.py +132 -0
  7. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/flow.py +35 -1
  8. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/rules.py +102 -30
  9. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0/pycheck_neuron.egg-info}/PKG-INFO +27 -5
  10. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck_neuron.egg-info/SOURCES.txt +2 -0
  11. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pyproject.toml +4 -5
  12. pycheck_neuron-1.2.0/tests/test_config.py +98 -0
  13. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/tests/test_rules.py +106 -0
  14. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/LICENSE +0 -0
  15. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/__main__.py +0 -0
  16. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/audit.py +0 -0
  17. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck/server.py +0 -0
  18. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck_neuron.egg-info/dependency_links.txt +0 -0
  19. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck_neuron.egg-info/entry_points.txt +0 -0
  20. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck_neuron.egg-info/requires.txt +0 -0
  21. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/pycheck_neuron.egg-info/top_level.txt +0 -0
  22. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/setup.cfg +0 -0
  23. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/tests/test_audit.py +0 -0
  24. {pycheck_neuron-1.1.2 → pycheck_neuron-1.2.0}/tests/test_cli.py +0 -0
  25. {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.1.2
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-Expression: MIT
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.7
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.1.2"
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>") -> Tuple[List[dict], List[str]]:
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) -> Tuple[List[dict], List[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 not is_json and not is_gitlab:
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 == 'periods':
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(periods=-{val}) accesses data {val} rows INTO THE FUTURE",
319
- f"Use .shift(periods={val}) to access past data instead"))
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 == 'periods':
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(periods=-{val}) accesses future data",
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(periods={int(kw.value.value)}) accesses future data",
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(periods={int(val)}) accesses future data",
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() (forward fill) or .fillna(0) instead")]
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
- ".rolling(center=True) uses future data on both sides of the window",
661
- "Use .rolling(center=False) for causal (backward-looking) windows")]
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 == 'periods':
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(periods=-{val}) computes difference using future data",
1087
- f"Use .diff(periods={val}) to compute backward differences"))
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
- if isinstance(node.func, ast.Attribute) and node.func.attr == 'merge_asof':
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
- elif isinstance(node.func, ast.Name) and node.func.id == 'merge_asof':
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 == 'direction':
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"pd.merge_asof() with direction='{kw.value.value}' matches future timestamps (lookahead bias)",
1141
- "Use direction='backward' for point-in-time causal event joins")]
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) or numpy.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
- if isinstance(obj, ast.Name) and obj.id in ('np', 'numpy'):
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"np.roll(array, -{val}) rotates data {val} positions from the FUTURE (no NaN boundary)",
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 uses 'shift' param)
1422
+ # Also check keyword: shift=... (numpy) or shifts=... (torch)
1351
1423
  for kw in node.keywords:
1352
- if kw.arg == 'shift':
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"np.roll(shift=-{val}) rotates data {val} positions from the FUTURE (no NaN boundary)",
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.1.2
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-Expression: MIT
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.7
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.1.2"
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.7"
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