jx 0.5.1__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 CHANGED
@@ -36,7 +36,7 @@ class LazyString(UserString):
36
36
  self._seq = seq
37
37
 
38
38
  @cached_property
39
- def data(self): # type: ignore
39
+ def data(self):
40
40
  return str(self._seq)
41
41
 
42
42
 
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: tuple[str, ...] = ()
27
- optional: dict[str, t.Any] = field(default_factory=dict) # { attr: default_value }
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 followed
101
- by a colon: `prefix:sub/folder/component.jinja`. If the importing is
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
- for filepath in base_path.rglob("*.jinja"):
137
- relpath = f"{prefix}{filepath.relative_to(base_path).as_posix()}"
138
- if relpath in self.components:
139
- logger.debug(f"Component already exists: {relpath}")
140
- continue
141
- cdata = CData(
142
- base_path=base_path, path=filepath, mtime=filepath.stat().st_mtime
143
- )
144
- self.components[relpath] = cdata
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 self.components:
148
- self.components[relpath] = self.get_component_data(relpath)
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
@@ -252,37 +260,46 @@ class Catalog:
252
260
  e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
253
261
 
254
262
  """
255
- cdata = self.components.get(relpath)
256
- if not cdata:
257
- raise ImportError(relpath)
258
-
259
- mtime = cdata.path.stat().st_mtime if self.auto_reload else 0
260
- if cdata.code is not None:
261
- if self.auto_reload:
262
- if mtime == cdata.mtime:
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:
263
274
  return cdata
264
- else:
265
- return cdata
266
275
 
267
- source = cdata.path.read_text()
268
- meta = extract_metadata(source, base_path=cdata.base_path, fullpath=cdata.path)
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)
269
283
 
270
- parser = JxParser(
271
- name=relpath, source=source, components=list(meta.imports.keys())
272
- )
273
- parsed_source, slots = parser.parse()
274
- code = self.jinja_env.compile(
275
- source=parsed_source, name=relpath, filename=cdata.path.as_posix()
276
- )
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
+ )
277
292
 
278
- cdata.code = code
279
- cdata.required = meta.required
280
- cdata.optional = meta.optional
281
- cdata.imports = meta.imports
282
- cdata.css = meta.css
283
- cdata.js = meta.js
284
- cdata.slots = slots
285
- return cdata
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
286
303
 
287
304
  def get_component(self, relpath: str) -> Component:
288
305
  """
@@ -294,24 +311,63 @@ class Catalog:
294
311
  e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
295
312
 
296
313
  """
297
- cdata = self.get_component_data(relpath)
298
- assert cdata.code is not None
299
- tmpl = jinja2.Template.from_code(
300
- self.jinja_env, cdata.code, self.jinja_env.globals
301
- )
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
+ )
302
320
 
303
- co = Component(
304
- relpath=relpath,
305
- tmpl=tmpl,
306
- get_component=self.get_component,
307
- required=cdata.required,
308
- optional=cdata.optional,
309
- imports=cdata.imports,
310
- css=cdata.css,
311
- js=cdata.js,
312
- slots=cdata.slots,
313
- )
314
- return co
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
+ }
315
371
 
316
372
  # Private
317
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: tuple[str, ...] = (),
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 tuple of required attribute names.
56
+ A dictionary of required attribute names mapped to their type (or None).
54
57
  optional:
55
- A dictionary of optional attributes and their default values.
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
- props[key] = kw.pop(key)
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 DuplicateDefDeclaration, InvalidArgument, InvalidImport
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: tuple[str, ...] = ()
46
- optional: dict[str, t.Any] = field(default_factory=dict) # { attr: default_value }
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
- import_path = (fullpath.parent / import_path).resolve().relative_to(base_path).as_posix()
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 parse_args_expr(expr: str) -> tuple[tuple[str, ...], dict[str, t.Any]]:
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
- arg_names = [arg.arg for arg in args.kwonlyargs]
133
- for name, value in zip(arg_names, args.kw_defaults): # noqa: B905
134
- if value is None:
135
- required.append(name)
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
- expr = ast.unparse(value)
138
- optional[name] = eval_expression(expr)
176
+ default_expr = ast.unparse(default)
177
+ optional[arg.arg] = (eval_expression(default_expr), arg_type)
139
178
 
140
- return tuple(required), optional
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{line}"
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{line}"
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{line}"
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.5.1
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jx = jx.cli:main
jx-0.5.1.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=NenZZeSoFsRBxFSDwusxzIJWvljOeoRS_N6VmEon_Nw,13489
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.1.dist-info/licenses/LICENSE,sha256=RHwNifuIFfQM9QUhA2FQfnqlBcnhBHlJEVp8QcRllew,1076
10
- jx-0.5.1.dist-info/METADATA,sha256=962jklA9-leX_gkSR9C3rpk54bcYvsUgWAbGFB7Fn3s,2722
11
- jx-0.5.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- jx-0.5.1.dist-info/top_level.txt,sha256=P61YQxqfmzVpxTMe3C48gt0vc6fnHLF8Ml0JXC-QuEI,3
13
- jx-0.5.1.dist-info/RECORD,,
File without changes