jx 0.1.0__py3-none-any.whl → 0.2.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
@@ -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'
jx/catalog.py CHANGED
@@ -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
jx/component.py CHANGED
@@ -22,9 +22,10 @@ class Component:
22
22
  "get_component",
23
23
  "required",
24
24
  "optional",
25
+ "imports",
25
26
  "css",
26
27
  "js",
27
- "imports",
28
+ "slots",
28
29
  "globals",
29
30
  )
30
31
 
@@ -36,19 +37,45 @@ class Component:
36
37
  get_component: Callable[[str], "Component"],
37
38
  required: tuple[str, ...] = (),
38
39
  optional: dict[str, t.Any] | None = None,
40
+ imports: dict[str, str] | None = None,
39
41
  css: tuple[str, ...] = (),
40
42
  js: tuple[str, ...] = (),
41
- imports: dict[str, str] | None = None,
43
+ slots: tuple[str, ...] = (),
42
44
  ) -> None:
45
+ """
46
+ Internal object that represents a Jx component.
47
+
48
+ Arguments:
49
+ relpath:
50
+ The "name" of the component.
51
+ tmpl:
52
+ The jinja2.Template for the component.
53
+ get_component:
54
+ A callable that retrieves a component by its name/relpath.
55
+ required:
56
+ A tuple of required attribute names.
57
+ optional:
58
+ A dictionary of optional attributes and their default values.
59
+ imports:
60
+ A dictionary of imported component names as "name": "relpath" pairs.
61
+ css:
62
+ A tuple of CSS file URLs.
63
+ js:
64
+ A tuple of JS file URLs.
65
+ slots:
66
+ A tuple of slot names.
67
+
68
+ """
43
69
  self.relpath = relpath
44
70
  self.tmpl = tmpl
45
71
  self.get_component = get_component
46
72
 
47
73
  self.required = required
48
74
  self.optional = optional or {}
75
+ self.imports = imports or {}
49
76
  self.css = css
50
77
  self.js = js
51
- self.imports = imports or {}
78
+ self.slots = slots
52
79
 
53
80
  self.globals: dict[str, t.Any] = {}
54
81
 
@@ -57,10 +84,10 @@ class Component:
57
84
  *,
58
85
  content: str | None = None,
59
86
  attrs: Attrs | dict[str, t.Any] | None = None,
60
- caller: Callable[[], str] | None = None,
87
+ caller: Callable[[str], str] | None = None,
61
88
  **params: t.Any
62
89
  ) -> Markup:
63
- content = content if content is not None else caller() if caller else ""
90
+ content = content if content is not None else caller("") if caller else ""
64
91
  attrs = attrs.as_dict if isinstance(attrs, Attrs) else attrs or {}
65
92
  params = {**attrs, **params}
66
93
  props, attrs = self.filter_attrs(params)
@@ -69,6 +96,14 @@ class Component:
69
96
  globals.setdefault("attrs", Attrs(attrs))
70
97
  globals.setdefault("content", content)
71
98
 
99
+ slots = {}
100
+ if caller:
101
+ for name in self.slots:
102
+ body = caller(name)
103
+ if body != content:
104
+ slots[name] = body
105
+ props["_slots"] = slots
106
+
72
107
  html = self.tmpl.render({**props, **globals}).lstrip()
73
108
  return Markup(html)
74
109
 
@@ -93,41 +128,41 @@ class Component:
93
128
  child.globals = self.globals
94
129
  return child
95
130
 
96
- def collect_css(self, visited: set[str] | None = None) -> list[str]:
131
+ def collect_css(self, _visited: set[str] | None = None) -> list[str]:
97
132
  """
98
133
  Returns a list of CSS files for the component and its children.
99
134
  """
100
135
  urls = dict.fromkeys(self.css, 1)
101
- visited = visited or set()
102
- visited.add(self.relpath)
136
+ _visited = _visited or set()
137
+ _visited.add(self.relpath)
103
138
 
104
139
  for name, relpath in self.imports.items():
105
- if relpath in visited:
140
+ if relpath in _visited:
106
141
  continue
107
142
  co = self.get_child(name)
108
- for file in co.collect_css(visited=visited):
143
+ for file in co.collect_css(_visited=_visited):
109
144
  if file not in urls:
110
145
  urls[file] = 1
111
- visited.add(relpath)
146
+ _visited.add(relpath)
112
147
 
113
148
  return list(urls.keys())
114
149
 
115
- def collect_js(self, visited: set[str] | None = None) -> list[str]:
150
+ def collect_js(self, _visited: set[str] | None = None) -> list[str]:
116
151
  """
117
152
  Returns a list of JS files for the component and its children.
118
153
  """
119
154
  urls = dict.fromkeys(self.js, 1)
120
- visited = visited or set()
121
- visited.add(self.relpath)
155
+ _visited = _visited or set()
156
+ _visited.add(self.relpath)
122
157
 
123
158
  for name, relpath in self.imports.items():
124
- if relpath in visited:
159
+ if relpath in _visited:
125
160
  continue
126
161
  co = self.get_child(name)
127
- for file in co.collect_js(visited=visited):
162
+ for file in co.collect_js(_visited=_visited):
128
163
  if file not in urls:
129
164
  urls[file] = 1
130
- visited.add(relpath)
165
+ _visited.add(relpath)
131
166
 
132
167
  return list(urls.keys())
133
168
 
@@ -135,10 +170,6 @@ class Component:
135
170
  """
136
171
  Uses the `collect_css()` list to generate an HTML fragment
137
172
  with `<link rel="stylesheet" href="{url}">` tags.
138
-
139
- Unless it's an external URL (e.g.: beginning with "http://" or "https://")
140
- or a root-relative URL (e.g.: starting with "/"),
141
- the URL is prefixed by `base_url`.
142
173
  """
143
174
  html = []
144
175
  for url in self.collect_css():
@@ -151,9 +182,15 @@ class Component:
151
182
  Uses the `collected_js()` list to generate an HTML fragment
152
183
  with `<script type="module" src="{url}"></script>` tags.
153
184
 
154
- Unless it's an external URL (e.g.: beginning with "http://" or "https://"),
155
- the URL is prefixed by `base_url`. A hash can also be added to
156
- invalidate the cache if the content changes, if `fingerprint` is `True`.
185
+ Arguments:
186
+ module:
187
+ Whether to render the script tags as modules, e.g.:
188
+ `<script type="module" src="..."></script>`
189
+ defer:
190
+ Whether to add the `defer` attribute to the script tags,
191
+ if `module` is `False` (all module scripts are also deferred), e.g.:
192
+ `<script src="..." defer></script>`
193
+
157
194
  """
158
195
  html = []
159
196
  for url in self.collect_js():
@@ -172,9 +209,16 @@ class Component:
172
209
  Calls `render_css()` and `render_js()` to generate
173
210
  an HTML fragment with `<link rel="stylesheet" href="{url}">`
174
211
  and `<script type="module" src="{url}"></script>` tags.
175
- Unless it's an external URL (e.g.: beginning with "http://" or "https://"),
176
- the URL is prefixed by `base_url`. A hash can also be added to
177
- invalidate the cache if the content changes, if `fingerprint` is `True`.
212
+
213
+ Arguments:
214
+ module:
215
+ Whether to render the script tags as modules, e.g.:
216
+ `<script type="module" src="..."></script>`
217
+ defer:
218
+ Whether to add the `defer` attribute to the script tags,
219
+ if `module` is `False` (all module scripts are also deferred), e.g.:
220
+ `<script src="..." defer></script>`
221
+
178
222
  """
179
223
  html_css = self.render_css()
180
224
  html_js = self.render_js()
jx/meta.py CHANGED
@@ -61,7 +61,8 @@ def extract_metadata(source: str, base_path: Path, fullpath: Path) -> Meta:
61
61
  The absolute full path of the current template.
62
62
 
63
63
  Returns:
64
- A Meta object containing the extracted metadata.
64
+ A `Meta` object containing the extracted metadata.
65
+
65
66
  """
66
67
  meta = Meta()
67
68
 
@@ -138,7 +139,7 @@ def parse_args_expr(expr: str) -> tuple[tuple[str, ...], dict[str, t.Any]]:
138
139
  return tuple(required), optional
139
140
 
140
141
 
141
- def eval_expression(input_string):
142
+ def eval_expression(input_string: str) -> t.Any:
142
143
  code = compile(input_string, "<string>", "eval")
143
144
  for name in code.co_names:
144
145
  if name not in ALLOWED_NAMES_IN_EXPRESSION_VALUES:
jx/parser.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
3
3
  """
4
+
4
5
  import re
5
6
  import typing as t
6
7
  from uuid import uuid4
@@ -11,7 +12,7 @@ from .exceptions import TemplateSyntaxError
11
12
  from .utils import logger
12
13
 
13
14
 
14
- BLOCK_CALL = '{% call _get("[TAG]").render([ATTRS]) -%}[CONTENT]{%- endcall %}'
15
+ BLOCK_CALL = '{% call(_slot="") _get("[TAG]").render([ATTRS]) -%}[CONTENT]{%- endcall %}'
15
16
  INLINE_CALL = '{{ _get("[TAG]").render([ATTRS]) }}'
16
17
 
17
18
  re_raw = r"\{%-?\s*raw\s*-?%\}.+?\{%-?\s*endraw\s*-?%\}"
@@ -32,6 +33,17 @@ re_attr = r"""
32
33
  """
33
34
  RX_ATTR = re.compile(re_attr, re.VERBOSE | re.DOTALL)
34
35
 
36
+ RE_LSTRIP = r"\s*(?P<lstrip>-?)%}"
37
+ RE_RSTRIP = r"{%(?P<rstrip>-?)\s*"
38
+
39
+ RE_SLOT_OPEN = r"{%-?\s*slot\s+(?P<name>[0-9A-Za-z_.:$-]+)" + RE_LSTRIP
40
+ RE_SLOT_CLOSE = RE_RSTRIP + r"endslot\s*-?%}"
41
+ RX_SLOT = re.compile(rf"{RE_SLOT_OPEN}(?P<default>.*?)({RE_SLOT_CLOSE})", re.DOTALL)
42
+
43
+ RE_FILL_OPEN = r"{%-?\s*fill\s+(?P<name>[0-9A-Za-z_.:$-]+)" + RE_LSTRIP
44
+ RE_FILL_CLOSE = RE_RSTRIP + r"endfill\s*-?%}"
45
+ RX_FILL = re.compile(rf"{RE_FILL_OPEN}(?P<body>.*?)({RE_FILL_CLOSE})", re.DOTALL)
46
+
35
47
 
36
48
  def escape(s: t.Any, /) -> Markup:
37
49
  return Markup(
@@ -45,20 +57,66 @@ def escape(s: t.Any, /) -> Markup:
45
57
 
46
58
 
47
59
  class JxParser:
48
- def __init__(self, *, name: str, source: str, components: list[str]) :
60
+ def __init__(
61
+ self,
62
+ *,
63
+ name: str,
64
+ source: str,
65
+ components: list[str],
66
+ ):
67
+ """
68
+ A parser that transforms a template's source code by replacing
69
+ TitledCased HTML tags with their corresponding component calls.
70
+
71
+ Only the names defined in the `components` list are allowed.
72
+
73
+ Arguments:
74
+ name:
75
+ The name of the template for error reporting.
76
+ source:
77
+ The source code of the template.
78
+ components:
79
+ A list of allowed component names.
80
+
81
+ """
49
82
  self.name = name
50
83
  self.source = source
51
84
  self.components = components
52
85
 
53
- def parse(self, *, validate_tags: bool = True) -> str:
86
+ def parse(self, *, validate_tags: bool = True) -> tuple[str, tuple[str, ...]]:
87
+ """
88
+ Parses the template source code.
89
+
90
+ Arguments:
91
+ validate_tags:
92
+ Whether to raise an error for unknown TitleCased tags.
93
+
94
+ Returns:
95
+ - The transformed template source code
96
+ - The list of slot names.
97
+
98
+ Raises:
99
+ TemplateSyntaxError:
100
+ If the template contains unknown components or syntax errors.
101
+
102
+ """
54
103
  raw_blocks = {}
55
104
  source = self.source
56
105
  source, raw_blocks = self.replace_raw_blocks(source)
57
106
  source = self.process_tags(source, validate_tags=validate_tags)
107
+ source, slots = self.process_slots(source)
58
108
  source = self.restore_raw_blocks(source, raw_blocks)
59
- return source
109
+ return source, slots
60
110
 
61
111
  def replace_raw_blocks(self, source: str) -> tuple[str, dict[str, str]]:
112
+ """
113
+ Replace the `{% raw %}` blocks with temporary placeholders.
114
+
115
+ Arguments:
116
+ source:
117
+ The template source code.
118
+
119
+ """
62
120
  raw_blocks = {}
63
121
  while True:
64
122
  match = RX_RAW.search(source)
@@ -73,11 +131,32 @@ class JxParser:
73
131
  return source, raw_blocks
74
132
 
75
133
  def restore_raw_blocks(self, source: str, raw_blocks: dict[str, str]) -> str:
134
+ """
135
+ Restores the original `{% raw %}` blocks from the temporary placeholders.
136
+
137
+ Arguments:
138
+ source:
139
+ The template source code.
140
+ raw_blocks:
141
+ A dictionary mapping placeholder keys to their original raw block content.
142
+
143
+ """
76
144
  for uid, code in raw_blocks.items():
77
145
  source = source.replace(uid, code)
78
146
  return source
79
147
 
80
148
  def process_tags(self, source: str, *, validate_tags: bool = True) -> str:
149
+ """
150
+ Search for TitledCased HTML tags in the template source code and replace
151
+ them with their corresponding component calls.
152
+
153
+ Arguments:
154
+ source:
155
+ The template source code.
156
+ validate_tags:
157
+ Whether to raise an error for unknown TitleCased tags.
158
+
159
+ """
81
160
  while True:
82
161
  match = RX_TAG_NAME.search(source)
83
162
  if not match:
@@ -85,21 +164,43 @@ class JxParser:
85
164
  source = self.replace_tag(source, match, validate_tags=validate_tags)
86
165
  return source
87
166
 
88
- def replace_tag(self, source: str, match: re.Match, *, validate_tags: bool = True) -> str:
167
+ def replace_tag(
168
+ self,
169
+ source: str,
170
+ match: re.Match,
171
+ *,
172
+ validate_tags: bool = True,
173
+ ) -> str:
174
+ """
175
+ Replaces a single TitledCased HTML tag with its corresponding component call.
176
+
177
+ Arguments:
178
+ source:
179
+ The template source code.
180
+ match:
181
+ The regex match object for the tag.
182
+ validate_tags:
183
+ Whether to raise an error for unknown TitleCased tags.
184
+
185
+ """
89
186
  start, curr = match.span(0)
90
187
  lineno = source[:start].count("\n") + 1
91
188
 
92
189
  tag = match.group("tag")
93
190
  if validate_tags and tag not in self.components:
94
191
  line = self.source.split("\n")[lineno - 1]
95
- raise TemplateSyntaxError(f"[{self.name}:{lineno}] Unknown component `{tag}`\n{line}")
192
+ raise TemplateSyntaxError(
193
+ f"[{self.name}:{lineno}] Unknown component `{tag}`\n{line}"
194
+ )
96
195
 
97
- attrs, end = self._parse_opening_tag(source, lineno=lineno, start=curr - 1)
196
+ raw_attrs, end = self._parse_opening_tag(source, lineno=lineno, start=curr - 1)
98
197
  if end == -1:
99
198
  line = self.source.split("\n")[lineno - 1]
100
- raise TemplateSyntaxError(f"[{self.name}:{lineno}] Syntax error: `{tag}`\n{line}")
199
+ raise TemplateSyntaxError(
200
+ f"[{self.name}:{lineno}] Syntax error: `{tag}`\n{line}"
201
+ )
101
202
 
102
- inline = source[end - 2:end] == "/>"
203
+ inline = source[end - 2 : end] == "/>"
103
204
  if inline:
104
205
  content = ""
105
206
  else:
@@ -107,39 +208,135 @@ class JxParser:
107
208
  index = source.find(close_tag, end, None)
108
209
  if index == -1:
109
210
  line = self.source.split("\n")[lineno - 1]
110
- raise TemplateSyntaxError(f"[{self.name}:{lineno}] Unclosed component `{tag}`\n{line}")
211
+ raise TemplateSyntaxError(
212
+ f"[{self.name}:{lineno}] Unclosed component `{tag}`\n{line}"
213
+ )
111
214
 
112
215
  content = source[end:index]
113
216
  end = index + len(close_tag)
114
217
 
115
- attrs_list = self._parse_attrs(attrs)
116
- repl = self._build_call(tag, attrs_list, content)
218
+ if content:
219
+ content = self.process_fills(content)
117
220
 
221
+ attrs = self._parse_attrs(raw_attrs)
222
+ repl = self._build_call(tag, attrs, content)
118
223
  return f"{source[:start]}{repl}{source[end:]}"
119
224
 
120
- def _parse_opening_tag(self, source: str, *, lineno: int, start: int) -> tuple[str, int]:
225
+ def process_slots(self, source: str) -> tuple[str, tuple[str, ...]]:
226
+ """
227
+ Extracts slot content from the template source code.
228
+
229
+ Arguments:
230
+ source:
231
+ The template source code
232
+
233
+ Returns:
234
+ - The transformed template source code
235
+ - The list of slot names.
236
+
237
+ """
238
+ slots = {}
239
+ while True:
240
+ match = RX_SLOT.search(source)
241
+ if not match:
242
+ break
243
+ start, end = match.span(0)
244
+ slot_name = match.group("name")
245
+ slot_default = match.group("default") or ""
246
+ lstrip = match.group("lstrip") == "-"
247
+ rstrip = match.group("rstrip") == "-"
248
+ if lstrip:
249
+ slot_default = slot_default.lstrip()
250
+ if rstrip:
251
+ slot_default = slot_default.rstrip()
252
+
253
+ slot_expr = "".join([
254
+ "{% if _slots.get('", slot_name,
255
+ "') %}{{ _slots['", slot_name,
256
+ "'] }}{% else %}", slot_default,
257
+ "{% endif %}"
258
+ ])
259
+ source = f"{source[:start]}{slot_expr}{source[end:]}"
260
+ slots[slot_name] = 1
261
+
262
+ return source, tuple(slots.keys())
263
+
264
+ def process_fills(self, source: str) -> str:
265
+ """
266
+ Processes `{% fill slot_name %}...{% endfill %}` blocks in the template source code.
267
+
268
+ Arguments:
269
+ source:
270
+ The template source code.
271
+
272
+ Returns:
273
+ The modified source code prepended by fill contents as `if` statements.
274
+
275
+ """
276
+ fills = {}
277
+
278
+ while True:
279
+ match = RX_FILL.search(source)
280
+ if not match:
281
+ break
282
+ start, end = match.span(0)
283
+ fill_name = match.group("name")
284
+ fill_body = match.group("body") or ""
285
+ lstrip = match.group("lstrip") == "-"
286
+ rstrip = match.group("rstrip") == "-"
287
+ if lstrip:
288
+ fill_body = fill_body.lstrip()
289
+ if rstrip:
290
+ fill_body = fill_body.rstrip()
291
+ fills[fill_name] = fill_body
292
+ source = f"{source[:start]}{source[end:]}"
293
+
294
+ if not fills:
295
+ return source
296
+
297
+ ifs = []
298
+ for fill_name, fill_body in fills.items():
299
+ ifs.append(f"{{% elif _slot == '{fill_name}' %}}{fill_body}")
300
+ # Replace the first occurrence of "elif" with "if"
301
+ str_ifs = f"\n{{% {''.join(ifs)[5:]}"
302
+
303
+ return f"{str_ifs}{{% else -%}}\n{source.strip()}\n{{%- endif %}}\n"
304
+
305
+ # Private
306
+
307
+ def _parse_opening_tag(
308
+ self, source: str, *, lineno: int, start: int
309
+ ) -> tuple[str, int]:
310
+ """
311
+ Parses the opening tag and returns the raw attributes and the position
312
+ where the opening tag ends.
313
+ """
121
314
  eof = len(source)
122
- in_single_quotes = in_double_quotes = in_braces = False # dentro de '…' / "…"
315
+ in_single_quotes = in_double_quotes = in_braces = False
123
316
  i = start
124
317
  end = -1
125
318
 
126
319
  while i < eof:
127
320
  ch = source[i]
128
- ch2 = source[i:i + 2]
321
+ ch2 = source[i : i + 2]
129
322
  # print(ch, ch2, in_single_quotes, in_double_quotes, in_braces)
130
323
 
131
324
  # Detects {{ … }} only when NOT inside quotes
132
325
  if not in_single_quotes and not in_double_quotes:
133
326
  if ch2 == "{{":
134
327
  if in_braces:
135
- raise TemplateSyntaxError(f"[{self.name}:{lineno}] Unmatched braces")
328
+ raise TemplateSyntaxError(
329
+ f"[{self.name}:{lineno}] Unmatched braces"
330
+ )
136
331
  in_braces = True
137
332
  i += 2
138
333
  continue
139
334
 
140
335
  if ch2 == "}}":
141
336
  if not in_braces:
142
- raise TemplateSyntaxError(f"[{self.name}:{lineno}] Unmatched braces")
337
+ raise TemplateSyntaxError(
338
+ f"[{self.name}:{lineno}] Unmatched braces"
339
+ )
143
340
  in_braces = False
144
341
  i += 2
145
342
  continue
@@ -164,33 +361,25 @@ class JxParser:
164
361
  attrs = source[start:end].strip().removesuffix("/>").removesuffix(">")
165
362
  return attrs, end
166
363
 
167
- def _parse_attrs(self, attrs: str) -> list[tuple[str, str]]:
168
- attrs = attrs.replace("\n", " ").strip()
169
- if not attrs:
364
+ def _parse_attrs(self, raw_attrs: str) -> list[str]:
365
+ """
366
+ Parses the HTML attributes string and returns a list of '"key":value'
367
+ strings to be used in a components call.
368
+ """
369
+ raw_attrs = raw_attrs.replace("\n", " ").strip()
370
+ if not raw_attrs:
170
371
  return []
171
- return RX_ATTR.findall(attrs)
172
372
 
173
- def _build_call(
174
- self,
175
- tag: str,
176
- attrs_list: list[tuple[str, str]],
177
- content: str = "",
178
- ) -> str:
179
- logger.debug(f"{tag} {attrs_list} {'inline' if not content else ''}")
180
373
  attrs = []
181
- for name, value in attrs_list:
374
+ for name, value in RX_ATTR.findall(raw_attrs):
182
375
  name = name.strip().replace("-", "_")
183
376
  value = value.strip()
184
377
 
185
378
  if not value:
186
- attrs.append(f'"{name}"=True')
379
+ attrs.append(f'"{name}":True')
187
380
  else:
188
- # vue-like syntax
189
- # if (
190
- # name[0] == ":"
191
- # and value[0] in ("\"'")
192
- # and value[-1] in ("\"'")
193
- # ):
381
+ # vue-like syntax could be possible
382
+ # if (name[0] == ":" and value[0] in ("\"'") and value[-1] in ("\"'")):
194
383
  # value = value[1:-1].strip()
195
384
  # name = name.lstrip(":")
196
385
 
@@ -198,16 +387,23 @@ class JxParser:
198
387
  if value[:2] == "{{" and value[-2:] == "}}":
199
388
  value = value[2:-2].strip()
200
389
 
201
- attrs.append(f'"{name}"={value}')
390
+ attrs.append(f'"{name}":{value}')
391
+
392
+ return attrs
393
+
394
+ def _build_call(self, tag: str, attrs: list[str], content: str = "") -> str:
395
+ """
396
+ Builds a component call string.
397
+ """
398
+ logger.debug(f"{tag} {attrs} {'inline' if not content else ''}")
202
399
 
203
400
  str_attrs = ""
204
401
  if attrs:
205
- str_attrs = "**{" + ", ".join([a.replace("=", ":", 1) for a in attrs]) + "}"
402
+ str_attrs = "**{" + ", ".join(attrs) + "}"
206
403
 
207
404
  if content:
208
405
  return (
209
- BLOCK_CALL
210
- .replace("[TAG]", tag)
406
+ BLOCK_CALL.replace("[TAG]", tag)
211
407
  .replace("[ATTRS]", str_attrs)
212
408
  .replace("[CONTENT]", content)
213
409
  )
jx/utils.py CHANGED
@@ -9,5 +9,17 @@ logger = logging.getLogger("jx")
9
9
 
10
10
 
11
11
  def get_random_id(prefix: str = "id") -> str:
12
+ """
13
+ Returns an unique string suitable to be used for HTML element IDs.
14
+
15
+ HTML form elements, popovers, and other components require unique IDs
16
+ to function correctly. When you are writing custom components, this function
17
+ can be used to generate default IDs for such elements, so you don't have to
18
+ make it a required argument.
19
+
20
+ Arguments:
21
+ prefix: The prefix to use for the ID. Defaults to "id".
22
+
23
+ """
12
24
  return f"{prefix}-{str(uuid.uuid4().hex)}"
13
25
 
@@ -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
+ ```
@@ -0,0 +1,13 @@
1
+ jx/__init__.py,sha256=MZ7KbNZkQx2D5CJpccSKuyJi179iAff3a9jG7DwEBAE,152
2
+ jx/attrs.py,sha256=_Cj1s4PEHiq_TiG2IvKiUkTqyThH4YgS4HwUn09zzGU,10414
3
+ jx/catalog.py,sha256=EaG0jNqf4JJkif9Nrnp72vEr8kX1BslJGRig9pOGgC8,11567
4
+ jx/component.py,sha256=9TkyX10brWRCDw6AC0tEJ70DWaDmkl_UE8i_Uqfta6M,7100
5
+ jx/exceptions.py,sha256=rDWxkgOqeMPptmQ21joB2ujfpIU5EwMKtAL-ImB9nuA,1518
6
+ jx/meta.py,sha256=lh3EFyp2_Y0R80aXaT3kVPNPcB3CREdHHePdQP4ZnR4,5148
7
+ jx/parser.py,sha256=LW1iHh5_Ac8B368RhfiBPe3w7_1tdLf5LjCl3rfNKu8,12868
8
+ jx/utils.py,sha256=dZMIAdVLdmkyPsiX14a9aIkZRfgAnf0SNEnvfyg8K98,669
9
+ jx-0.2.0.dist-info/licenses/LICENSE,sha256=RHwNifuIFfQM9QUhA2FQfnqlBcnhBHlJEVp8QcRllew,1076
10
+ jx-0.2.0.dist-info/METADATA,sha256=F0kiqI8PoNntEX_EuiOkdu9OzHbHiaYZ8eWsvAWZptA,2761
11
+ jx-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ jx-0.2.0.dist-info/top_level.txt,sha256=P61YQxqfmzVpxTMe3C48gt0vc6fnHLF8Ml0JXC-QuEI,3
13
+ jx-0.2.0.dist-info/RECORD,,
jx-0.1.0.dist-info/RECORD DELETED
@@ -1,13 +0,0 @@
1
- jx/__init__.py,sha256=MZ7KbNZkQx2D5CJpccSKuyJi179iAff3a9jG7DwEBAE,152
2
- jx/attrs.py,sha256=zjcZhaVxpgqE0nAE-kDDqKLG3J_AuZg_q4pk6iHy9FE,10054
3
- jx/catalog.py,sha256=0YDOz3QJnQC2MMCKfL_Zbkrff7CQaK4M-DCMh2lA6Cs,7037
4
- jx/component.py,sha256=Iv4ixTbof02N3cAXW6QcluoT30vFe3aHaRRWEJX1znw,5845
5
- jx/exceptions.py,sha256=rDWxkgOqeMPptmQ21joB2ujfpIU5EwMKtAL-ImB9nuA,1518
6
- jx/meta.py,sha256=8PWv09laXiIb-6rwcJ0KFT0rmuIpOfR59wiH_GZ1hZw,5131
7
- jx/parser.py,sha256=BlOcLH83A0xxbzQYijzbGukVpotDUg3uj32qTTLYxHc,7103
8
- jx/utils.py,sha256=UDuyu3pIXirct71XbTClhMFeMaHr2ujmcQ6s2tKGBrY,232
9
- jx-0.1.0.dist-info/licenses/LICENSE,sha256=RHwNifuIFfQM9QUhA2FQfnqlBcnhBHlJEVp8QcRllew,1076
10
- jx-0.1.0.dist-info/METADATA,sha256=DuJ03AieE3hsJMKN8SLtlIAeXqre4oypWAIvCA5CVa0,1674
11
- jx-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- jx-0.1.0.dist-info/top_level.txt,sha256=P61YQxqfmzVpxTMe3C48gt0vc6fnHLF8Ml0JXC-QuEI,3
13
- jx-0.1.0.dist-info/RECORD,,
File without changes
File without changes