jx 0.5.0__tar.gz → 0.6.0__tar.gz
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.
- {jx-0.5.0/src/jx.egg-info → jx-0.6.0}/PKG-INFO +2 -1
- {jx-0.5.0 → jx-0.6.0}/pyproject.toml +8 -3
- {jx-0.5.0 → jx-0.6.0}/src/jx/attrs.py +1 -1
- {jx-0.5.0 → jx-0.6.0}/src/jx/catalog.py +125 -71
- jx-0.6.0/src/jx/cli.py +186 -0
- {jx-0.5.0 → jx-0.6.0}/src/jx/component.py +28 -10
- {jx-0.5.0 → jx-0.6.0}/src/jx/exceptions.py +42 -0
- {jx-0.5.0 → jx-0.6.0}/src/jx/meta.py +73 -14
- {jx-0.5.0 → jx-0.6.0}/src/jx/parser.py +21 -7
- {jx-0.5.0 → jx-0.6.0/src/jx.egg-info}/PKG-INFO +2 -1
- {jx-0.5.0 → jx-0.6.0}/src/jx.egg-info/SOURCES.txt +5 -1
- jx-0.6.0/src/jx.egg-info/entry_points.txt +2 -0
- {jx-0.5.0 → jx-0.6.0}/tests/test_catalog.py +121 -1
- jx-0.6.0/tests/test_cli.py +171 -0
- {jx-0.5.0 → jx-0.6.0}/tests/test_component.py +288 -2
- {jx-0.5.0 → jx-0.6.0}/tests/test_meta.py +108 -15
- jx-0.6.0/tests/test_thread_safety_race.py +107 -0
- {jx-0.5.0 → jx-0.6.0}/LICENSE +0 -0
- {jx-0.5.0 → jx-0.6.0}/README.md +0 -0
- {jx-0.5.0 → jx-0.6.0}/setup.cfg +0 -0
- {jx-0.5.0 → jx-0.6.0}/src/jx/__init__.py +0 -0
- {jx-0.5.0 → jx-0.6.0}/src/jx/utils.py +0 -0
- {jx-0.5.0 → jx-0.6.0}/src/jx.egg-info/dependency_links.txt +0 -0
- {jx-0.5.0 → jx-0.6.0}/src/jx.egg-info/requires.txt +0 -0
- {jx-0.5.0 → jx-0.6.0}/src/jx.egg-info/top_level.txt +0 -0
- {jx-0.5.0 → jx-0.6.0}/tests/test_attrs.py +0 -0
- {jx-0.5.0 → jx-0.6.0}/tests/test_parser.py +0 -0
- {jx-0.5.0 → jx-0.6.0}/tests/test_slots.py +0 -0
- {jx-0.5.0 → jx-0.6.0}/tests/test_thread_safety.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jx
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Replace your HTML templates with Python server-Side components
|
|
5
5
|
Author-email: Juan Pablo Scaletti <juanpablo@jpscaletti.com>
|
|
6
|
+
License-Expression: MIT
|
|
6
7
|
Project-URL: Code, https://github.com/jpsca/jx
|
|
7
8
|
Project-URL: Documentation, https://jx.scaletti.dev/
|
|
8
9
|
Classifier: Development Status :: 4 - Beta
|
|
@@ -4,12 +4,12 @@ requires = ["setuptools"]
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "jx"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Replace your HTML templates with Python server-Side components"
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Juan Pablo Scaletti", email = "juanpablo@jpscaletti.com"},
|
|
11
11
|
]
|
|
12
|
-
license =
|
|
12
|
+
license = "MIT"
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 4 - Beta",
|
|
@@ -31,6 +31,9 @@ dependencies = [
|
|
|
31
31
|
"jinja2 >= 3.0",
|
|
32
32
|
]
|
|
33
33
|
|
|
34
|
+
[project.scripts]
|
|
35
|
+
jx = "jx.cli:main"
|
|
36
|
+
|
|
34
37
|
[project.urls]
|
|
35
38
|
Code = "https://github.com/jpsca/jx"
|
|
36
39
|
Documentation = "https://jx.scaletti.dev/"
|
|
@@ -43,7 +46,7 @@ dev = [
|
|
|
43
46
|
"ty",
|
|
44
47
|
]
|
|
45
48
|
docs = [
|
|
46
|
-
"writeadoc>=0.
|
|
49
|
+
"writeadoc>=0.14",
|
|
47
50
|
]
|
|
48
51
|
test = [
|
|
49
52
|
"pytest >= 7.2",
|
|
@@ -58,7 +61,9 @@ where = ["src"]
|
|
|
58
61
|
|
|
59
62
|
[tool.ty.src]
|
|
60
63
|
exclude = [
|
|
64
|
+
"benchmark",
|
|
61
65
|
"docs",
|
|
66
|
+
"tests",
|
|
62
67
|
]
|
|
63
68
|
|
|
64
69
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Jx | Copyright (c) Juan-Pablo Scaletti
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import threading
|
|
5
6
|
import typing as t
|
|
6
7
|
from dataclasses import dataclass, field
|
|
7
8
|
from pathlib import Path
|
|
@@ -11,7 +12,7 @@ import jinja2
|
|
|
11
12
|
|
|
12
13
|
from . import utils
|
|
13
14
|
from .component import Component
|
|
14
|
-
from .exceptions import ImportError
|
|
15
|
+
from .exceptions import FileEncodingError, ImportError
|
|
15
16
|
from .meta import extract_metadata
|
|
16
17
|
from .parser import JxParser
|
|
17
18
|
from .utils import logger
|
|
@@ -23,8 +24,8 @@ class CData:
|
|
|
23
24
|
path: Path
|
|
24
25
|
mtime: float
|
|
25
26
|
code: CodeType | None = None
|
|
26
|
-
required:
|
|
27
|
-
optional: dict[str, t.Any] = field(default_factory=dict) # { attr:
|
|
27
|
+
required: dict[str, type | None] = field(default_factory=dict) # { attr: type or None }
|
|
28
|
+
optional: dict[str, tuple[t.Any, type | None]] = field(default_factory=dict) # { attr: (default, type or None) }
|
|
28
29
|
imports: dict[str, str] = field(default_factory=dict) # { name: relpath }
|
|
29
30
|
css: tuple[str, ...] = ()
|
|
30
31
|
js: tuple[str, ...] = ()
|
|
@@ -73,6 +74,7 @@ class Catalog:
|
|
|
73
74
|
Variables to make available to all components by default.
|
|
74
75
|
|
|
75
76
|
"""
|
|
77
|
+
self._lock = threading.RLock() # Protects self.components access
|
|
76
78
|
self.components = {}
|
|
77
79
|
self.jinja_env = self._make_jinja_env(
|
|
78
80
|
jinja_env=jinja_env,
|
|
@@ -97,8 +99,8 @@ class Catalog:
|
|
|
97
99
|
|
|
98
100
|
Relative imports cannot go outside the folder.
|
|
99
101
|
|
|
100
|
-
Components added with a prefix must be imported using the prefix
|
|
101
|
-
|
|
102
|
+
Components added with a prefix must be imported using the `@prefix/`
|
|
103
|
+
syntax: `@prefix/sub/folder/component.jinja`. If the importing is
|
|
102
104
|
done from within a component with the prefix itself, a relative
|
|
103
105
|
import can also be used, e.g.: `./component.jinja`.
|
|
104
106
|
|
|
@@ -133,19 +135,25 @@ class Catalog:
|
|
|
133
135
|
else:
|
|
134
136
|
logger.debug(f"Adding folder `{base_path}`")
|
|
135
137
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
with self._lock:
|
|
139
|
+
for filepath in base_path.rglob("*.jinja"):
|
|
140
|
+
relpath = f"{prefix}{filepath.relative_to(base_path).as_posix()}"
|
|
141
|
+
if relpath in self.components:
|
|
142
|
+
logger.debug(f"Component already exists: {relpath}")
|
|
143
|
+
continue
|
|
144
|
+
cdata = CData(
|
|
145
|
+
base_path=base_path, path=filepath, mtime=filepath.stat().st_mtime
|
|
146
|
+
)
|
|
147
|
+
self.components[relpath] = cdata
|
|
148
|
+
|
|
149
|
+
if preload:
|
|
150
|
+
# Take a snapshot of keys to avoid "dict changed size during iteration"
|
|
151
|
+
relpaths = list(self.components.keys())
|
|
152
|
+
|
|
153
|
+
# Preload outside the lock to avoid holding it during compilation
|
|
146
154
|
if preload:
|
|
147
|
-
for relpath in
|
|
148
|
-
self.
|
|
155
|
+
for relpath in relpaths:
|
|
156
|
+
self.get_component_data(relpath)
|
|
149
157
|
|
|
150
158
|
def render(
|
|
151
159
|
self, relpath: str, globals: dict[str, t.Any] | None = None, **kwargs
|
|
@@ -172,17 +180,15 @@ class Catalog:
|
|
|
172
180
|
co = self.get_component(relpath)
|
|
173
181
|
|
|
174
182
|
globals = globals or {}
|
|
175
|
-
globals.update(
|
|
176
|
-
{
|
|
177
|
-
"
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
"render": co.render_assets,
|
|
183
|
-
},
|
|
183
|
+
globals.update({
|
|
184
|
+
"assets": {
|
|
185
|
+
"collect_css": co.collect_css,
|
|
186
|
+
"collect_js": co.collect_js,
|
|
187
|
+
"render_css": co.render_css,
|
|
188
|
+
"render_js": co.render_js,
|
|
189
|
+
"render": co.render_assets,
|
|
184
190
|
}
|
|
185
|
-
)
|
|
191
|
+
})
|
|
186
192
|
co.globals = globals
|
|
187
193
|
|
|
188
194
|
return co.render(**kwargs)
|
|
@@ -254,37 +260,46 @@ class Catalog:
|
|
|
254
260
|
e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
|
|
255
261
|
|
|
256
262
|
"""
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if
|
|
264
|
-
if
|
|
263
|
+
with self._lock:
|
|
264
|
+
cdata = self.components.get(relpath)
|
|
265
|
+
if not cdata:
|
|
266
|
+
raise ImportError(relpath)
|
|
267
|
+
|
|
268
|
+
mtime = cdata.path.stat().st_mtime if self.auto_reload else 0
|
|
269
|
+
if cdata.code is not None:
|
|
270
|
+
if self.auto_reload:
|
|
271
|
+
if mtime == cdata.mtime:
|
|
272
|
+
return cdata
|
|
273
|
+
else:
|
|
265
274
|
return cdata
|
|
266
|
-
else:
|
|
267
|
-
return cdata
|
|
268
275
|
|
|
269
|
-
|
|
270
|
-
|
|
276
|
+
# Need to recompile - read file and parse while holding the lock
|
|
277
|
+
# to prevent other threads from seeing partial state
|
|
278
|
+
try:
|
|
279
|
+
source = cdata.path.read_text(encoding="utf-8")
|
|
280
|
+
except UnicodeDecodeError as err:
|
|
281
|
+
raise FileEncodingError(cdata.path.as_posix()) from err
|
|
282
|
+
meta = extract_metadata(source, base_path=cdata.base_path, fullpath=cdata.path)
|
|
271
283
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
284
|
+
parser = JxParser(
|
|
285
|
+
name=relpath, source=source, components=list(meta.imports.keys())
|
|
286
|
+
)
|
|
287
|
+
parsed_source, slots = parser.parse()
|
|
288
|
+
logger.debug(f"Parsed {relpath}:\n{parsed_source}")
|
|
289
|
+
code = self.jinja_env.compile(
|
|
290
|
+
source=parsed_source, name=relpath, filename=cdata.path.as_posix()
|
|
291
|
+
)
|
|
279
292
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
293
|
+
# Update all fields atomically (from other threads' perspective)
|
|
294
|
+
cdata.mtime = mtime
|
|
295
|
+
cdata.code = code
|
|
296
|
+
cdata.required = meta.required
|
|
297
|
+
cdata.optional = meta.optional
|
|
298
|
+
cdata.imports = meta.imports
|
|
299
|
+
cdata.css = meta.css
|
|
300
|
+
cdata.js = meta.js
|
|
301
|
+
cdata.slots = slots
|
|
302
|
+
return cdata
|
|
288
303
|
|
|
289
304
|
def get_component(self, relpath: str) -> Component:
|
|
290
305
|
"""
|
|
@@ -296,24 +311,63 @@ class Catalog:
|
|
|
296
311
|
e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
|
|
297
312
|
|
|
298
313
|
"""
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
314
|
+
with self._lock:
|
|
315
|
+
cdata = self.get_component_data(relpath)
|
|
316
|
+
assert cdata.code is not None # for type checker
|
|
317
|
+
tmpl = jinja2.Template.from_code(
|
|
318
|
+
self.jinja_env, cdata.code, self.jinja_env.globals
|
|
319
|
+
)
|
|
304
320
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
321
|
+
co = Component(
|
|
322
|
+
relpath=relpath,
|
|
323
|
+
tmpl=tmpl,
|
|
324
|
+
get_component=self.get_component,
|
|
325
|
+
required=cdata.required,
|
|
326
|
+
optional=cdata.optional,
|
|
327
|
+
imports=cdata.imports,
|
|
328
|
+
css=cdata.css,
|
|
329
|
+
js=cdata.js,
|
|
330
|
+
slots=cdata.slots,
|
|
331
|
+
)
|
|
332
|
+
return co
|
|
333
|
+
|
|
334
|
+
def list_components(self) -> list[str]:
|
|
335
|
+
"""
|
|
336
|
+
Return all registered component paths.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
A list of component relative paths (e.g., ["button.jinja", "card.jinja"]).
|
|
340
|
+
|
|
341
|
+
"""
|
|
342
|
+
with self._lock:
|
|
343
|
+
return list(self.components.keys())
|
|
344
|
+
|
|
345
|
+
def get_signature(self, relpath: str) -> dict[str, t.Any]:
|
|
346
|
+
"""
|
|
347
|
+
Return a component's signature including its arguments and metadata.
|
|
348
|
+
|
|
349
|
+
Arguments:
|
|
350
|
+
relpath:
|
|
351
|
+
The path of the component, including the extension, relative to its view folder.
|
|
352
|
+
e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
A dictionary containing:
|
|
356
|
+
- required: dict of required argument names mapped to their type (or None)
|
|
357
|
+
- optional: dict of optional arguments mapped to (default_value, type or None)
|
|
358
|
+
- slots: tuple of slot names
|
|
359
|
+
- css: tuple of CSS file URLs
|
|
360
|
+
- js: tuple of JS file URLs
|
|
361
|
+
|
|
362
|
+
"""
|
|
363
|
+
cdata = self.get_component_data(relpath)
|
|
364
|
+
return {
|
|
365
|
+
"required": cdata.required,
|
|
366
|
+
"optional": cdata.optional,
|
|
367
|
+
"slots": cdata.slots,
|
|
368
|
+
"css": cdata.css,
|
|
369
|
+
"js": cdata.js,
|
|
370
|
+
}
|
|
317
371
|
|
|
318
372
|
# Private
|
|
319
373
|
|
jx-0.6.0/src/jx/cli.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jx | Copyright (c) Juan-Pablo Scaletti
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from difflib import get_close_matches
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .catalog import Catalog
|
|
12
|
+
from .exceptions import JxException
|
|
13
|
+
from .meta import extract_metadata
|
|
14
|
+
from .parser import RX_TAG_NAME
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_component_tags(source: str) -> list[tuple[str, int]]:
|
|
18
|
+
"""
|
|
19
|
+
Find all component tags in the source and their line numbers.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
List of (tag_name, line_number) tuples.
|
|
23
|
+
"""
|
|
24
|
+
tags = []
|
|
25
|
+
lines = source.split("\n")
|
|
26
|
+
for line_num, line in enumerate(lines, start=1):
|
|
27
|
+
for match in RX_TAG_NAME.finditer(line):
|
|
28
|
+
tags.append((match.group("tag"), line_num))
|
|
29
|
+
return tags
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_component(
|
|
33
|
+
catalog: Catalog,
|
|
34
|
+
relpath: str,
|
|
35
|
+
all_components: set[str],
|
|
36
|
+
) -> list[str]:
|
|
37
|
+
"""
|
|
38
|
+
Check a single component for issues.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
List of error messages (empty if no errors).
|
|
42
|
+
"""
|
|
43
|
+
errors = []
|
|
44
|
+
cdata = catalog.components[relpath]
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
source = cdata.path.read_text(encoding="utf-8")
|
|
48
|
+
except UnicodeDecodeError:
|
|
49
|
+
return [f"{relpath} - Not valid UTF-8"]
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
meta = extract_metadata(source, base_path=cdata.base_path, fullpath=cdata.path)
|
|
53
|
+
except JxException as err:
|
|
54
|
+
return [f"{relpath} - {err}"]
|
|
55
|
+
|
|
56
|
+
# Check that all imports exist
|
|
57
|
+
for _import_name, import_path in meta.imports.items():
|
|
58
|
+
if import_path not in all_components:
|
|
59
|
+
suggestion = suggest_component(import_path, all_components)
|
|
60
|
+
msg = f"{relpath} - Unknown import '{import_path}'"
|
|
61
|
+
if suggestion:
|
|
62
|
+
msg += f" (did you mean '{suggestion}'?)"
|
|
63
|
+
errors.append(msg)
|
|
64
|
+
|
|
65
|
+
# Build set of available component names for this file
|
|
66
|
+
available = set(meta.imports.keys())
|
|
67
|
+
|
|
68
|
+
# Check all component tags used in the source
|
|
69
|
+
for tag, line_num in find_component_tags(source):
|
|
70
|
+
if tag not in available:
|
|
71
|
+
# Check if it exists in the catalog without being imported
|
|
72
|
+
matching = [c for c in all_components if component_matches_tag(c, tag)]
|
|
73
|
+
if matching:
|
|
74
|
+
errors.append(
|
|
75
|
+
f"{relpath}:{line_num} - Component '{tag}' used but not imported"
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
suggestion = suggest_tag(tag, available, all_components)
|
|
79
|
+
msg = f"{relpath}:{line_num} - Unknown component '{tag}'"
|
|
80
|
+
if suggestion:
|
|
81
|
+
msg += f" (did you mean '{suggestion}'?)"
|
|
82
|
+
errors.append(msg)
|
|
83
|
+
|
|
84
|
+
return errors
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def component_matches_tag(relpath: str, tag: str) -> bool:
|
|
88
|
+
"""Check if a component relpath could match a tag name."""
|
|
89
|
+
# "button.jinja" -> "Button", "close-btn.jinja" -> "CloseBtn"
|
|
90
|
+
name = Path(relpath).stem
|
|
91
|
+
normalized = "".join(part.capitalize() for part in re.split(r"[-_]", name))
|
|
92
|
+
return normalized == tag
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def suggest_component(path: str, all_components: set[str]) -> str | None:
|
|
96
|
+
"""Suggest a similar component path."""
|
|
97
|
+
matches = get_close_matches(path, all_components, n=1, cutoff=0.6)
|
|
98
|
+
return matches[0] if matches else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def suggest_tag(tag: str, imported: set[str], all_components: set[str]) -> str | None:
|
|
102
|
+
"""Suggest a similar tag name."""
|
|
103
|
+
# First try imported names
|
|
104
|
+
matches = get_close_matches(tag, imported, n=1, cutoff=0.6)
|
|
105
|
+
if matches:
|
|
106
|
+
return matches[0]
|
|
107
|
+
|
|
108
|
+
# Then try deriving tag names from all components
|
|
109
|
+
all_tags = set()
|
|
110
|
+
for relpath in all_components:
|
|
111
|
+
name = Path(relpath).stem
|
|
112
|
+
normalized = "".join(part.capitalize() for part in re.split(r"[-_]", name))
|
|
113
|
+
all_tags.add(normalized)
|
|
114
|
+
|
|
115
|
+
matches = get_close_matches(tag, all_tags, n=1, cutoff=0.6)
|
|
116
|
+
return matches[0] if matches else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def check(paths: list[Path]) -> int:
|
|
120
|
+
"""
|
|
121
|
+
Check components in the given paths.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Exit code (0 for success, 1 for errors).
|
|
125
|
+
"""
|
|
126
|
+
catalog = Catalog()
|
|
127
|
+
|
|
128
|
+
for path in paths:
|
|
129
|
+
if path.is_dir():
|
|
130
|
+
catalog.add_folder(path, preload=False)
|
|
131
|
+
elif path.is_file():
|
|
132
|
+
# Single file - add its parent folder
|
|
133
|
+
catalog.add_folder(path.parent, preload=False)
|
|
134
|
+
|
|
135
|
+
all_components = set(catalog.components.keys())
|
|
136
|
+
|
|
137
|
+
if not all_components:
|
|
138
|
+
print("No components found")
|
|
139
|
+
return 1
|
|
140
|
+
|
|
141
|
+
total_errors = 0
|
|
142
|
+
checked = 0
|
|
143
|
+
|
|
144
|
+
for relpath in sorted(all_components):
|
|
145
|
+
errors = check_component(catalog, relpath, all_components)
|
|
146
|
+
checked += 1
|
|
147
|
+
|
|
148
|
+
if errors:
|
|
149
|
+
for error in errors:
|
|
150
|
+
print(f"\u2717 {error}")
|
|
151
|
+
total_errors += len(errors)
|
|
152
|
+
else:
|
|
153
|
+
print(f"\u2713 {relpath} - OK")
|
|
154
|
+
|
|
155
|
+
print()
|
|
156
|
+
print(f"{checked} component{'s' if checked != 1 else ''} checked, {total_errors} error{'s' if total_errors != 1 else ''}")
|
|
157
|
+
|
|
158
|
+
return 1 if total_errors > 0 else 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main() -> None: # pragma: no cover
|
|
162
|
+
parser = argparse.ArgumentParser(
|
|
163
|
+
prog="jx",
|
|
164
|
+
description="Jx component validation tool",
|
|
165
|
+
)
|
|
166
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
167
|
+
|
|
168
|
+
check_parser = subparsers.add_parser("check", help="Validate components")
|
|
169
|
+
check_parser.add_argument(
|
|
170
|
+
"paths",
|
|
171
|
+
nargs="+",
|
|
172
|
+
type=Path,
|
|
173
|
+
help="Paths to component folders or files",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
args = parser.parse_args()
|
|
177
|
+
|
|
178
|
+
if args.command == "check":
|
|
179
|
+
sys.exit(check(args.paths))
|
|
180
|
+
else:
|
|
181
|
+
parser.print_help()
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
main()
|
|
@@ -9,7 +9,10 @@ import jinja2
|
|
|
9
9
|
from markupsafe import Markup
|
|
10
10
|
|
|
11
11
|
from .attrs import Attrs
|
|
12
|
-
from .exceptions import MissingRequiredArgument
|
|
12
|
+
from .exceptions import InvalidPropType, MaxRecursionDepthError, MissingRequiredArgument
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
MAX_COMPONENT_DEPTH = 100
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
class Component:
|
|
@@ -32,8 +35,8 @@ class Component:
|
|
|
32
35
|
relpath: str,
|
|
33
36
|
tmpl: jinja2.Template,
|
|
34
37
|
get_component: Callable[[str], "Component"],
|
|
35
|
-
required:
|
|
36
|
-
optional: dict[str, t.Any] | None = None,
|
|
38
|
+
required: dict[str, type | None] | None = None,
|
|
39
|
+
optional: dict[str, tuple[t.Any, type | None]] | None = None,
|
|
37
40
|
imports: dict[str, str] | None = None,
|
|
38
41
|
css: tuple[str, ...] = (),
|
|
39
42
|
js: tuple[str, ...] = (),
|
|
@@ -50,9 +53,9 @@ class Component:
|
|
|
50
53
|
get_component:
|
|
51
54
|
A callable that retrieves a component by its name/relpath.
|
|
52
55
|
required:
|
|
53
|
-
A
|
|
56
|
+
A dictionary of required attribute names mapped to their type (or None).
|
|
54
57
|
optional:
|
|
55
|
-
A dictionary of optional attributes
|
|
58
|
+
A dictionary of optional attributes mapped to (default_value, type or None).
|
|
56
59
|
imports:
|
|
57
60
|
A dictionary of imported component names as "name": "relpath" pairs.
|
|
58
61
|
css:
|
|
@@ -67,7 +70,7 @@ class Component:
|
|
|
67
70
|
self.tmpl = tmpl
|
|
68
71
|
self.get_component = get_component
|
|
69
72
|
|
|
70
|
-
self.required = required
|
|
73
|
+
self.required = required or {}
|
|
71
74
|
self.optional = optional or {}
|
|
72
75
|
self.imports = imports or {}
|
|
73
76
|
self.css = css
|
|
@@ -84,6 +87,14 @@ class Component:
|
|
|
84
87
|
caller: Callable[[str], str] | None = None,
|
|
85
88
|
**params: t.Any,
|
|
86
89
|
) -> Markup:
|
|
90
|
+
# Check recursion depth
|
|
91
|
+
depth = self.globals.get("_depth", 0)
|
|
92
|
+
if depth > MAX_COMPONENT_DEPTH:
|
|
93
|
+
raise MaxRecursionDepthError(MAX_COMPONENT_DEPTH)
|
|
94
|
+
|
|
95
|
+
# Increment depth for child components
|
|
96
|
+
self.globals = {**self.globals, "_depth": depth + 1}
|
|
97
|
+
|
|
87
98
|
content = content if content is not None else caller("") if caller else ""
|
|
88
99
|
attrs = attrs.as_dict if isinstance(attrs, Attrs) else attrs or {}
|
|
89
100
|
params = {**attrs, **params}
|
|
@@ -109,13 +120,20 @@ class Component:
|
|
|
109
120
|
) -> tuple[dict[str, t.Any], dict[str, t.Any]]:
|
|
110
121
|
props = {}
|
|
111
122
|
|
|
112
|
-
for key in self.required:
|
|
123
|
+
for key, expected_type in self.required.items():
|
|
113
124
|
if key not in kw:
|
|
114
125
|
raise MissingRequiredArgument(self.relpath, key)
|
|
115
|
-
|
|
126
|
+
value = kw.pop(key)
|
|
127
|
+
if expected_type is not None and not isinstance(value, expected_type):
|
|
128
|
+
raise InvalidPropType(self.relpath, key, expected_type, type(value))
|
|
129
|
+
props[key] = value
|
|
130
|
+
|
|
131
|
+
for key, (default, expected_type) in self.optional.items():
|
|
132
|
+
value = kw.pop(key, default)
|
|
133
|
+
if expected_type is not None and not isinstance(value, expected_type):
|
|
134
|
+
raise InvalidPropType(self.relpath, key, expected_type, type(value))
|
|
135
|
+
props[key] = value
|
|
116
136
|
|
|
117
|
-
for key in self.optional:
|
|
118
|
-
props[key] = kw.pop(key, self.optional[key])
|
|
119
137
|
extra = kw.copy()
|
|
120
138
|
return props, extra
|
|
121
139
|
|
|
@@ -36,6 +36,18 @@ class MissingRequiredArgument(JxException):
|
|
|
36
36
|
super().__init__(msg, **kw)
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
class InvalidPropType(JxException):
|
|
40
|
+
"""
|
|
41
|
+
Raised when a component prop has an invalid type.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self, component: str, arg: str, expected: type, got: type, **kw
|
|
46
|
+
) -> None:
|
|
47
|
+
msg = f"{component}: `{arg}` expected {expected.__name__}, got {got.__name__}"
|
|
48
|
+
super().__init__(msg, **kw)
|
|
49
|
+
|
|
50
|
+
|
|
39
51
|
class DuplicateDefDeclaration(JxException):
|
|
40
52
|
"""
|
|
41
53
|
Raised when a component has more then one `{#def ... #}` declarations.
|
|
@@ -57,3 +69,33 @@ class InvalidImport(JxException):
|
|
|
57
69
|
"""
|
|
58
70
|
Raised when the import cannot be parsed
|
|
59
71
|
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PathTraversalError(JxException):
|
|
75
|
+
"""
|
|
76
|
+
Raised when an import path attempts to escape the component root directory.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, path: str, **kw) -> None:
|
|
80
|
+
msg = f"Import path escapes component root: {path}"
|
|
81
|
+
super().__init__(msg, **kw)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MaxRecursionDepthError(JxException):
|
|
85
|
+
"""
|
|
86
|
+
Raised when component nesting exceeds the maximum allowed depth.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, max_depth: int, **kw) -> None:
|
|
90
|
+
msg = f"Maximum component nesting depth exceeded ({max_depth})"
|
|
91
|
+
super().__init__(msg, **kw)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FileEncodingError(JxException):
|
|
95
|
+
"""
|
|
96
|
+
Raised when a component file cannot be read due to encoding issues.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, path: str, **kw) -> None:
|
|
100
|
+
msg = f"Cannot read {path}: not valid UTF-8"
|
|
101
|
+
super().__init__(msg, **kw)
|