Skip to content

Commit 3fe9390

Browse files
Lukas Geigerclaude
andcommitted
feat: add headless --lint CLI mode
Adds `--lint <file>` flag that runs flake8/pylint/AST linting without starting the GUI. Output follows standard `file:line:col: code message` format. Exit codes: 0 clean, 1 findings, 2 error. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3f4d54c commit 3fe9390

4 files changed

Lines changed: 168 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
1010
- EXE neu gebaut 2026-06-01 (PyInstaller `--onefile`, `PythonBox.exe`); 14/14 Tests grün, Smoke-Test bestanden. Vorherige EXE: 2026-04-29.
1111

1212
### Hinzugefügt / Added
13+
- CLI-Lint-Modus: `python PythonBox_v8.py --lint <datei>` führt headless Linting durch (flake8 → pylint → AST-Fallback) und gibt Ergebnisse auf stdout aus. Exit-Codes: 0 = sauber, 1 = Findings, 2 = Fehler. Kein GUI-Start. Nützlich für CI, Automationen und LLM-Agenten.
14+
- `tests/test_cli_lint.py` mit 5 Tests für den CLI-Lint-Modus.
15+
- CLI-Parsing mit `argparse` (`parse_cli_args()`), rückwärtskompatibel zu `--open` und nackten Dateipfaden.
1316
- `llms.txt` mit kanonischem Repo-Kontext, Zielgruppe, Suchphrasen und Abgrenzung zu Devbox/Python-Box/Pybricks.
1417
- README-Starttabelle und GitHub-Actions-Badge für schnellere Nutzerführung.
1518
- App- und Fenstericon über `PythonBox.ico`.

PythonBox_v8.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import shutil
3939
import json
4040
import ast
41+
import argparse
4142
import subprocess
4243
import re
4344
import threading
@@ -195,6 +196,69 @@ def parse_startup_file_argument(argv: Optional[List[str]] = None) -> Optional[st
195196

196197
return None
197198

199+
200+
def parse_cli_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
201+
"""Parse CLI arguments for GUI and headless modes."""
202+
parser = argparse.ArgumentParser(
203+
prog="PythonBox",
204+
description="Python Code Architect — Editor und Linter",
205+
add_help=False,
206+
)
207+
parser.add_argument("--open", metavar="DATEI", help="Datei im Editor öffnen")
208+
parser.add_argument("--lint", metavar="DATEI", help="Datei headless linten (kein GUI)")
209+
parser.add_argument("file", nargs="?", default=None, help="Datei im Editor öffnen")
210+
parser.add_argument("-h", "--help", action="help", default=argparse.SUPPRESS,
211+
help="Diese Hilfe anzeigen")
212+
213+
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
214+
if args.open is None and args.file:
215+
args.open = args.file
216+
return args
217+
218+
219+
def run_lint_cli(file_path: str) -> int:
220+
"""Run linter on a file in headless mode and print results to stdout."""
221+
path = Path(file_path)
222+
if not path.is_file():
223+
print(f"Fehler: Datei nicht gefunden: {file_path}", file=sys.stderr)
224+
return 2
225+
226+
try:
227+
code = path.read_text(encoding="utf-8")
228+
except UnicodeDecodeError:
229+
try:
230+
code = path.read_text(encoding="latin-1")
231+
except Exception as exc:
232+
print(f"Fehler: Datei nicht lesbar: {exc}", file=sys.stderr)
233+
return 2
234+
235+
runner = LinterRunner()
236+
results = runner.run_linter(code, str(path))
237+
238+
results.sort(key=lambda r: (r.get("line", 0), r.get("column", 0)))
239+
240+
errors = 0
241+
warnings = 0
242+
for r in results:
243+
severity = r.get("severity", "warning")
244+
if severity == "error":
245+
errors += 1
246+
else:
247+
warnings += 1
248+
line = r.get("line", 0)
249+
col = r.get("column", 0)
250+
code_id = r.get("code", "?")
251+
msg = r.get("message", "").strip()
252+
print(f"{path}:{line}:{col}: {code_id} {msg}")
253+
254+
if results:
255+
print(f"\n{errors} Fehler, {warnings} Warnungen")
256+
return 1
257+
else:
258+
print(f"{path}: Keine Findings")
259+
return 0
260+
261+
198262
# ============================================================================
199263
# MODERN UI THEME
200264
# ============================================================================
@@ -4068,20 +4132,25 @@ def run_external_tool(self, path):
40684132
# ============================================================================
40694133

40704134
def main(argv: Optional[List[str]] = None):
4135+
args = parse_cli_args(argv)
4136+
4137+
if args.lint:
4138+
sys.exit(run_lint_cli(args.lint))
4139+
40714140
cli_args = list(sys.argv[1:] if argv is None else argv)
4072-
startup_file = parse_startup_file_argument(cli_args)
4141+
startup_file = args.open
40734142
app = QApplication([sys.argv[0], *cli_args])
40744143
icon_path = Path(__file__).with_name("PythonBox.ico")
40754144
icon = QIcon(str(icon_path)) if icon_path.exists() else QIcon()
40764145
if not icon.isNull():
40774146
app.setWindowIcon(icon)
40784147
set_dark_theme(app)
4079-
4148+
40804149
window = PythonArchitect(startup_file=startup_file)
40814150
if not icon.isNull():
40824151
window.setWindowIcon(icon)
40834152
window.show()
4084-
4153+
40854154
sys.exit(app.exec())
40864155

40874156

llms.txt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,16 @@ PythonBox is a lightweight local-first Python IDE for Windows. It combines a PyS
2121
- `PythonBox_v8.py`: main PySide6 application.
2222
- `README.md`: user-facing overview, installation, features, privacy boundary, and discovery keywords.
2323
- `PORTIERUNGSPLAN.md`: platform strategy for Windows-first desktop use and source-smoke portability.
24-
- `tests/`: regression tests for Qt6 compatibility, execution routing, Git diff/status handling, minimap settings, and an offscreen window smoke test.
24+
- `tests/`: regression tests for Qt6 compatibility, execution routing, Git diff/status handling, minimap settings, CLI lint mode, and an offscreen window smoke test.
2525
- `.github/workflows/tests.yml`: Windows regression test workflow for Python 3.10, 3.11, and 3.12.
2626

27+
## CLI usage
28+
29+
- `python PythonBox_v8.py` — start GUI
30+
- `python PythonBox_v8.py --open <file>` — start GUI with file open
31+
- `python PythonBox_v8.py <file>` — start GUI with file open (shorthand)
32+
- `python PythonBox_v8.py --lint <file>` — headless lint (no GUI), exit 0/1/2
33+
2734
## Search phrases
2835

2936
python ide; lightweight python editor; pyside6 code editor; windows python ide; local-first developer tool; pdb debugger gui; python linting; code folding; git diff editor; offline python editor; VS Code handoff; PyCharm handoff.
@@ -36,4 +43,4 @@ PythonBox is not Jetify Devbox, Microsoft Dev Box, Box Python SDK, Pybricks, or
3643

3744
PythonBox does not include telemetry, cloud sync, bundled credentials, or built-in external service API calls. It opens, saves, and executes files only through user-triggered local workflows.
3845

39-
## Last-checked: 2026-06-12
46+
## Last-checked: 2026-06-19

tests/test_cli_lint.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import os
2+
import subprocess
3+
import sys
4+
import tempfile
5+
import unittest
6+
from pathlib import Path
7+
8+
ROOT = Path(__file__).resolve().parents[1]
9+
SCRIPT = ROOT / "PythonBox_v8.py"
10+
11+
12+
def run_lint(target: str, timeout: int = 30) -> subprocess.CompletedProcess:
13+
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
14+
return subprocess.run(
15+
[sys.executable, str(SCRIPT), "--lint", target],
16+
capture_output=True, text=True, timeout=timeout, env=env,
17+
)
18+
19+
20+
class TestCLILint(unittest.TestCase):
21+
22+
def test_lint_clean_file(self):
23+
with tempfile.NamedTemporaryFile(suffix=".py", mode="w",
24+
encoding="utf-8", delete=False) as f:
25+
f.write("x = 1\n")
26+
f.flush()
27+
path = f.name
28+
try:
29+
result = run_lint(path)
30+
self.assertIn("Keine Findings", result.stdout)
31+
self.assertEqual(result.returncode, 0)
32+
finally:
33+
os.unlink(path)
34+
35+
def test_lint_syntax_error(self):
36+
with tempfile.NamedTemporaryFile(suffix=".py", mode="w",
37+
encoding="utf-8", delete=False) as f:
38+
f.write("def foo(\n")
39+
f.flush()
40+
path = f.name
41+
try:
42+
result = run_lint(path)
43+
self.assertEqual(result.returncode, 1)
44+
self.assertTrue(len(result.stdout.strip()) > 0)
45+
finally:
46+
os.unlink(path)
47+
48+
def test_lint_nonexistent_file(self):
49+
result = run_lint("nonexistent_file_xyz_12345.py")
50+
self.assertEqual(result.returncode, 2)
51+
self.assertIn("nicht gefunden", result.stderr)
52+
53+
def test_lint_output_format(self):
54+
with tempfile.NamedTemporaryFile(suffix=".py", mode="w",
55+
encoding="utf-8", delete=False) as f:
56+
f.write("import os\nimport os\n")
57+
f.flush()
58+
path = f.name
59+
try:
60+
result = run_lint(path)
61+
if result.returncode == 1:
62+
lines = result.stdout.strip().split("\n")
63+
finding_lines = [l for l in lines if path in l]
64+
for line in finding_lines:
65+
self.assertRegex(line, r".+:\d+:\d+: \S+ .+")
66+
finally:
67+
os.unlink(path)
68+
69+
def test_no_gui_started(self):
70+
with tempfile.NamedTemporaryFile(suffix=".py", mode="w",
71+
encoding="utf-8", delete=False) as f:
72+
f.write("x = 1\n")
73+
f.flush()
74+
path = f.name
75+
try:
76+
result = run_lint(path)
77+
self.assertNotIn("QApplication", result.stderr)
78+
self.assertNotIn("Traceback", result.stderr)
79+
finally:
80+
os.unlink(path)
81+
82+
83+
if __name__ == "__main__":
84+
unittest.main()

0 commit comments

Comments
 (0)