collab-runtime 0.2.9__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.
Files changed (82) hide show
  1. collab/__init__.py +77 -0
  2. collab/__main__.py +11 -0
  3. collab_runtime-0.2.9.dist-info/METADATA +218 -0
  4. collab_runtime-0.2.9.dist-info/RECORD +82 -0
  5. collab_runtime-0.2.9.dist-info/WHEEL +5 -0
  6. collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
  7. collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
  8. collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
  9. scripts/cleanup.py +395 -0
  10. scripts/collab_git_hook.py +190 -0
  11. scripts/format_code.py +594 -0
  12. scripts/generate_tests.py +560 -0
  13. scripts/validate_code.py +1397 -0
  14. src/__init__.py +4 -0
  15. src/dashboard/index.html +1131 -0
  16. src/live_locks_watcher.py +1982 -0
  17. src/lock_client.py +4268 -0
  18. src/logging_config.py +259 -0
  19. src/main.py +436 -0
  20. tests/backend/__init__.py +0 -0
  21. tests/backend/functional/__init__.py +0 -0
  22. tests/backend/functional/test_package_imports.py +43 -0
  23. tests/backend/integration/__init__.py +0 -0
  24. tests/backend/integration/test_cli_contract_parity.py +220 -0
  25. tests/backend/performance/__init__.py +0 -0
  26. tests/backend/reliability/__init__.py +0 -0
  27. tests/backend/security/__init__.py +0 -0
  28. tests/backend/unit/live_locks_watcher/__init__.py +5 -0
  29. tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
  30. tests/backend/unit/live_locks_watcher/conftest.py +18 -0
  31. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
  32. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
  33. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
  34. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
  35. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
  36. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
  37. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
  38. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
  39. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
  40. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
  41. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
  42. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
  43. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
  44. tests/backend/unit/lock_client/__init__.py +1 -0
  45. tests/backend/unit/lock_client/_helpers.py +132 -0
  46. tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
  47. tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
  48. tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
  49. tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
  50. tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
  51. tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
  52. tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
  53. tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
  54. tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
  55. tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
  56. tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
  57. tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
  58. tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
  59. tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
  60. tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
  61. tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
  62. tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
  63. tests/backend/unit/scripts/__init__.py +1 -0
  64. tests/backend/unit/scripts/_helpers.py +42 -0
  65. tests/backend/unit/scripts/test_cleanup.py +285 -0
  66. tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
  67. tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
  68. tests/backend/unit/scripts/test_format_code.py +368 -0
  69. tests/backend/unit/scripts/test_format_code_ported.py +177 -0
  70. tests/backend/unit/scripts/test_generate_tests.py +305 -0
  71. tests/backend/unit/scripts/test_hook_templates.py +357 -0
  72. tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
  73. tests/backend/unit/scripts/test_validate_code.py +867 -0
  74. tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
  75. tests/backend/unit/test_entrypoints_main_run.py +83 -0
  76. tests/backend/unit/test_logging_config.py +529 -0
  77. tests/backend/unit/test_main_watch_pid_file.py +278 -0
  78. tests/conftest.py +167 -0
  79. tests/frontend/__init__.py +0 -0
  80. tests/frontend/jest/__init__.py +0 -0
  81. tests/frontend/playwright/__init__.py +0 -0
  82. tests/packaging/test_smoke_install.py +76 -0
scripts/format_code.py ADDED
@@ -0,0 +1,594 @@
1
+ #!/usr/bin/env python3
2
+ """Code Formatting Script - Single Source of Truth for ALL formatting.
3
+
4
+ REQUIREMENT: For YAML formatting, you must have both 'prettier' and
5
+ 'prettier-plugin-yaml' installed as dev dependencies:
6
+ npm install --save-dev prettier prettier-plugin-yaml
7
+
8
+ Actively formats code using configured formatters:
9
+ - Whitespace: trailing whitespace removal, EOF newline normalization (ALL text files)
10
+ - Python: ruff (lint fixing + unsafe fixes), isort, black, docformatter
11
+ - JavaScript/CSS: prettier (quiet in format mode)
12
+ - Documentation: prettier (quiet in format mode, with prettier-plugin-yaml for YAML)
13
+ - Templates: djlint (Jinja2/HTML)
14
+
15
+ ARCHITECTURE:
16
+ format_code.py = the ONLY tool that MODIFIES files (formatter).
17
+ pre-commit hooks = CHECK-ONLY gate (--check mode, never modify files).
18
+
19
+ Usage:
20
+ python scripts/format_code.py # Format everything
21
+ python scripts/format_code.py --backend # Python only
22
+ python scripts/format_code.py --frontend # JS + templates only
23
+ python scripts/format_code.py --check # Check only (pre-commit)
24
+ """
25
+
26
+ import argparse
27
+ import os
28
+ import subprocess
29
+ import sys
30
+ from pathlib import Path
31
+ from typing import Optional
32
+
33
+ sys.path.insert(0, str(Path(__file__).parent))
34
+ from cleanup import clean_caches # noqa: E402
35
+
36
+ # UTF-8 for Windows + ANSI colors (standardized - matches setup scripts)
37
+ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
38
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined,union-attr]
39
+ sys.stderr.reconfigure(encoding="utf-8") # type: ignore[attr-defined,union-attr]
40
+
41
+ GREEN = "\033[92m"
42
+ RED = "\033[91m"
43
+ CYAN = "\033[96m"
44
+ RESET = "\033[0m"
45
+ BOLD = "\033[1m"
46
+ MAGENTA = "\033[95m"
47
+
48
+
49
+ class CodeFormatter:
50
+ """Handles code formatting with colored, professional, numbered output."""
51
+
52
+ # NOTE: .html/.htm are intentionally excluded from whitespace pass because
53
+ # djlint can re-introduce trailing whitespace in template line wrapping.
54
+ TEXT_EXTENSIONS: frozenset[str] = frozenset(
55
+ {
56
+ ".py",
57
+ ".js",
58
+ ".ts",
59
+ ".jsx",
60
+ ".tsx",
61
+ ".css",
62
+ ".json",
63
+ ".yaml",
64
+ ".yml",
65
+ ".toml",
66
+ ".ini",
67
+ ".cfg",
68
+ ".env",
69
+ ".md",
70
+ ".txt",
71
+ ".rst",
72
+ ".sh",
73
+ ".bash",
74
+ ".ps1",
75
+ ".bat",
76
+ ".cmd",
77
+ ".sql",
78
+ ".xml",
79
+ ".csv",
80
+ }
81
+ )
82
+
83
+ def __init__(self, check_only: bool = False, files: Optional[list[str]] = None):
84
+ self.check_only = check_only
85
+ self.files = files
86
+ self.root_dir = Path(__file__).parent.parent
87
+ self.failed_tools: list[tuple[str, str, bool]] = []
88
+
89
+ def _get_targets(
90
+ self, extensions: tuple[str, ...], default: list[str]
91
+ ) -> list[str]:
92
+ if not self.files:
93
+ return default
94
+ return [f for f in self.files if f.lower().endswith(extensions)]
95
+
96
+ def _prepare_env(self) -> dict:
97
+ env = os.environ.copy()
98
+ scripts_dir = "Scripts" if sys.platform == "win32" else "bin"
99
+ venv_scripts = self.root_dir / ".venv" / scripts_dir
100
+ if venv_scripts.exists():
101
+ env["PATH"] = f"{venv_scripts}{os.pathsep}{env.get('PATH', '')}"
102
+ env["PYTHONIOENCODING"] = "utf-8"
103
+ return env
104
+
105
+ def _get_python_executable(self) -> str:
106
+ """Prefer repository .venv Python for module-based tool execution."""
107
+ scripts_dir = "Scripts" if sys.platform == "win32" else "bin"
108
+ python_name = "python.exe" if sys.platform == "win32" else "python"
109
+ venv_python = self.root_dir / ".venv" / scripts_dir / python_name
110
+ if venv_python.exists():
111
+ return str(venv_python)
112
+ return sys.executable
113
+
114
+ def _exec(
115
+ self, cmd: list[str], suppress_output: bool = False
116
+ ) -> tuple[bool, Optional[subprocess.CompletedProcess]]:
117
+ try:
118
+ if sys.platform == "win32" and cmd[0] in ("npm", "npx"):
119
+ cmd = ["cmd", "/c"] + cmd
120
+ result = subprocess.run(
121
+ cmd,
122
+ cwd=self.root_dir,
123
+ capture_output=True,
124
+ text=True,
125
+ encoding="utf-8",
126
+ errors="replace",
127
+ check=False,
128
+ env=self._prepare_env(),
129
+ )
130
+ if not suppress_output:
131
+ printed = False
132
+ if result.stdout.strip():
133
+ for line in result.stdout.strip().splitlines():
134
+ print(f" {line}")
135
+ printed = True
136
+ if result.stderr.strip():
137
+ for line in result.stderr.strip().splitlines():
138
+ print(f" {line}", file=sys.stderr)
139
+ printed = True
140
+ if result.returncode == 0 and not printed:
141
+ print(" All checks passed!")
142
+ return result.returncode == 0, result
143
+ except FileNotFoundError:
144
+ if not suppress_output:
145
+ print(f" Tool not found: {cmd[0]}")
146
+ return False, None
147
+ except Exception as exc:
148
+ if not suppress_output:
149
+ print(f" Error: {exc}")
150
+ return False, None
151
+
152
+ def _run_tool_step(
153
+ self,
154
+ description: str,
155
+ fix_cmd: Optional[list[str]],
156
+ check_cmd: Optional[list[str]],
157
+ section: str,
158
+ section_idx: int,
159
+ section_total: int,
160
+ ) -> bool:
161
+ """Run one formatting tool step with fully standardized output."""
162
+ step_header = f"[{section.upper()} {section_idx}/{section_total}] {description}"
163
+ print(f"\n{CYAN}{step_header}...{RESET}")
164
+
165
+ if self.check_only:
166
+ cmd = check_cmd or fix_cmd
167
+ assert cmd is not None, "At least one of fix_cmd or check_cmd required"
168
+ print(f" {MAGENTA}Command: {' '.join(cmd)}{RESET}")
169
+ success, _ = self._exec(cmd)
170
+ if success:
171
+ print(f" {GREEN}✅ {description} - SUCCESS{RESET}")
172
+ else:
173
+ print(f" {RED}❌ {description} - ISSUES FOUND{RESET}")
174
+ self.failed_tools.append((step_header, description, False))
175
+ return success
176
+
177
+ primary_cmd = fix_cmd if fix_cmd is not None else check_cmd
178
+ assert primary_cmd is not None, "At least one of fix_cmd or check_cmd required"
179
+ print(f" {MAGENTA}Command: {' '.join(primary_cmd)}{RESET}")
180
+ success, _ = self._exec(primary_cmd)
181
+
182
+ if success:
183
+ print(f" {GREEN}✅ {description} - SUCCESS{RESET}")
184
+ return True
185
+
186
+ print(f" {RED}❌ {description} - ISSUES FOUND{RESET}")
187
+
188
+ if check_cmd:
189
+ print(f"\n {MAGENTA}Command: {' '.join(check_cmd)}{RESET}")
190
+ check_ok, _ = self._exec(check_cmd, suppress_output=True)
191
+ if check_ok:
192
+ print(
193
+ f" {GREEN}✅ {description} (check) - All issues fixed - "
194
+ f"no further action needed.{RESET}"
195
+ )
196
+ else:
197
+ print(
198
+ f" {RED}❌ {description} (check) - Issues remain - "
199
+ f"manual fix required.{RESET}"
200
+ )
201
+ self.failed_tools.append((step_header, description, True))
202
+ return check_ok
203
+
204
+ self.failed_tools.append((step_header, description, False))
205
+ return False
206
+
207
+ def normalize_whitespace(self) -> bool:
208
+ print("\n" + "=" * 80)
209
+ print(f"{BOLD}WHITESPACE & EOF NORMALIZATION{RESET}")
210
+ print("=" * 80)
211
+
212
+ if self.files:
213
+ tracked_files = self.files
214
+ else:
215
+ try:
216
+ proc = subprocess.run(
217
+ ["git", "ls-files"],
218
+ cwd=self.root_dir,
219
+ capture_output=True,
220
+ text=True,
221
+ encoding="utf-8",
222
+ check=True,
223
+ )
224
+ tracked_files = [f for f in proc.stdout.strip().split("\n") if f]
225
+ except (subprocess.CalledProcessError, FileNotFoundError):
226
+ print(" ⚠️ Could not list git files - skipping whitespace")
227
+ return True
228
+
229
+ issues: list[str] = []
230
+ fix = not self.check_only
231
+
232
+ for rel_path in tracked_files:
233
+ filepath = self.root_dir / rel_path
234
+ if (
235
+ filepath.suffix.lower() not in self.TEXT_EXTENSIONS
236
+ or not filepath.is_file()
237
+ ):
238
+ continue
239
+
240
+ try:
241
+ raw = filepath.read_bytes()
242
+ except OSError:
243
+ continue
244
+ if not raw or b"\x00" in raw:
245
+ continue
246
+
247
+ fixed = self._normalize_whitespace(raw)
248
+ if fixed != raw:
249
+ issues.append(rel_path)
250
+ if fix:
251
+ filepath.write_bytes(fixed)
252
+
253
+ if not issues:
254
+ print(f" {GREEN}✅ Whitespace & EOF - all files clean{RESET}")
255
+ return True
256
+
257
+ if fix:
258
+ print(f" {GREEN}✅ Fixed whitespace/EOF in {len(issues)} file(s){RESET}")
259
+ return True
260
+
261
+ print(f" {RED}❌ {len(issues)} file(s) have whitespace/EOF issues{RESET}")
262
+ for rel_file in issues[:10]:
263
+ print(f" - {rel_file}")
264
+ if len(issues) > 10:
265
+ print(f" ... and {len(issues) - 10} more")
266
+ return False
267
+
268
+ @staticmethod
269
+ def _normalize_whitespace(content: bytes) -> bytes:
270
+ uses_crlf = b"\r\n" in content
271
+ normalized = content.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
272
+ lines = normalized.split(b"\n")
273
+ stripped = [line.rstrip(b" \t") for line in lines]
274
+ result = b"\n".join(stripped).rstrip(b"\n") + b"\n"
275
+ if uses_crlf:
276
+ result = result.replace(b"\n", b"\r\n")
277
+ return result
278
+
279
+ def format_python(self) -> bool:
280
+ targets = self._get_targets(
281
+ (".py",),
282
+ [
283
+ "src",
284
+ "tests",
285
+ "scripts",
286
+ ],
287
+ )
288
+ if not targets:
289
+ return True
290
+
291
+ print("\n" + "=" * 80)
292
+ print(f"{BOLD}BACKEND CODE FORMATTING{RESET}")
293
+ print("=" * 80)
294
+
295
+ flake8_exclude = (
296
+ "--exclude="
297
+ ".venv,node_modules,__pycache__,.git,.pytest_cache,"
298
+ "htmlcov,playwright-report"
299
+ )
300
+ flake8_opts = [
301
+ flake8_exclude,
302
+ "--count",
303
+ "--show-source",
304
+ "--statistics",
305
+ "--max-line-length=88",
306
+ ]
307
+
308
+ steps: list[tuple[str, Optional[list[str]], list[str]]] = [
309
+ (
310
+ "Import sorting (isort)",
311
+ ["isort"] + targets,
312
+ ["isort"] + targets + ["--check-only"],
313
+ ),
314
+ (
315
+ "Code formatting (black)",
316
+ ["black"] + targets,
317
+ ["black", "--check"] + targets,
318
+ ),
319
+ (
320
+ "Docstring formatting (docformatter)",
321
+ ["docformatter", "--in-place", "-r"] + targets,
322
+ ["docformatter", "--check", "-r"] + targets,
323
+ ),
324
+ (
325
+ "Ruff linting & fixing",
326
+ ["ruff", "check", "--no-cache"] + targets + ["--fix", "--unsafe-fixes"],
327
+ ["ruff", "check", "--no-cache"] + targets,
328
+ ),
329
+ (
330
+ "Final linting (flake8)",
331
+ None,
332
+ ["flake8"] + targets + flake8_opts,
333
+ ),
334
+ ]
335
+
336
+ all_passed = True
337
+ for idx, (desc, fix_cmd, check_cmd) in enumerate(steps, 1):
338
+ all_passed &= self._run_tool_step(
339
+ desc, fix_cmd, check_cmd, "BACKEND", idx, len(steps)
340
+ )
341
+ return all_passed
342
+
343
+ def _check_prettier(self) -> bool:
344
+ npm_cmd = ["cmd", "/c", "npm"] if sys.platform == "win32" else ["npm"]
345
+ result = subprocess.run(
346
+ npm_cmd + ["list", "prettier"],
347
+ cwd=self.root_dir,
348
+ capture_output=True,
349
+ check=False,
350
+ )
351
+ if result.returncode != 0:
352
+ return False
353
+
354
+ plugin_result = subprocess.run(
355
+ npm_cmd + ["list", "prettier-plugin-yaml"],
356
+ cwd=self.root_dir,
357
+ capture_output=True,
358
+ check=False,
359
+ )
360
+ if plugin_result.returncode != 0:
361
+ print(
362
+ " ⚠️ prettier-plugin-yaml not installed - YAML files will NOT be "
363
+ "formatted!\n"
364
+ " Run: npm install --save-dev prettier-plugin-yaml"
365
+ )
366
+ return False
367
+ return True
368
+
369
+ def _filter_glob_targets(self, patterns: list[str]) -> list[str]:
370
+ return [pattern for pattern in patterns if list(self.root_dir.glob(pattern))]
371
+
372
+ def format_frontend(self) -> bool:
373
+ base_targets = self._filter_glob_targets(
374
+ [
375
+ "src/**/*.js",
376
+ "src/**/*.css",
377
+ "tests/**/*.js",
378
+ ]
379
+ )
380
+
381
+ targets = self._get_targets(
382
+ (".js", ".jsx", ".ts", ".tsx", ".css", ".scss"),
383
+ base_targets,
384
+ )
385
+ if not targets:
386
+ return True
387
+
388
+ print("\n" + "=" * 80)
389
+ print(f"{BOLD}FRONTEND CODE FORMATTING{RESET}")
390
+ print("=" * 80)
391
+
392
+ if not self._check_prettier():
393
+ print(" ℹ️ Prettier not installed - skipping frontend")
394
+ return True
395
+
396
+ return self._run_tool_step(
397
+ "JavaScript/CSS (prettier)",
398
+ ["npx", "prettier", "--write", "--log-level", "silent"] + targets,
399
+ ["npx", "prettier", "--check"] + targets,
400
+ "FRONTEND",
401
+ 1,
402
+ 1,
403
+ )
404
+
405
+ def format_docs(self) -> bool:
406
+ doc_targets = self._get_targets(
407
+ (".md", ".json"),
408
+ self._filter_glob_targets(
409
+ [
410
+ "docs/**/*.md",
411
+ "*.md",
412
+ "*.json",
413
+ ".github/**/*.md",
414
+ "tests/**/*.md",
415
+ ".agents/**/*.md",
416
+ ]
417
+ ),
418
+ )
419
+ if not doc_targets:
420
+ return True
421
+
422
+ print("\n" + "=" * 80)
423
+ print(f"{BOLD}DOCUMENTATION FORMATTING{RESET}")
424
+ print("=" * 80)
425
+
426
+ return self._run_tool_step(
427
+ "Markdown/JSON (prettier)",
428
+ ["npx", "prettier", "--write", "--log-level", "silent"] + doc_targets,
429
+ ["npx", "prettier", "--check"] + doc_targets,
430
+ "DOCS",
431
+ 1,
432
+ 1,
433
+ )
434
+
435
+ def format_yaml(self) -> bool:
436
+ exclude_dirs = {".venv", "node_modules", ".git", "__pycache__"}
437
+ yaml_files = []
438
+ for ext in ("*.yaml", "*.yml"):
439
+ for path in self.root_dir.rglob(ext):
440
+ if not any(part in exclude_dirs for part in path.parts):
441
+ yaml_files.append(str(path))
442
+ if not yaml_files:
443
+ return True
444
+
445
+ print("\n" + "=" * 80)
446
+ print(f"{BOLD}YAML FORMATTING & LINTING{RESET}")
447
+ print("=" * 80)
448
+
449
+ all_passed = True
450
+ all_passed &= self._run_tool_step(
451
+ "YAML (prettier)",
452
+ ["npx", "prettier", "--write", "--log-level", "silent"] + yaml_files,
453
+ ["npx", "prettier", "--check"] + yaml_files,
454
+ "YAML",
455
+ 1,
456
+ 2,
457
+ )
458
+ all_passed &= self._run_tool_step(
459
+ "YAML (yamllint)",
460
+ None,
461
+ ["yamllint", "--strict"] + yaml_files,
462
+ "YAML",
463
+ 2,
464
+ 2,
465
+ )
466
+ return all_passed
467
+
468
+ def format_templates(self) -> bool:
469
+ template_dirs = ["src/dashboard"]
470
+ targets = self._get_targets((".html", ".htm"), template_dirs)
471
+ if not targets:
472
+ return True
473
+
474
+ python = self._get_python_executable()
475
+ djlint_check, _ = self._exec([python, "-m", "djlint", "--version"], True)
476
+ if not djlint_check:
477
+ print("\n" + "=" * 80)
478
+ print(f"{BOLD}JINJA2 TEMPLATE FORMATTING{RESET}")
479
+ print("=" * 80)
480
+ print(" ℹ️ djlint not installed - skipping template formatting")
481
+ return True
482
+
483
+ print("\n" + "=" * 80)
484
+ print(f"{BOLD}JINJA2 TEMPLATE FORMATTING{RESET}")
485
+ print("=" * 80)
486
+
487
+ description = "Jinja2 templates (djlint)"
488
+ step_header = "[TEMPLATES 1/1] Jinja2 templates (djlint)"
489
+ fix_cmd = [python, "-m", "djlint"] + targets + ["--reformat", "--quiet"]
490
+ check_cmd = [python, "-m", "djlint"] + targets + ["--check"]
491
+
492
+ print(f"\n{CYAN}{step_header}...{RESET}")
493
+ print(f" {MAGENTA}Command: {' '.join(fix_cmd)}{RESET}")
494
+
495
+ fix_ok, _ = self._exec(fix_cmd, suppress_output=True)
496
+ if fix_ok:
497
+ print(" All checks passed!")
498
+ print(f" {GREEN}✅ {description} - SUCCESS{RESET}")
499
+ return True
500
+
501
+ print(
502
+ f" {CYAN}ℹ️ {description} - changes applied; "
503
+ f"running verification check...{RESET}"
504
+ )
505
+ print(f"\n {MAGENTA}Command: {' '.join(check_cmd)}{RESET}")
506
+ check_ok, _ = self._exec(check_cmd, suppress_output=True)
507
+
508
+ if check_ok:
509
+ print(
510
+ f" {GREEN}✅ {description} - All issues fixed - "
511
+ f"no further action needed.{RESET}"
512
+ )
513
+ return True
514
+
515
+ print(
516
+ f" {RED}❌ {description} (check) - Issues remain - "
517
+ f"manual fix required.{RESET}"
518
+ )
519
+ self._exec(check_cmd, suppress_output=False)
520
+ self.failed_tools.append((step_header, description, True))
521
+ return False
522
+
523
+ def print_summary(self) -> None:
524
+ print("\n" + "=" * 80)
525
+ print(f"{BOLD}FORMATTING SUMMARY{RESET}")
526
+ print("=" * 80)
527
+ if not self.failed_tools:
528
+ mode = "check" if self.check_only else "formatting"
529
+ print(f" {GREEN}✅ All {mode} operations completed successfully!{RESET}")
530
+ return
531
+
532
+ print(f" {RED}❌ {len(self.failed_tools)} operation(s) failed{RESET}")
533
+ for step_header, _, _ in self.failed_tools:
534
+ print(f" - {step_header}")
535
+ print(f"\n {RED}⚠️ Review the errors above and fix manually.{RESET}")
536
+
537
+
538
+ def main() -> int:
539
+ parser = argparse.ArgumentParser(
540
+ description="Format code using configured formatters"
541
+ )
542
+ parser.add_argument("--backend", action="store_true", help="Python only")
543
+ parser.add_argument(
544
+ "--frontend", action="store_true", help="JS/CSS + templates only"
545
+ )
546
+ parser.add_argument("--docs", action="store_true", help="Markdown/JSON only")
547
+ parser.add_argument("--check", action="store_true", help="Check only (pre-commit)")
548
+ parser.add_argument(
549
+ "--update-hooks", action="store_true", help="pre-commit autoupdate"
550
+ )
551
+ parser.add_argument("files", nargs="*", help="Specific files to format")
552
+
553
+ args = parser.parse_args()
554
+
555
+ run_all = not (args.backend or args.frontend or args.docs)
556
+ format_backend = args.backend or run_all
557
+ format_frontend = args.frontend or run_all
558
+ format_docs = args.docs or run_all
559
+
560
+ formatter = CodeFormatter(
561
+ check_only=args.check, files=args.files if args.files else None
562
+ )
563
+
564
+ all_passed = True
565
+ all_passed &= formatter.normalize_whitespace()
566
+
567
+ if format_backend:
568
+ all_passed &= formatter.format_python()
569
+
570
+ if format_frontend:
571
+ all_passed &= formatter.format_frontend()
572
+ all_passed &= formatter.format_templates()
573
+
574
+ if format_docs:
575
+ all_passed &= formatter.format_docs()
576
+
577
+ all_passed &= formatter.format_yaml()
578
+
579
+ formatter.print_summary()
580
+
581
+ print("\n" + "=" * 80)
582
+ print(f"{BOLD}CLEANUP{RESET}")
583
+ print("=" * 80)
584
+ count = clean_caches(dry_run=False)
585
+ if count:
586
+ print(f" {GREEN}✅ Removed {count} cache artifact(s){RESET}")
587
+ else:
588
+ print(f" ✨ Repo already clean{RESET}")
589
+
590
+ return 0 if all_passed else 1
591
+
592
+
593
+ if __name__ == "__main__":
594
+ sys.exit(main())