-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlint.py
More file actions
217 lines (191 loc) · 8.94 KB
/
Copy pathlint.py
File metadata and controls
217 lines (191 loc) · 8.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Lint a Celerp module without installing the app.
Runs the same structural checks the loader runs at startup, so you catch
problems in seconds instead of on a failed boot:
- the folder has an __init__.py with a PLUGIN_MANIFEST
- the manifest has the required identity fields and at least one slot/route
- the module name is not in the reserved `celerp-` namespace
- the manifest and its nav slots use only keys Celerp actually reads, so a
misspelled or invented key is not silently ignored at load time
- no source file imports a protected celerp internal (revenue-gated; the
loader rejects modules that do)
- no fragment is rendered with str(); FT.__str__ returns the element id, so
that sends the browser a word where its markup should be
Usage: python lint.py path/to/your-module-folder
Exit 0 = clean, 1 = problems (printed).
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
# Kept in sync with celerp/modules/loader.py _PROTECTED_BSL_INTERNALS.
PROTECTED = {
"celerp.session_gate", "celerp.ai.service", "celerp.ai.quota",
"celerp.gateway", "celerp.connectors",
}
REQUIRED_FIELDS = ("name", "version", "display_name", "license")
# Every key Celerp reads out of a manifest. An unknown key is not an error to the
# loader, it is simply ignored, which is why it has to be an error here: a module
# whose gating key is misspelled ships wide open and nothing says a word.
MANIFEST_KEYS = {
"name", "version", "display_name", "label", "description", "license", "author",
"min_celerp_version", "api_routes", "ui_routes", "slots", "migrations",
"table_prefix", "depends_on", "soft_depends", "requires", "first_party",
}
# The keys a nav slot entry may carry (ui/components/shell.py builds the sidebar).
NAV_ITEM_KEYS = {
"group", "key", "href", "label", "label_key", "order", "settings_href",
"permission",
}
# min_celerp_version is optional, but when set it must be a dotted version so
# the loader's comparison means something.
MIN_VERSION_RE = re.compile(r"^\d+(\.\d+){0,2}$")
def _load_manifest(init_file: Path) -> tuple[dict | None, str | None]:
"""Return (manifest, error). A syntax error in `__init__.py` is the most
common first mistake, so it is reported by name instead of raised as a
traceback - the point of this script is to name the problem."""
try:
tree = ast.parse(init_file.read_text())
except SyntaxError as exc:
return None, f"could not parse the file (line {exc.lineno}: {exc.msg})"
except (OSError, UnicodeDecodeError) as exc:
return None, f"could not be read ({exc})"
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for t in node.targets:
if isinstance(t, ast.Name) and t.id == "PLUGIN_MANIFEST":
try:
return ast.literal_eval(node.value), None
except Exception:
return None, None
return None, None
def _protected_imports(py_file: Path) -> set[str]:
hits: set[str] = set()
try:
tree = ast.parse(py_file.read_text())
except Exception:
return hits
for node in ast.walk(tree):
names = []
if isinstance(node, ast.Import):
names = [a.name for a in node.names]
elif isinstance(node, ast.ImportFrom) and node.module:
names = [node.module]
for name in names:
for p in PROTECTED:
if name == p or name.startswith(p + "."):
hits.add(p)
return hits
def _manifest_key_problems(manifest: dict) -> list[str]:
"""Keys Celerp will read straight past."""
problems = []
for key in sorted(k for k in manifest if k not in MANIFEST_KEYS):
problems.append(f"manifest has unknown key {key!r} - Celerp reads none of it, "
f"so it does nothing at load time")
for index, item in enumerate(_nav_items(manifest)):
for key in sorted(k for k in item if k not in NAV_ITEM_KEYS):
hint = (" - core hides a nav entry by the role's \"permission\", so this "
"entry is visible to everyone" if key == "min_role" else "")
problems.append(f"nav slot entry {index} has unknown key {key!r}{hint}")
return problems
def _nav_items(manifest: dict) -> list[dict]:
"""The nav slot's entries, whichever shape the author wrote it in."""
nav = (manifest.get("slots") or {}).get("nav")
if isinstance(nav, dict):
return [nav]
if isinstance(nav, list):
return [item for item in nav if isinstance(item, dict)]
return []
def _str_rendered_fragments(py_file: Path) -> list[str]:
"""Lines that hand a fragment to str() instead of to_xml().
`FT.__str__` returns `self.id`, so `str(Div(..., id="rows"))` is the five
characters "rows". It raises nothing, logs nothing, and every HTMX swap
replaces the page region with that word, which is why this check exists.
"""
try:
tree = ast.parse(py_file.read_text())
except Exception:
return []
found = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
called = _called_name(node)
for inner in [node] + list(node.args):
if not (isinstance(inner, ast.Call) and _called_name(inner) == "str"):
continue
arg = inner.args[0] if inner.args else None
# An FT constructor is capitalised (Div, Table); so is any response
# class, so a str() inside one is suspect whatever its argument is.
builds_element = (isinstance(arg, ast.Call)
and (_called_name(arg) or "")[:1].isupper())
if builds_element or (called or "").endswith("Response"):
found.append(str(inner.lineno))
return sorted(set(found), key=int)
def _called_name(node: ast.Call) -> str | None:
func = node.func
if isinstance(func, ast.Name):
return func.id
if isinstance(func, ast.Attribute):
return func.attr
return None
def lint(folder: Path) -> list[str]:
problems: list[str] = []
init_file = folder / "__init__.py"
if not init_file.exists():
return [f"{folder}: no __init__.py (a module folder must have one)"]
manifest, error = _load_manifest(init_file)
if error is not None:
return [f"{init_file}: {error}"]
if manifest is None:
return [f"{init_file}: no parseable PLUGIN_MANIFEST dict"]
for field in REQUIRED_FIELDS:
if not manifest.get(field):
problems.append(f"manifest missing required field: {field!r}")
name = str(manifest.get("name", ""))
if name.startswith("celerp-"):
problems.append(f"name {name!r} uses the reserved `celerp-` namespace - "
"prefix with your own vendor name")
# Celerp installs a module under its manifest name, whatever the folder is
# called, so renaming only one of the two lands the module somewhere the
# author is not looking (or on top of the module they copied).
if name and folder.name != name:
problems.append(f"folder name {folder.name!r} does not match "
f"PLUGIN_MANIFEST['name'] {name!r} - Celerp installs modules "
f"under the manifest name, so this would install as {name!r}")
min_version = manifest.get("min_celerp_version")
if min_version is not None and not MIN_VERSION_RE.match(str(min_version)):
problems.append(f"min_celerp_version {min_version!r} is not a dotted version "
"number like '1.4.2' - the version check would not work")
if not (manifest.get("slots") or manifest.get("api_routes") or manifest.get("ui_routes")):
problems.append("manifest declares no slots and no routes - the module does nothing")
problems.extend(_manifest_key_problems(manifest))
for py_file in folder.rglob("*.py"):
rel = py_file.relative_to(folder)
hits = _protected_imports(py_file)
for h in sorted(hits):
problems.append(f"{rel}: imports protected internal {h!r} "
"- the loader will reject this module")
lines = _str_rendered_fragments(py_file)
if lines:
problems.append(f"{rel}: renders a fragment with str() at line(s) "
f"{', '.join(lines)} - FT.__str__ returns the element id, so "
f"the browser gets that word instead of markup; use to_xml(...)")
return problems
def main() -> int:
if len(sys.argv) != 2:
print("usage: python lint.py path/to/your-module-folder")
return 2
folder = Path(sys.argv[1]).resolve()
problems = lint(folder)
if problems:
print(f"✗ {folder.name}: {len(problems)} problem(s)")
for p in problems:
print(f" - {p}")
return 1
print(f"✓ {folder.name}: looks good")
return 0
if __name__ == "__main__":
raise SystemExit(main())