jx 0.1.0__py3-none-any.whl → 0.3.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/catalog.py CHANGED
@@ -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,18 +24,18 @@ 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
- imports: dict[str, str] = field(default_factory=dict) # { component_name: relpath }
27
+ optional: dict[str, t.Any] = field(default_factory=dict) # { attr: default_value }
28
+ imports: dict[str, str] = field(default_factory=dict) # { name: relpath }
28
29
  css: tuple[str, ...] = ()
29
30
  js: tuple[str, ...] = ()
31
+ slots: tuple[str, ...] = ()
30
32
 
31
33
 
32
34
  class Catalog:
33
- """
34
- The object that manages the components and their global settings.
35
35
 
36
- Arguments:
37
- """
36
+ # IDEA: This dict could be replaced by a dict-like object
37
+ # that uses a LRU cache (to limit the memory used)
38
+ # or even a shared Redis/Memcache cache.
38
39
  components: dict[str, CData]
39
40
 
40
41
  def __init__(
@@ -42,14 +43,38 @@ class Catalog:
42
43
  folder: str | Path | None = None,
43
44
  *,
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,
48
49
  auto_reload: bool = True,
49
50
  **globals: t.Any,
50
51
  ) -> None:
52
+ """
53
+ Manager of the components and their global settings.
54
+
55
+ Arguments:
56
+ folder:
57
+ Optional folder path to scan for components. It's a shortcut to
58
+ calling `add_folder` when only one is used.
59
+ jinja_env:
60
+ Optional Jinja2 environment to use for rendering.
61
+ extensions:
62
+ Optional extra Jinja2 extensions to add to the environment.
63
+ filters:
64
+ Optional extra Jinja2 filters to add to the environment.
65
+ tests:
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.
72
+ **globals:
73
+ Variables to make available to all components by default.
74
+
75
+ """
51
76
  self.components = {}
52
- self.jinja_env = self.make_jinja_env(
77
+ self.jinja_env = self._make_jinja_env(
53
78
  jinja_env=jinja_env,
54
79
  globals=globals,
55
80
  filters=filters,
@@ -60,64 +85,18 @@ class Catalog:
60
85
  if folder:
61
86
  self.add_folder(folder)
62
87
 
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
88
  def add_folder(
100
- self,
101
- path: str | Path,
102
- *,
103
- prefix: str = "",
89
+ self, path: str | Path, *, prefix: str = "", preload: bool = True
104
90
  ) -> None:
105
91
  """
106
92
  Add a folder path from which to search for components, optionally under a prefix.
107
93
 
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
94
  Components without a prefix can be imported as a path relative to the folder,
118
95
  e.g.: `sub/folder/component.jinja` or with a path relative to the component
119
96
  where it is used: `./folder/component.jinja`.
120
97
 
98
+ Relative imports cannot go outside the folder.
99
+
121
100
  Components added with a prefix must be imported using the prefix followed
122
101
  by a colon: `prefix:sub/folder/component.jinja`. If the importing is
123
102
  done from within a component with the prefix itself, a relative
@@ -128,6 +107,23 @@ class Catalog:
128
107
  with a component with the same subpath/filename, the one in the folder
129
108
  added **first** will be used and the other ignored.
130
109
 
110
+ WARNING: You cannot move or delete components files from the folder after
111
+ calling this method, but you can call it again to add new components added
112
+ to the folder. This is unrelated to the value of `preload`.
113
+
114
+ Arguments:
115
+ path:
116
+ Absolute path of the folder with component files.
117
+ prefix:
118
+ Optional path prefix that all the components in the folder
119
+ will have. The default is empty.
120
+ preload:
121
+ Whether to preload the data of components in the folder.
122
+ If set to `True` (the default), the component data will be loaded into
123
+ memory when the folder is added, instead of just before rendering it.
124
+ This makes the first render faster at the expense of a few
125
+ microseconds upfront.
126
+
131
127
  """
132
128
  base_path = Path(path).resolve()
133
129
  prefix = prefix.replace("\\", "/").strip("./@ ")
@@ -143,16 +139,121 @@ class Catalog:
143
139
  logger.debug(f"Component already exists: {relpath}")
144
140
  continue
145
141
  cdata = CData(
146
- base_path=base_path,
147
- path=filepath,
148
- mtime=filepath.stat().st_mtime
142
+ base_path=base_path, path=filepath, mtime=filepath.stat().st_mtime
149
143
  )
150
144
  self.components[relpath] = cdata
151
145
 
152
- for relpath in self.components:
153
- self.components[relpath] = self.get_component_data(relpath)
146
+ if preload:
147
+ for relpath in self.components:
148
+ self.components[relpath] = self.get_component_data(relpath)
149
+
150
+ def render(
151
+ self, relpath: str, globals: dict[str, t.Any] | None = None, **kwargs
152
+ ) -> str:
153
+ """
154
+ Render a component with the given relative path and context.
155
+
156
+ Arguments:
157
+ relpath:
158
+ The path of the component to render, including the extension,relative to its view folder.
159
+ e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
160
+ globals:
161
+ Optional global variables to make available to the component and all its
162
+ imported components.
163
+ **kwargs:
164
+ Keyword arguments to pass to the component.
165
+ They will be available in the component's context but not to its imported components.
166
+
167
+ Returns:
168
+ The rendered component as a string.
169
+
170
+ """
171
+ relpath = relpath.replace("\\", "/").strip("/")
172
+ co = self.get_component(relpath)
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
+
232
+ globals = globals or {}
233
+ globals.update({
234
+ "assets": {
235
+ "css": co.collect_css,
236
+ "js": co.collect_js,
237
+ "render_css": co.render_css,
238
+ "render_js": co.render_js,
239
+ "render": co.render_assets,
240
+ }
241
+ })
242
+ co.globals = globals
243
+
244
+ return co.render(**kwargs)
154
245
 
155
246
  def get_component_data(self, relpath: str) -> CData:
247
+ """
248
+ Get the component data from the cache.
249
+ If the file has been updated, the component is re-processed.
250
+
251
+ Arguments:
252
+ relpath:
253
+ The path of the component to render, including the extension,relative to its view folder.
254
+ e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
255
+
256
+ """
156
257
  cdata = self.components.get(relpath)
157
258
  if not cdata:
158
259
  raise ImportError(relpath)
@@ -169,15 +270,11 @@ class Catalog:
169
270
  meta = extract_metadata(source, base_path=cdata.base_path, fullpath=cdata.path)
170
271
 
171
272
  parser = JxParser(
172
- name=relpath,
173
- source=source,
174
- components=list(meta.imports.keys())
273
+ name=relpath, source=source, components=list(meta.imports.keys())
175
274
  )
176
- parsed_source = parser.parse()
275
+ parsed_source, slots = parser.parse()
177
276
  code = self.jinja_env.compile(
178
- source=parsed_source,
179
- name=relpath,
180
- filename=cdata.path.as_posix()
277
+ source=parsed_source, name=relpath, filename=cdata.path.as_posix()
181
278
  )
182
279
 
183
280
  cdata.code = code
@@ -186,15 +283,23 @@ class Catalog:
186
283
  cdata.imports = meta.imports
187
284
  cdata.css = meta.css
188
285
  cdata.js = meta.js
286
+ cdata.slots = slots
189
287
  return cdata
190
288
 
191
289
  def get_component(self, relpath: str) -> Component:
290
+ """
291
+ Instantiate and return a component object by its relative path.
292
+
293
+ Arguments:
294
+ relpath:
295
+ The path of the component to render, including the extension,relative to its view folder.
296
+ e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
297
+
298
+ """
192
299
  cdata = self.get_component_data(relpath)
193
300
  assert cdata.code is not None
194
301
  tmpl = jinja2.Template.from_code(
195
- self.jinja_env,
196
- cdata.code,
197
- self.jinja_env.globals
302
+ self.jinja_env, cdata.code, self.jinja_env.globals
198
303
  )
199
304
 
200
305
  co = Component(
@@ -203,25 +308,74 @@ class Catalog:
203
308
  get_component=self.get_component,
204
309
  required=cdata.required,
205
310
  optional=cdata.optional,
311
+ imports=cdata.imports,
206
312
  css=cdata.css,
207
313
  js=cdata.js,
208
- imports=cdata.imports
314
+ slots=cdata.slots,
209
315
  )
210
316
  return co
211
317
 
212
- def render(self, relpath: str, globals: dict[str, t.Any] | None = None, **kwargs) -> str:
213
- co = self.get_component(relpath)
318
+ # Private
319
+
320
+ def _make_jinja_env(
321
+ self,
322
+ *,
323
+ jinja_env: jinja2.Environment | None = None,
324
+ globals: dict[str, t.Any] | None = None,
325
+ filters: dict[str, t.Any] | None = None,
326
+ tests: dict[str, t.Any] | None = None,
327
+ extensions: list | None = None,
328
+ ) -> jinja2.Environment:
329
+ """
330
+ Create a new Jinja2 environment with the specified settings.
331
+
332
+ Arguments:
333
+ jinja_env:
334
+ Optional Jinja2 environment to use as a base.
335
+ globals:
336
+ Optional global variables to add to the environment.
337
+ filters:
338
+ Optional extra Jinja2 filters to add to the environment.
339
+ extensions:
340
+ Optional extra Jinja2 extensions to add to the environment.
341
+ tests:
342
+ Optional extra Jinja2 tests to add to the environment.
343
+
344
+ """
345
+ jinja_env = jinja_env or getattr(self, "jinja_env", None)
346
+ if jinja_env:
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
351
+ else:
352
+ env = jinja2.Environment()
214
353
 
215
354
  globals = globals or {}
216
- 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
- },
224
- })
225
- co.globals = globals
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
+ )
362
+ env.globals.update(globals)
226
363
 
227
- return co.render(**kwargs)
364
+ filters = filters or {}
365
+ env.filters.update(filters)
366
+
367
+ tests = tests or {}
368
+ env.tests.update(tests)
369
+
370
+ extensions = extensions or []
371
+ # The "jinja2.ext.do" extension allows the use of the "do" statement in templates,
372
+ # that execute statements without outputting a value.
373
+ # Is specially useful for manipulating the `attrs` object.
374
+ extensions.extend(["jinja2.ext.do"])
375
+ for ext in extensions:
376
+ env.add_extension(ext)
377
+
378
+ env.autoescape = True
379
+ env.undefined = jinja2.StrictUndefined
380
+
381
+ return env
jx/component.py CHANGED
@@ -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",
@@ -22,9 +19,10 @@ class Component:
22
19
  "get_component",
23
20
  "required",
24
21
  "optional",
22
+ "imports",
25
23
  "css",
26
24
  "js",
27
- "imports",
25
+ "slots",
28
26
  "globals",
29
27
  )
30
28
 
@@ -36,19 +34,45 @@ class Component:
36
34
  get_component: Callable[[str], "Component"],
37
35
  required: tuple[str, ...] = (),
38
36
  optional: dict[str, t.Any] | None = None,
37
+ imports: dict[str, str] | None = None,
39
38
  css: tuple[str, ...] = (),
40
39
  js: tuple[str, ...] = (),
41
- imports: dict[str, str] | None = None,
40
+ slots: tuple[str, ...] = (),
42
41
  ) -> None:
42
+ """
43
+ Internal object that represents a Jx component.
44
+
45
+ Arguments:
46
+ relpath:
47
+ The "name" of the component.
48
+ tmpl:
49
+ The jinja2.Template for the component.
50
+ get_component:
51
+ A callable that retrieves a component by its name/relpath.
52
+ required:
53
+ A tuple of required attribute names.
54
+ optional:
55
+ A dictionary of optional attributes and their default values.
56
+ imports:
57
+ A dictionary of imported component names as "name": "relpath" pairs.
58
+ css:
59
+ A tuple of CSS file URLs.
60
+ js:
61
+ A tuple of JS file URLs.
62
+ slots:
63
+ A tuple of slot names.
64
+
65
+ """
43
66
  self.relpath = relpath
44
67
  self.tmpl = tmpl
45
68
  self.get_component = get_component
46
69
 
47
70
  self.required = required
48
71
  self.optional = optional or {}
72
+ self.imports = imports or {}
49
73
  self.css = css
50
74
  self.js = js
51
- self.imports = imports or {}
75
+ self.slots = slots
52
76
 
53
77
  self.globals: dict[str, t.Any] = {}
54
78
 
@@ -57,10 +81,10 @@ class Component:
57
81
  *,
58
82
  content: str | None = None,
59
83
  attrs: Attrs | dict[str, t.Any] | None = None,
60
- caller: Callable[[], str] | None = None,
61
- **params: t.Any
84
+ caller: Callable[[str], str] | None = None,
85
+ **params: t.Any,
62
86
  ) -> Markup:
63
- content = content if content is not None else caller() if caller else ""
87
+ content = content if content is not None else caller("") if caller else ""
64
88
  attrs = attrs.as_dict if isinstance(attrs, Attrs) else attrs or {}
65
89
  params = {**attrs, **params}
66
90
  props, attrs = self.filter_attrs(params)
@@ -69,6 +93,14 @@ class Component:
69
93
  globals.setdefault("attrs", Attrs(attrs))
70
94
  globals.setdefault("content", content)
71
95
 
96
+ slots = {}
97
+ if caller:
98
+ for name in self.slots:
99
+ body = caller(name)
100
+ if body != content:
101
+ slots[name] = body
102
+ props["_slots"] = slots
103
+
72
104
  html = self.tmpl.render({**props, **globals}).lstrip()
73
105
  return Markup(html)
74
106
 
@@ -93,41 +125,41 @@ class Component:
93
125
  child.globals = self.globals
94
126
  return child
95
127
 
96
- def collect_css(self, visited: set[str] | None = None) -> list[str]:
128
+ def collect_css(self, _visited: set[str] | None = None) -> list[str]:
97
129
  """
98
130
  Returns a list of CSS files for the component and its children.
99
131
  """
100
132
  urls = dict.fromkeys(self.css, 1)
101
- visited = visited or set()
102
- visited.add(self.relpath)
133
+ _visited = _visited or set()
134
+ _visited.add(self.relpath)
103
135
 
104
136
  for name, relpath in self.imports.items():
105
- if relpath in visited:
137
+ if relpath in _visited:
106
138
  continue
107
139
  co = self.get_child(name)
108
- for file in co.collect_css(visited=visited):
140
+ for file in co.collect_css(_visited=_visited):
109
141
  if file not in urls:
110
142
  urls[file] = 1
111
- visited.add(relpath)
143
+ _visited.add(relpath)
112
144
 
113
145
  return list(urls.keys())
114
146
 
115
- def collect_js(self, visited: set[str] | None = None) -> list[str]:
147
+ def collect_js(self, _visited: set[str] | None = None) -> list[str]:
116
148
  """
117
149
  Returns a list of JS files for the component and its children.
118
150
  """
119
151
  urls = dict.fromkeys(self.js, 1)
120
- visited = visited or set()
121
- visited.add(self.relpath)
152
+ _visited = _visited or set()
153
+ _visited.add(self.relpath)
122
154
 
123
155
  for name, relpath in self.imports.items():
124
- if relpath in visited:
156
+ if relpath in _visited:
125
157
  continue
126
158
  co = self.get_child(name)
127
- for file in co.collect_js(visited=visited):
159
+ for file in co.collect_js(_visited=_visited):
128
160
  if file not in urls:
129
161
  urls[file] = 1
130
- visited.add(relpath)
162
+ _visited.add(relpath)
131
163
 
132
164
  return list(urls.keys())
133
165
 
@@ -135,10 +167,6 @@ class Component:
135
167
  """
136
168
  Uses the `collect_css()` list to generate an HTML fragment
137
169
  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
170
  """
143
171
  html = []
144
172
  for url in self.collect_css():
@@ -151,9 +179,15 @@ class Component:
151
179
  Uses the `collected_js()` list to generate an HTML fragment
152
180
  with `<script type="module" src="{url}"></script>` tags.
153
181
 
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`.
182
+ Arguments:
183
+ module:
184
+ Whether to render the script tags as modules, e.g.:
185
+ `<script type="module" src="..."></script>`
186
+ defer:
187
+ Whether to add the `defer` attribute to the script tags,
188
+ if `module` is `False` (all module scripts are also deferred), e.g.:
189
+ `<script src="..." defer></script>`
190
+
157
191
  """
158
192
  html = []
159
193
  for url in self.collect_js():
@@ -172,9 +206,16 @@ class Component:
172
206
  Calls `render_css()` and `render_js()` to generate
173
207
  an HTML fragment with `<link rel="stylesheet" href="{url}">`
174
208
  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`.
209
+
210
+ Arguments:
211
+ module:
212
+ Whether to render the script tags as modules, e.g.:
213
+ `<script type="module" src="..."></script>`
214
+ defer:
215
+ Whether to add the `defer` attribute to the script tags,
216
+ if `module` is `False` (all module scripts are also deferred), e.g.:
217
+ `<script src="..." defer></script>`
218
+
178
219
  """
179
220
  html_css = self.render_css()
180
221
  html_js = self.render_js()
jx/exceptions.py CHANGED
@@ -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)
jx/meta.py CHANGED
@@ -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,12 +57,13 @@ 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
- A Meta object containing the extracted metadata.
65
+ A `Meta` object containing the extracted metadata.
66
+
65
67
  """
66
68
  meta = Meta()
67
69
 
@@ -138,7 +140,7 @@ def parse_args_expr(expr: str) -> tuple[tuple[str, ...], dict[str, t.Any]]:
138
140
  return tuple(required), optional
139
141
 
140
142
 
141
- def eval_expression(input_string):
143
+ def eval_expression(input_string: str) -> t.Any:
142
144
  code = compile(input_string, "<string>", "eval")
143
145
  for name in code.co_names:
144
146
  if name not in ALLOWED_NAMES_IN_EXPRESSION_VALUES: