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/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from .base import BaseComponent
2
+ from .dataclasses import Tag
3
+ from .finder import Finder
4
+ from .parser import Parser
5
+ from .registry import Registry
6
+ from .renderer import Renderer
7
+
8
+ __all__ = [
9
+ "BaseComponent",
10
+ "Renderer",
11
+ "Finder",
12
+ "Parser",
13
+ "Registry",
14
+ "Tag",
15
+ ]
pyjinhx/base.py ADDED
@@ -0,0 +1,184 @@
1
+ import logging
2
+ from typing import Any, Optional
3
+
4
+ from markupsafe import Markup
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
6
+
7
+ from .registry import Registry
8
+ from .renderer import Renderer, RenderSession
9
+
10
+ logger = logging.getLogger("pyjinhx")
11
+ logger.setLevel(logging.WARNING)
12
+
13
+
14
+ class NestedComponentWrapper(BaseModel):
15
+ """
16
+ A wrapper for nested components. Enables access to the component's properties and rendered HTML.
17
+
18
+ Attributes:
19
+ html: The rendered HTML string of the nested component.
20
+ props: The original component instance, or None for template-only components.
21
+ """
22
+
23
+ html: str
24
+ props: Optional["BaseComponent"]
25
+
26
+ def __str__(self) -> Markup:
27
+ return self.html
28
+
29
+
30
+ class BaseComponent(BaseModel):
31
+ """
32
+ Base class for defining reusable UI components with Pydantic validation and Jinja2 templating.
33
+
34
+ Subclasses are automatically registered and can be rendered using their corresponding
35
+ HTML/Jinja templates. Components support nested composition, automatic JavaScript collection,
36
+ and can be used directly in Jinja templates via the `__html__` protocol.
37
+
38
+ Attributes:
39
+ id: Unique identifier for the component instance.
40
+ js: Paths to additional JavaScript files to include when rendering.
41
+ """
42
+
43
+ model_config = ConfigDict(extra="allow")
44
+
45
+ id: str = Field(..., description="The unique ID for this component.")
46
+ js: list[str] = Field(
47
+ default_factory=list,
48
+ description="List of paths to extra JavaScript files to include.",
49
+ )
50
+
51
+ @field_validator("id", mode="before")
52
+ def validate_id(cls, v):
53
+ if not v:
54
+ raise ValueError("ID is required")
55
+ return str(v)
56
+
57
+ def __init_subclass__(cls, **kwargs):
58
+ """Automatically register the component class at definition time."""
59
+ super().__init_subclass__(**kwargs)
60
+ Registry.register_class(cls)
61
+
62
+ def __init__(self, **kwargs):
63
+ super().__init__(**kwargs)
64
+ Registry.register_instance(self)
65
+
66
+ def __html__(self) -> Markup:
67
+ """
68
+ Render the component when used in a Jinja template context.
69
+
70
+ Enables cleaner template syntax: `{{ component }}` instead of `{{ component.render() }}`.
71
+
72
+ Returns:
73
+ The rendered HTML as a Markup object.
74
+ """
75
+ return self._render()
76
+
77
+ def _update_context_(
78
+ self,
79
+ context: dict[str, Any],
80
+ field_name: str,
81
+ field_value: Any,
82
+ *,
83
+ renderer: Renderer,
84
+ session: RenderSession,
85
+ ) -> dict[str, Any]:
86
+ """
87
+ Updates the context with rendered components by their ID.
88
+ """
89
+ if isinstance(field_value, BaseComponent):
90
+ context[field_name] = NestedComponentWrapper(
91
+ html=field_value._render(
92
+ base_context=context,
93
+ _renderer=renderer,
94
+ _session=session,
95
+ ),
96
+ props=field_value,
97
+ )
98
+ elif isinstance(field_value, list):
99
+ processed_list = []
100
+ for item in field_value:
101
+ if isinstance(item, BaseComponent):
102
+ processed_list.append(
103
+ NestedComponentWrapper(
104
+ html=item._render(
105
+ base_context=context,
106
+ _renderer=renderer,
107
+ _session=session,
108
+ ),
109
+ props=item,
110
+ )
111
+ )
112
+ else:
113
+ processed_list.append(item)
114
+ if processed_list:
115
+ context[field_name] = processed_list
116
+ elif isinstance(field_value, dict):
117
+ processed_dict = {}
118
+ for key, value in field_value.items():
119
+ if isinstance(value, BaseComponent):
120
+ processed_dict[key] = NestedComponentWrapper(
121
+ html=value._render(
122
+ base_context=context,
123
+ _renderer=renderer,
124
+ _session=session,
125
+ ),
126
+ props=value,
127
+ )
128
+ else:
129
+ processed_dict[key] = value
130
+ if processed_dict:
131
+ context[field_name] = processed_dict
132
+ return context
133
+
134
+ def _render(
135
+ self,
136
+ source: str | None = None,
137
+ base_context: dict[str, Any] | None = None,
138
+ *,
139
+ _renderer: Renderer | None = None,
140
+ _session: RenderSession | None = None,
141
+ _template_path: str | None = None,
142
+ ) -> Markup:
143
+ renderer = _renderer or Renderer.get_default_renderer()
144
+
145
+ is_root = base_context is None and _session is None
146
+ session = _session or renderer.new_session()
147
+
148
+ if base_context is None:
149
+ context: dict[str, Any] = self.model_dump()
150
+ else:
151
+ context = {**base_context, **self.model_dump()}
152
+
153
+ for field_name in type(self).model_fields.keys():
154
+ field_value = getattr(self, field_name)
155
+ context = self._update_context_(
156
+ context,
157
+ field_name,
158
+ field_value,
159
+ renderer=renderer,
160
+ session=session,
161
+ )
162
+
163
+ return renderer.render_component_with_context(
164
+ self,
165
+ context=context,
166
+ template_source=source,
167
+ template_path=_template_path,
168
+ session=session,
169
+ is_root=is_root,
170
+ collect_component_js=source is None,
171
+ )
172
+
173
+ def render(self) -> Markup:
174
+ """
175
+ Render this component to HTML using its associated Jinja template.
176
+
177
+ The template is auto-discovered based on the component class name (e.g., `MyButton` looks
178
+ for `my_button.html` or `my_button.jinja`). All component fields are available in the
179
+ template context, and nested components are rendered recursively.
180
+
181
+ Returns:
182
+ The rendered HTML as a Markup object (safe for direct use in templates).
183
+ """
184
+ return self._render()
pyjinhx/dataclasses.py ADDED
@@ -0,0 +1,19 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ @dataclass
4
+ class Tag:
5
+ """
6
+ Represents a parsed HTML/component tag with its attributes and children.
7
+
8
+ Used by the Parser to build a tree structure from HTML-like markup containing
9
+ PascalCase component tags (e.g., `<MyButton text="OK">`).
10
+
11
+ Attributes:
12
+ name: The tag name (e.g., "MyButton", "div").
13
+ attrs: Dictionary of attribute names to values.
14
+ children: Nested tags or raw text content within this tag.
15
+ """
16
+
17
+ name: str
18
+ attrs: dict[str, str]
19
+ children: list["Tag | str"] = field(default_factory=list)
pyjinhx/finder.py ADDED
@@ -0,0 +1,257 @@
1
+ import inspect
2
+ import os
3
+ from dataclasses import dataclass, field
4
+
5
+ from jinja2 import FileSystemLoader
6
+
7
+ from .utils import (
8
+ detect_root_directory,
9
+ normalize_path_separators,
10
+ pascal_case_to_snake_case,
11
+ tag_name_to_template_filenames,
12
+ )
13
+
14
+
15
+ @dataclass
16
+ class Finder:
17
+ """
18
+ Find files under a root directory.
19
+
20
+ Centralizes template discovery logic with caching to avoid repeated directory walks.
21
+
22
+ Attributes:
23
+ root: The root directory to search within.
24
+ """
25
+
26
+ root: str
27
+ _index: dict[str, str] = field(default_factory=dict, init=False)
28
+ _is_indexed: bool = field(default=False, init=False)
29
+
30
+ # ---------
31
+ # Helpers
32
+ # ---------
33
+
34
+ def _build_index(self) -> None:
35
+ if self._is_indexed:
36
+ return
37
+
38
+ for current_root, dir_names, file_names in os.walk(self.root):
39
+ dir_names.sort()
40
+ file_names.sort()
41
+ for file_name in file_names:
42
+ self._index.setdefault(file_name, os.path.join(current_root, file_name))
43
+
44
+ self._is_indexed = True
45
+
46
+ @staticmethod
47
+ def get_loader_root(loader: FileSystemLoader) -> str:
48
+ """
49
+ Return the first search root from a Jinja FileSystemLoader.
50
+
51
+ Jinja allows searchpath to be a string or a list of strings; PyJinHx uses the first entry.
52
+
53
+ Args:
54
+ loader: The Jinja FileSystemLoader to extract the root from.
55
+
56
+ Returns:
57
+ The first search path directory.
58
+ """
59
+ search_path = loader.searchpath
60
+ if isinstance(search_path, list):
61
+ return search_path[0]
62
+ return search_path
63
+
64
+ @staticmethod
65
+ def detect_root_directory(
66
+ start_directory: str | None = None,
67
+ project_markers: list[str] | None = None,
68
+ ) -> str:
69
+ """
70
+ Find the project root by walking upward from a starting directory until a marker file is found.
71
+
72
+ Args:
73
+ start_directory: Directory to start searching from. Defaults to current working directory.
74
+ project_markers: Files/directories indicating project root (e.g., "pyproject.toml", ".git").
75
+
76
+ Returns:
77
+ The detected project root directory, or the start directory if no marker is found.
78
+ """
79
+ return detect_root_directory(
80
+ start_directory=start_directory,
81
+ project_markers=project_markers,
82
+ )
83
+
84
+ @staticmethod
85
+ def find_in_directory(directory: str, filename: str) -> str | None:
86
+ """
87
+ Check if a file exists directly in a directory (no recursive search).
88
+
89
+ Useful for component-adjacent assets (e.g., auto-discovered JS files).
90
+
91
+ Args:
92
+ directory: The directory to check.
93
+ filename: The filename to look for.
94
+
95
+ Returns:
96
+ The full path to the file if it exists, or None otherwise.
97
+ """
98
+ candidate_path = os.path.join(directory, filename)
99
+ if os.path.exists(candidate_path):
100
+ return candidate_path
101
+ return None
102
+
103
+ @staticmethod
104
+ def get_class_directory(component_class: type) -> str:
105
+ """
106
+ Return the directory containing the given class's source file.
107
+
108
+ Args:
109
+ component_class: The class to locate.
110
+
111
+ Returns:
112
+ The directory path with normalized separators.
113
+
114
+ Example:
115
+ >>> Finder.get_class_directory(Button)
116
+ '/app/components/ui'
117
+ """
118
+ return normalize_path_separators(
119
+ os.path.dirname(inspect.getfile(component_class))
120
+ )
121
+
122
+ @staticmethod
123
+ def get_relative_template_path(
124
+ component_dir: str, search_root: str, component_name: str
125
+ ) -> str:
126
+ """
127
+ Compute the template path relative to the Jinja loader root.
128
+
129
+ Args:
130
+ component_dir: Absolute path to the component's directory.
131
+ search_root: The Jinja loader's root directory.
132
+ component_name: The PascalCase component name.
133
+
134
+ Returns:
135
+ The relative template path (e.g., "components/ui/button.html").
136
+
137
+ Example:
138
+ >>> Finder.get_relative_template_path("/app/components/ui", "/app", "Button")
139
+ 'components/ui/button.html'
140
+ """
141
+ relative_dir = normalize_path_separators(
142
+ os.path.relpath(component_dir, search_root)
143
+ )
144
+ filename = f"{pascal_case_to_snake_case(component_name)}.html"
145
+ return f"{relative_dir}/{filename}"
146
+
147
+ @staticmethod
148
+ def get_relative_template_paths(
149
+ component_dir: str,
150
+ search_root: str,
151
+ component_name: str,
152
+ *,
153
+ extensions: tuple[str, ...] = (".html", ".jinja"),
154
+ ) -> list[str]:
155
+ """
156
+ Compute candidate template paths relative to the Jinja loader root.
157
+
158
+ Args:
159
+ component_dir: Absolute path to the component's directory.
160
+ search_root: The Jinja loader's root directory.
161
+ component_name: The PascalCase component name.
162
+ extensions: File extensions to try, in order of preference.
163
+
164
+ Returns:
165
+ List of relative template paths to try during auto-lookup.
166
+ """
167
+ relative_dir = normalize_path_separators(
168
+ os.path.relpath(component_dir, search_root)
169
+ )
170
+ snake_name = pascal_case_to_snake_case(component_name)
171
+ return [f"{relative_dir}/{snake_name}{extension}" for extension in extensions]
172
+
173
+ # ------------------
174
+ # Public instance API
175
+ # ------------------
176
+
177
+ def find(self, filename: str) -> str:
178
+ """
179
+ Find a file by name under the root directory.
180
+
181
+ Args:
182
+ filename: The filename to search for.
183
+
184
+ Returns:
185
+ The full path to the first matching file.
186
+
187
+ Raises:
188
+ FileNotFoundError: If the file cannot be found under root.
189
+ """
190
+ self._build_index()
191
+ found_path = self._index.get(filename)
192
+ if found_path is None:
193
+ raise FileNotFoundError(f"Template not found: {filename} under {self.root}")
194
+ return found_path
195
+
196
+ def find_template_for_tag(self, tag_name: str) -> str:
197
+ """
198
+ Resolve a PascalCase component tag name to its template path.
199
+
200
+ Tries multiple extensions (.html, .jinja) in order of preference.
201
+
202
+ Args:
203
+ tag_name: The PascalCase component tag name (e.g., "ButtonGroup").
204
+
205
+ Returns:
206
+ The full path to the template file.
207
+
208
+ Raises:
209
+ FileNotFoundError: If no matching template is found.
210
+
211
+ Example:
212
+ >>> finder.find_template_for_tag("ButtonGroup")
213
+ '/app/components/button_group.html'
214
+ """
215
+ last_error: FileNotFoundError | None = None
216
+ for candidate_filename in tag_name_to_template_filenames(tag_name):
217
+ try:
218
+ return self.find(candidate_filename)
219
+ except FileNotFoundError as exc:
220
+ last_error = exc
221
+ if last_error is None:
222
+ raise FileNotFoundError(
223
+ f"Template not found for tag: {tag_name} under {self.root}"
224
+ )
225
+ raise last_error
226
+
227
+ def collect_javascript_files(self, relative_to_root: bool = False) -> list[str]:
228
+ """
229
+ Collect all JavaScript files under `root`.
230
+
231
+ Args:
232
+ relative_to_root: If True, return paths relative to `root` (useful for building static file lists).
233
+ If False, return absolute paths.
234
+
235
+ Returns:
236
+ A deterministic, sorted list of `.js` file paths (directories and file names are walked in sorted order).
237
+ """
238
+ javascript_files: list[str] = []
239
+
240
+ if not os.path.exists(self.root):
241
+ return javascript_files
242
+
243
+ for current_root, dir_names, file_names in os.walk(self.root):
244
+ dir_names.sort()
245
+ file_names.sort()
246
+ for file_name in file_names:
247
+ if not file_name.lower().endswith(".js"):
248
+ continue
249
+ full_path = os.path.join(current_root, file_name)
250
+ if relative_to_root:
251
+ javascript_files.append(
252
+ normalize_path_separators(os.path.relpath(full_path, self.root))
253
+ )
254
+ else:
255
+ javascript_files.append(normalize_path_separators(full_path))
256
+
257
+ return javascript_files
pyjinhx/parser.py ADDED
@@ -0,0 +1,70 @@
1
+ import re
2
+ from html.parser import HTMLParser
3
+
4
+ from .dataclasses import Tag
5
+ from .utils import extract_tag_name_from_raw
6
+
7
+ RE_PASCAL_CASE_TAG_NAME = re.compile(r"^[A-Z](?:[a-z]+(?:[A-Z][a-z]+)*)?$")
8
+
9
+
10
+ class Parser(HTMLParser):
11
+ """
12
+ HTML parser that identifies PascalCase component tags and builds a tree of Tag nodes.
13
+
14
+ Standard HTML tags are passed through as raw strings, while PascalCase tags (e.g., `<MyButton>`)
15
+ are parsed into Tag objects for component rendering. After calling `feed(html)`, the parsed
16
+ structure is available in `root_nodes`.
17
+
18
+ Attributes:
19
+ root_nodes: List of top-level parsed nodes (Tag objects or raw HTML strings).
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ super().__init__()
24
+ self._stack: list[Tag] = []
25
+ self.root_nodes: list[Tag | str] = []
26
+
27
+ def _is_custom_component(self, tag_name: str) -> bool:
28
+ return bool(RE_PASCAL_CASE_TAG_NAME.match(tag_name))
29
+
30
+ def _attrs_to_dict(self, attrs: list[tuple[str, str | None]]) -> dict[str, str]:
31
+ return {attr_name: (attr_value or "") for attr_name, attr_value in attrs}
32
+
33
+ def _append_child(self, node: Tag | str) -> None:
34
+ if self._stack:
35
+ self._stack[-1].children.append(node)
36
+ else:
37
+ self.root_nodes.append(node)
38
+
39
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
40
+ raw = self.get_starttag_text() or f"<{tag}>"
41
+ original_tag_name = extract_tag_name_from_raw(raw) or tag
42
+
43
+ if self._is_custom_component(original_tag_name):
44
+ tag_node = Tag(name=original_tag_name, attrs=self._attrs_to_dict(attrs))
45
+ self._stack.append(tag_node)
46
+ return
47
+
48
+ self._append_child(raw)
49
+
50
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
51
+ raw = self.get_starttag_text() or f"<{tag} />"
52
+ original_tag_name = extract_tag_name_from_raw(raw) or tag
53
+
54
+ if self._is_custom_component(original_tag_name):
55
+ tag_node = Tag(name=original_tag_name, attrs=self._attrs_to_dict(attrs))
56
+ self._append_child(tag_node)
57
+ return
58
+
59
+ self._append_child(raw)
60
+
61
+ def handle_endtag(self, tag: str) -> None:
62
+ if self._stack and self._stack[-1].name.lower() == tag.lower():
63
+ tag_node = self._stack.pop()
64
+ self._append_child(tag_node)
65
+ return
66
+
67
+ self._append_child(f"</{tag}>")
68
+
69
+ def handle_data(self, data: str) -> None:
70
+ self._append_child(data)
pyjinhx/registry.py ADDED
@@ -0,0 +1,92 @@
1
+ import logging
2
+ from contextvars import ContextVar
3
+ from typing import TYPE_CHECKING, ClassVar
4
+
5
+ logger = logging.getLogger("pyjinhx")
6
+
7
+ if TYPE_CHECKING:
8
+ from .base import BaseComponent
9
+
10
+
11
+ _registry_context: ContextVar[dict[str, "BaseComponent"]] = ContextVar(
12
+ "component_registry", default={}
13
+ )
14
+
15
+
16
+ class Registry:
17
+ """
18
+ Central registry for component classes and instances.
19
+
20
+ Provides two registries:
21
+ - Class registry: Maps component class names to their types (process-wide).
22
+ - Instance registry: Maps component IDs to instances (context-local, thread-safe).
23
+
24
+ Component classes are auto-registered when subclassing BaseComponent. Instances are
25
+ registered upon instantiation, enabling cross-referencing in templates by ID.
26
+ """
27
+
28
+ _class_registry: ClassVar[dict[str, type["BaseComponent"]]] = {}
29
+
30
+ @classmethod
31
+ def register_class(cls, component_class: type["BaseComponent"]) -> None:
32
+ """
33
+ Register a component class by its name.
34
+
35
+ Called automatically when subclassing BaseComponent.
36
+
37
+ Args:
38
+ component_class: The component class to register.
39
+ """
40
+ class_name = component_class.__name__
41
+ if class_name in cls._class_registry:
42
+ logger.warning(
43
+ f"Component class {class_name} is already registered. Overwriting..."
44
+ )
45
+ cls._class_registry[class_name] = component_class
46
+
47
+ @classmethod
48
+ def get_classes(cls) -> dict[str, type["BaseComponent"]]:
49
+ """
50
+ Return a copy of all registered component classes.
51
+
52
+ Returns:
53
+ Dictionary mapping class names to component class types.
54
+ """
55
+ return cls._class_registry.copy()
56
+
57
+ @classmethod
58
+ def clear_classes(cls) -> None:
59
+ """Remove all registered component classes. Useful for testing."""
60
+ cls._class_registry.clear()
61
+
62
+ @classmethod
63
+ def register_instance(cls, component: "BaseComponent") -> None:
64
+ """
65
+ Register a component instance by its ID.
66
+
67
+ Called automatically on instantiation.
68
+
69
+ Args:
70
+ component: The component instance to register.
71
+ """
72
+ registry = _registry_context.get()
73
+ if component.id in registry:
74
+ logger.warning(
75
+ f"While registering{component.__class__.__name__}(id={component.id}) found an existing component with the same id. Overwriting..."
76
+ )
77
+ registry[component.id] = component
78
+
79
+ @classmethod
80
+ def get_instances(cls) -> dict[str, "BaseComponent"]:
81
+ """
82
+ Return all registered component instances in the current context.
83
+
84
+ Returns:
85
+ Dictionary mapping component IDs to component instances.
86
+ """
87
+ return _registry_context.get()
88
+
89
+ @classmethod
90
+ def clear_instances(cls) -> None:
91
+ """Remove all registered component instances from the current context."""
92
+ _registry_context.set({})