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/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
|