jx 0.2.0__tar.gz → 0.3.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,11 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jx
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Replace your HTML templates with Python server-Side components
5
5
  Author-email: Juan Pablo Scaletti <juanpablo@jpscaletti.com>
6
- Project-URL: homepage, https://jx.scaletti.dev/
7
- Project-URL: repository, https://github.com/jpsca/jx
8
- Project-URL: documentation, https://jx.scaletti.dev/
6
+ Project-URL: Homepage, https://jx.scaletti.dev/
7
+ Project-URL: GitHub, https://github.com/jpsca/jx
9
8
  Classifier: Development Status :: 4 - Beta
10
9
  Classifier: Environment :: Web Environment
11
10
  Classifier: Intended Audience :: Developers
@@ -19,23 +18,21 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
18
  Classifier: Topic :: Software Development :: User Interfaces
20
19
  Classifier: Topic :: Text Processing :: Markup :: HTML
21
20
  Classifier: Typing :: Typed
22
- Requires-Python: <4,>=3.11
21
+ Requires-Python: <4,>=3.12
23
22
  Description-Content-Type: text/markdown
24
23
  License-File: LICENSE
25
24
  Requires-Dist: jinja2>=3.0
26
- Requires-Dist: markupsafe>=2.0
27
- Requires-Dist: ty>=0.0.1a15
28
25
  Dynamic: license-file
29
26
 
30
27
  <h1>
31
- <img src="https://github.com/jpsca/jx/raw/main/docs/logo-jx.png" height="100" align="top">
28
+ <img src="./docs/logo-jx.png" height="100" align="top">
32
29
  </h1>
33
30
 
34
31
  Super components powers for your Jinja templates.
35
32
 
36
33
  <p>
37
- <img alt="python: 3.11, 3.12, 3.13, 3.14" src="https://github.com/jpsca/jx/raw/main/docs/python.svg">
38
- <img alt="license: MIT" src="https://github.com/jpsca/jx/raw/main/docs/license.svg">
34
+ <img alt="python: 3.11, 3.12, 3.13, 3.14" src="./docs/python.svg">
35
+ <img alt="license: MIT" src="./docs/license.svg">
39
36
  </p>
40
37
 
41
38
  From chaos to clarity: The power of components in your server-side-rendered Python web app.
@@ -1,12 +1,12 @@
1
1
  <h1>
2
- <img src="https://github.com/jpsca/jx/raw/main/docs/logo-jx.png" height="100" align="top">
2
+ <img src="./docs/logo-jx.png" height="100" align="top">
3
3
  </h1>
4
4
 
5
5
  Super components powers for your Jinja templates.
6
6
 
7
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">
8
+ <img alt="python: 3.11, 3.12, 3.13, 3.14" src="./docs/python.svg">
9
+ <img alt="license: MIT" src="./docs/license.svg">
10
10
  </p>
11
11
 
12
12
  From chaos to clarity: The power of components in your server-side-rendered Python web app.
@@ -4,7 +4,7 @@ requires = ["setuptools"]
4
4
 
5
5
  [project]
6
6
  name = "jx"
7
- version = "0.2.0"
7
+ version = "0.3.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"},
@@ -26,25 +26,24 @@ classifiers = [
26
26
  "Topic :: Text Processing :: Markup :: HTML",
27
27
  "Typing :: Typed",
28
28
  ]
29
- requires-python = ">=3.11,<4"
29
+ requires-python = ">=3.12,<4"
30
30
  dependencies = [
31
31
  "jinja2 >= 3.0",
32
- "markupsafe >= 2.0",
33
- "ty>=0.0.1a15",
34
32
  ]
35
33
 
36
34
  [project.urls]
37
- homepage = "https://jx.scaletti.dev/"
38
- repository = "https://github.com/jpsca/jx"
39
- documentation = "https://jx.scaletti.dev/"
35
+ Homepage = "https://jx.scaletti.dev/"
36
+ GitHub = "https://github.com/jpsca/jx"
40
37
 
41
38
  [dependency-groups]
42
39
  dev = [
43
- "ipdb >= 0.13",
40
+ "ipdb",
44
41
  "pre-commit",
45
- "ruff >= 0.2.0",
46
42
  "tox-uv",
47
- "ty>=0.0.1a15",
43
+ "ty",
44
+ ]
45
+ docs = [
46
+ "writeadoc>=0.7.0",
48
47
  ]
49
48
  test = [
50
49
  "pytest >= 7.2",
@@ -82,10 +81,9 @@ addopts = "--doctest-modules"
82
81
  legacy_tox_ini = """
83
82
  [tox]
84
83
  env_list =
85
- 3.11
86
- 3.12
87
- 3.13
88
84
  3.14
85
+ 3.13
86
+ 3.12
89
87
 
90
88
  [testenv]
91
89
  runner = uv-venv-lock-runner
@@ -100,7 +98,7 @@ commands =
100
98
  [tool.ruff]
101
99
  line-length = 90
102
100
  indent-width = 4
103
- target-version = "py311"
101
+ target-version = "py312"
104
102
  exclude = [
105
103
  ".*",
106
104
  "_build",
@@ -1,5 +1,6 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
+
4
5
  from .catalog import CData, Catalog # noqa
5
6
  from .exceptions import * # noqa
@@ -1,7 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
- import re
4
+
5
5
  import typing as t
6
6
  from collections import UserString
7
7
  from functools import cached_property
@@ -14,10 +14,6 @@ CLASS_ALT_KEY = "classes"
14
14
  CLASS_KEYS = (CLASS_KEY, CLASS_ALT_KEY)
15
15
 
16
16
 
17
- def split(ssl: str) -> list[str]:
18
- return re.split(r"\s+", ssl.strip())
19
-
20
-
21
17
  def quote(text: str) -> str:
22
18
  if '"' in text:
23
19
  if "'" in text:
@@ -45,6 +41,10 @@ class LazyString(UserString):
45
41
 
46
42
 
47
43
  class Attrs:
44
+ __classes: tuple[str, ...]
45
+ __attributes: dict[str, str | LazyString]
46
+ __properties: set[str]
47
+
48
48
  def __init__(self, attrs: "dict[str, t.Any| LazyString]") -> None:
49
49
  """
50
50
  Contains all the HTML attributes/properties (a property is an
@@ -62,11 +62,15 @@ class Attrs:
62
62
  attributes: "dict[str, str | LazyString]" = {}
63
63
  properties: set[str] = set()
64
64
 
65
- class_names = split(" ".join([
65
+ class_names = (" ".join([
66
66
  str(attrs.pop(CLASS_KEY, "")),
67
67
  str(attrs.get(CLASS_ALT_KEY, "")),
68
- ]))
69
- self.__classes = {name for name in class_names if name}
68
+ ])).strip().split()
69
+ classes = []
70
+ for name in class_names:
71
+ if name and name not in classes:
72
+ classes.append(name)
73
+ self.__classes = tuple(classes)
70
74
 
71
75
  for name, value in attrs.items():
72
76
  if name.startswith("_"):
@@ -83,7 +87,7 @@ class Attrs:
83
87
  @property
84
88
  def classes(self) -> str:
85
89
  """
86
- All the HTML classes alphabetically sorted and separated by a space.
90
+ All the HTML classes separated by a space.
87
91
 
88
92
  Example:
89
93
 
@@ -91,11 +95,11 @@ class Attrs:
91
95
  attrs = Attrs({"class": "italic bold bg-blue wide abcde"})
92
96
  attrs.set(class="bold text-white")
93
97
  print(attrs.classes)
94
- abcde bg-blue bold italic text-white wide
98
+ italic bold bg-blue wide abcde text-white
95
99
  ```
96
100
 
97
101
  """
98
- return " ".join(sorted((self.__classes)))
102
+ return " ".join(self.__classes)
99
103
 
100
104
  @property
101
105
  def as_dict(self) -> dict[str, t.Any]:
@@ -116,7 +120,7 @@ class Attrs:
116
120
  attrs.as_dict
117
121
  {
118
122
  "aria_label": "hello",
119
- "class": "ipsum lorem",
123
+ "class": "lorem ipsum",
120
124
  "id": "world",
121
125
  "data_test": True,
122
126
  "hidden": True
@@ -155,8 +159,6 @@ class Attrs:
155
159
  - The underscores in the names will be translated automatically to dashes,
156
160
  so `aria_selected` becomes the attribute `aria-selected`.
157
161
 
158
- TODO: vue-style
159
-
160
162
  Example:
161
163
 
162
164
  ```python
@@ -172,7 +174,7 @@ class Attrs:
172
174
  attrs = Attrs({"class": "b c a"})
173
175
  attrs.set(class="c b f d e")
174
176
  attrs.as_dict
175
- {"class": "a b c d e f"}
177
+ {"class": "b c a f d e"}
176
178
  ```
177
179
 
178
180
  """
@@ -212,7 +214,9 @@ class Attrs:
212
214
  continue
213
215
 
214
216
  if name in CLASS_KEYS:
215
- self.add_class(value)
217
+ if not self.__classes:
218
+ self.add_class(value)
219
+ continue
216
220
 
217
221
  name = name.replace("_", "-")
218
222
  if name not in self.__attributes:
@@ -220,10 +224,10 @@ class Attrs:
220
224
 
221
225
  def add_class(self, *values: str) -> None:
222
226
  """
223
- Adds one or more classes to the list of classes, if not already present.
227
+ Adds one or more classes to the end of the list of classes,
228
+ if not already present.
224
229
 
225
230
  Arguments:
226
-
227
231
  values:
228
232
  One or more class names to add, separated by spaces.
229
233
 
@@ -231,15 +235,44 @@ class Attrs:
231
235
 
232
236
  ```python
233
237
  attrs = Attrs({"class": "a b c"})
234
- attrs.add_class("c", "d")
238
+ attrs.add_class("c d")
235
239
  attrs.as_dict
236
240
  {"class": "a b c d"}
237
241
  ```
238
242
 
239
243
  """
240
244
  for names in values:
241
- for name in split(names):
242
- self.__classes.add(name)
245
+ for name in names.strip().split():
246
+ if name not in self.__classes:
247
+ self.__classes += (name,)
248
+
249
+ def prepend_class(self, *values: str) -> None:
250
+ """
251
+ Adds one or more classes to the beginning of the list of classes,
252
+ if not already present.
253
+
254
+ Arguments:
255
+ values:
256
+ One or more class names to add, separated by spaces.
257
+
258
+ Example:
259
+
260
+ ```python
261
+ attrs = Attrs({"class": "a b c"})
262
+ attrs.add_class("c d |")
263
+ attrs.as_dict
264
+ {"class": "d | a b c"}
265
+ ```
266
+
267
+ """
268
+ new_classes = [
269
+ name
270
+ for names in values
271
+ for name in names.strip().split()
272
+ if name not in self.__classes
273
+ ]
274
+
275
+ self.__classes = tuple(new_classes) + self.__classes
243
276
 
244
277
  def remove_class(self, *names: str) -> None:
245
278
  """
@@ -255,8 +288,7 @@ class Attrs:
255
288
  ```
256
289
 
257
290
  """
258
- for name in names:
259
- self.__classes.remove(name)
291
+ self.__classes = tuple(c for c in self.__classes if c not in names)
260
292
 
261
293
  def get(self, name: str, default: t.Any = None) -> t.Any:
262
294
  """
@@ -358,7 +390,7 @@ class Attrs:
358
390
  Removes an attribute or property.
359
391
  """
360
392
  if name in CLASS_KEYS:
361
- self.__classes = set()
393
+ self.__classes = ()
362
394
  if name in self.__attributes:
363
395
  del self.__attributes[name]
364
396
  if name in self.__properties:
@@ -1,6 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
+
4
5
  import typing as t
5
6
  from dataclasses import dataclass, field
6
7
  from pathlib import Path
@@ -23,7 +24,7 @@ class CData:
23
24
  mtime: float
24
25
  code: CodeType | None = None
25
26
  required: tuple[str, ...] = ()
26
- optional: dict[str, t.Any] = field(default_factory=dict) # { attr: default_value }
27
+ optional: dict[str, t.Any] = field(default_factory=dict) # { attr: default_value }
27
28
  imports: dict[str, str] = field(default_factory=dict) # { name: relpath }
28
29
  css: tuple[str, ...] = ()
29
30
  js: tuple[str, ...] = ()
@@ -31,8 +32,9 @@ class CData:
31
32
 
32
33
 
33
34
  class Catalog:
35
+
34
36
  # IDEA: This dict could be replaced by a dict-like object
35
- # that usesa LRU cache (to limit the memory used)
37
+ # that uses a LRU cache (to limit the memory used)
36
38
  # or even a shared Redis/Memcache cache.
37
39
  components: dict[str, CData]
38
40
 
@@ -40,11 +42,11 @@ class Catalog:
40
42
  self,
41
43
  folder: str | Path | None = None,
42
44
  *,
43
- auto_reload: bool = True,
44
45
  jinja_env: jinja2.Environment | None = None,
46
+ extensions: list | None = None,
45
47
  filters: dict[str, t.Any] | None = None,
46
48
  tests: dict[str, t.Any] | None = None,
47
- extensions: list | None = None,
49
+ auto_reload: bool = True,
48
50
  **globals: t.Any,
49
51
  ) -> None:
50
52
  """
@@ -54,19 +56,19 @@ class Catalog:
54
56
  folder:
55
57
  Optional folder path to scan for components. It's a shortcut to
56
58
  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
59
  jinja_env:
63
60
  Optional Jinja2 environment to use for rendering.
64
- filters:
65
- Optional extra Jinja2 filters to add to the environment.
66
61
  extensions:
67
62
  Optional extra Jinja2 extensions to add to the environment.
63
+ filters:
64
+ Optional extra Jinja2 filters to add to the environment.
68
65
  tests:
69
66
  Optional extra Jinja2 tests to add to the environment.
67
+ auto_reload:
68
+ Whether to check the last-modified time of the components files and
69
+ automatically re-process them if they change. The performance impact of
70
+ leaving it on is minimal, but *might* be noticeable when rendering a
71
+ component that uses a large number of child components.
70
72
  **globals:
71
73
  Variables to make available to all components by default.
72
74
 
@@ -84,11 +86,7 @@ class Catalog:
84
86
  self.add_folder(folder)
85
87
 
86
88
  def add_folder(
87
- self,
88
- path: str | Path,
89
- *,
90
- prefix: str = "",
91
- preload: bool = True
89
+ self, path: str | Path, *, prefix: str = "", preload: bool = True
92
90
  ) -> None:
93
91
  """
94
92
  Add a folder path from which to search for components, optionally under a prefix.
@@ -141,9 +139,7 @@ class Catalog:
141
139
  logger.debug(f"Component already exists: {relpath}")
142
140
  continue
143
141
  cdata = CData(
144
- base_path=base_path,
145
- path=filepath,
146
- mtime=filepath.stat().st_mtime
142
+ base_path=base_path, path=filepath, mtime=filepath.stat().st_mtime
147
143
  )
148
144
  self.components[relpath] = cdata
149
145
 
@@ -151,7 +147,9 @@ class Catalog:
151
147
  for relpath in self.components:
152
148
  self.components[relpath] = self.get_component_data(relpath)
153
149
 
154
- def render(self, relpath: str, globals: dict[str, t.Any] | None = None, **kwargs) -> str:
150
+ def render(
151
+ self, relpath: str, globals: dict[str, t.Any] | None = None, **kwargs
152
+ ) -> str:
155
153
  """
156
154
  Render a component with the given relative path and context.
157
155
 
@@ -173,6 +171,64 @@ class Catalog:
173
171
  relpath = relpath.replace("\\", "/").strip("/")
174
172
  co = self.get_component(relpath)
175
173
 
174
+ globals = globals or {}
175
+ globals.update(
176
+ {
177
+ "assets": {
178
+ "css": co.collect_css,
179
+ "js": co.collect_js,
180
+ "render_css": co.render_css,
181
+ "render_js": co.render_js,
182
+ "render": co.render_assets,
183
+ },
184
+ }
185
+ )
186
+ co.globals = globals
187
+
188
+ return co.render(**kwargs)
189
+
190
+ def render_string(
191
+ self, source: str, globals: dict[str, t.Any] | None = None, **kwargs
192
+ ) -> str:
193
+ """
194
+ Render a component from a string source.
195
+ Works like `render`, but the component is not cached and cannot do relative imports.
196
+
197
+ Arguments:
198
+ source:
199
+ The Jinja2 source code of the component to render.
200
+ globals:
201
+ Optional global variables to make available to the component and all its
202
+ imported components.
203
+ **kwargs:
204
+ Keyword arguments to pass to the component.
205
+ They will be available in the component's context but not to its imported components.
206
+
207
+ Returns:
208
+ The rendered component as a string.
209
+
210
+ """
211
+ meta = extract_metadata(source, base_path=Path(), fullpath=Path())
212
+ name = "<string>"
213
+
214
+ parser = JxParser(name=name, source=source, components=list(meta.imports.keys()))
215
+ parsed_source, slots = parser.parse()
216
+
217
+ code = self.jinja_env.compile(source=parsed_source, name=name, filename=name)
218
+ tmpl = jinja2.Template.from_code(self.jinja_env, code, self.jinja_env.globals)
219
+
220
+ co = Component(
221
+ relpath=name,
222
+ tmpl=tmpl,
223
+ get_component=self.get_component,
224
+ required=meta.required,
225
+ optional=meta.optional,
226
+ imports=meta.imports,
227
+ css=meta.css,
228
+ js=meta.js,
229
+ slots=slots,
230
+ )
231
+
176
232
  globals = globals or {}
177
233
  globals.update({
178
234
  "assets": {
@@ -181,7 +237,7 @@ class Catalog:
181
237
  "render_css": co.render_css,
182
238
  "render_js": co.render_js,
183
239
  "render": co.render_assets,
184
- },
240
+ }
185
241
  })
186
242
  co.globals = globals
187
243
 
@@ -189,8 +245,8 @@ class Catalog:
189
245
 
190
246
  def get_component_data(self, relpath: str) -> CData:
191
247
  """
192
- Get the component data from the cache, or load it from the file system
193
- if needed.
248
+ Get the component data from the cache.
249
+ If the file has been updated, the component is re-processed.
194
250
 
195
251
  Arguments:
196
252
  relpath:
@@ -214,15 +270,11 @@ class Catalog:
214
270
  meta = extract_metadata(source, base_path=cdata.base_path, fullpath=cdata.path)
215
271
 
216
272
  parser = JxParser(
217
- name=relpath,
218
- source=source,
219
- components=list(meta.imports.keys())
273
+ name=relpath, source=source, components=list(meta.imports.keys())
220
274
  )
221
275
  parsed_source, slots = parser.parse()
222
276
  code = self.jinja_env.compile(
223
- source=parsed_source,
224
- name=relpath,
225
- filename=cdata.path.as_posix()
277
+ source=parsed_source, name=relpath, filename=cdata.path.as_posix()
226
278
  )
227
279
 
228
280
  cdata.code = code
@@ -247,9 +299,7 @@ class Catalog:
247
299
  cdata = self.get_component_data(relpath)
248
300
  assert cdata.code is not None
249
301
  tmpl = jinja2.Template.from_code(
250
- self.jinja_env,
251
- cdata.code,
252
- self.jinja_env.globals
302
+ self.jinja_env, cdata.code, self.jinja_env.globals
253
303
  )
254
304
 
255
305
  co = Component(
@@ -279,9 +329,6 @@ class Catalog:
279
329
  """
280
330
  Create a new Jinja2 environment with the specified settings.
281
331
 
282
- If an existing environment is provided, an "overlay" of it will
283
- be created and used.
284
-
285
332
  Arguments:
286
333
  jinja_env:
287
334
  Optional Jinja2 environment to use as a base.
@@ -297,16 +344,21 @@ class Catalog:
297
344
  """
298
345
  jinja_env = jinja_env or getattr(self, "jinja_env", None)
299
346
  if jinja_env:
300
- env = jinja_env.overlay()
347
+ # It could be `jinja_env.overlay()` instead, but that might
348
+ # might lead to confusion if the user expects changes
349
+ # to the original environment to be reflected here.
350
+ env = jinja_env
301
351
  else:
302
352
  env = jinja2.Environment()
303
353
 
304
354
  globals = globals or {}
305
- globals.update({
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,
309
- })
355
+ globals.update(
356
+ {
357
+ # A unique ID generator for HTML elements, see `utils.get_random_id`
358
+ # docstring for more information.
359
+ "_get_random_id": utils.get_random_id,
360
+ }
361
+ )
310
362
  env.globals.update(globals)
311
363
 
312
364
  filters = filters or {}
@@ -1,7 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
- import re
4
+
5
5
  import typing as t
6
6
  from collections.abc import Callable
7
7
 
@@ -12,9 +12,6 @@ from .attrs import Attrs
12
12
  from .exceptions import MissingRequiredArgument
13
13
 
14
14
 
15
- rx_external_url = re.compile(r"^[a-z]+://", re.IGNORECASE)
16
-
17
-
18
15
  class Component:
19
16
  __slots__ = (
20
17
  "relpath",
@@ -85,7 +82,7 @@ class Component:
85
82
  content: str | None = None,
86
83
  attrs: Attrs | dict[str, t.Any] | None = None,
87
84
  caller: Callable[[str], str] | None = None,
88
- **params: t.Any
85
+ **params: t.Any,
89
86
  ) -> Markup:
90
87
  content = content if content is not None else caller("") if caller else ""
91
88
  attrs = attrs.as_dict if isinstance(attrs, Attrs) else attrs or {}
@@ -1,7 +1,8 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
4
 
5
+
5
6
  class JxException(Exception):
6
7
  """Base class for all Jx exceptions."""
7
8
 
@@ -18,6 +19,7 @@ class ImportError(JxException):
18
19
  Raised when an import fails.
19
20
  This is usually caused by a missing or inaccessible component.
20
21
  """
22
+
21
23
  def __init__(self, relpath: str, **kw) -> None:
22
24
  msg = f"Component not found: {relpath}"
23
25
  super().__init__(msg, **kw)
@@ -1,6 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
+
4
5
  import ast
5
6
  import re
6
7
  import typing as t
@@ -56,9 +57,9 @@ def extract_metadata(source: str, base_path: Path, fullpath: Path) -> Meta:
56
57
  source:
57
58
  The template source code.
58
59
  base_path:
59
- Absolute base path for all the template files
60
+ Absolute base path for all the template files, for relative imports.
60
61
  fullpath:
61
- The absolute full path of the current template.
62
+ The absolute full path of the current template, for relative imports.
62
63
 
63
64
  Returns:
64
65
  A `Meta` object containing the extracted metadata.
@@ -1,5 +1,5 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
4
 
5
5
  import re
@@ -1,6 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
+
4
5
  import logging
5
6
  import uuid
6
7
 
@@ -1,11 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jx
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Replace your HTML templates with Python server-Side components
5
5
  Author-email: Juan Pablo Scaletti <juanpablo@jpscaletti.com>
6
- Project-URL: homepage, https://jx.scaletti.dev/
7
- Project-URL: repository, https://github.com/jpsca/jx
8
- Project-URL: documentation, https://jx.scaletti.dev/
6
+ Project-URL: Homepage, https://jx.scaletti.dev/
7
+ Project-URL: GitHub, https://github.com/jpsca/jx
9
8
  Classifier: Development Status :: 4 - Beta
10
9
  Classifier: Environment :: Web Environment
11
10
  Classifier: Intended Audience :: Developers
@@ -19,23 +18,21 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
18
  Classifier: Topic :: Software Development :: User Interfaces
20
19
  Classifier: Topic :: Text Processing :: Markup :: HTML
21
20
  Classifier: Typing :: Typed
22
- Requires-Python: <4,>=3.11
21
+ Requires-Python: <4,>=3.12
23
22
  Description-Content-Type: text/markdown
24
23
  License-File: LICENSE
25
24
  Requires-Dist: jinja2>=3.0
26
- Requires-Dist: markupsafe>=2.0
27
- Requires-Dist: ty>=0.0.1a15
28
25
  Dynamic: license-file
29
26
 
30
27
  <h1>
31
- <img src="https://github.com/jpsca/jx/raw/main/docs/logo-jx.png" height="100" align="top">
28
+ <img src="./docs/logo-jx.png" height="100" align="top">
32
29
  </h1>
33
30
 
34
31
  Super components powers for your Jinja templates.
35
32
 
36
33
  <p>
37
- <img alt="python: 3.11, 3.12, 3.13, 3.14" src="https://github.com/jpsca/jx/raw/main/docs/python.svg">
38
- <img alt="license: MIT" src="https://github.com/jpsca/jx/raw/main/docs/license.svg">
34
+ <img alt="python: 3.11, 3.12, 3.13, 3.14" src="./docs/python.svg">
35
+ <img alt="license: MIT" src="./docs/license.svg">
39
36
  </p>
40
37
 
41
38
  From chaos to clarity: The power of components in your server-side-rendered Python web app.
@@ -0,0 +1 @@
1
+ jinja2>=3.0
@@ -1,6 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
+
4
5
  import pytest
5
6
 
6
7
  from jx.attrs import Attrs
@@ -19,8 +20,8 @@ def test_parse_initial_attrs():
19
20
  "_content": "content",
20
21
  }
21
22
  )
22
- assert attrs.classes == "a1 b2 c3 z4"
23
- assert attrs.get("class") == "a1 b2 c3 z4"
23
+ assert attrs.classes == "z4 c3 a1 b2"
24
+ assert attrs.get("class") == "z4 c3 a1 b2"
24
25
  assert attrs.get("data-position") == "top"
25
26
  assert attrs.get("data_position") == "top"
26
27
  assert attrs.get("title") == "hi"
@@ -37,6 +38,7 @@ def test_parse_initial_attrs():
37
38
  assert attrs.get("data-position") is None
38
39
  assert attrs.get("data_position") is None
39
40
 
41
+
40
42
  def test_getattr():
41
43
  attrs = Attrs(
42
44
  {
@@ -45,7 +47,7 @@ def test_getattr():
45
47
  "open": True,
46
48
  }
47
49
  )
48
- assert attrs["class"] == "a1 b2 c3 z4"
50
+ assert attrs["class"] == "z4 c3 a1 b2"
49
51
  assert attrs["title"] == "hi"
50
52
  assert attrs["open"] is True
51
53
  assert attrs["lorem"] is None
@@ -59,7 +61,7 @@ def test_deltattr():
59
61
  "open": True,
60
62
  }
61
63
  )
62
- assert attrs["class"] == "a1 b2 c3 z4"
64
+ assert attrs["class"] == "z4 c3 a1 b2"
63
65
  del attrs["title"]
64
66
  assert attrs["title"] is None
65
67
 
@@ -74,7 +76,7 @@ def test_render():
74
76
  "disabled": False,
75
77
  }
76
78
  )
77
- assert 'class="a1 b2 c3 z4" data-position="top" title="hi" open' == attrs.render()
79
+ assert 'class="z4 c3 a1 b2" data-position="top" title="hi" open' == attrs.render()
78
80
 
79
81
 
80
82
  def test_set():
@@ -90,15 +92,18 @@ def test_set():
90
92
  def test_class_management():
91
93
  attrs = Attrs(
92
94
  {
93
- "class": "z4 c3 a1 z4 b2",
95
+ "class": "z4 c3 a1 z4 b2",
94
96
  }
95
97
  )
96
98
  attrs.set(classes="lorem bipsum lorem a1")
97
99
 
98
- assert attrs.classes == "a1 b2 bipsum c3 lorem z4"
100
+ assert attrs.classes == "z4 c3 a1 b2 lorem bipsum"
99
101
 
100
- attrs.remove_class("bipsum")
101
- assert attrs.classes == "a1 b2 c3 lorem z4"
102
+ attrs.remove_class("lorem")
103
+ assert attrs.classes == "z4 c3 a1 b2 bipsum"
104
+
105
+ attrs.prepend_class("button |", "wat")
106
+ assert attrs.classes == "button | wat z4 c3 a1 b2 bipsum"
102
107
 
103
108
  attrs.set(classes=None)
104
109
  attrs.set(classes="meh")
@@ -121,11 +126,11 @@ def test_setdefault():
121
126
 
122
127
 
123
128
  def test_setdefault_classes():
124
- attrs = Attrs(
125
- {
126
- "class": "a",
127
- }
128
- )
129
+ attrs = Attrs({"class": "a"})
130
+ attrs.setdefault(classes="a b c")
131
+ assert 'class="a"' == attrs.render()
132
+
133
+ attrs = Attrs({})
129
134
  attrs.setdefault(classes="a b c")
130
135
  assert 'class="a b c"' == attrs.render()
131
136
 
@@ -141,7 +146,7 @@ def test_as_dict():
141
146
  }
142
147
  )
143
148
  assert attrs.as_dict == {
144
- "class": "a1 b2 c3 z4",
149
+ "class": "z4 c3 a1 b2",
145
150
  "data-position": "top",
146
151
  "title": "hi",
147
152
  "open": True,
@@ -165,7 +170,7 @@ def test_as_dict_no_classes():
165
170
 
166
171
  def test_render_attrs_lik_set():
167
172
  attrs = Attrs({"class": "lorem"})
168
- expected = 'class="ipsum lorem" data-position="top" title="hi" open'
173
+ expected = 'class="lorem ipsum" data-position="top" title="hi" open'
169
174
  result = attrs.render(
170
175
  title="hi",
171
176
  data_position="top",
@@ -178,7 +183,7 @@ def test_render_attrs_lik_set():
178
183
 
179
184
  def test_do_not_escape_tailwind_syntax():
180
185
  attrs = Attrs({"class": "lorem [&_a]:flex"})
181
- expected = 'class="[&_a]:flex ipsum lorem" title="Hi&Stuff"'
186
+ expected = 'class="lorem [&_a]:flex ipsum" title="Hi&Stuff"'
182
187
  result = attrs.render(
183
188
  **{
184
189
  "title": "Hi&Stuff",
@@ -1,6 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
+
4
5
  import jinja2
5
6
  import pytest
6
7
 
@@ -27,6 +28,27 @@ def test_add_folder(folder):
27
28
  assert catalog.components["b.jinja"].code is not None
28
29
 
29
30
 
31
+ def test_add_folder_no_preload(folder):
32
+ (folder / "a.jinja").write_text("AAAAA")
33
+ (folder / "b.jinja").write_text("BBBBB")
34
+
35
+ catalog = Catalog()
36
+ catalog.add_folder(folder, preload=False)
37
+
38
+ assert "a.jinja" in catalog.components
39
+ assert "b.jinja" in catalog.components
40
+
41
+ assert catalog.components["a.jinja"].base_path == folder
42
+ assert catalog.components["a.jinja"].path == folder / "a.jinja"
43
+ assert catalog.components["a.jinja"].mtime > 0
44
+ assert catalog.components["a.jinja"].code is None
45
+
46
+ assert catalog.components["b.jinja"].base_path == folder
47
+ assert catalog.components["b.jinja"].path == folder / "b.jinja"
48
+ assert catalog.components["b.jinja"].mtime > 0
49
+ assert catalog.components["b.jinja"].code is None
50
+
51
+
30
52
  def test_add_folder_nested(tmp_path):
31
53
  folder = tmp_path / "views"
32
54
  nested = folder / "a" / "b" / "c"
@@ -1,6 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
+
4
5
  import pytest
5
6
 
6
7
  from jx import Catalog, MissingRequiredArgument, TemplateSyntaxError
@@ -17,6 +18,17 @@ def test_render_simple(folder):
17
18
  assert html.strip() == '<button id="btn1">Submit</button>'
18
19
 
19
20
 
21
+ def test_render_simple_from_string():
22
+ source = """
23
+ {# def bid, text="Click me!" #}
24
+ <button id="{{ bid }}">{{ text }}</button>
25
+ """
26
+
27
+ cat = Catalog()
28
+ html = cat.render_string(source, bid="btn1", text="Submit")
29
+ assert html.strip() == '<button id="btn1">Submit</button>'
30
+
31
+
20
32
  def test_render_content(folder):
21
33
  (folder / "child.jinja").write_text("""
22
34
  <span>{{ content }}</span>
@@ -32,6 +44,19 @@ def test_render_content(folder):
32
44
  assert html.strip() == "<div><span>Hello</span></div>"
33
45
 
34
46
 
47
+ def test_render_content_from_string(folder):
48
+ (folder / "child.jinja").write_text("""
49
+ <span>{{ content }}</span>
50
+ """)
51
+ source = """
52
+ {# import "child.jinja" as Child #}
53
+ <div><Child>Hello</Child></div>
54
+ """
55
+ cat = Catalog(folder)
56
+ html = cat.render_string(source)
57
+ assert html.strip() == "<div><span>Hello</span></div>"
58
+
59
+
35
60
  def test_render_custom_content(folder):
36
61
  (folder / "child.jinja").write_text("""
37
62
  <span>{{ content }}</span>
@@ -142,6 +167,21 @@ def test_render_globals(folder):
142
167
  assert cat.render("page.jinja") == '<div class="ipsum"><p>ipsum</p></div>'
143
168
 
144
169
 
170
+ def test_render_globals_from_string(folder):
171
+ (folder / "child.jinja").write_text("""<p>{{ lorem }}</p>""")
172
+
173
+ (folder / "layout.jinja").write_text("""<div class="{{ lorem }}">{{ content }}</div>""")
174
+
175
+ source = """
176
+ {# import "layout.jinja" as Layout #}
177
+ {# import "child.jinja" as Child #}
178
+ <Layout><Child /></Layout>
179
+ """
180
+
181
+ cat = Catalog(folder, lorem="ipsum")
182
+ assert cat.render_string(source) == '<div class="ipsum"><p>ipsum</p></div>'
183
+
184
+
145
185
  def test_collect_assets(folder):
146
186
  (folder / "child.jinja").write_text("""
147
187
  {# css "child.css", "/static/common/parent.css" #}
@@ -1,6 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
+
4
5
  from pathlib import Path
5
6
 
6
7
  import pytest
@@ -1,6 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
+
4
5
  import pytest
5
6
 
6
7
  from jx import TemplateSyntaxError
@@ -1,6 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
+
4
5
  from jx import Catalog
5
6
 
6
7
 
@@ -1,6 +1,7 @@
1
1
  """
2
- Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
2
+ Jx | Copyright (c) Juan-Pablo Scaletti
3
3
  """
4
+
4
5
  from threading import Thread
5
6
 
6
7
  from jx import Catalog
@@ -1,3 +0,0 @@
1
- jinja2>=3.0
2
- markupsafe>=2.0
3
- ty>=0.0.1a15
File without changes
File without changes
File without changes
File without changes