scitex-linter 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
scitex_linter/cli.py ADDED
@@ -0,0 +1,449 @@
1
+ """CLI entry point for scitex-linter.
2
+
3
+ Usage:
4
+ scitex-linter lint <path> [--json] [--severity] [--category] [--no-color]
5
+ scitex-linter python <script.py> [--strict] [-- script_args...]
6
+ scitex-linter list-rules [--json] [--category] [--severity]
7
+ scitex-linter mcp start
8
+ scitex-linter mcp list-tools
9
+ scitex-linter --help-recursive
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from . import __version__
18
+ from .checker import lint_file
19
+ from .formatter import format_issue, format_summary, to_json
20
+ from .rules import ALL_RULES, SEVERITY_ORDER
21
+
22
+ # =========================================================================
23
+ # File collection helper
24
+ # =========================================================================
25
+
26
+
27
+ def _collect_files(path: Path, recursive: bool = True) -> list:
28
+ """Collect Python files from a path."""
29
+ if path.is_file():
30
+ return [path]
31
+ if path.is_dir():
32
+ pattern = "**/*.py" if recursive else "*.py"
33
+ skip = {"__pycache__", ".git", "node_modules", ".tox", "venv", ".venv"}
34
+ return sorted(
35
+ p for p in path.glob(pattern) if not any(s in p.parts for s in skip)
36
+ )
37
+ return []
38
+
39
+
40
+ # =========================================================================
41
+ # Subcommand: lint
42
+ # =========================================================================
43
+
44
+
45
+ def _register_lint(subparsers) -> None:
46
+ p = subparsers.add_parser(
47
+ "lint",
48
+ help="Lint Python files for SciTeX pattern compliance",
49
+ description="Lint Python files for SciTeX pattern compliance.",
50
+ )
51
+ p.add_argument("path", help="Python file or directory to lint")
52
+ p.add_argument("--json", action="store_true", dest="as_json", help="Output as JSON")
53
+ p.add_argument("--no-color", action="store_true", help="Disable colored output")
54
+ p.add_argument(
55
+ "--severity",
56
+ choices=["error", "warning", "info"],
57
+ default="info",
58
+ help="Minimum severity to report (default: info)",
59
+ )
60
+ p.add_argument(
61
+ "--category",
62
+ help="Filter by category (comma-separated: structure,import,io,plot,stats)",
63
+ )
64
+ p.set_defaults(func=_cmd_lint)
65
+
66
+
67
+ def _cmd_lint(args) -> int:
68
+ use_color = not args.no_color and sys.stdout.isatty()
69
+ min_sev = SEVERITY_ORDER[args.severity]
70
+ categories = set(args.category.split(",")) if args.category else None
71
+
72
+ target = Path(args.path)
73
+ if not target.exists():
74
+ print(f"Error: {args.path} not found", file=sys.stderr)
75
+ return 2
76
+
77
+ files = _collect_files(target)
78
+ if not files:
79
+ print(f"No Python files found in {args.path}", file=sys.stderr)
80
+ return 0
81
+
82
+ all_results = {}
83
+ for f in files:
84
+ issues = lint_file(str(f))
85
+ issues = [
86
+ i
87
+ for i in issues
88
+ if SEVERITY_ORDER[i.rule.severity] >= min_sev
89
+ and (categories is None or i.rule.category in categories)
90
+ ]
91
+ if issues:
92
+ all_results[str(f)] = issues
93
+
94
+ # JSON output
95
+ if args.as_json:
96
+ combined = {fp: to_json(issues, fp) for fp, issues in all_results.items()}
97
+ print(json.dumps(combined, indent=2))
98
+ has_errors = any(
99
+ any(i.rule.severity == "error" for i in issues)
100
+ for issues in all_results.values()
101
+ )
102
+ return 2 if has_errors else (1 if all_results else 0)
103
+
104
+ # Terminal output
105
+ if not all_results:
106
+ msg = "All files clean"
107
+ if use_color:
108
+ print(f"\033[92m{msg}\033[0m")
109
+ else:
110
+ print(msg)
111
+ return 0
112
+
113
+ has_errors = False
114
+ for filepath, issues in all_results.items():
115
+ for issue in issues:
116
+ print(format_issue(issue, filepath, color=use_color))
117
+ if issue.rule.severity == "error":
118
+ has_errors = True
119
+ print(format_summary(issues, filepath, color=use_color))
120
+ print()
121
+
122
+ return 2 if has_errors else 1
123
+
124
+
125
+ # =========================================================================
126
+ # Subcommand: python (lint then execute)
127
+ # =========================================================================
128
+
129
+
130
+ def _register_python(subparsers) -> None:
131
+ p = subparsers.add_parser(
132
+ "python",
133
+ help="Lint then execute a Python script",
134
+ description=(
135
+ "Lint a Python script, then execute it.\n"
136
+ "Use -- to separate script arguments: scitex-linter python script.py -- --arg1"
137
+ ),
138
+ )
139
+ p.add_argument("script", help="Python script to run")
140
+ p.add_argument("--strict", action="store_true", help="Abort on lint errors")
141
+ p.set_defaults(func=_cmd_python)
142
+
143
+
144
+ def _cmd_python(args) -> int:
145
+ from .runner import run_script
146
+
147
+ # Extract script args: everything after -- in sys.argv (or test argv)
148
+ # argparse already consumed known flags; remaining unknown args go to script
149
+ script_args = getattr(args, "_script_args", [])
150
+ return run_script(args.script, strict=args.strict, script_args=script_args)
151
+
152
+
153
+ # =========================================================================
154
+ # Subcommand: list-rules
155
+ # =========================================================================
156
+
157
+
158
+ def _register_list_rules(subparsers) -> None:
159
+ p = subparsers.add_parser(
160
+ "list-rules",
161
+ help="List all lint rules",
162
+ description="List all available SciTeX lint rules.",
163
+ )
164
+ p.add_argument("--json", action="store_true", dest="as_json", help="Output as JSON")
165
+ p.add_argument(
166
+ "--category",
167
+ help="Filter by category (comma-separated: structure,import,io,plot,stats)",
168
+ )
169
+ p.add_argument(
170
+ "--severity",
171
+ choices=["error", "warning", "info"],
172
+ help="Filter by severity",
173
+ )
174
+ p.set_defaults(func=_cmd_list_rules)
175
+
176
+
177
+ def _cmd_list_rules(args) -> int:
178
+ categories = set(args.category.split(",")) if args.category else None
179
+ rules_list = list(ALL_RULES.values())
180
+
181
+ if categories:
182
+ rules_list = [r for r in rules_list if r.category in categories]
183
+ if args.severity:
184
+ rules_list = [r for r in rules_list if r.severity == args.severity]
185
+
186
+ if args.as_json:
187
+ data = [
188
+ {
189
+ "id": r.id,
190
+ "severity": r.severity,
191
+ "category": r.category,
192
+ "message": r.message,
193
+ "suggestion": r.suggestion,
194
+ }
195
+ for r in rules_list
196
+ ]
197
+ print(json.dumps(data, indent=2))
198
+ return 0
199
+
200
+ use_color = sys.stdout.isatty()
201
+ sev_color = {"error": "\033[91m", "warning": "\033[93m", "info": "\033[94m"}
202
+ reset = "\033[0m"
203
+
204
+ for r in rules_list:
205
+ if use_color:
206
+ c = sev_color.get(r.severity, "")
207
+ print(f" {c}{r.id}{reset} [{r.severity}] {r.message}")
208
+ else:
209
+ print(f" {r.id} [{r.severity}] {r.message}")
210
+
211
+ print(f"\n {len(rules_list)} rules")
212
+ return 0
213
+
214
+
215
+ # =========================================================================
216
+ # Subcommand: mcp
217
+ # =========================================================================
218
+
219
+
220
+ def _register_mcp(subparsers) -> None:
221
+ p = subparsers.add_parser(
222
+ "mcp",
223
+ help="MCP server commands",
224
+ description="Manage the scitex-linter MCP server.",
225
+ )
226
+ mcp_sub = p.add_subparsers(dest="mcp_command")
227
+
228
+ start_p = mcp_sub.add_parser("start", help="Start the MCP server (stdio)")
229
+ start_p.add_argument(
230
+ "--transport",
231
+ choices=["stdio", "sse"],
232
+ default="stdio",
233
+ help="Transport mode (default: stdio)",
234
+ )
235
+ start_p.set_defaults(func=_cmd_mcp_start)
236
+
237
+ list_p = mcp_sub.add_parser("list-tools", help="List available MCP tools")
238
+ list_p.set_defaults(func=_cmd_mcp_list_tools)
239
+
240
+ doctor_p = mcp_sub.add_parser("doctor", help="Check MCP server health")
241
+ doctor_p.set_defaults(func=_cmd_mcp_doctor)
242
+
243
+ install_p = mcp_sub.add_parser(
244
+ "installation", help="Show Claude Desktop configuration"
245
+ )
246
+ install_p.set_defaults(func=_cmd_mcp_installation)
247
+
248
+ p.set_defaults(func=lambda args: _cmd_mcp_help(p, args))
249
+
250
+
251
+ def _cmd_mcp_help(parser, args) -> int:
252
+ if not hasattr(args, "mcp_command") or args.mcp_command is None:
253
+ parser.print_help()
254
+ return 0
255
+ return 0
256
+
257
+
258
+ def _cmd_mcp_start(args) -> int:
259
+ try:
260
+ from ._server import run_server
261
+
262
+ run_server(transport=args.transport)
263
+ return 0
264
+ except ImportError:
265
+ print(
266
+ "fastmcp is required for MCP server. "
267
+ "Install with: pip install scitex-linter[mcp]",
268
+ file=sys.stderr,
269
+ )
270
+ return 1
271
+
272
+
273
+ def _cmd_mcp_list_tools(args) -> int:
274
+ tools = [
275
+ ("linter_lint", "Lint a Python file for SciTeX pattern compliance"),
276
+ ("linter_list_rules", "List all available lint rules"),
277
+ ("linter_check_source", "Lint Python source code string"),
278
+ ]
279
+ for name, desc in tools:
280
+ print(f" {name:30s} {desc}")
281
+ print(f"\n {len(tools)} tools")
282
+ return 0
283
+
284
+
285
+ def _cmd_mcp_doctor(args) -> int:
286
+ import shutil
287
+
288
+ print(f"scitex-linter {__version__}\n")
289
+ print("Health Check")
290
+ print("=" * 40)
291
+
292
+ checks = []
293
+
294
+ try:
295
+ import fastmcp
296
+
297
+ checks.append(("fastmcp", True, fastmcp.__version__))
298
+ except ImportError:
299
+ checks.append(("fastmcp", False, "not installed"))
300
+
301
+ try:
302
+ from ._mcp.tools import register_all_tools # noqa: F401
303
+
304
+ checks.append(("MCP tools", True, "3 tools"))
305
+ except Exception as e:
306
+ checks.append(("MCP tools", False, str(e)))
307
+
308
+ cli_path = shutil.which("scitex-linter")
309
+ if cli_path:
310
+ checks.append(("CLI", True, cli_path))
311
+ else:
312
+ checks.append(("CLI", False, "not in PATH"))
313
+
314
+ rule_count = len(ALL_RULES)
315
+ checks.append(("Rules", True, f"{rule_count} rules"))
316
+
317
+ all_ok = True
318
+ for name, ok, info in checks:
319
+ status = "\u2713" if ok else "\u2717"
320
+ if not ok:
321
+ all_ok = False
322
+ print(f" {status} {name}: {info}")
323
+
324
+ print()
325
+ if all_ok:
326
+ print("All checks passed!")
327
+ else:
328
+ print("Some checks failed. Run 'pip install scitex-linter[mcp]' to fix.")
329
+
330
+ return 0 if all_ok else 1
331
+
332
+
333
+ def _cmd_mcp_installation(args) -> int:
334
+ import shutil
335
+
336
+ print(f"scitex-linter {__version__}\n")
337
+ print("Add this to your Claude Desktop config file:\n")
338
+ print(" macOS: ~/Library/Application Support/Claude/claude_desktop_config.json")
339
+ print(" Linux: ~/.config/Claude/claude_desktop_config.json\n")
340
+
341
+ cli_path = shutil.which("scitex-linter")
342
+ if cli_path:
343
+ print(f"Your installation path: {cli_path}\n")
344
+
345
+ config = (
346
+ "{\n"
347
+ ' "mcpServers": {\n'
348
+ ' "scitex-linter": {\n'
349
+ f' "command": "{cli_path or "scitex-linter"}",\n'
350
+ ' "args": ["mcp", "start"]\n'
351
+ " }\n"
352
+ " }\n"
353
+ "}"
354
+ )
355
+ print(config)
356
+ return 0
357
+
358
+
359
+ # =========================================================================
360
+ # --help-recursive
361
+ # =========================================================================
362
+
363
+
364
+ def _print_help_recursive(parser, subparsers_actions) -> None:
365
+ """Print help for all commands recursively."""
366
+ cyan = "\033[96m" if sys.stdout.isatty() else ""
367
+ bold = "\033[1m" if sys.stdout.isatty() else ""
368
+ reset = "\033[0m" if sys.stdout.isatty() else ""
369
+
370
+ bar = "\u2501" * 3
371
+ print(f"\n{cyan}{bar} scitex-linter {bar}{reset}\n")
372
+ parser.print_help()
373
+
374
+ for action in subparsers_actions:
375
+ for choice, subparser in action.choices.items():
376
+ print(f"\n{cyan}{bar} scitex-linter {choice} {bar}{reset}\n")
377
+ subparser.print_help()
378
+
379
+ # Nested subparsers (e.g., mcp -> start, list-tools)
380
+ if subparser._subparsers is not None:
381
+ for sub_action in subparser._subparsers._group_actions:
382
+ if not hasattr(sub_action, "choices") or not sub_action.choices:
383
+ continue
384
+ for sub_choice, sub_subparser in sub_action.choices.items():
385
+ print(
386
+ f"\n{cyan}{bar} scitex-linter {choice} {sub_choice} {bar}{reset}\n"
387
+ )
388
+ sub_subparser.print_help()
389
+
390
+
391
+ # =========================================================================
392
+ # Main entry point
393
+ # =========================================================================
394
+
395
+
396
+ def main(argv: list = None) -> int:
397
+ parser = argparse.ArgumentParser(
398
+ prog="scitex-linter",
399
+ description="SciTeX Linter \u2014 enforce reproducible research patterns",
400
+ )
401
+ parser.add_argument(
402
+ "-V", "--version", action="version", version=f"%(prog)s {__version__}"
403
+ )
404
+ parser.add_argument(
405
+ "--help-recursive",
406
+ action="store_true",
407
+ help="Show help for all commands",
408
+ )
409
+
410
+ subparsers = parser.add_subparsers(dest="command")
411
+
412
+ _register_lint(subparsers)
413
+ _register_python(subparsers)
414
+ _register_list_rules(subparsers)
415
+ _register_mcp(subparsers)
416
+
417
+ # Split on -- to capture script args for the 'python' subcommand
418
+ raw = argv if argv is not None else sys.argv[1:]
419
+ script_args = []
420
+ if "--" in raw:
421
+ idx = raw.index("--")
422
+ script_args = raw[idx + 1 :]
423
+ raw = raw[:idx]
424
+
425
+ args = parser.parse_args(raw)
426
+
427
+ # Attach script_args for the run subcommand
428
+ args._script_args = script_args
429
+
430
+ if args.help_recursive:
431
+ subparsers_actions = [
432
+ a for a in parser._subparsers._group_actions if hasattr(a, "choices")
433
+ ]
434
+ _print_help_recursive(parser, subparsers_actions)
435
+ return 0
436
+
437
+ if args.command is None:
438
+ parser.print_help()
439
+ return 0
440
+
441
+ if hasattr(args, "func"):
442
+ return args.func(args)
443
+
444
+ parser.print_help()
445
+ return 0
446
+
447
+
448
+ if __name__ == "__main__":
449
+ sys.exit(main())
@@ -0,0 +1,34 @@
1
+ """Flake8 plugin for SciTeX linter.
2
+
3
+ Usage:
4
+ pip install scitex-linter
5
+ flake8 --select STX script.py
6
+ """
7
+
8
+ import ast
9
+
10
+ from .checker import SciTeXChecker
11
+
12
+
13
+ class SciTeXFlake8Checker:
14
+ """Flake8 checker wrapping the SciTeX AST visitor."""
15
+
16
+ name = "scitex-linter"
17
+ version = "0.1.0"
18
+
19
+ def __init__(self, tree: ast.AST, filename: str = "<stdin>", lines: list = None):
20
+ self._tree = tree
21
+ self._filename = filename
22
+ self._lines = lines or []
23
+
24
+ def run(self):
25
+ """Yield (line, col, message, type) tuples for flake8."""
26
+ source_lines = [line.rstrip("\n") for line in self._lines]
27
+ checker = SciTeXChecker(source_lines, filepath=self._filename)
28
+ checker.visit(self._tree)
29
+
30
+ for issue in checker.get_issues():
31
+ # flake8 format: (line, col, "CODE message", type)
32
+ code = issue.rule.id.replace("-", "") # STX-S001 -> STXS001
33
+ msg = f"{code} {issue.rule.message}"
34
+ yield (issue.line, issue.col, msg, type(self))
@@ -0,0 +1,95 @@
1
+ """Output formatting for terminal and JSON."""
2
+
3
+
4
+ from .checker import Issue
5
+
6
+ # ANSI colors
7
+ _RED = "\033[91m"
8
+ _YELLOW = "\033[93m"
9
+ _BLUE = "\033[94m"
10
+ _GREEN = "\033[92m"
11
+ _GRAY = "\033[90m"
12
+ _BOLD = "\033[1m"
13
+ _RESET = "\033[0m"
14
+
15
+ _SEV_COLOR = {"error": _RED, "warning": _YELLOW, "info": _BLUE}
16
+ _SEV_ICON = {"error": "E", "warning": "W", "info": "I"}
17
+
18
+
19
+ def format_issue(issue: Issue, filepath: str, color: bool = True) -> str:
20
+ if not color:
21
+ return _format_plain(issue, filepath)
22
+
23
+ sev = issue.rule.severity
24
+ c = _SEV_COLOR.get(sev, "")
25
+ icon = _SEV_ICON.get(sev, "?")
26
+ lines = [
27
+ f" {c}{icon}{_RESET} {_BOLD}{filepath}:{issue.line}:{issue.col}{_RESET}"
28
+ f" {c}{issue.rule.id}{_RESET}",
29
+ ]
30
+ if issue.source_line:
31
+ lines.append(f" {_GRAY}{issue.source_line}{_RESET}")
32
+ lines.append(f" {c}{issue.rule.message}{_RESET}")
33
+ lines.append(f" {_GREEN}{issue.rule.suggestion}{_RESET}")
34
+ return "\n".join(lines)
35
+
36
+
37
+ def _format_plain(issue: Issue, filepath: str) -> str:
38
+ icon = _SEV_ICON.get(issue.rule.severity, "?")
39
+ lines = [
40
+ f" {icon} {filepath}:{issue.line}:{issue.col} {issue.rule.id}",
41
+ ]
42
+ if issue.source_line:
43
+ lines.append(f" {issue.source_line}")
44
+ lines.append(f" {issue.rule.message}")
45
+ lines.append(f" {issue.rule.suggestion}")
46
+ return "\n".join(lines)
47
+
48
+
49
+ def format_summary(issues: list, filepath: str, color: bool = True) -> str:
50
+ if not issues:
51
+ if color:
52
+ return f"{_GREEN}OK{_RESET} {filepath}"
53
+ return f"OK {filepath}"
54
+
55
+ errors = sum(1 for i in issues if i.rule.severity == "error")
56
+ warnings = sum(1 for i in issues if i.rule.severity == "warning")
57
+ infos = sum(1 for i in issues if i.rule.severity == "info")
58
+
59
+ parts = []
60
+ if errors:
61
+ label = f"{errors} error{'s' if errors != 1 else ''}"
62
+ parts.append(f"{_RED}{label}{_RESET}" if color else label)
63
+ if warnings:
64
+ label = f"{warnings} warning{'s' if warnings != 1 else ''}"
65
+ parts.append(f"{_YELLOW}{label}{_RESET}" if color else label)
66
+ if infos:
67
+ label = f"{infos} info"
68
+ parts.append(f"{_BLUE}{label}{_RESET}" if color else label)
69
+
70
+ fp = f"{_BOLD}{filepath}{_RESET}" if color else filepath
71
+ return f" {', '.join(parts)} in {fp}"
72
+
73
+
74
+ def to_json(issues: list, filepath: str) -> dict:
75
+ return {
76
+ "file": filepath,
77
+ "issues": [
78
+ {
79
+ "rule_id": i.rule.id,
80
+ "severity": i.rule.severity,
81
+ "category": i.rule.category,
82
+ "line": i.line,
83
+ "col": i.col,
84
+ "message": i.rule.message,
85
+ "suggestion": i.rule.suggestion,
86
+ "source_line": i.source_line,
87
+ }
88
+ for i in issues
89
+ ],
90
+ "summary": {
91
+ "errors": sum(1 for i in issues if i.rule.severity == "error"),
92
+ "warnings": sum(1 for i in issues if i.rule.severity == "warning"),
93
+ "infos": sum(1 for i in issues if i.rule.severity == "info"),
94
+ },
95
+ }