devicectl-core 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.
- devicectl/__init__.py +18 -0
- devicectl/cli/__init__.py +1 -0
- devicectl/cli/command.py +95 -0
- devicectl/cli/exits.py +32 -0
- devicectl/cli/fanout.py +142 -0
- devicectl/cli/main.py +69 -0
- devicectl/cli/output.py +299 -0
- devicectl/cli/parser.py +80 -0
- devicectl/cli/report.py +86 -0
- devicectl/cli/target.py +26 -0
- devicectl/clock.py +57 -0
- devicectl/devtools/__init__.py +6 -0
- devicectl/devtools/frontlint.py +935 -0
- devicectl/devtools/htmcheck.py +396 -0
- devicectl/devtools/rendercheck.py +384 -0
- devicectl/doctor.py +112 -0
- devicectl/errors.py +68 -0
- devicectl/fields.py +564 -0
- devicectl/meta.py +64 -0
- devicectl/paths.py +40 -0
- devicectl/progress.py +77 -0
- devicectl/report.py +67 -0
- devicectl/testing.py +199 -0
- devicectl/trace.py +333 -0
- devicectl/web/__init__.py +1 -0
- devicectl/web/agents.py +94 -0
- devicectl/web/events.py +171 -0
- devicectl/web/http.py +243 -0
- devicectl/web/progress.py +101 -0
- devicectl/web/server.py +1013 -0
- devicectl/web/static/core.css +3034 -0
- devicectl/web/static/js/api.js +198 -0
- devicectl/web/static/js/band.js +640 -0
- devicectl/web/static/js/chart.js +400 -0
- devicectl/web/static/js/drafts.js +312 -0
- devicectl/web/static/js/notify.js +272 -0
- devicectl/web/static/js/panels.js +432 -0
- devicectl/web/static/js/shell.js +672 -0
- devicectl/web/static/js/trace.js +133 -0
- devicectl/web/static/js/ui.js +1139 -0
- devicectl/web/static/vendor/preact-htm.module.js +27 -0
- devicectl/web/worker.py +697 -0
- devicectl_core-0.1.0.dist-info/METADATA +131 -0
- devicectl_core-0.1.0.dist-info/RECORD +47 -0
- devicectl_core-0.1.0.dist-info/WHEEL +4 -0
- devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
- devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render every ``html`` template in the web UI, and report what mis-parses.
|
|
3
|
+
|
|
4
|
+
The templates are tagged-template literals driven through the vendored
|
|
5
|
+
preact-htm bundle -- the same code the browser runs -- so this check needs
|
|
6
|
+
no browser and no build step, only a small JavaScript engine. It exists
|
|
7
|
+
because of a bug class no static check can see. An attribute expression
|
|
8
|
+
that has lost its ``$`` -- ``onWrite={(payload) =>`` -- is read by htm as
|
|
9
|
+
a quoted value whose text then swallows the markup up to the next ``}``;
|
|
10
|
+
the page does not crash, the tab is just quietly wrong: elements without
|
|
11
|
+
tag names, a handler's source rendered as prose. Something like this
|
|
12
|
+
shipped once, and neither Biome nor the wiring checks saw anything.
|
|
13
|
+
|
|
14
|
+
So that is the check: evaluate each template through the real parser with
|
|
15
|
+
every free identifier stubbed, then walk the resulting virtual DOM and
|
|
16
|
+
report the one shape no valid output contains -- an element whose type is
|
|
17
|
+
``undefined``, its tag name consumed as text (``H001``). The stubs cannot
|
|
18
|
+
hide a mis-parse: parsing happens in the template's statics, before any
|
|
19
|
+
value is ever looked up, so anything that renders cleanly under stubs
|
|
20
|
+
parses cleanly in the browser.
|
|
21
|
+
|
|
22
|
+
The parser is also what says where the markup ends, which catches a
|
|
23
|
+
second mistake that renders rather than crashing. A template is markup,
|
|
24
|
+
not code, and markup has no comments: a ``/* ... */`` written inside one
|
|
25
|
+
is not stripped by anything -- htm keeps it as text and the page shows it
|
|
26
|
+
to the user, in the middle of a card (``H004``). The tag is wrapped on
|
|
27
|
+
the way in so each template's statics -- its markup, everything that is
|
|
28
|
+
not a ``${...}`` -- can be read as it parses, which is what keeps a real
|
|
29
|
+
comment inside an interpolation from being mistaken for one.
|
|
30
|
+
|
|
31
|
+
The engine is PyMiniRacer: an embedded V8 in one wheel, so no Node, no
|
|
32
|
+
npm and nothing to compile. It is neither a dependency of any program here
|
|
33
|
+
nor one of the default dev set -- it is ~80 MB and publishes no armv7
|
|
34
|
+
wheel, and these tools have to stay installable on a Raspberry Pi. It
|
|
35
|
+
lives in the ``browser`` dependency group instead::
|
|
36
|
+
|
|
37
|
+
uv sync --group browser
|
|
38
|
+
|
|
39
|
+
and the check skips itself, saying so, when the engine is absent. Run it
|
|
40
|
+
over a program's own UI::
|
|
41
|
+
|
|
42
|
+
python -m devicectl.devtools.htmcheck src/<program>/web/static
|
|
43
|
+
|
|
44
|
+
or let that program's pytest do it; either way a problem is one line of
|
|
45
|
+
``path:line: CODE message``.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import re
|
|
51
|
+
import sys
|
|
52
|
+
from dataclasses import dataclass
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
# ty: ignore[unresolved-import] -- the engine lives in the `browser`
|
|
57
|
+
# dependency group, so it is absent from a default checkout by design and
|
|
58
|
+
# the type checker never sees it.
|
|
59
|
+
from py_mini_racer import MiniRacer
|
|
60
|
+
except ImportError: # the engine is a tool, not a dependency (see Biome)
|
|
61
|
+
MiniRacer = None # type: ignore[assignment]
|
|
62
|
+
|
|
63
|
+
# The vendor bundle's single export statement names the minified locals it
|
|
64
|
+
# binds; htm's ``html`` is the tag this check drives. Replacing the export
|
|
65
|
+
# with plain ``var`` bindings makes the bundle evaluable as a script, which
|
|
66
|
+
# is the only way to load it in an engine without ES modules.
|
|
67
|
+
VENDOR_EXPORT = re.compile(r"export\{[^}]*\};?\s*$")
|
|
68
|
+
BINDINGS = "var h = a, html = fe, render = M;"
|
|
69
|
+
|
|
70
|
+
# What every template may reference without naming it: the language's own
|
|
71
|
+
# words and globals. Everything else is a free identifier -- a prop, an
|
|
72
|
+
# import, a helper -- and gets a stub, because the parse happens before any
|
|
73
|
+
# of them is ever looked up.
|
|
74
|
+
RESERVED = frozenset(
|
|
75
|
+
"""
|
|
76
|
+
break case catch class const continue debugger default delete do else
|
|
77
|
+
export extends finally for function if import in instanceof new return
|
|
78
|
+
super switch this throw try typeof var void while with yield let static
|
|
79
|
+
enum await implements package protected interface private public
|
|
80
|
+
arguments eval true false null undefined
|
|
81
|
+
""".split()
|
|
82
|
+
)
|
|
83
|
+
ENGINE_GLOBALS = frozenset(
|
|
84
|
+
"""
|
|
85
|
+
h html render Math JSON String Number Array Object Boolean Symbol Proxy
|
|
86
|
+
Map Set Date RegExp Error isNaN parseInt parseFloat console window
|
|
87
|
+
document globalThis
|
|
88
|
+
""".split()
|
|
89
|
+
)
|
|
90
|
+
IDENTIFIER = re.compile(r"[A-Za-z_$][\w$]*")
|
|
91
|
+
|
|
92
|
+
# What a template is not: ``html`` in prose (a comment) or in a string.
|
|
93
|
+
# Their interiors are blanked before the search, same length so the line
|
|
94
|
+
# numbers survive; template literals are left alone, because one template
|
|
95
|
+
# inside another's interpolation is code, not prose.
|
|
96
|
+
_COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S)
|
|
97
|
+
_QUOTED = re.compile(r"'(?:[^'\\\n]|\\.)*'|\"(?:[^\"\\\n]|\\.)*\"")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _blanked(text: str) -> str:
|
|
101
|
+
"""Replace comment and string interiors with spaces, keeping the shape."""
|
|
102
|
+
|
|
103
|
+
def blanks(match: re.Match[str]) -> str:
|
|
104
|
+
whole = match.group(0)
|
|
105
|
+
return whole[0] + " " * (len(whole) - 2) + whole[-1]
|
|
106
|
+
|
|
107
|
+
out = _COMMENT.sub(lambda m: " " * len(m.group(0)), text)
|
|
108
|
+
return _QUOTED.sub(blanks, out)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def templates(text: str) -> list[tuple[int, str]]:
|
|
112
|
+
"""Find every ``html`...``` template and the line it starts on.
|
|
113
|
+
|
|
114
|
+
The scan for openers runs over the blanked text, so a mention in
|
|
115
|
+
prose is not a template; the span then comes from the real text,
|
|
116
|
+
brace-aware -- a backtick inside ``${...}`` belongs to a nested
|
|
117
|
+
template -- because half the UI nests one template inside another's
|
|
118
|
+
interpolations.
|
|
119
|
+
"""
|
|
120
|
+
where = _blanked(text)
|
|
121
|
+
found: list[tuple[int, str]] = []
|
|
122
|
+
start = 0
|
|
123
|
+
while True:
|
|
124
|
+
at = where.find("html`", start)
|
|
125
|
+
if at < 0:
|
|
126
|
+
return found
|
|
127
|
+
line = text.count("\n", 0, at) + 1
|
|
128
|
+
i = at + len("html`")
|
|
129
|
+
depth = 0
|
|
130
|
+
while i < len(text):
|
|
131
|
+
ch = text[i]
|
|
132
|
+
if ch == "{":
|
|
133
|
+
depth += 1
|
|
134
|
+
elif ch == "}":
|
|
135
|
+
depth -= 1
|
|
136
|
+
elif ch == "`" and depth == 0:
|
|
137
|
+
break
|
|
138
|
+
i += 1
|
|
139
|
+
if i >= len(text): # an unterminated template: biome's to say, not ours
|
|
140
|
+
start = at + 5
|
|
141
|
+
continue
|
|
142
|
+
found.append((line, text[at : i + 1]))
|
|
143
|
+
start = i + 1
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# A stub stands in for whatever the template is tooled against: call it,
|
|
147
|
+
# read any property, iterate it, spread it -- none of that is this check's
|
|
148
|
+
# business, and none of it can hide a mis-parse, which happens in the
|
|
149
|
+
# statics before any value is touched.
|
|
150
|
+
#
|
|
151
|
+
# With one thing it does have to do: call the functions it is handed. Most
|
|
152
|
+
# of this UI's markup is written inside a callback -- ``${rows.map((row) =>
|
|
153
|
+
# html`...`)}`` -- and a callback nobody calls is a template nobody parses.
|
|
154
|
+
# A stub that only returned itself stopped the check at the first ``.map``,
|
|
155
|
+
# so a card whose rows were built that way was reported clean whatever was
|
|
156
|
+
# wrong with them. What the call itself does is still not this check's
|
|
157
|
+
# business, so what it throws is dropped: the parse the check came for has
|
|
158
|
+
# already happened by then. Nor does the markup a callback builds have to
|
|
159
|
+
# come back out of it -- a stub still hands its caller a stub rather than
|
|
160
|
+
# the mapped array, and every template reports itself to the tap instead.
|
|
161
|
+
STUBS = """
|
|
162
|
+
var depth = 0;
|
|
163
|
+
function stubFor() {
|
|
164
|
+
const stub = new Proxy(function () {}, {
|
|
165
|
+
get(target, key) {
|
|
166
|
+
if (key === Symbol.iterator) return function* () {};
|
|
167
|
+
if (key === 'length') return 0;
|
|
168
|
+
if (key === Symbol.toPrimitive) return () => 'stub';
|
|
169
|
+
return stub;
|
|
170
|
+
},
|
|
171
|
+
apply(target, self, args) {
|
|
172
|
+
for (const arg of args) {
|
|
173
|
+
if (typeof arg !== 'function' || depth > 8) continue;
|
|
174
|
+
depth += 1;
|
|
175
|
+
try { arg(stubFor(), 0, stubFor()); } catch (err) {} finally { depth -= 1; }
|
|
176
|
+
}
|
|
177
|
+
return stub;
|
|
178
|
+
},
|
|
179
|
+
has() { return true; },
|
|
180
|
+
});
|
|
181
|
+
return stub;
|
|
182
|
+
}
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# The vendored tag, wrapped so the check can read both what it was handed
|
|
187
|
+
# and what it made of it. A tagged template's ``strings.raw`` is its
|
|
188
|
+
# statics: the markup, split at the interpolations, exactly as written.
|
|
189
|
+
#
|
|
190
|
+
# Every template reports itself here, nested ones included, which is what
|
|
191
|
+
# makes a template built inside a callback checkable at all -- its result
|
|
192
|
+
# is walked from this list rather than from the tree, because the stub
|
|
193
|
+
# that called the callback threw the return value away.
|
|
194
|
+
TAP = """
|
|
195
|
+
function tapped(seen, built) {
|
|
196
|
+
return function (strings) {
|
|
197
|
+
const raw = strings.raw || strings;
|
|
198
|
+
for (const part of raw) seen.push(part);
|
|
199
|
+
const out = html.apply(null, arguments);
|
|
200
|
+
built.push(out);
|
|
201
|
+
return out;
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# The one shape a mis-parsed template reliably leaves behind: something in
|
|
208
|
+
# the output with no ``type``. That is htm's own half-built ``[tag, props,
|
|
209
|
+
# ...]`` coming back whole rather than being made into an element, so what
|
|
210
|
+
# the walk actually meets is the bare props object; a template that parsed
|
|
211
|
+
# hands back elements, whose type is always a tag name or a component.
|
|
212
|
+
#
|
|
213
|
+
# It is only ever given a template's own output -- every result the tap
|
|
214
|
+
# collected -- because a plain object is only wrong where an element was
|
|
215
|
+
# meant. One returned from a ``.map`` that builds a select's entries is
|
|
216
|
+
# data, and reading the rule onto those would condemn half the UI.
|
|
217
|
+
WALK = """
|
|
218
|
+
function walk(node, issues) {
|
|
219
|
+
if (node == null || node === undefined) return;
|
|
220
|
+
if (Array.isArray(node)) { node.forEach((child) => walk(child, issues)); return; }
|
|
221
|
+
if (typeof node !== 'object') return;
|
|
222
|
+
if (node.type === undefined) issues.push('an element without a tag name');
|
|
223
|
+
const kids = node.props && node.props.children;
|
|
224
|
+
if (kids != null) walk(kids, issues);
|
|
225
|
+
}
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Comment-shaped text in a template's markup. A block comment is reported
|
|
230
|
+
# wherever it sits, because nothing else in this UI's markup is written
|
|
231
|
+
# ``/* ... */``. A line comment is only reported when it starts a line: a
|
|
232
|
+
# static can begin mid-line, just after an interpolation, and ``${base}//x``
|
|
233
|
+
# is a path, not a comment -- as is the ``//`` in every URL.
|
|
234
|
+
MARKUP_COMMENT = (
|
|
235
|
+
re.compile(r"/\*.*?\*/", re.S),
|
|
236
|
+
re.compile(r"\n[ \t]*//[^\n]*"),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def markup_comments(statics: list[str]) -> list[str]:
|
|
241
|
+
"""Find the comment-shaped text a template would render, in order."""
|
|
242
|
+
return [
|
|
243
|
+
match.group(0).strip()
|
|
244
|
+
for static in statics
|
|
245
|
+
for pattern in MARKUP_COMMENT
|
|
246
|
+
for match in pattern.finditer(static)
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True)
|
|
251
|
+
class Problem:
|
|
252
|
+
"""One thing wrong, at one place, in the words a reader needs."""
|
|
253
|
+
|
|
254
|
+
path: Path
|
|
255
|
+
line: int
|
|
256
|
+
code: str
|
|
257
|
+
message: str
|
|
258
|
+
|
|
259
|
+
def render(self, root: Path) -> str:
|
|
260
|
+
"""One line of ``path:line: CODE message``, relative to the root."""
|
|
261
|
+
where = (
|
|
262
|
+
self.path.relative_to(root) if self.path.is_relative_to(root) else self.path
|
|
263
|
+
)
|
|
264
|
+
return f"{where}:{self.line}: {self.code} {self.message}"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def check_file(path: Path, text: str, engine) -> list[Problem]:
|
|
268
|
+
"""Evaluate one module's templates through the vendored parser."""
|
|
269
|
+
found: list[Problem] = []
|
|
270
|
+
for line, template in templates(text):
|
|
271
|
+
# Every identifier the template names becomes a parameter, every
|
|
272
|
+
# parameter a stub: the parse runs to completion no matter what the
|
|
273
|
+
# component was tooled against.
|
|
274
|
+
names = sorted(set(IDENTIFIER.findall(template)) - RESERVED - ENGINE_GLOBALS)
|
|
275
|
+
params = ", ".join(names)
|
|
276
|
+
args = ", ".join("stubFor()" for _ in names)
|
|
277
|
+
wrapped = (
|
|
278
|
+
f"(function ({params}) {{ var __seen = [], __built = []; "
|
|
279
|
+
f"var html = tapped(__seen, __built); "
|
|
280
|
+
f"var threw = null; "
|
|
281
|
+
f"try {{ {template}; }} catch (err) {{ threw = String(err); }} "
|
|
282
|
+
f"var issues = []; walk(__built, issues); "
|
|
283
|
+
f"if (threw !== null) issues = [threw]; "
|
|
284
|
+
f"issues = issues.filter(function (m, i) {{ return issues.indexOf(m) === i; }}); "
|
|
285
|
+
f"return JSON.stringify({{ issues: issues, statics: __seen }}); }})({args})"
|
|
286
|
+
)
|
|
287
|
+
try:
|
|
288
|
+
report = str(engine.eval(wrapped))
|
|
289
|
+
except Exception as err: # noqa: BLE001 - the engine refused the wrapper itself
|
|
290
|
+
found.append(Problem(path, line, "H002", f"could not be evaluated: {err}"))
|
|
291
|
+
continue
|
|
292
|
+
issues, statics = read_report(report)
|
|
293
|
+
if issues:
|
|
294
|
+
found.append(Problem(path, line, "H001", "; ".join(issues)))
|
|
295
|
+
# A template that never parsed has no markup to read; whatever the
|
|
296
|
+
# statics hold, the mis-parse above is the thing to fix first.
|
|
297
|
+
for comment in [] if issues else markup_comments(statics):
|
|
298
|
+
found.append(
|
|
299
|
+
Problem(
|
|
300
|
+
path,
|
|
301
|
+
line + template.count("\n", 0, max(template.find(comment), 0)),
|
|
302
|
+
"H004",
|
|
303
|
+
f"a comment inside the template, which the page shows as "
|
|
304
|
+
f"text: {shorten(comment)}",
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
return found
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def shorten(text: str, width: int = 60) -> str:
|
|
311
|
+
"""One line of it, enough to find it by."""
|
|
312
|
+
one = " ".join(text.split())
|
|
313
|
+
return one if len(one) <= width else f"{one[: width - 3]}..."
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def read_report(report: str) -> tuple[list[str], list[str]]:
|
|
317
|
+
"""Read back the run's findings; a malformed report is its own finding."""
|
|
318
|
+
import json
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
doc = json.loads(report)
|
|
322
|
+
return [str(item) for item in doc["issues"]], [
|
|
323
|
+
str(part) for part in doc["statics"]
|
|
324
|
+
]
|
|
325
|
+
except (ValueError, KeyError, TypeError):
|
|
326
|
+
return [f"unreportable: {report[:100]}"], []
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def check_tree(root: Path, *, core: Path | None = None) -> list[Problem]:
|
|
330
|
+
"""Check every module under ``js/`` against the vendored parser.
|
|
331
|
+
|
|
332
|
+
``core`` names the shared package's static tree, which is where the
|
|
333
|
+
runtime lives for a program that imports the shared frontend: there is
|
|
334
|
+
one copy of Preact on a page, and a program that adopts this package
|
|
335
|
+
has no ``vendor/`` of its own.
|
|
336
|
+
"""
|
|
337
|
+
if MiniRacer is None:
|
|
338
|
+
print("htmcheck: mini-racer is not installed; nothing was checked")
|
|
339
|
+
print(" install the engine with: uv sync --group browser")
|
|
340
|
+
return []
|
|
341
|
+
vendor = next(root.glob("vendor/*.js"), None)
|
|
342
|
+
if vendor is None and core is not None:
|
|
343
|
+
vendor = next(core.glob("vendor/*.js"), None)
|
|
344
|
+
if vendor is None:
|
|
345
|
+
return [Problem(root, 0, "H003", "no vendored runtime under vendor/")]
|
|
346
|
+
engine = MiniRacer()
|
|
347
|
+
engine.eval(VENDOR_EXPORT.sub(BINDINGS, vendor.read_text(encoding="utf-8")))
|
|
348
|
+
engine.eval(STUBS)
|
|
349
|
+
engine.eval(TAP)
|
|
350
|
+
engine.eval(WALK)
|
|
351
|
+
found: list[Problem] = []
|
|
352
|
+
for path in sorted(root.glob("js/*.js")):
|
|
353
|
+
found += check_file(path, path.read_text(encoding="utf-8"), engine)
|
|
354
|
+
return found
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
USAGE = (
|
|
358
|
+
"usage: python -m devicectl.devtools.htmcheck <static-root> "
|
|
359
|
+
"[--core <shared-static-root>]"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def main(argv: list[str] | None = None) -> int:
|
|
364
|
+
"""Check the static tree named on the command line."""
|
|
365
|
+
if MiniRacer is None:
|
|
366
|
+
print(
|
|
367
|
+
"htmcheck: skipping -- mini-racer is not installed.\n"
|
|
368
|
+
" the engine lives in the browser group: uv sync --group browser"
|
|
369
|
+
)
|
|
370
|
+
return 0
|
|
371
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
372
|
+
core: Path | None = None
|
|
373
|
+
if "--core" in args:
|
|
374
|
+
at = args.index("--core")
|
|
375
|
+
if at + 1 >= len(args):
|
|
376
|
+
print(USAGE)
|
|
377
|
+
return 2
|
|
378
|
+
core = Path(args[at + 1])
|
|
379
|
+
del args[at : at + 2]
|
|
380
|
+
if not args:
|
|
381
|
+
print(USAGE)
|
|
382
|
+
return 2
|
|
383
|
+
root = Path(args[0])
|
|
384
|
+
problems = check_tree(root, core=core)
|
|
385
|
+
for problem in problems:
|
|
386
|
+
print(problem.render(root.parent))
|
|
387
|
+
print(
|
|
388
|
+
f"htmcheck: {len(problems)} problem(s) in {root}"
|
|
389
|
+
if problems
|
|
390
|
+
else f"htmcheck: clean ({root})"
|
|
391
|
+
)
|
|
392
|
+
return 1 if problems else 0
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
if __name__ == "__main__":
|
|
396
|
+
raise SystemExit(main())
|