jx 0.5.1__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.
@@ -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
@@ -4,12 +4,12 @@ requires = ["setuptools"]
4
4
 
5
5
  [project]
6
6
  name = "jx"
7
- version = "0.5.1"
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 = { "file" = "MIT-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.7.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
 
@@ -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
 
@@ -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-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: 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
 
@@ -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)