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 +15 -0
- pyjinhx/base.py +184 -0
- pyjinhx/dataclasses.py +19 -0
- pyjinhx/finder.py +257 -0
- pyjinhx/parser.py +70 -0
- pyjinhx/registry.py +92 -0
- pyjinhx/renderer.py +415 -0
- pyjinhx/utils.py +113 -0
- pyjinhx-0.2.2.dist-info/METADATA +205 -0
- pyjinhx-0.2.2.dist-info/RECORD +12 -0
- pyjinhx-0.2.2.dist-info/WHEEL +4 -0
- pyjinhx-0.2.2.dist-info/licenses/LICENSE.txt +21 -0
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({})
|