create-caspian-app 1.3.6 → 1.3.7

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.
@@ -0,0 +1,721 @@
1
+ """App formatter: Python via ruff, authored markup via djLint (`npm run format`).
2
+
3
+ Two surfaces, one command:
4
+
5
+ 1. **Python** -- `ruff format` over `main.py`, `src/**`, `settings/*.py`,
6
+ `tests/**`. A Python formatter never rewrites string *contents*, so the
7
+ markup inside `html(r\"\"\"...\"\"\")` is byte-preserved; this was verified
8
+ across every block in `src/` before adopting it.
9
+
10
+ 2. **Markup** -- the template inside every `html(r\"\"\"...\"\"\")` call, formatted
11
+ with djLint and then *proved* unchanged in rendering before being written.
12
+
13
+ Why djLint, and why the proof
14
+ -----------------------------
15
+ This project layers four dialects in one string: HTML, Jinja `{{ }}`/`{% %}`,
16
+ PulsePoint `{ }`, and JavaScript inside `<script>` -- and they nest, e.g.
17
+ `class="... {currentUrl === '{{ item['href'] }}' ? 'a' : 'b'}"`. Prettier has no
18
+ Jinja awareness: it de-indents `{% for %}` blocks to column 0 and joins
19
+ `{% endfor %} {% endfor %}` onto one line. djLint is Jinja-aware and, critically,
20
+ does not reflow text -- so PulsePoint expressions survive intact.
21
+
22
+ djLint is still a general HTML formatter, and it will make changes that are
23
+ correct for HTML but wrong here. The one that matters: it inserts a newline
24
+ between a block tag and an adjacent inline or `<x-*>` tag, which renders as a
25
+ visible space, because a custom element's `display` is set by CSS the formatter
26
+ cannot see. So no block is trusted -- each is proved equivalent by
27
+ `_markup_equivalence` before it is written, and skipped with a reason if not.
28
+
29
+ Two regions are additionally masked away before djLint runs, so they are
30
+ preserved byte-for-byte by construction rather than by proof:
31
+
32
+ * `<script>` / `<style>` -- djLint reads `/>` inside a JS regex as a tag
33
+ delimiter and rewrites `.replace(/>/g, …)` into `.replace( />/g, …)`.
34
+ * `<pre>` / `<textarea>` -- whitespace there renders literally.
35
+
36
+ Usage (from the project root):
37
+
38
+ python settings/format.py # format Python + markup
39
+ python settings/format.py --check # report only; exit 1 if work remains
40
+ python settings/format.py --markup # markup only, skip ruff format
41
+ python settings/format.py --python # ruff format only, skip markup
42
+
43
+ `npm run check:fix` runs this first, so formatting settles before the fixer and
44
+ the gate look at the code.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import argparse
50
+ import ast
51
+ import re
52
+ import shutil
53
+ import subprocess
54
+ import sys
55
+ import tempfile
56
+ from dataclasses import dataclass, field
57
+ from pathlib import Path
58
+
59
+ import _markup_equivalence as eq
60
+
61
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
62
+ SCAN_ROOT = PROJECT_ROOT / "src"
63
+
64
+ # Generated or vendored trees that are not hand-authored.
65
+ EXCLUDED_PARTS = {"__pycache__", "node_modules", ".venv", "prisma"}
66
+
67
+ # Paths handed to `ruff format`. Mirrors the gate's Python surface.
68
+ PYTHON_TARGETS = ("main.py", "src", "settings", "tests")
69
+
70
+ # Excluded from formatting. The test is NOT "was this generated" -- it is
71
+ # "is this regenerated wholesale and never hand-edited". The Prisma client is
72
+ # rewritten in full by `prisma generate` and is never touched by hand, so
73
+ # formatting it is pure churn that the next generate undoes; it also holds no
74
+ # markup, so nothing is lost by leaving it alone.
75
+ #
76
+ # Component libraries under `src/lib/**` are deliberately NOT listed here even
77
+ # though a CLI first installed them: they are hand-maintained in this workspace,
78
+ # and they hold the majority of the repo's markup blocks. Excluding them would
79
+ # remove most of the formatter's reach.
80
+ PYTHON_FORMAT_EXCLUDE = ("src/lib/prisma",)
81
+
82
+ # djLint settings. Passed explicitly rather than read from pyproject, because
83
+ # blocks are formatted in a temp directory outside the project.
84
+ DJLINT_INDENT = "2"
85
+ DJLINT_MAX_LINE = "120"
86
+
87
+ # Register `<x-*>` component tags with djLint. Without this it does not know
88
+ # them, treats them as inline, and leaves a component tree flat:
89
+ #
90
+ # <x-shell>
91
+ # <x-brand />
92
+ # </x-shell>
93
+ #
94
+ # Registered, they nest like any other container. This does not weaken the
95
+ # safety check -- `_markup_equivalence` still treats `<x-*>` as inline, so any
96
+ # block where the new indentation would actually add rendered whitespace is
97
+ # still skipped.
98
+ DJLINT_CUSTOM_HTML = r"x-[\w-]+"
99
+
100
+ OPAQUE = re.compile(r"(<(script|style|pre|textarea)\b[^>]*>)(.*?)(</\2\s*>)", re.I | re.S)
101
+
102
+ try:
103
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
104
+ except AttributeError, ValueError:
105
+ pass
106
+
107
+ _TTY = sys.stdout.isatty()
108
+
109
+
110
+ def _c(code: str, text: str) -> str:
111
+ return f"\033[{code}m{text}\033[0m" if _TTY else text
112
+
113
+
114
+ def red(t: str) -> str:
115
+ return _c("31", t)
116
+
117
+
118
+ def green(t: str) -> str:
119
+ return _c("32", t)
120
+
121
+
122
+ def yellow(t: str) -> str:
123
+ return _c("33", t)
124
+
125
+
126
+ def bold(t: str) -> str:
127
+ return _c("1", t)
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # masking
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def mask_opaque(markup: str) -> tuple[str, dict[str, str]]:
136
+ """Replace `<script>`/`<style>`/`<pre>`/`<textarea>` bodies with tokens."""
137
+ store: dict[str, str] = {}
138
+
139
+ def sub(m: re.Match[str]) -> str:
140
+ key = f"PPMASK{len(store):04d}Z"
141
+ store[key] = m.group(3)
142
+ return f"{m.group(1)}{key}{m.group(4)}"
143
+
144
+ return OPAQUE.sub(sub, markup), store
145
+
146
+
147
+ def unmask_opaque(markup: str, store: dict[str, str]) -> str:
148
+ """Restore masked bodies, re-indenting code to its new nesting depth.
149
+
150
+ `<pre>`/`<textarea>` are restored verbatim -- their whitespace renders.
151
+ `<script>`/`<style>` are re-indented as a block, which cannot change what
152
+ the code does but keeps the output readable.
153
+ """
154
+ for key, body in store.items():
155
+ pattern = re.compile(
156
+ r"([ \t]*)(<(script|style|pre|textarea)\b[^>]*>)" + key + r"(</\3\s*>)",
157
+ re.I,
158
+ )
159
+
160
+ def repl(m: re.Match[str], body: str = body) -> str:
161
+ indent, open_tag, tag, close_tag = (
162
+ m.group(1),
163
+ m.group(2),
164
+ m.group(3).lower(),
165
+ m.group(4),
166
+ )
167
+ if tag in ("pre", "textarea"):
168
+ return f"{indent}{open_tag}{body}{close_tag}"
169
+ lines = body.splitlines()
170
+ while lines and not lines[0].strip():
171
+ lines.pop(0)
172
+ while lines and not lines[-1].strip():
173
+ lines.pop()
174
+ if not lines:
175
+ return f"{indent}{open_tag}{close_tag}"
176
+ base = min((len(ln) - len(ln.lstrip()) for ln in lines if ln.strip()), default=0)
177
+ inner = indent + " "
178
+ rendered = "\n".join((inner + ln[base:].rstrip()) if ln.strip() else "" for ln in lines)
179
+ return f"{indent}{open_tag}\n{rendered}\n{indent}{close_tag}"
180
+
181
+ markup = pattern.sub(repl, markup, count=1)
182
+ return markup
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # block discovery
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ @dataclass
191
+ class Block:
192
+ """One `html(r\"\"\"...\"\"\")` template argument in a Python file."""
193
+
194
+ path: Path
195
+ lineno: int
196
+ start: int # byte offset of the string literal's opening quote
197
+ end: int # byte offset just past its closing quote
198
+ prefix: str # the literal's opening delimiter, e.g. `r\"\"\"`
199
+ quote: str # the closing delimiter
200
+ source: str # the markup itself
201
+
202
+
203
+ def _rel(path: Path) -> str:
204
+ """Project-relative path for reports, tolerating a path outside the root."""
205
+ try:
206
+ return path.relative_to(PROJECT_ROOT).as_posix()
207
+ except ValueError:
208
+ return path.as_posix()
209
+
210
+
211
+ def _iter_python_files() -> list[Path]:
212
+ if not SCAN_ROOT.exists():
213
+ return []
214
+ return sorted(p for p in SCAN_ROOT.rglob("*.py") if not EXCLUDED_PARTS.intersection(p.parts))
215
+
216
+
217
+ def find_blocks(path: Path) -> list[Block]:
218
+ """Every raw triple-quoted `html(...)` template argument in one file.
219
+
220
+ Only the `html(r\"\"\"...\"\"\")` form is touched. That is the single markup
221
+ entrypoint the `templates` gate already enforces (`html-form`), so any other
222
+ shape is a gate failure to fix rather than something to reformat.
223
+ """
224
+ try:
225
+ text = path.read_text(encoding="utf-8")
226
+ tree = ast.parse(text)
227
+ except OSError, UnicodeDecodeError, SyntaxError:
228
+ return []
229
+
230
+ lines = text.splitlines(keepends=True)
231
+ offsets = [0]
232
+ for line in lines:
233
+ offsets.append(offsets[-1] + len(line))
234
+
235
+ blocks: list[Block] = []
236
+ for node in ast.walk(tree):
237
+ if not isinstance(node, ast.Call):
238
+ continue
239
+ name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
240
+ if name != "html" or not node.args:
241
+ continue
242
+ arg = node.args[0]
243
+ if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)):
244
+ continue
245
+ if arg.end_lineno is None or arg.end_col_offset is None:
246
+ continue
247
+ start = offsets[arg.lineno - 1] + arg.col_offset
248
+ end = offsets[arg.end_lineno - 1] + arg.end_col_offset
249
+ literal = text[start:end]
250
+ match = re.match(r'^([rR]?)("""|\'\'\')', literal)
251
+ if not match or not match.group(1):
252
+ continue # not the raw triple-quoted form; `templates` reports it
253
+ prefix, quote = match.group(0), match.group(2)
254
+ if not literal.endswith(quote):
255
+ continue
256
+ blocks.append(
257
+ Block(
258
+ path=path,
259
+ lineno=arg.lineno,
260
+ start=start,
261
+ end=end,
262
+ prefix=prefix,
263
+ quote=quote,
264
+ source=literal[len(prefix) : -len(quote)],
265
+ )
266
+ )
267
+ return blocks
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # djLint
272
+ # ---------------------------------------------------------------------------
273
+
274
+
275
+ def _djlint_available() -> bool:
276
+ return shutil.which("djlint") is not None or _djlint_module()
277
+
278
+
279
+ def _djlint_module() -> bool:
280
+ try:
281
+ subprocess.run(
282
+ [sys.executable, "-m", "djlint", "--version"],
283
+ capture_output=True,
284
+ timeout=60,
285
+ )
286
+ return True
287
+ except OSError, subprocess.SubprocessError:
288
+ return False
289
+
290
+
291
+ def djlint_batch(sources: list[str]) -> list[str | None]:
292
+ """Format many markup fragments in a single djLint run.
293
+
294
+ djLint costs roughly a second of interpreter start-up per invocation, and
295
+ this repo has well over 500 blocks. Writing them all into one temp directory
296
+ and reformatting it once turns minutes into seconds.
297
+ """
298
+ if not sources:
299
+ return []
300
+ results: list[str | None] = [None] * len(sources)
301
+ with tempfile.TemporaryDirectory(prefix="pp-format-") as tmp:
302
+ root = Path(tmp)
303
+ for index, text in enumerate(sources):
304
+ (root / f"{index:05d}.html").write_text(text, encoding="utf-8")
305
+ proc = subprocess.run(
306
+ [
307
+ sys.executable,
308
+ "-m",
309
+ "djlint",
310
+ str(root),
311
+ "--reformat",
312
+ "--profile",
313
+ "jinja",
314
+ "--indent",
315
+ DJLINT_INDENT,
316
+ "--max-line-length",
317
+ DJLINT_MAX_LINE,
318
+ "--preserve-blank-lines",
319
+ "--custom-html",
320
+ DJLINT_CUSTOM_HTML,
321
+ ],
322
+ capture_output=True,
323
+ text=True,
324
+ encoding="utf-8",
325
+ errors="replace",
326
+ )
327
+ # djLint exits 1 when it reformatted something; only >1 is a real error.
328
+ if proc.returncode > 1:
329
+ return results
330
+ for index in range(len(sources)):
331
+ try:
332
+ results[index] = (root / f"{index:05d}.html").read_text(encoding="utf-8")
333
+ except OSError:
334
+ results[index] = None
335
+ return results
336
+
337
+
338
+ def format_markup(source: str, formatted_skeleton: str | None, store: dict[str, str]) -> str | None:
339
+ if formatted_skeleton is None:
340
+ return None
341
+ return unmask_opaque(formatted_skeleton, store)
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # splicing
346
+ # ---------------------------------------------------------------------------
347
+
348
+
349
+ def render_literal(block: Block, markup: str) -> str | None:
350
+ """Rebuild the Python string literal around freshly formatted markup.
351
+
352
+ Returns None when the result could not be spliced back safely.
353
+ """
354
+ body = markup.strip("\n")
355
+ if block.quote in body:
356
+ return None # would terminate the literal early
357
+ if body.endswith("\\"):
358
+ return None # a trailing backslash escapes the closing quote
359
+ original_multiline = "\n" in block.source.strip("\n") or block.source.startswith("\n")
360
+ if "\n" in body or original_multiline:
361
+ body = f"\n{body}\n"
362
+ return f"{block.prefix}{body}{block.quote}"
363
+
364
+
365
+ @dataclass
366
+ class Skip:
367
+ path: str
368
+ lineno: int
369
+ reason: str
370
+
371
+
372
+ @dataclass
373
+ class MarkupReport:
374
+ scanned: int = 0
375
+ formatted: int = 0
376
+ already: int = 0
377
+ files_changed: int = 0
378
+ skips: list[Skip] = field(default_factory=list)
379
+ error: str = ""
380
+
381
+
382
+ def format_markup_blocks(*, write: bool) -> MarkupReport:
383
+ report = MarkupReport()
384
+
385
+ files = _iter_python_files()
386
+ per_file: dict[Path, list[Block]] = {}
387
+ flat: list[tuple[Path, Block]] = []
388
+ for path in files:
389
+ blocks = find_blocks(path)
390
+ if blocks:
391
+ per_file[path] = blocks
392
+ flat.extend((path, b) for b in blocks)
393
+
394
+ report.scanned = len(flat)
395
+ if not flat:
396
+ return report
397
+
398
+ masked: list[str] = []
399
+ stores: list[dict[str, str]] = []
400
+ for _, block in flat:
401
+ skeleton, store = mask_opaque(block.source)
402
+ masked.append(skeleton)
403
+ stores.append(store)
404
+
405
+ outputs = djlint_batch(masked)
406
+ if all(o is None for o in outputs):
407
+ report.error = "djLint did not run. Install it with `uv sync --group dev`."
408
+ return report
409
+
410
+ # Decide each block, then apply per file from the bottom up so earlier
411
+ # offsets stay valid.
412
+ decisions: dict[Path, list[tuple[Block, str]]] = {}
413
+ for (path, block), skeleton, store in zip(flat, outputs, stores):
414
+ rel = _rel(path)
415
+ formatted = format_markup(block.source, skeleton, store)
416
+ if formatted is None:
417
+ report.skips.append(Skip(rel, block.lineno, "djLint could not format this block"))
418
+ continue
419
+ if formatted.strip("\n") == block.source.strip("\n"):
420
+ report.already += 1
421
+ continue
422
+ same, why = eq.equivalent(block.source, formatted)
423
+ if not same:
424
+ report.skips.append(Skip(rel, block.lineno, why))
425
+ continue
426
+ literal = render_literal(block, formatted)
427
+ if literal is None:
428
+ report.skips.append(Skip(rel, block.lineno, "could not be spliced back safely"))
429
+ continue
430
+ decisions.setdefault(path, []).append((block, literal))
431
+ report.formatted += 1
432
+
433
+ report.files_changed = len(decisions)
434
+ if not write:
435
+ return report
436
+
437
+ for path, items in decisions.items():
438
+ text = path.read_text(encoding="utf-8")
439
+ for block, literal in sorted(items, key=lambda i: i[0].start, reverse=True):
440
+ text = text[: block.start] + literal + text[block.end :]
441
+ # Never leave a file that no longer parses, or whose markup did not land
442
+ # exactly as intended.
443
+ try:
444
+ ast.parse(text)
445
+ except SyntaxError:
446
+ report.skips.append(Skip(_rel(path), 0, "rewrite would not parse; file left unchanged"))
447
+ report.formatted -= len(items)
448
+ report.files_changed -= 1
449
+ continue
450
+ path.write_text(text, encoding="utf-8")
451
+
452
+ return report
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # ruff format
457
+ # ---------------------------------------------------------------------------
458
+
459
+
460
+ def _ruff_format_cmd(targets: list[str]) -> list[str]:
461
+ cmd = [sys.executable, "-m", "ruff", "format", *targets]
462
+ for excluded in PYTHON_FORMAT_EXCLUDE:
463
+ cmd += ["--exclude", excluded]
464
+ return cmd
465
+
466
+
467
+ def hug_html_call_openings(paths: list[Path], *, write: bool) -> list[Path]:
468
+ """Put `html(r\"\"\"` back on one line, so the markup starts at line 1.
469
+
470
+ `ruff format` always explodes a call whose first argument is a multiline
471
+ string when the call has other arguments, producing:
472
+
473
+ return html(
474
+ r\"\"\"
475
+ <div>…
476
+
477
+ That buries the template one level deeper and separates `html(` from the
478
+ markup it opens. The house style is `html(r\"\"\"` with the markup starting
479
+ on the next line, so this runs *after* ruff and closes the gap.
480
+
481
+ Ruff will re-split these on its next run, in default and preview style
482
+ alike, so the two steps must always run as a pair -- which is why
483
+ `--check` re-runs the whole pipeline rather than calling
484
+ `ruff format --check` directly.
485
+
486
+ Returns the paths that needed the fix.
487
+ """
488
+ touched: list[Path] = []
489
+ for path in paths:
490
+ try:
491
+ text = path.read_text(encoding="utf-8")
492
+ except OSError, UnicodeDecodeError:
493
+ continue
494
+ if "html(" not in text:
495
+ continue
496
+ try:
497
+ tree = ast.parse(text)
498
+ except SyntaxError:
499
+ continue
500
+
501
+ lines = text.splitlines(keepends=True)
502
+ offsets = [0]
503
+ for line in lines:
504
+ offsets.append(offsets[-1] + len(line))
505
+
506
+ cuts: list[tuple[int, int]] = []
507
+ for node in ast.walk(tree):
508
+ if not isinstance(node, ast.Call):
509
+ continue
510
+ name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
511
+ if name != "html" or not node.args:
512
+ continue
513
+ arg = node.args[0]
514
+ if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)):
515
+ continue
516
+ func_line, func_col = node.func.end_lineno, node.func.end_col_offset
517
+ if func_line is None or func_col is None:
518
+ continue
519
+ paren = text.find("(", offsets[func_line - 1] + func_col)
520
+ if paren == -1:
521
+ continue
522
+ literal_start = offsets[arg.lineno - 1] + arg.col_offset
523
+ gap = text[paren + 1 : literal_start]
524
+ # Only close a gap that is pure whitespace spanning a line break;
525
+ # anything else means this is not the shape ruff produced.
526
+ if gap and gap.strip() == "" and "\n" in gap:
527
+ cuts.append((paren + 1, literal_start))
528
+
529
+ if not cuts:
530
+ continue
531
+ touched.append(path)
532
+ if not write:
533
+ continue
534
+ for start, end in sorted(cuts, reverse=True):
535
+ text = text[:start] + text[end:]
536
+ try:
537
+ ast.parse(text)
538
+ except SyntaxError:
539
+ continue # leave the file as ruff wrote it rather than risk it
540
+ path.write_text(text, encoding="utf-8")
541
+ return touched
542
+
543
+
544
+ def _python_files_for_hug() -> list[Path]:
545
+ """Every formatted Python file that could contain an `html(...)` call."""
546
+ found: list[Path] = []
547
+ for target in PYTHON_TARGETS:
548
+ base = PROJECT_ROOT / target
549
+ if not base.exists():
550
+ continue
551
+ candidates = [base] if base.is_file() else sorted(base.rglob("*.py"))
552
+ for path in candidates:
553
+ rel = path.relative_to(PROJECT_ROOT).as_posix()
554
+ if EXCLUDED_PARTS.intersection(path.parts):
555
+ continue
556
+ if any(rel.startswith(x) for x in PYTHON_FORMAT_EXCLUDE):
557
+ continue
558
+ found.append(path)
559
+ return found
560
+
561
+
562
+ _PYTHON_PIPELINE_ROUNDS = 4
563
+
564
+
565
+ def _snapshot(files: list[Path]) -> dict[Path, bytes]:
566
+ out: dict[Path, bytes] = {}
567
+ for path in files:
568
+ try:
569
+ out[path] = path.read_bytes()
570
+ except OSError:
571
+ continue
572
+ return out
573
+
574
+
575
+ def _python_pipeline(root: Path, targets: list[str], files: list[Path]) -> int:
576
+ """Run `ruff format` + the `html(` hug until the tree stops changing.
577
+
578
+ One pass is not enough, because the two steps feed each other. Ruff splits
579
+ `html(` off its template; the hug rejoins it; and for a call whose template
580
+ is the *only* argument, that rejoin then lets ruff pull the closing `)` up
581
+ on its next run. Iterating to a fixed point is what makes the result stable
582
+ -- and it is what lets `--check` replay this exact function against a mirror
583
+ of the tree, instead of trusting `ruff format --check`, which would flag
584
+ every hugged call as unformatted because ruff is what splits them.
585
+
586
+ It settles in a couple of rounds: a multi-argument call lands on
587
+ ruff-splits-then-hug-rejoins, which is a fixed point of the *pair* even
588
+ though neither step is idempotent alone.
589
+
590
+ Returns the number of files whose bytes changed.
591
+ """
592
+ initial = _snapshot(files)
593
+ for _ in range(_PYTHON_PIPELINE_ROUNDS):
594
+ before = _snapshot(files)
595
+ subprocess.run(
596
+ _ruff_format_cmd(targets),
597
+ cwd=root,
598
+ capture_output=True,
599
+ text=True,
600
+ encoding="utf-8",
601
+ errors="replace",
602
+ )
603
+ hug_html_call_openings(files, write=True)
604
+ if _snapshot(files) == before:
605
+ break
606
+ final = _snapshot(files)
607
+ return sum(1 for path, data in initial.items() if final.get(path) != data)
608
+
609
+
610
+ def run_ruff_format(*, write: bool) -> tuple[bool, str]:
611
+ targets = [t for t in PYTHON_TARGETS if (PROJECT_ROOT / t).exists()]
612
+ files = _python_files_for_hug()
613
+
614
+ if write:
615
+ changed = _python_pipeline(PROJECT_ROOT, targets, files)
616
+ total = len(files)
617
+ if changed:
618
+ return True, f"{changed} file(s) reformatted, {total - changed} left unchanged"
619
+ return True, f"{total} files already formatted"
620
+
621
+ with tempfile.TemporaryDirectory(prefix="pp-pyfmt-") as tmp:
622
+ root = Path(tmp)
623
+ # Ruff resolves `include`/`exclude` relative to the config's directory,
624
+ # so the mirror needs the same relative layout and its own copy of the
625
+ # config, or every file is filtered out as "not part of the project".
626
+ (root / "pyproject.toml").write_bytes((PROJECT_ROOT / "pyproject.toml").read_bytes())
627
+ mirrored: list[tuple[Path, Path]] = []
628
+ for path in files:
629
+ dest = root / path.relative_to(PROJECT_ROOT)
630
+ dest.parent.mkdir(parents=True, exist_ok=True)
631
+ try:
632
+ dest.write_bytes(path.read_bytes())
633
+ except OSError:
634
+ continue
635
+ mirrored.append((path, dest))
636
+
637
+ _python_pipeline(root, targets, [d for _, d in mirrored])
638
+ differing = sum(
639
+ 1 for original, dest in mirrored if original.read_bytes() != dest.read_bytes()
640
+ )
641
+
642
+ total = len(mirrored)
643
+ if differing:
644
+ return (
645
+ False,
646
+ f"{differing} file(s) would be reformatted, {total - differing} already formatted",
647
+ )
648
+ return True, f"{total} files already formatted"
649
+
650
+
651
+ # ---------------------------------------------------------------------------
652
+ # reporting
653
+ # ---------------------------------------------------------------------------
654
+
655
+
656
+ def print_report(report: MarkupReport, ruff_line: str, *, write: bool) -> None:
657
+ verb = "formatted" if write else "would format"
658
+ if ruff_line:
659
+ print(f"{bold('python')} {ruff_line}")
660
+ if report.error:
661
+ print(f"{bold('markup')} {red(report.error)}")
662
+ return
663
+ print(
664
+ f"{bold('markup')} {verb} {report.formatted} of {report.scanned} block(s); "
665
+ f"{report.already} already formatted; {len(report.skips)} skipped"
666
+ )
667
+ if not report.skips:
668
+ return
669
+ print(
670
+ f"\n{yellow('skipped')} — djLint's output could not be proved to render "
671
+ f"identically, so these were left alone:"
672
+ )
673
+ by_file: dict[str, list[Skip]] = {}
674
+ for skip in report.skips:
675
+ by_file.setdefault(skip.path, []).append(skip)
676
+ for path in sorted(by_file):
677
+ print(f" {path}")
678
+ for skip in sorted(by_file[path], key=lambda s: s.lineno):
679
+ print(f" {skip.lineno}: {skip.reason}")
680
+
681
+
682
+ def main() -> int:
683
+ parser = argparse.ArgumentParser(
684
+ description="Format app Python (ruff) and authored markup (djLint)."
685
+ )
686
+ parser.add_argument(
687
+ "--check",
688
+ action="store_true",
689
+ help="report what would change without writing; exit 1 if work remains",
690
+ )
691
+ parser.add_argument("--python", action="store_true", help="format Python only")
692
+ parser.add_argument("--markup", action="store_true", help="format markup only")
693
+ args = parser.parse_args()
694
+
695
+ do_python = args.python or not args.markup
696
+ do_markup = args.markup or not args.python
697
+ write = not args.check
698
+
699
+ # Markup first, Python second. Reformatting a template changes how many
700
+ # lines its string literal spans, which can change how ruff wraps the
701
+ # enclosing `html(...)` call. Running ruff last means one pass converges;
702
+ # the other order leaves files that `--check` would still flag.
703
+ report = MarkupReport()
704
+ if do_markup:
705
+ report = format_markup_blocks(write=write)
706
+
707
+ ruff_ok, ruff_line = (True, "")
708
+ if do_python:
709
+ ruff_ok, ruff_line = run_ruff_format(write=write)
710
+
711
+ print_report(report, ruff_line, write=write)
712
+
713
+ if report.error:
714
+ return 1
715
+ if args.check:
716
+ return 0 if (ruff_ok and report.formatted == 0) else 1
717
+ return 0
718
+
719
+
720
+ if __name__ == "__main__":
721
+ raise SystemExit(main())