jx 0.1.0__tar.gz → 0.2.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jx
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Replace your HTML templates with Python server-Side components
5
5
  Author-email: Juan Pablo Scaletti <juanpablo@jpscaletti.com>
6
6
  Project-URL: homepage, https://jx.scaletti.dev/
@@ -24,6 +24,7 @@ Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
25
  Requires-Dist: jinja2>=3.0
26
26
  Requires-Dist: markupsafe>=2.0
27
+ Requires-Dist: ty>=0.0.1a15
27
28
  Dynamic: license-file
28
29
 
29
30
  <h1>
@@ -40,3 +41,38 @@ Super components powers for your Jinja templates.
40
41
  From chaos to clarity: The power of components in your server-side-rendered Python web app.
41
42
 
42
43
  <!-- Documentation: https://jx.scaletti.dev/ -->
44
+
45
+ ## How It Works
46
+
47
+ Jx is a Python library for creating reusable template components with Jinja2. It works by pre-parsing the template source and replacing TitleCased HTML tags with Jinja calls that render the component.
48
+
49
+ ### Component Definition
50
+
51
+ Components are defined as regular Jinja2 templates (.jinja files) with special metadata comments:
52
+
53
+ - `{# def parameter1 parameter2=default_value #}` - Defines required and optional parameters
54
+ - `{# import "path/to/component.jinja" as ComponentName #}` - Imports other components
55
+ - `{# css "/path/to/style.css" #}` - Includes CSS files
56
+ - `{# js "/path/to/script.js" #}` - Includes JavaScript files
57
+
58
+ Example component:
59
+
60
+ ```jinja
61
+ {# def message #}
62
+ {# import "button.jinja" as Button #}
63
+
64
+ <div class="greeting">{{ message }}</div>
65
+ <Button text="OK" />
66
+ ```
67
+
68
+ ### Usage Example
69
+
70
+ ```python
71
+ from jx import Catalog
72
+
73
+ # Create a catalog and add a components folder
74
+ catalog = Catalog("templates/components")
75
+
76
+ # Render a component with parameters
77
+ html = catalog.render("card.jinja", title="Hello", content="This is a card")
78
+ ```
jx-0.2.0/README.md ADDED
@@ -0,0 +1,49 @@
1
+ <h1>
2
+ <img src="https://github.com/jpsca/jx/raw/main/docs/logo-jx.png" height="100" align="top">
3
+ </h1>
4
+
5
+ Super components powers for your Jinja templates.
6
+
7
+ <p>
8
+ <img alt="python: 3.11, 3.12, 3.13, 3.14" src="https://github.com/jpsca/jx/raw/main/docs/python.svg">
9
+ <img alt="license: MIT" src="https://github.com/jpsca/jx/raw/main/docs/license.svg">
10
+ </p>
11
+
12
+ From chaos to clarity: The power of components in your server-side-rendered Python web app.
13
+
14
+ <!-- Documentation: https://jx.scaletti.dev/ -->
15
+
16
+ ## How It Works
17
+
18
+ Jx is a Python library for creating reusable template components with Jinja2. It works by pre-parsing the template source and replacing TitleCased HTML tags with Jinja calls that render the component.
19
+
20
+ ### Component Definition
21
+
22
+ Components are defined as regular Jinja2 templates (.jinja files) with special metadata comments:
23
+
24
+ - `{# def parameter1 parameter2=default_value #}` - Defines required and optional parameters
25
+ - `{# import "path/to/component.jinja" as ComponentName #}` - Imports other components
26
+ - `{# css "/path/to/style.css" #}` - Includes CSS files
27
+ - `{# js "/path/to/script.js" #}` - Includes JavaScript files
28
+
29
+ Example component:
30
+
31
+ ```jinja
32
+ {# def message #}
33
+ {# import "button.jinja" as Button #}
34
+
35
+ <div class="greeting">{{ message }}</div>
36
+ <Button text="OK" />
37
+ ```
38
+
39
+ ### Usage Example
40
+
41
+ ```python
42
+ from jx import Catalog
43
+
44
+ # Create a catalog and add a components folder
45
+ catalog = Catalog("templates/components")
46
+
47
+ # Render a component with parameters
48
+ html = catalog.render("card.jinja", title="Hello", content="This is a card")
49
+ ```
@@ -4,7 +4,7 @@ requires = ["setuptools"]
4
4
 
5
5
  [project]
6
6
  name = "jx"
7
- version = "0.1.0"
7
+ version = "0.2.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"},
@@ -30,6 +30,7 @@ requires-python = ">=3.11,<4"
30
30
  dependencies = [
31
31
  "jinja2 >= 3.0",
32
32
  "markupsafe >= 2.0",
33
+ "ty>=0.0.1a15",
33
34
  ]
34
35
 
35
36
  [project.urls]
@@ -30,14 +30,13 @@ def quote(text: str) -> str:
30
30
 
31
31
 
32
32
  class LazyString(UserString):
33
- """
34
- Behave like regular strings, but the actual casting of the initial value
35
- is deferred until the value is actually required.
36
- """
37
-
38
33
  __slots__ = ("_seq",)
39
34
 
40
35
  def __init__(self, seq):
36
+ """
37
+ Behave like regular strings, but the actual casting of the initial value
38
+ is deferred until the value is actually required.
39
+ """
41
40
  self._seq = seq
42
41
 
43
42
  @cached_property
@@ -46,21 +45,20 @@ class LazyString(UserString):
46
45
 
47
46
 
48
47
  class Attrs:
49
- """
50
- Contains all the HTML attributes/properties (a property is an
51
- attribute without a value) passed to a component but that weren't
52
- in the declared attributes list.
53
-
54
- For HTML classes you can use the name "classes" (instead of "class")
55
- if you need to.
48
+ def __init__(self, attrs: "dict[str, t.Any| LazyString]") -> None:
49
+ """
50
+ Contains all the HTML attributes/properties (a property is an
51
+ attribute without a value) passed to a component but that weren't
52
+ in the declared attributes list.
56
53
 
57
- **NOTE**: The string values passed to this class, are not cast to `str` until
58
- the string representation is actually needed, for example when
59
- `attrs.render()` is invoked.
54
+ For HTML classes you can use the name "classes" (instead of "class")
55
+ if you need to.
60
56
 
61
- """
57
+ **NOTE**: The string values passed to this class, are not cast to `str` until
58
+ the string representation is actually needed, for example when
59
+ `attrs.render()` is invoked.
62
60
 
63
- def __init__(self, attrs: "dict[str, t.Any| LazyString]") -> None:
61
+ """
64
62
  attributes: "dict[str, str | LazyString]" = {}
65
63
  properties: set[str] = set()
66
64
 
@@ -90,7 +88,7 @@ class Attrs:
90
88
  Example:
91
89
 
92
90
  ```python
93
- attrs = HTMLAttrs({"class": "italic bold bg-blue wide abcde"})
91
+ attrs = Attrs({"class": "italic bold bg-blue wide abcde"})
94
92
  attrs.set(class="bold text-white")
95
93
  print(attrs.classes)
96
94
  abcde bg-blue bold italic text-white wide
@@ -108,7 +106,7 @@ class Attrs:
108
106
  Example:
109
107
 
110
108
  ```python
111
- attrs = HTMLAttrs({
109
+ attrs = Attrs({
112
110
  "class": "lorem ipsum",
113
111
  "data_test": True,
114
112
  "hidden": True,
@@ -157,10 +155,12 @@ class Attrs:
157
155
  - The underscores in the names will be translated automatically to dashes,
158
156
  so `aria_selected` becomes the attribute `aria-selected`.
159
157
 
158
+ TODO: vue-style
159
+
160
160
  Example:
161
161
 
162
162
  ```python
163
- attrs = HTMLAttrs({"secret": "qwertyuiop"})
163
+ attrs = Attrs({"secret": "qwertyuiop"})
164
164
  attrs.set(secret=False)
165
165
  attrs.as_dict
166
166
  {}
@@ -169,7 +169,7 @@ class Attrs:
169
169
  attrs.as_dict
170
170
  {"count":42, "lorem":"ipsum", "data_good": True}
171
171
 
172
- attrs = HTMLAttrs({"class": "b c a"})
172
+ attrs = Attrs({"class": "b c a"})
173
173
  attrs.set(class="c b f d e")
174
174
  attrs.as_dict
175
175
  {"class": "a b c d e f"}
@@ -199,7 +199,7 @@ class Attrs:
199
199
  Example:
200
200
 
201
201
  ```python
202
- attrs = HTMLAttrs({"lorem": "ipsum"})
202
+ attrs = Attrs({"lorem": "ipsum"})
203
203
  attrs.setdefault(tabindex=0, lorem="meh")
204
204
  attrs.as_dict
205
205
  # "tabindex" changed but "lorem" didn't
@@ -222,10 +222,15 @@ class Attrs:
222
222
  """
223
223
  Adds one or more classes to the list of classes, if not already present.
224
224
 
225
+ Arguments:
226
+
227
+ values:
228
+ One or more class names to add, separated by spaces.
229
+
225
230
  Example:
226
231
 
227
232
  ```python
228
- attrs = HTMLAttrs({"class": "a b c"})
233
+ attrs = Attrs({"class": "a b c"})
229
234
  attrs.add_class("c", "d")
230
235
  attrs.as_dict
231
236
  {"class": "a b c d"}
@@ -243,7 +248,7 @@ class Attrs:
243
248
  Example:
244
249
 
245
250
  ```python
246
- attrs = HTMLAttrs({"class": "a b c"})
251
+ attrs = Attrs({"class": "a b c"})
247
252
  attrs.remove_class("c", "d")
248
253
  attrs.as_dict
249
254
  {"class": "a b"}
@@ -258,10 +263,18 @@ class Attrs:
258
263
  Returns the value of the attribute or property,
259
264
  or the default value if it doesn't exists.
260
265
 
266
+ Arguments:
267
+
268
+ name:
269
+ The name of the attribute or property to get.
270
+
271
+ default:
272
+ The default value to return if the attribute or property doesn't exist.
273
+
261
274
  Example:
262
275
 
263
276
  ```python
264
- attrs = HTMLAttrs({"lorem": "ipsum", "hidden": True})
277
+ attrs = Attrs({"lorem": "ipsum", "hidden": True})
265
278
 
266
279
  attrs.get("lorem", defaut="bar")
267
280
  'ipsum'
@@ -291,7 +304,7 @@ class Attrs:
291
304
  Renders the attributes and properties as a string.
292
305
 
293
306
  Any arguments you use with this function are merged with the existing
294
- attibutes/properties by the same rules as the `HTMLAttrs.set()` function:
307
+ attibutes/properties by the same rules as the `Attrs.set()` function:
295
308
 
296
309
  - Pass a name and a value to set an attribute (e.g. `type="text"`)
297
310
  - Use `True` as a value to set a property (e.g. `disabled`)
@@ -308,7 +321,7 @@ class Attrs:
308
321
  Example:
309
322
 
310
323
  ```python
311
- attrs = HTMLAttrs({"class": "ipsum", "data_good": True, "width": 42})
324
+ attrs = Attrs({"class": "ipsum", "data_good": True, "width": 42})
312
325
 
313
326
  attrs.render()
314
327
  'class="ipsum" width="42" data-good'
@@ -24,32 +24,55 @@ class CData:
24
24
  code: CodeType | None = None
25
25
  required: tuple[str, ...] = ()
26
26
  optional: dict[str, t.Any] = field(default_factory=dict) # { attr: default_value }
27
- imports: dict[str, str] = field(default_factory=dict) # { component_name: relpath }
27
+ imports: dict[str, str] = field(default_factory=dict) # { name: relpath }
28
28
  css: tuple[str, ...] = ()
29
29
  js: tuple[str, ...] = ()
30
+ slots: tuple[str, ...] = ()
30
31
 
31
32
 
32
33
  class Catalog:
33
- """
34
- The object that manages the components and their global settings.
35
-
36
- Arguments:
37
- """
34
+ # IDEA: This dict could be replaced by a dict-like object
35
+ # that usesa LRU cache (to limit the memory used)
36
+ # or even a shared Redis/Memcache cache.
38
37
  components: dict[str, CData]
39
38
 
40
39
  def __init__(
41
40
  self,
42
41
  folder: str | Path | None = None,
43
42
  *,
43
+ auto_reload: bool = True,
44
44
  jinja_env: jinja2.Environment | None = None,
45
45
  filters: dict[str, t.Any] | None = None,
46
46
  tests: dict[str, t.Any] | None = None,
47
47
  extensions: list | None = None,
48
- auto_reload: bool = True,
49
48
  **globals: t.Any,
50
49
  ) -> None:
50
+ """
51
+ Manager of the components and their global settings.
52
+
53
+ Arguments:
54
+ folder:
55
+ Optional folder path to scan for components. It's a shortcut to
56
+ calling `add_folder` when only one is used.
57
+ auto_reload:
58
+ Whether to check the last-modified time of the components files and
59
+ automatically re-process them if they change. The performance impact of
60
+ leaving it on is minimal, but *might* be noticeable when rendering a page
61
+ that uses a large number of different components.
62
+ jinja_env:
63
+ Optional Jinja2 environment to use for rendering.
64
+ filters:
65
+ Optional extra Jinja2 filters to add to the environment.
66
+ extensions:
67
+ Optional extra Jinja2 extensions to add to the environment.
68
+ tests:
69
+ Optional extra Jinja2 tests to add to the environment.
70
+ **globals:
71
+ Variables to make available to all components by default.
72
+
73
+ """
51
74
  self.components = {}
52
- self.jinja_env = self.make_jinja_env(
75
+ self.jinja_env = self._make_jinja_env(
53
76
  jinja_env=jinja_env,
54
77
  globals=globals,
55
78
  filters=filters,
@@ -60,64 +83,22 @@ class Catalog:
60
83
  if folder:
61
84
  self.add_folder(folder)
62
85
 
63
- def make_jinja_env(
64
- self,
65
- *,
66
- jinja_env: jinja2.Environment | None = None,
67
- globals: dict[str, t.Any] | None = None,
68
- filters: dict[str, t.Any] | None = None,
69
- tests: dict[str, t.Any] | None = None,
70
- extensions: list | None = None,
71
- ) -> jinja2.Environment:
72
- env = jinja2.Environment()
73
- jinja_env = jinja_env or getattr(self, "jinja_env", None)
74
- if jinja_env:
75
- env = jinja_env.overlay()
76
-
77
- globals = globals or {}
78
- globals.update({
79
- "_get_random_id": utils.get_random_id,
80
- })
81
- env.globals.update(globals)
82
-
83
- filters = filters or {}
84
- env.filters.update(filters)
85
-
86
- tests = tests or {}
87
- env.tests.update(tests)
88
-
89
- extensions = extensions or []
90
- extensions.extend(["jinja2.ext.do"])
91
- for ext in extensions:
92
- env.add_extension(ext)
93
-
94
- env.autoescape = True
95
- env.undefined = jinja2.StrictUndefined
96
-
97
- return env
98
-
99
86
  def add_folder(
100
87
  self,
101
88
  path: str | Path,
102
89
  *,
103
90
  prefix: str = "",
91
+ preload: bool = True
104
92
  ) -> None:
105
93
  """
106
94
  Add a folder path from which to search for components, optionally under a prefix.
107
95
 
108
- Arguments:
109
-
110
- path:
111
- Absolute path of the folder with component files.
112
-
113
- prefix:
114
- Optional path prefix that all the components in the folder
115
- will have. The default is empty.
116
-
117
96
  Components without a prefix can be imported as a path relative to the folder,
118
97
  e.g.: `sub/folder/component.jinja` or with a path relative to the component
119
98
  where it is used: `./folder/component.jinja`.
120
99
 
100
+ Relative imports cannot go outside the folder.
101
+
121
102
  Components added with a prefix must be imported using the prefix followed
122
103
  by a colon: `prefix:sub/folder/component.jinja`. If the importing is
123
104
  done from within a component with the prefix itself, a relative
@@ -128,6 +109,23 @@ class Catalog:
128
109
  with a component with the same subpath/filename, the one in the folder
129
110
  added **first** will be used and the other ignored.
130
111
 
112
+ WARNING: You cannot move or delete components files from the folder after
113
+ calling this method, but you can call it again to add new components added
114
+ to the folder. This is unrelated to the value of `preload`.
115
+
116
+ Arguments:
117
+ path:
118
+ Absolute path of the folder with component files.
119
+ prefix:
120
+ Optional path prefix that all the components in the folder
121
+ will have. The default is empty.
122
+ preload:
123
+ Whether to preload the data of components in the folder.
124
+ If set to `True` (the default), the component data will be loaded into
125
+ memory when the folder is added, instead of just before rendering it.
126
+ This makes the first render faster at the expense of a few
127
+ microseconds upfront.
128
+
131
129
  """
132
130
  base_path = Path(path).resolve()
133
131
  prefix = prefix.replace("\\", "/").strip("./@ ")
@@ -149,10 +147,57 @@ class Catalog:
149
147
  )
150
148
  self.components[relpath] = cdata
151
149
 
152
- for relpath in self.components:
153
- self.components[relpath] = self.get_component_data(relpath)
150
+ if preload:
151
+ for relpath in self.components:
152
+ self.components[relpath] = self.get_component_data(relpath)
153
+
154
+ def render(self, relpath: str, globals: dict[str, t.Any] | None = None, **kwargs) -> str:
155
+ """
156
+ Render a component with the given relative path and context.
157
+
158
+ Arguments:
159
+ relpath:
160
+ The path of the component to render, including the extension,relative to its view folder.
161
+ e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
162
+ globals:
163
+ Optional global variables to make available to the component and all its
164
+ imported components.
165
+ **kwargs:
166
+ Keyword arguments to pass to the component.
167
+ They will be available in the component's context but not to its imported components.
168
+
169
+ Returns:
170
+ The rendered component as a string.
171
+
172
+ """
173
+ relpath = relpath.replace("\\", "/").strip("/")
174
+ co = self.get_component(relpath)
175
+
176
+ globals = globals or {}
177
+ globals.update({
178
+ "assets": {
179
+ "css": co.collect_css,
180
+ "js": co.collect_js,
181
+ "render_css": co.render_css,
182
+ "render_js": co.render_js,
183
+ "render": co.render_assets,
184
+ },
185
+ })
186
+ co.globals = globals
187
+
188
+ return co.render(**kwargs)
154
189
 
155
190
  def get_component_data(self, relpath: str) -> CData:
191
+ """
192
+ Get the component data from the cache, or load it from the file system
193
+ if needed.
194
+
195
+ Arguments:
196
+ relpath:
197
+ The path of the component to render, including the extension,relative to its view folder.
198
+ e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
199
+
200
+ """
156
201
  cdata = self.components.get(relpath)
157
202
  if not cdata:
158
203
  raise ImportError(relpath)
@@ -173,7 +218,7 @@ class Catalog:
173
218
  source=source,
174
219
  components=list(meta.imports.keys())
175
220
  )
176
- parsed_source = parser.parse()
221
+ parsed_source, slots = parser.parse()
177
222
  code = self.jinja_env.compile(
178
223
  source=parsed_source,
179
224
  name=relpath,
@@ -186,9 +231,19 @@ class Catalog:
186
231
  cdata.imports = meta.imports
187
232
  cdata.css = meta.css
188
233
  cdata.js = meta.js
234
+ cdata.slots = slots
189
235
  return cdata
190
236
 
191
237
  def get_component(self, relpath: str) -> Component:
238
+ """
239
+ Instantiate and return a component object by its relative path.
240
+
241
+ Arguments:
242
+ relpath:
243
+ The path of the component to render, including the extension,relative to its view folder.
244
+ e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
245
+
246
+ """
192
247
  cdata = self.get_component_data(relpath)
193
248
  assert cdata.code is not None
194
249
  tmpl = jinja2.Template.from_code(
@@ -203,25 +258,72 @@ class Catalog:
203
258
  get_component=self.get_component,
204
259
  required=cdata.required,
205
260
  optional=cdata.optional,
261
+ imports=cdata.imports,
206
262
  css=cdata.css,
207
263
  js=cdata.js,
208
- imports=cdata.imports
264
+ slots=cdata.slots,
209
265
  )
210
266
  return co
211
267
 
212
- def render(self, relpath: str, globals: dict[str, t.Any] | None = None, **kwargs) -> str:
213
- co = self.get_component(relpath)
268
+ # Private
269
+
270
+ def _make_jinja_env(
271
+ self,
272
+ *,
273
+ jinja_env: jinja2.Environment | None = None,
274
+ globals: dict[str, t.Any] | None = None,
275
+ filters: dict[str, t.Any] | None = None,
276
+ tests: dict[str, t.Any] | None = None,
277
+ extensions: list | None = None,
278
+ ) -> jinja2.Environment:
279
+ """
280
+ Create a new Jinja2 environment with the specified settings.
281
+
282
+ If an existing environment is provided, an "overlay" of it will
283
+ be created and used.
284
+
285
+ Arguments:
286
+ jinja_env:
287
+ Optional Jinja2 environment to use as a base.
288
+ globals:
289
+ Optional global variables to add to the environment.
290
+ filters:
291
+ Optional extra Jinja2 filters to add to the environment.
292
+ extensions:
293
+ Optional extra Jinja2 extensions to add to the environment.
294
+ tests:
295
+ Optional extra Jinja2 tests to add to the environment.
296
+
297
+ """
298
+ jinja_env = jinja_env or getattr(self, "jinja_env", None)
299
+ if jinja_env:
300
+ env = jinja_env.overlay()
301
+ else:
302
+ env = jinja2.Environment()
214
303
 
215
304
  globals = globals or {}
216
305
  globals.update({
217
- "assets": {
218
- "css": co.collect_css,
219
- "js": co.collect_js,
220
- "render_css": co.render_css,
221
- "render_js": co.render_js,
222
- "render": co.render_assets,
223
- },
306
+ # A unique ID generator for HTML elements, see `utils.get_random_id`
307
+ # docstring for more information.
308
+ "_get_random_id": utils.get_random_id,
224
309
  })
225
- co.globals = globals
310
+ env.globals.update(globals)
226
311
 
227
- return co.render(**kwargs)
312
+ filters = filters or {}
313
+ env.filters.update(filters)
314
+
315
+ tests = tests or {}
316
+ env.tests.update(tests)
317
+
318
+ extensions = extensions or []
319
+ # The "jinja2.ext.do" extension allows the use of the "do" statement in templates,
320
+ # that execute statements without outputting a value.
321
+ # Is specially useful for manipulating the `attrs` object.
322
+ extensions.extend(["jinja2.ext.do"])
323
+ for ext in extensions:
324
+ env.add_extension(ext)
325
+
326
+ env.autoescape = True
327
+ env.undefined = jinja2.StrictUndefined
328
+
329
+ return env