jx 0.5.0__py3-none-any.whl → 0.6.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.
- jx/attrs.py +1 -1
- jx/catalog.py +125 -71
- jx/cli.py +186 -0
- jx/component.py +28 -10
- jx/exceptions.py +42 -0
- jx/meta.py +73 -14
- jx/parser.py +21 -7
- {jx-0.5.0.dist-info → jx-0.6.0.dist-info}/METADATA +2 -1
- jx-0.6.0.dist-info/RECORD +15 -0
- {jx-0.5.0.dist-info → jx-0.6.0.dist-info}/WHEEL +1 -1
- jx-0.6.0.dist-info/entry_points.txt +2 -0
- jx-0.5.0.dist-info/RECORD +0 -13
- {jx-0.5.0.dist-info → jx-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {jx-0.5.0.dist-info → jx-0.6.0.dist-info}/top_level.txt +0 -0
jx/attrs.py
CHANGED
jx/catalog.py
CHANGED
|
@@ -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/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()
|
jx/component.py
CHANGED
|
@@ -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
|
|
jx/exceptions.py
CHANGED
|
@@ -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)
|
jx/meta.py
CHANGED
|
@@ -3,12 +3,18 @@ Jx | Copyright (c) Juan-Pablo Scaletti
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import ast
|
|
6
|
+
import builtins
|
|
6
7
|
import re
|
|
7
8
|
import typing as t
|
|
8
9
|
from dataclasses import dataclass, field
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
|
-
from .exceptions import
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
DuplicateDefDeclaration,
|
|
14
|
+
InvalidArgument,
|
|
15
|
+
InvalidImport,
|
|
16
|
+
PathTraversalError,
|
|
17
|
+
)
|
|
12
18
|
from .parser import re_tag_name
|
|
13
19
|
|
|
14
20
|
|
|
@@ -42,8 +48,8 @@ ALLOWED_NAMES_IN_EXPRESSION_VALUES = {
|
|
|
42
48
|
|
|
43
49
|
@dataclass(slots=True)
|
|
44
50
|
class Meta:
|
|
45
|
-
required:
|
|
46
|
-
optional: dict[str, t.Any] = field(default_factory=dict)
|
|
51
|
+
required: dict[str, type | None] = field(default_factory=dict) # { attr: type or None }
|
|
52
|
+
optional: dict[str, tuple[t.Any, type | None]] = field(default_factory=dict) # { attr: (default, type or None) }
|
|
47
53
|
imports: dict[str, str] = field(default_factory=dict) # { component_name: relpath }
|
|
48
54
|
css: tuple[str, ...] = ()
|
|
49
55
|
js: tuple[str, ...] = ()
|
|
@@ -92,7 +98,9 @@ def extract_metadata(source: str, base_path: Path, fullpath: Path) -> Meta:
|
|
|
92
98
|
expr = RX_INTER_COMMENTS.sub("", expr).replace("\n", " ")
|
|
93
99
|
import_path, import_name = parse_import_expr(expr)
|
|
94
100
|
if import_path.startswith("."):
|
|
95
|
-
|
|
101
|
+
resolved = (fullpath.parent / import_path).resolve()
|
|
102
|
+
validate_import_path(import_path, resolved, base_path)
|
|
103
|
+
import_path = resolved.relative_to(base_path).as_posix()
|
|
96
104
|
meta.imports[import_name] = import_path
|
|
97
105
|
continue
|
|
98
106
|
|
|
@@ -118,10 +126,41 @@ def read_metadata_item(source: str, rx_start: re.Pattern) -> str:
|
|
|
118
126
|
return source[start.end():].strip()
|
|
119
127
|
|
|
120
128
|
|
|
121
|
-
def
|
|
129
|
+
def annotation_to_type(annotation: ast.expr | None) -> type | None:
|
|
130
|
+
"""
|
|
131
|
+
Convert an AST annotation node to a Python type.
|
|
132
|
+
Returns None if the annotation is not a supported builtin type.
|
|
133
|
+
|
|
134
|
+
::: note
|
|
135
|
+
For generic types like `list[str]` or `dict[str, int]`, only the base
|
|
136
|
+
type (`list`, `dict`) is extracted. The generic parameters are discarded.
|
|
137
|
+
This is sufficient for basic `isinstance()` validation but won't validate
|
|
138
|
+
element types.
|
|
139
|
+
|
|
140
|
+
To preserve full generic type info in the future, we could use
|
|
141
|
+
`eval(ast.unparse(annotation), {"__builtins__": {}}, vars(builtins))`
|
|
142
|
+
which returns the actual generic type object, which can be used with more
|
|
143
|
+
advanced type checking libraries like `typeguard` or manual element validation.
|
|
144
|
+
:::
|
|
145
|
+
"""
|
|
146
|
+
if annotation is None:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
# For generics like `list[str]`, extract the base type
|
|
150
|
+
if isinstance(annotation, ast.Subscript):
|
|
151
|
+
annotation = annotation.value
|
|
152
|
+
|
|
153
|
+
if isinstance(annotation, ast.Name):
|
|
154
|
+
result = getattr(builtins, annotation.id, None)
|
|
155
|
+
return result if isinstance(result, type) else None
|
|
156
|
+
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_args_expr(expr: str) -> tuple[dict[str, type | None], dict[str, tuple[t.Any, type | None]]]:
|
|
122
161
|
expr = expr.strip(" *,/")
|
|
123
|
-
required =
|
|
124
|
-
optional = {}
|
|
162
|
+
required: dict[str, type | None] = {}
|
|
163
|
+
optional: dict[str, tuple[t.Any, type | None]] = {}
|
|
125
164
|
|
|
126
165
|
try:
|
|
127
166
|
p = ast.parse(f"def component(*,\n{expr}\n): pass")
|
|
@@ -129,15 +168,15 @@ def parse_args_expr(expr: str) -> tuple[tuple[str, ...], dict[str, t.Any]]:
|
|
|
129
168
|
raise InvalidArgument(err) from err
|
|
130
169
|
|
|
131
170
|
args = p.body[0].args # type: ignore
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if
|
|
135
|
-
required.
|
|
171
|
+
for arg, default in zip(args.kwonlyargs, args.kw_defaults): # noqa: B905
|
|
172
|
+
arg_type = annotation_to_type(arg.annotation)
|
|
173
|
+
if default is None:
|
|
174
|
+
required[arg.arg] = arg_type
|
|
136
175
|
continue
|
|
137
|
-
|
|
138
|
-
optional[
|
|
176
|
+
default_expr = ast.unparse(default)
|
|
177
|
+
optional[arg.arg] = (eval_expression(default_expr), arg_type)
|
|
139
178
|
|
|
140
|
-
return
|
|
179
|
+
return required, optional
|
|
141
180
|
|
|
142
181
|
|
|
143
182
|
def eval_expression(input_string: str) -> t.Any:
|
|
@@ -166,3 +205,23 @@ def parse_import_expr(expr: str) -> tuple[str, str]:
|
|
|
166
205
|
if not match:
|
|
167
206
|
raise InvalidImport(expr)
|
|
168
207
|
return match.group(1), match.group(2)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def validate_import_path(path: str, resolved: Path, base_path: Path) -> None:
|
|
211
|
+
"""
|
|
212
|
+
Validate that the resolved import path does not escape the component root.
|
|
213
|
+
|
|
214
|
+
Arguments:
|
|
215
|
+
path:
|
|
216
|
+
The original import path string (for error messages).
|
|
217
|
+
resolved:
|
|
218
|
+
The resolved absolute path of the import.
|
|
219
|
+
base_path:
|
|
220
|
+
The base path that all imports must stay within.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
PathTraversalError: If the resolved path escapes the base path.
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
if not resolved.is_relative_to(base_path):
|
|
227
|
+
raise PathTraversalError(path)
|
jx/parser.py
CHANGED
|
@@ -185,19 +185,25 @@ class JxParser:
|
|
|
185
185
|
"""
|
|
186
186
|
start, curr = match.span(0)
|
|
187
187
|
lineno = source[:start].count("\n") + 1
|
|
188
|
+
line_start = source.rfind("\n", 0, start) + 1
|
|
189
|
+
col = start - line_start
|
|
188
190
|
|
|
189
191
|
tag = match.group("tag")
|
|
190
192
|
if validate_tags and tag not in self.components:
|
|
191
193
|
line = self.source.split("\n")[lineno - 1]
|
|
192
194
|
raise TemplateSyntaxError(
|
|
193
|
-
f"[{self.name}:{lineno}] Unknown component `{tag}`\n
|
|
195
|
+
f"[{self.name}:{lineno}:{col}] Unknown component `{tag}`\n"
|
|
196
|
+
f" {line}\n"
|
|
197
|
+
f" {' ' * col}^"
|
|
194
198
|
)
|
|
195
199
|
|
|
196
|
-
raw_attrs, end = self._parse_opening_tag(source, lineno=lineno, start=curr - 1)
|
|
200
|
+
raw_attrs, end = self._parse_opening_tag(source, lineno=lineno, col=col, start=curr - 1)
|
|
197
201
|
if end == -1:
|
|
198
202
|
line = self.source.split("\n")[lineno - 1]
|
|
199
203
|
raise TemplateSyntaxError(
|
|
200
|
-
f"[{self.name}:{lineno}] Syntax error: `{tag}`\n
|
|
204
|
+
f"[{self.name}:{lineno}:{col}] Syntax error: `{tag}`\n"
|
|
205
|
+
f" {line}\n"
|
|
206
|
+
f" {' ' * col}^"
|
|
201
207
|
)
|
|
202
208
|
|
|
203
209
|
inline = source[end - 2 : end] == "/>"
|
|
@@ -209,7 +215,9 @@ class JxParser:
|
|
|
209
215
|
if index == -1:
|
|
210
216
|
line = self.source.split("\n")[lineno - 1]
|
|
211
217
|
raise TemplateSyntaxError(
|
|
212
|
-
f"[{self.name}:{lineno}] Unclosed component `{tag}`\n
|
|
218
|
+
f"[{self.name}:{lineno}:{col}] Unclosed component `{tag}`\n"
|
|
219
|
+
f" {line}\n"
|
|
220
|
+
f" {' ' * col}^"
|
|
213
221
|
)
|
|
214
222
|
|
|
215
223
|
content = source[end:index]
|
|
@@ -305,7 +313,7 @@ class JxParser:
|
|
|
305
313
|
# Private
|
|
306
314
|
|
|
307
315
|
def _parse_opening_tag(
|
|
308
|
-
self, source: str, *, lineno: int, start: int
|
|
316
|
+
self, source: str, *, lineno: int, col: int, start: int
|
|
309
317
|
) -> tuple[str, int]:
|
|
310
318
|
"""
|
|
311
319
|
Parses the opening tag and returns the raw attributes and the position
|
|
@@ -325,8 +333,11 @@ class JxParser:
|
|
|
325
333
|
if not in_single_quotes and not in_double_quotes:
|
|
326
334
|
if ch2 == "{{":
|
|
327
335
|
if in_braces:
|
|
336
|
+
line = self.source.split("\n")[lineno - 1]
|
|
328
337
|
raise TemplateSyntaxError(
|
|
329
|
-
f"[{self.name}:{lineno}] Unmatched braces"
|
|
338
|
+
f"[{self.name}:{lineno}:{col}] Unmatched braces\n"
|
|
339
|
+
f" {line}\n"
|
|
340
|
+
f" {' ' * col}^"
|
|
330
341
|
)
|
|
331
342
|
in_braces = True
|
|
332
343
|
i += 2
|
|
@@ -334,8 +345,11 @@ class JxParser:
|
|
|
334
345
|
|
|
335
346
|
if ch2 == "}}":
|
|
336
347
|
if not in_braces:
|
|
348
|
+
line = self.source.split("\n")[lineno - 1]
|
|
337
349
|
raise TemplateSyntaxError(
|
|
338
|
-
f"[{self.name}:{lineno}] Unmatched braces"
|
|
350
|
+
f"[{self.name}:{lineno}:{col}] Unmatched braces\n"
|
|
351
|
+
f" {line}\n"
|
|
352
|
+
f" {' ' * col}^"
|
|
339
353
|
)
|
|
340
354
|
in_braces = False
|
|
341
355
|
i += 2
|
|
@@ -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
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
jx/__init__.py,sha256=f05q3I3xvO72xqVOhtWI_KMSVa0vACbGsK2eMrhzO1g,126
|
|
2
|
+
jx/attrs.py,sha256=qb66pyGj2MhCZuMrtoJnpD0qYCCghu69AWBs3yOWRvM,11328
|
|
3
|
+
jx/catalog.py,sha256=f-JrW1DpBKtWvnXCUWx_xXfuRQEblMQXy5HYYE7c8p0,15989
|
|
4
|
+
jx/cli.py,sha256=kzAw9_Mn9VkUjfjnosliID2kUJ5T7FwMBF3ckhkeGNo,5501
|
|
5
|
+
jx/component.py,sha256=ViOUfZO6L7p8q-GI_fZRBRE7Kl-Rfrog_Us6OHIYha8,7891
|
|
6
|
+
jx/exceptions.py,sha256=mp7tcZFmAFQDzqnUMqdBN9AcrH8dKwrGnG1IyCKVpgM,2673
|
|
7
|
+
jx/meta.py,sha256=PsFdctgfdCAsI5EB_uk9nPajOoEJphJ2RRwj3PYnyBA,7295
|
|
8
|
+
jx/parser.py,sha256=AFiCE3-ohUadIFRUNY-ivObx7WW0yzpm6XWaYLXRFOU,13475
|
|
9
|
+
jx/utils.py,sha256=BTxDPhiWuKHRcVCND07OGkYLhzPp9G6iDgDC7H2o4Lo,643
|
|
10
|
+
jx-0.6.0.dist-info/licenses/LICENSE,sha256=RHwNifuIFfQM9QUhA2FQfnqlBcnhBHlJEVp8QcRllew,1076
|
|
11
|
+
jx-0.6.0.dist-info/METADATA,sha256=cZ8CZ_di4ub6wkbfIM2G4pbL6JOlqW7BvH4rGHt88bI,2746
|
|
12
|
+
jx-0.6.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
13
|
+
jx-0.6.0.dist-info/entry_points.txt,sha256=I3eKEemDmEWl2InZmbOpZFYKSien8Yuo26PzbBU72SA,35
|
|
14
|
+
jx-0.6.0.dist-info/top_level.txt,sha256=P61YQxqfmzVpxTMe3C48gt0vc6fnHLF8Ml0JXC-QuEI,3
|
|
15
|
+
jx-0.6.0.dist-info/RECORD,,
|
jx-0.5.0.dist-info/RECORD
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
jx/__init__.py,sha256=f05q3I3xvO72xqVOhtWI_KMSVa0vACbGsK2eMrhzO1g,126
|
|
2
|
-
jx/attrs.py,sha256=3cSSJb-idpD2CdhA_BZLX-OId1t8c2nzJp0jSJpTjvs,11344
|
|
3
|
-
jx/catalog.py,sha256=o6NLtCgUJbx8S_8J_evp-d7YPjQNClhbu187iNYxXJA,13528
|
|
4
|
-
jx/component.py,sha256=04Ic7wGm65uUPAiVGhz8YbD2orBbasKd1dFo4FtbIv4,7004
|
|
5
|
-
jx/exceptions.py,sha256=eQVCjt49FOgOjdGPPAhU4eIgBvumQ6BXmTh_siYpyIg,1493
|
|
6
|
-
jx/meta.py,sha256=nKSeZzgdtJEjPoc7PaYGhBkBbwFRC97XZc_GUMBndPI,5167
|
|
7
|
-
jx/parser.py,sha256=AtjSqCwLps18gi2fzi4CkkszH0iqSIzBbGNX76kCuNc,12843
|
|
8
|
-
jx/utils.py,sha256=BTxDPhiWuKHRcVCND07OGkYLhzPp9G6iDgDC7H2o4Lo,643
|
|
9
|
-
jx-0.5.0.dist-info/licenses/LICENSE,sha256=RHwNifuIFfQM9QUhA2FQfnqlBcnhBHlJEVp8QcRllew,1076
|
|
10
|
-
jx-0.5.0.dist-info/METADATA,sha256=ui4eW1QhDzEgl5Z9nVuclSZMnSo927n6cHWPjtfR7PQ,2722
|
|
11
|
-
jx-0.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
-
jx-0.5.0.dist-info/top_level.txt,sha256=P61YQxqfmzVpxTMe3C48gt0vc6fnHLF8Ml0JXC-QuEI,3
|
|
13
|
-
jx-0.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|