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.
- package/dist/.github/copilot-instructions.md +5 -2
- package/dist/AGENTS.md +5 -1
- package/dist/index.js +1 -1
- package/dist/main.py +152 -156
- package/dist/public/js/pp-reactive-v2.min.js +1 -1
- package/dist/settings/_component_imports.py +1 -0
- package/dist/settings/_markup_equivalence.py +320 -0
- package/dist/settings/browser_log.py +13 -6
- package/dist/settings/build-static.py +5 -4
- package/dist/settings/check.py +6 -3
- package/dist/settings/check_templates.py +90 -21
- package/dist/settings/fix.py +23 -5
- package/dist/settings/format.py +721 -0
- package/dist/settings/serve-static.py +1 -2
- package/dist/src/app/index.py +67 -133
- package/dist/src/app/layout.py +15 -0
- package/dist/tests/README.md +119 -4
- package/dist/tests/conftest.py +1 -0
- package/dist/tests/test_health_route.py +1 -3
- package/dist/tests/test_main_helpers.py +4 -7
- package/package.json +1 -1
|
@@ -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())
|