pyjinhx 0.2.2__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.
pyjinhx/renderer.py ADDED
@@ -0,0 +1,415 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING, Any, ClassVar
8
+
9
+ from jinja2 import Environment, FileSystemLoader, Template
10
+ from jinja2.exceptions import TemplateNotFound
11
+ from markupsafe import Markup
12
+
13
+ from .dataclasses import Tag
14
+ from .finder import Finder
15
+ from .parser import Parser
16
+ from .registry import Registry
17
+ from .utils import detect_root_directory, pascal_case_to_kebab_case
18
+
19
+ if TYPE_CHECKING:
20
+ from .base import BaseComponent
21
+
22
+
23
+ @dataclass
24
+ class RenderSession:
25
+ """
26
+ Per-render state for script aggregation and deduplication.
27
+
28
+ Attributes:
29
+ scripts: Collected JavaScript code snippets to inject.
30
+ collected_js_files: Set of JS file paths already processed (for deduplication).
31
+ """
32
+
33
+ scripts: list[str] = field(default_factory=list)
34
+ collected_js_files: set[str] = field(default_factory=set)
35
+
36
+
37
+ class Renderer:
38
+ """
39
+ Shared rendering engine used by `BaseComponent` rendering and HTML-like custom-tag rendering.
40
+
41
+ This renderer centralizes:
42
+ - Jinja template loading (by component class or explicit file/source)
43
+ - Expansion of PascalCase custom tags inside rendered markup
44
+ - JavaScript collection/deduping and root-level script injection
45
+ - Rendering of HTML-like source strings into component output
46
+ """
47
+
48
+ def __init__(
49
+ self, environment: Environment, *, auto_id: bool = True, inline_js: bool | None = None
50
+ ) -> None:
51
+ """
52
+ Initialize a Renderer with the given Jinja environment.
53
+
54
+ Args:
55
+ environment: The Jinja2 Environment to use for template rendering.
56
+ auto_id: If True (default), generate UUIDs for components without explicit IDs.
57
+ inline_js: If True, JavaScript is collected and injected as <script> tags.
58
+ If False, no scripts are injected. Defaults to the class-level setting.
59
+ """
60
+ self._environment = environment
61
+ self._auto_id = auto_id
62
+ self._inline_js = inline_js if inline_js is not None else Renderer._default_inline_js
63
+ self._template_finder_cache: dict[str, Finder] = {}
64
+
65
+ _default_environment: ClassVar[Environment | None] = None
66
+ _default_inline_js: ClassVar[bool] = True
67
+ _default_renderers: ClassVar[dict[tuple[int, bool, bool], "Renderer"]] = {}
68
+
69
+ @classmethod
70
+ def peek_default_environment(cls) -> Environment | None:
71
+ """
72
+ Return the currently configured default environment without auto-initializing.
73
+
74
+ Returns:
75
+ The default Jinja Environment, or None if not yet configured.
76
+ """
77
+ return cls._default_environment
78
+
79
+ @classmethod
80
+ def set_default_environment(
81
+ cls, environment: Environment | str | os.PathLike[str] | None
82
+ ) -> None:
83
+ """
84
+ Set or clear the process-wide default Jinja environment.
85
+
86
+ Args:
87
+ environment: A Jinja Environment instance, a path to a template directory,
88
+ or None to clear the default and reset to auto-detection.
89
+ """
90
+ if environment is None or isinstance(environment, Environment):
91
+ cls._default_environment = environment
92
+ else:
93
+ cls._default_environment = Environment(
94
+ loader=FileSystemLoader(os.fspath(environment))
95
+ )
96
+ cls._default_renderers.clear()
97
+
98
+ @classmethod
99
+ def set_default_inline_js(cls, inline_js: bool) -> None:
100
+ """
101
+ Set the process-wide default for inline JavaScript injection.
102
+
103
+ Args:
104
+ inline_js: If True (default), JavaScript is collected and injected as <script> tags.
105
+ If False, no scripts are injected. Use Finder.collect_javascript_files() for static serving.
106
+ """
107
+ cls._default_inline_js = inline_js
108
+ cls._default_renderers.clear()
109
+
110
+ @classmethod
111
+ def get_default_environment(cls) -> Environment:
112
+ """
113
+ Return the default Jinja environment, auto-initializing if needed.
114
+
115
+ If no environment is configured, one is created using auto-detected project root.
116
+
117
+ Returns:
118
+ The default Jinja Environment instance.
119
+ """
120
+ if cls._default_environment is None:
121
+ root_dir = detect_root_directory()
122
+ cls._default_environment = Environment(loader=FileSystemLoader(root_dir))
123
+ return cls._default_environment
124
+
125
+ @classmethod
126
+ def get_default_renderer(
127
+ cls, *, auto_id: bool = True, inline_js: bool | None = None
128
+ ) -> "Renderer":
129
+ """
130
+ Return a cached default renderer instance.
131
+
132
+ Args:
133
+ auto_id: If True, generate UUIDs for components without explicit IDs.
134
+ inline_js: If True, JavaScript is collected and injected as <script> tags.
135
+ If False, no scripts are injected. Defaults to the class-level setting.
136
+
137
+ Returns:
138
+ A Renderer instance cached by (environment identity, auto_id, inline_js).
139
+ """
140
+ environment = cls.get_default_environment()
141
+ effective_inline_js = inline_js if inline_js is not None else cls._default_inline_js
142
+ cache_key = (id(environment), auto_id, effective_inline_js)
143
+ renderer = cls._default_renderers.get(cache_key)
144
+ if renderer is None:
145
+ renderer = Renderer(environment, auto_id=auto_id, inline_js=effective_inline_js)
146
+ cls._default_renderers[cache_key] = renderer
147
+ return renderer
148
+
149
+ @property
150
+ def environment(self) -> Environment:
151
+ """
152
+ The Jinja Environment used by this renderer.
153
+
154
+ Returns:
155
+ The Jinja Environment instance.
156
+ """
157
+ return self._environment
158
+
159
+ def new_session(self) -> RenderSession:
160
+ """
161
+ Create a new render session for tracking scripts during rendering.
162
+
163
+ Returns:
164
+ A fresh RenderSession instance.
165
+ """
166
+ return RenderSession()
167
+
168
+ def _get_loader_root(self) -> str:
169
+ loader = self._environment.loader
170
+ if not isinstance(loader, FileSystemLoader):
171
+ raise ValueError("Jinja2 loader must be a FileSystemLoader")
172
+ return Finder.get_loader_root(loader)
173
+
174
+ def _get_finder_for_root(self, search_root: str) -> Finder:
175
+ finder = self._template_finder_cache.get(search_root)
176
+ if finder is None:
177
+ finder = Finder(search_root)
178
+ self._template_finder_cache[search_root] = finder
179
+ return finder
180
+
181
+ def _load_template_for_component(
182
+ self,
183
+ component: "BaseComponent",
184
+ *,
185
+ template_source: str | None,
186
+ template_path: str | None,
187
+ ) -> Template:
188
+ if template_source is not None:
189
+ return self._environment.from_string(template_source)
190
+
191
+ if template_path is not None:
192
+ loader_root = self._get_loader_root()
193
+ relative_path = os.path.relpath(template_path, loader_root)
194
+ return self._environment.get_template(relative_path)
195
+
196
+ if type(component).__name__ == "BaseComponent":
197
+ raise FileNotFoundError(
198
+ "No template found. Use a BaseComponent subclass with an adjacent template file, "
199
+ "or use Renderer.render() with PascalCase tags."
200
+ )
201
+
202
+ loader_root = self._get_loader_root()
203
+ relative_template_paths = Finder.get_relative_template_paths(
204
+ component_dir=Finder.get_class_directory(type(component)),
205
+ search_root=loader_root,
206
+ component_name=type(component).__name__,
207
+ )
208
+
209
+ for relative_template_path in relative_template_paths:
210
+ try:
211
+ return self._environment.get_template(relative_template_path)
212
+ except TemplateNotFound:
213
+ continue
214
+
215
+ raise TemplateNotFound(
216
+ ", ".join(relative_template_paths) if relative_template_paths else "unknown"
217
+ )
218
+
219
+ def _collect_component_javascript(
220
+ self, component: "BaseComponent", session: RenderSession
221
+ ) -> None:
222
+ component_directory = Finder.get_class_directory(type(component))
223
+ javascript_filename = (
224
+ f"{pascal_case_to_kebab_case(type(component).__name__)}.js"
225
+ )
226
+ javascript_path = Finder.find_in_directory(
227
+ component_directory, javascript_filename
228
+ )
229
+ if not javascript_path:
230
+ return
231
+
232
+ if javascript_path in session.collected_js_files:
233
+ return
234
+
235
+ with open(javascript_path, "r") as file:
236
+ javascript_content = file.read()
237
+
238
+ if not javascript_content:
239
+ return
240
+
241
+ session.scripts.append(javascript_content)
242
+ session.collected_js_files.add(javascript_path)
243
+
244
+ def _collect_extra_javascript(
245
+ self, component: "BaseComponent", session: RenderSession
246
+ ) -> None:
247
+ for javascript_path in component.js:
248
+ normalized_path = os.path.normpath(javascript_path).replace("\\", "/")
249
+ if not os.path.exists(normalized_path):
250
+ continue
251
+ if normalized_path in session.collected_js_files:
252
+ continue
253
+ with open(normalized_path, "r") as file:
254
+ javascript_content = file.read()
255
+ if not javascript_content:
256
+ continue
257
+ session.scripts.append(javascript_content)
258
+ session.collected_js_files.add(normalized_path)
259
+
260
+ def _inject_scripts(self, markup: str, session: RenderSession) -> str:
261
+ if not session.scripts:
262
+ return markup
263
+ combined_script = "\n".join(session.scripts)
264
+ return f"<script>{combined_script}</script>\n{markup}"
265
+
266
+ def _find_template_for_tag(self, tag_name: str) -> str:
267
+ loader_root = self._get_loader_root()
268
+ finder = self._get_finder_for_root(loader_root)
269
+ return finder.find_template_for_tag(tag_name)
270
+
271
+ def _render_tag_node(
272
+ self,
273
+ node: Tag | str,
274
+ base_context: dict[str, Any],
275
+ session: RenderSession,
276
+ ) -> str:
277
+ if isinstance(node, str):
278
+ return node
279
+
280
+ rendered_children = "".join(
281
+ self._render_tag_node(child, base_context=base_context, session=session)
282
+ for child in node.children
283
+ ).strip()
284
+
285
+ component_id = node.attrs.get("id")
286
+ if not component_id:
287
+ if not self._auto_id:
288
+ raise ValueError(
289
+ f'Missing required "id" for <{node.name}> and auto_id=False'
290
+ )
291
+ component_id = f"{node.name.lower()}-{uuid.uuid4().hex}"
292
+
293
+ attrs_without_id = {k: v for k, v in node.attrs.items() if k != "id"}
294
+
295
+ template_path: str | None = None
296
+ try:
297
+ template_path = self._find_template_for_tag(node.name)
298
+ except FileNotFoundError:
299
+ pass
300
+
301
+ component_class = Registry.get_classes().get(node.name)
302
+ if component_class is not None:
303
+ component = component_class(
304
+ id=component_id,
305
+ content=rendered_children,
306
+ **attrs_without_id,
307
+ )
308
+ else:
309
+ if template_path is None:
310
+ raise FileNotFoundError(
311
+ f"No template found for <{node.name}>. "
312
+ f"Expected {node.name.lower()}.html or {node.name.lower()}.jinja"
313
+ )
314
+ from .base import BaseComponent # local import to avoid cycles
315
+
316
+ component = BaseComponent(
317
+ id=component_id,
318
+ content=rendered_children,
319
+ **attrs_without_id,
320
+ )
321
+
322
+ return str(
323
+ component._render(
324
+ base_context=base_context,
325
+ _renderer=self,
326
+ _session=session,
327
+ _template_path=template_path,
328
+ )
329
+ )
330
+
331
+ def _expand_custom_tags(
332
+ self,
333
+ markup: str,
334
+ base_context: dict[str, Any],
335
+ session: RenderSession,
336
+ ) -> str:
337
+ """
338
+ Expand PascalCase custom tags found inside `markup` by parsing and rendering them into HTML.
339
+ """
340
+ if "<" not in markup:
341
+ return markup
342
+
343
+ parser = Parser()
344
+ has_custom_tags = False
345
+ for match in re.finditer(r"<\s*([A-Za-z][A-Za-z0-9]*)", markup):
346
+ if parser._is_custom_component(match.group(1)):
347
+ has_custom_tags = True
348
+ break
349
+ if not has_custom_tags:
350
+ return markup
351
+
352
+ parser.feed(markup)
353
+ return "".join(
354
+ self._render_tag_node(node, base_context=base_context, session=session)
355
+ for node in parser.root_nodes
356
+ )
357
+
358
+ def render_component_with_context(
359
+ self,
360
+ component: "BaseComponent",
361
+ context: dict[str, Any],
362
+ template_source: str | None,
363
+ template_path: str | None,
364
+ session: RenderSession,
365
+ is_root: bool,
366
+ collect_component_js: bool,
367
+ ) -> Markup:
368
+ template = self._load_template_for_component(
369
+ component, template_source=template_source, template_path=template_path
370
+ )
371
+
372
+ render_context = dict(context)
373
+ render_context.update(Registry.get_instances())
374
+
375
+ rendered_markup = template.render(render_context)
376
+ rendered_markup = self._expand_custom_tags(
377
+ rendered_markup, base_context=render_context, session=session
378
+ )
379
+
380
+ if collect_component_js and self._inline_js:
381
+ self._collect_component_javascript(component, session)
382
+
383
+ if is_root:
384
+ if self._inline_js:
385
+ self._collect_extra_javascript(component, session)
386
+ rendered_markup = self._inject_scripts(rendered_markup, session)
387
+
388
+ return Markup(rendered_markup).unescape()
389
+
390
+ def render(self, source: str) -> str:
391
+ """
392
+ Render an HTML-like source string, expanding PascalCase component tags into HTML.
393
+
394
+ PascalCase tags (e.g., `<MyButton text="OK">`) are matched to registered component
395
+ classes or template files and rendered recursively. Standard HTML is passed through
396
+ unchanged. Associated JavaScript files are collected and injected as a `<script>` block.
397
+
398
+ Args:
399
+ source: HTML-like string containing component tags to render.
400
+
401
+ Returns:
402
+ The fully rendered HTML string with all components expanded.
403
+ """
404
+ parser = Parser()
405
+ parser.feed(source)
406
+
407
+ session = self.new_session()
408
+
409
+ rendered_markup = "".join(
410
+ self._render_tag_node(node, base_context={}, session=session)
411
+ for node in parser.root_nodes
412
+ )
413
+ if self._inline_js:
414
+ rendered_markup = self._inject_scripts(rendered_markup, session)
415
+ return rendered_markup.strip()
pyjinhx/utils.py ADDED
@@ -0,0 +1,113 @@
1
+ import os
2
+ import re
3
+
4
+
5
+ def pascal_case_to_snake_case(name: str) -> str:
6
+ """
7
+ Convert a PascalCase/CamelCase identifier into snake_case.
8
+
9
+ Args:
10
+ name: The identifier to convert.
11
+
12
+ Returns:
13
+ The snake_case version of the identifier.
14
+ """
15
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
16
+
17
+
18
+ def pascal_case_to_kebab_case(name: str) -> str:
19
+ """
20
+ Convert a PascalCase/CamelCase identifier into kebab-case.
21
+
22
+ Args:
23
+ name: The identifier to convert.
24
+
25
+ Returns:
26
+ The kebab-case version of the identifier.
27
+ """
28
+ return pascal_case_to_snake_case(name).replace("_", "-")
29
+
30
+
31
+ def tag_name_to_template_filenames(
32
+ tag_name: str, *, extensions: tuple[str, ...] = (".html", ".jinja")
33
+ ) -> list[str]:
34
+ """
35
+ Convert a component tag name into candidate template filenames.
36
+
37
+ Args:
38
+ tag_name: The PascalCase component tag name (e.g., "ButtonGroup").
39
+ extensions: File extensions to use, in order of preference.
40
+
41
+ Returns:
42
+ List of candidate filenames (e.g., ["button_group.html", "button_group.jinja"]).
43
+ """
44
+ snake_name = pascal_case_to_snake_case(tag_name)
45
+ return [f"{snake_name}{extension}" for extension in extensions]
46
+
47
+
48
+ def normalize_path_separators(path: str) -> str:
49
+ """
50
+ Normalize path separators to forward slashes.
51
+
52
+ Args:
53
+ path: The path string to normalize.
54
+
55
+ Returns:
56
+ The path with backslashes replaced by forward slashes.
57
+ """
58
+ return path.replace("\\", "/")
59
+
60
+
61
+ def extract_tag_name_from_raw(raw_tag: str) -> str:
62
+ """
63
+ Extract the tag name from a raw HTML start tag string.
64
+
65
+ Args:
66
+ raw_tag: The raw HTML tag string (e.g., '<Button text="OK"/>').
67
+
68
+ Returns:
69
+ The tag name, or an empty string if not found.
70
+
71
+ Example:
72
+ >>> extract_tag_name_from_raw('<Button text="OK"/>')
73
+ 'Button'
74
+ """
75
+ match = re.search(r"<\s*([A-Za-z][A-Za-z0-9]*)", raw_tag)
76
+ return match.group(1) if match else ""
77
+
78
+
79
+ def detect_root_directory(
80
+ start_directory: str | None = None,
81
+ project_markers: list[str] | None = None,
82
+ ) -> str:
83
+ """
84
+ Find the project root by walking upward until a marker file is found.
85
+
86
+ Args:
87
+ start_directory: Directory to start searching from. Defaults to current working directory.
88
+ project_markers: Files/directories indicating project root (e.g., "pyproject.toml", ".git").
89
+ Defaults to common markers like pyproject.toml, .git, package.json, etc.
90
+
91
+ Returns:
92
+ The detected project root directory, or the start directory if no marker is found.
93
+ """
94
+ current_dir = start_directory or os.getcwd()
95
+ markers = project_markers or [
96
+ "pyproject.toml",
97
+ "main.py",
98
+ "README.md",
99
+ ".git",
100
+ ".gitignore",
101
+ "package.json",
102
+ "uv.lock",
103
+ ".venv",
104
+ ]
105
+
106
+ search_dir = current_dir
107
+ while search_dir != os.path.dirname(search_dir):
108
+ for marker in markers:
109
+ if os.path.exists(os.path.join(search_dir, marker)):
110
+ return search_dir
111
+ search_dir = os.path.dirname(search_dir)
112
+
113
+ return current_dir