caspian-utils 0.0.12__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.
@@ -0,0 +1,183 @@
1
+ from .layout import env, load_template_file
2
+ from .html_attrs import get_attributes
3
+ from typing import Callable, TypeVar, Dict, Optional, Any
4
+ from markupsafe import Markup
5
+ import inspect
6
+ import os
7
+ import importlib
8
+ import importlib.util
9
+
10
+ R = TypeVar('R')
11
+
12
+ _component_registry: Dict[str, 'Component'] = {}
13
+
14
+
15
+ class Component:
16
+ """Wrapper class for PulsePoint components"""
17
+ _is_pp_component: bool = True
18
+ source_path: Optional[str] = None
19
+
20
+ def __init__(self, fn: Callable[..., str], source_path: Optional[str] = None):
21
+ self.fn = fn
22
+ self.source_path = source_path
23
+ self.__name__ = fn.__name__
24
+ self.__doc__ = fn.__doc__
25
+ self.__signature__ = inspect.signature(fn)
26
+ _component_registry[fn.__name__] = self
27
+
28
+ def __call__(self, *args, **kwargs) -> str:
29
+ # Standardize React-like props to Pythonic names
30
+ if "className" in kwargs:
31
+ kwargs["class_name"] = kwargs.pop("className")
32
+ if "htmlFor" in kwargs:
33
+ kwargs["html_for"] = kwargs.pop("htmlFor")
34
+ return self.fn(*args, **kwargs)
35
+
36
+
37
+ def component(fn: Callable[..., str]) -> Component:
38
+ """Decorator to mark and register a PulsePoint component"""
39
+ return Component(fn)
40
+
41
+
42
+ def load_component_from_path(path: str, component_name: str, base_dir: str = "") -> Component | None:
43
+ if path == '.' or path == './':
44
+ full_path = os.path.join(
45
+ base_dir, f"{component_name}.py") if base_dir else f"{component_name}.py"
46
+ elif path.startswith('./') or path.startswith('../'):
47
+ full_path = os.path.normpath(os.path.join(
48
+ base_dir, path)) if base_dir else os.path.normpath(path)
49
+ else:
50
+ full_path = path
51
+
52
+ if full_path.endswith('.py'):
53
+ file_path = full_path
54
+ elif os.path.isdir(full_path):
55
+ file_path = os.path.join(full_path, f"{component_name}.py")
56
+ else:
57
+ file_path = f"{full_path}.py" if not os.path.isdir(
58
+ full_path) else os.path.join(full_path, f"{component_name}.py")
59
+
60
+ if not os.path.exists(file_path):
61
+ return None
62
+
63
+ try:
64
+ cwd = os.getcwd()
65
+ abs_file_path = os.path.abspath(file_path)
66
+ if abs_file_path.startswith(cwd):
67
+ rel_path = os.path.relpath(abs_file_path, cwd)
68
+ if not rel_path.startswith('..'):
69
+ module_name = os.path.splitext(
70
+ rel_path)[0].replace(os.sep, '.')
71
+ module = importlib.import_module(module_name)
72
+ comp = getattr(module, component_name, None)
73
+ if isinstance(comp, Component):
74
+ comp.source_path = abs_file_path
75
+ return comp
76
+ except (ImportError, ValueError, AttributeError):
77
+ pass
78
+
79
+ try:
80
+ unique_module_name = f"pp_component_{os.path.abspath(file_path).replace(os.sep, '_').replace('.', '_')}"
81
+ spec = importlib.util.spec_from_file_location(
82
+ unique_module_name, file_path)
83
+ if spec is None or spec.loader is None:
84
+ return None
85
+ module = importlib.util.module_from_spec(spec)
86
+ spec.loader.exec_module(module)
87
+ comp = getattr(module, component_name, None)
88
+ if isinstance(comp, Component):
89
+ comp.source_path = file_path
90
+ return comp
91
+ except Exception as e:
92
+ print(
93
+ f"Error loading component {component_name} from {file_path}: {e}")
94
+ return None
95
+
96
+ return None
97
+
98
+
99
+ # ====
100
+ # TEMPLATE RENDERING (Jinja2 powered)
101
+ # ====
102
+
103
+ def render_html(file_path: str, **kwargs) -> str:
104
+ """
105
+ Load HTML template and render with Jinja2.
106
+
107
+ Behaves like React:
108
+ - [[ props ]] spreads attributes (class="...", id="...")
109
+ - [[ children ]] inserts the content (safe HTML)
110
+ - Supports passing __file__ to auto-discover sibling .html
111
+ """
112
+ # Resolve file path relative to caller
113
+ frame = inspect.currentframe()
114
+ if frame and frame.f_back:
115
+ caller_file = frame.f_back.f_globals.get('__file__', '')
116
+ if caller_file:
117
+ base_dir = os.path.dirname(caller_file)
118
+ full_path = os.path.join(base_dir, file_path)
119
+ else:
120
+ full_path = file_path
121
+ else:
122
+ full_path = file_path
123
+
124
+ if full_path.endswith('.py'):
125
+ full_path = full_path[:-3] + '.html'
126
+
127
+ content = load_template_file(full_path)
128
+
129
+ # 1. EXTRACT CHILDREN
130
+ # We remove 'children' from props so it doesn't get rendered as an attribute.
131
+ children_content = kwargs.pop("children", "")
132
+
133
+ # 2. MARK CHILDREN AS SAFE
134
+ # If children is None or empty, use empty string. Otherwise mark as safe HTML.
135
+ safe_children = Markup(children_content) if children_content else ""
136
+
137
+ # 3. GENERATE ATTRIBUTES
138
+ # Pass the remaining kwargs to get_attributes.
139
+ # Mark the result as Markup so Jinja doesn't escape the quotes (class="foo").
140
+ props_string = Markup(get_attributes(kwargs))
141
+
142
+ # 4. RENDER
143
+ # We pass 'children' explicitly as the safe Markup object.
144
+ # We pass 'props' explicitly as the safe attribute string.
145
+ template = env.from_string(content)
146
+ return template.render(props=props_string, children=safe_children, **kwargs)
147
+
148
+
149
+ def render_component(py_file: str, template_name: str, context: Optional[Dict[str, Any]] = None) -> str:
150
+ """
151
+ Render component template with context.
152
+ """
153
+ context = context or {}
154
+ dir_path = os.path.dirname(os.path.abspath(py_file))
155
+ html_path = os.path.join(dir_path, template_name)
156
+
157
+ content = load_template_file(html_path)
158
+
159
+ # 1. Separate props from children
160
+ attrs = context.copy()
161
+ children_content = attrs.pop("children", "")
162
+
163
+ # 2. Mark safe
164
+ props_string = Markup(get_attributes(attrs))
165
+ safe_children = Markup(children_content) if children_content else ""
166
+
167
+ # 3. Render
168
+ template = env.from_string(content)
169
+ return template.render(props=props_string, children=safe_children, **context)
170
+
171
+
172
+ # ====
173
+ # EXPORTS
174
+ # ====
175
+
176
+ __all__ = [
177
+ 'Component',
178
+ 'component',
179
+ 'load_component_from_path',
180
+ 'render_html',
181
+ 'render_component',
182
+ '_component_registry',
183
+ ]
@@ -0,0 +1,293 @@
1
+ from .string_helpers import camel_to_kebab
2
+ from .string_helpers import kebab_to_camel
3
+ from .string_helpers import has_mustache
4
+ import re
5
+ import uuid
6
+ import inspect
7
+ from typing import Dict, List, Optional
8
+ from .component_decorator import Component, load_component_from_path
9
+ import os
10
+
11
+
12
+ def convert_mustache_attrs_to_kebab_raw(html):
13
+ def replace_attr(match):
14
+ attr_name = match.group(1)
15
+ equals_quote = match.group(2)
16
+ value = match.group(3)
17
+ end_quote = match.group(4)
18
+
19
+ if has_mustache(value):
20
+ new_name = camel_to_kebab(attr_name)
21
+ return f'{new_name}{equals_quote}{value}{end_quote}'
22
+ return match.group(0)
23
+
24
+ pattern = r'([a-zA-Z_][a-zA-Z0-9_]*)(=["\'])([^"\']*?)(["\'])'
25
+ return re.sub(pattern, replace_attr, html)
26
+
27
+
28
+ def extract_component_original_attrs(html, pattern):
29
+ component_attrs_map = {}
30
+
31
+ for match in re.finditer(pattern, html):
32
+ component_name = match.group(1)
33
+ tag_start = match.start()
34
+
35
+ remaining = html[tag_start:]
36
+ tag_end_match = re.search(r'/?>', remaining)
37
+ if tag_end_match:
38
+ full_tag = remaining[:tag_end_match.end()]
39
+
40
+ attr_pattern = re.compile(
41
+ r'([a-zA-Z_][\w\-]*)\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)')
42
+ attrs_original = {}
43
+ for attr_match in attr_pattern.finditer(full_tag):
44
+ attr_name = attr_match.group(1)
45
+ attrs_original[attr_name.lower()] = attr_name
46
+
47
+ placeholder = f"ppcomponent{component_name.lower()}"
48
+ if placeholder not in component_attrs_map:
49
+ component_attrs_map[placeholder] = []
50
+ component_attrs_map[placeholder].append(attrs_original)
51
+
52
+ return component_attrs_map
53
+
54
+ # ============================================================================
55
+ # IMPORT PARSING
56
+ # ============================================================================
57
+
58
+
59
+ def extract_imports(html: str) -> List[Dict]:
60
+ """
61
+ Extract all import statements from HTML.
62
+
63
+ Supports:
64
+ <!-- @import Test from "./path" -->
65
+ <!-- @import Test from ./path --> (unquoted)
66
+ <!-- @import Test as MyTest from "./path" -->
67
+ <!-- @import { Button, Input } from "./path" -->
68
+ <!-- @import { Button as Btn, Input as Field } from "./path" -->
69
+ """
70
+ imports = []
71
+
72
+ # Grouped imports: @import { A, B as C } from "path" or from path
73
+ grouped_pattern = r'<!--\s*@import\s*\{([^}]+)\}\s*from\s*["\']?([^"\'>\s]+)["\']?\s*-->'
74
+ for match in re.finditer(grouped_pattern, html):
75
+ members = match.group(1)
76
+ path = match.group(2)
77
+
78
+ for member in members.split(','):
79
+ member = member.strip()
80
+ if not member:
81
+ continue
82
+ if ' as ' in member:
83
+ parts = member.split(' as ')
84
+ original = parts[0].strip()
85
+ alias = parts[1].strip()
86
+ else:
87
+ original = member
88
+ alias = member
89
+ imports.append({
90
+ 'original': original,
91
+ 'alias': alias,
92
+ 'path': path
93
+ })
94
+
95
+ # Single imports: @import Name from "path" OR @import Name as Alias from "path" (quotes optional)
96
+ single_pattern = r'<!--\s*@import\s+(?!\{)(\w+)(?:\s+as\s+(\w+))?\s+from\s*["\']?([^"\'>\s]+)["\']?\s*-->'
97
+ for match in re.finditer(single_pattern, html):
98
+ original = match.group(1)
99
+ alias = match.group(2) or original
100
+ path = match.group(3)
101
+ imports.append({
102
+ 'original': original,
103
+ 'alias': alias,
104
+ 'path': path
105
+ })
106
+
107
+ return imports
108
+
109
+
110
+ def strip_imports(html: str) -> str:
111
+ """Remove import comments from HTML"""
112
+ # Remove grouped imports (quoted or unquoted)
113
+ html = re.sub(
114
+ r'<!--\s*@import\s*\{[^}]+\}\s*from\s*["\']?[^"\'>\s]+["\']?\s*-->\n?', '', html)
115
+ # Remove single imports (quoted or unquoted)
116
+ html = re.sub(
117
+ r'<!--\s*@import\s+\w+(?:\s+as\s+\w+)?\s+from\s*["\']?[^"\'>\s]+["\']?\s*-->\n?', '', html)
118
+ return html
119
+
120
+
121
+ def build_alias_map(imports: List[Dict], base_dir: str = "") -> Dict[str, Component]:
122
+ """Build mapping from alias names to Component instances."""
123
+ alias_map = {}
124
+
125
+ for imp in imports:
126
+ original = imp['original']
127
+ alias = imp['alias']
128
+ path = imp['path']
129
+
130
+ component = load_component_from_path(path, original, base_dir)
131
+ if component:
132
+ alias_map[alias] = component
133
+
134
+ return alias_map
135
+
136
+
137
+ def process_imports(html: str, base_dir: str = "") -> tuple[str, Dict[str, Component]]:
138
+ """Process imports and return cleaned HTML with alias map."""
139
+ imports = extract_imports(html)
140
+ alias_map = build_alias_map(imports, base_dir)
141
+ cleaned_html = strip_imports(html)
142
+ return cleaned_html, alias_map
143
+
144
+
145
+ # ============================================================================
146
+ # COMPONENT HELPERS
147
+ # ============================================================================
148
+
149
+ def needs_parent_scope(attrs):
150
+ for key, value in attrs.items():
151
+ if key.lower().startswith('on'):
152
+ return True
153
+ if has_mustache(value):
154
+ return True
155
+ return False
156
+
157
+
158
+ def wrap_scope(html, scope):
159
+ return f'<!-- pp-scope:{scope} -->{html}<!-- /pp-scope -->'
160
+
161
+
162
+ def accepts_children(fn):
163
+ sig = fn.__signature__ if isinstance(
164
+ fn, Component) else inspect.signature(fn)
165
+ params = sig.parameters
166
+ return 'children' in params or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
167
+
168
+
169
+ # ============================================================================
170
+ # MAIN TRANSFORM
171
+ # ============================================================================
172
+
173
+ def transform_components(
174
+ html: str,
175
+ parent_scope: str = "app",
176
+ base_dir: str = "",
177
+ alias_map: Optional[Dict[str, Component]] = None,
178
+ _depth: int = 0
179
+ ) -> str:
180
+ """
181
+ Transform custom component tags. Components MUST be imported to be resolved.
182
+ Recursively processes the output of components to handle nested imports/components.
183
+ """
184
+ if _depth > 50:
185
+ return html
186
+
187
+ # 1. Process imports for the current level
188
+ html, local_alias_map = process_imports(html, base_dir)
189
+
190
+ if alias_map:
191
+ merged_map = {**alias_map, **local_alias_map}
192
+ else:
193
+ merged_map = local_alias_map
194
+
195
+ # If no components are imported, we might still have standard HTML to return,
196
+ # but we can't transform custom tags without a map.
197
+ if not merged_map:
198
+ return html
199
+
200
+ # 2. Convert mustache attributes (e.g. someProp="{val}") to kebab-case (some-prop="{val}")
201
+ html = convert_mustache_attrs_to_kebab_raw(html)
202
+
203
+ # 3. Process components iteratively
204
+ max_iterations = 100
205
+ iteration = 0
206
+
207
+ while iteration < max_iterations:
208
+ iteration += 1
209
+
210
+ # Find a component tag (prefer innermost by finding one without nested components)
211
+ # This regex looks for <CapitalizedTag ... /> or <CapitalizedTag ...>...</CapitalizedTag>
212
+ component_pattern = r'<([A-Z][a-zA-Z0-9]*)([^>]*?)(?:/>|>([\s\S]*?)</\1>)'
213
+
214
+ match = None
215
+ for m in re.finditer(component_pattern, html):
216
+ tag_name = m.group(1)
217
+ # Only process if we have an import for this tag
218
+ if tag_name in merged_map:
219
+ inner_content = m.group(3) or ""
220
+ # Check if this has nested components - if so, skip for now (inner-first strategy)
221
+ if not re.search(r'<[A-Z][a-zA-Z0-9]*[^>]*(?:/>|>)', inner_content):
222
+ match = m
223
+ break
224
+ elif match is None:
225
+ match = m # Fallback to first match if all have nested components
226
+
227
+ if not match:
228
+ break
229
+
230
+ tag_name = match.group(1)
231
+ attrs_str = match.group(2) or ""
232
+ children = match.group(3) or ""
233
+
234
+ component_fn = merged_map.get(tag_name)
235
+ if not component_fn:
236
+ # Should not happen due to the check in the loop, but safety first
237
+ break
238
+
239
+ # 4. Parse attributes
240
+ props = {}
241
+ attr_pattern = r'([a-zA-Z_][\w\-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|(\S+))'
242
+ for attr_match in re.finditer(attr_pattern, attrs_str):
243
+ attr_name = attr_match.group(1)
244
+ attr_value = attr_match.group(2) or attr_match.group(
245
+ 3) or attr_match.group(4) or ""
246
+ camel_name = kebab_to_camel(attr_name)
247
+ props[camel_name] = attr_value
248
+
249
+ # Handle boolean attributes (no value)
250
+ bool_attr_pattern = r'\s([a-zA-Z_][\w\-]*)(?=\s|/?>|$)(?!=)'
251
+ for bool_match in re.finditer(bool_attr_pattern, attrs_str):
252
+ attr_name = bool_match.group(1)
253
+ if attr_name not in props:
254
+ props[kebab_to_camel(attr_name)] = ""
255
+
256
+ unique_id = f"{tag_name.lower()}_{uuid.uuid4().hex[:8]}"
257
+
258
+ # 5. Add children if component accepts them
259
+ if children.strip() and accepts_children(component_fn):
260
+ props['children'] = wrap_scope(children, parent_scope)
261
+
262
+ # 6. Render the component (get the raw string output)
263
+ component_html = component_fn(**props)
264
+
265
+ # 7. RECURSIVE TRANSFORM [CRITICAL FIX]
266
+ # We must process the returned HTML to resolve imports and components
267
+ # inside the child component (e.g., EditTodo -> Button).
268
+ if hasattr(component_fn, 'source_path') and component_fn.source_path:
269
+ component_dir = os.path.dirname(component_fn.source_path)
270
+ component_html = transform_components(
271
+ component_html,
272
+ parent_scope=parent_scope, # Or a new scope if you want isolation
273
+ base_dir=component_dir, # Use the component's directory for relative imports
274
+ _depth=_depth + 1
275
+ )
276
+
277
+ # 8. Add pp-component attribute to root element of the result
278
+ # (This marks it for PulsePoint reactivity in the DOM)
279
+ component_html = re.sub(
280
+ r'^(\s*<[a-zA-Z][a-zA-Z0-9]*)',
281
+ rf'\1 pp-component="{unique_id}"',
282
+ component_html.strip(),
283
+ count=1
284
+ )
285
+
286
+ # 9. Wrap in scope if needed (for events/state binding)
287
+ if needs_parent_scope(props):
288
+ component_html = wrap_scope(component_html, parent_scope)
289
+
290
+ # 10. Replace the original tag in the HTML with the transformed result
291
+ html = html[:match.start()] + component_html + html[match.end():]
292
+
293
+ return html
casp/html_attrs.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+ from .string_helpers import camel_to_kebab
3
+ from typing import Any, Dict, Optional
4
+ import html
5
+ from tailwind_merge import TailwindMerge
6
+
7
+ _twm = TailwindMerge()
8
+
9
+ # Map Python-safe names to HTML attributes
10
+ ALIAS_MAP = {
11
+ "class_name": "class",
12
+ "className": "class",
13
+ "html_for": "for",
14
+ "htmlFor": "for"
15
+ }
16
+
17
+
18
+ def merge_classes(*classes: Any) -> str:
19
+ parts: list[str] = []
20
+ for c in classes:
21
+ if not c:
22
+ continue
23
+ if isinstance(c, (list, tuple, set)):
24
+ s = " ".join(str(x) for x in c if x)
25
+ else:
26
+ s = str(c).strip()
27
+ if s:
28
+ parts.append(s)
29
+ return _twm.merge(*parts) if parts else ""
30
+
31
+
32
+ def _to_attr_value(v: Any) -> Optional[str]:
33
+ # XML-strict: booleans should have explicit values
34
+ if v is None:
35
+ return None
36
+ if isinstance(v, bool):
37
+ return "true" if v else None # omit false
38
+ if isinstance(v, (list, tuple, set)):
39
+ s = " ".join(str(x) for x in v if x)
40
+ return s if s else None
41
+ s = str(v)
42
+ return s if s != "" else None
43
+
44
+
45
+ def get_attributes(props: Dict[str, Any], overrides: Optional[Dict[str, Any]] = None) -> str:
46
+ """
47
+ Build ONE attribute string.
48
+ - Aliases (class_name -> class) are always resolved.
49
+ - If the VALUE contains mustache syntax (e.g. "{var}"), the KEY is converted to kebab-case.
50
+ - Otherwise, the KEY maintains its original casing.
51
+ - Overrides take priority over props.
52
+ """
53
+
54
+ final_attrs: Dict[str, str] = {}
55
+
56
+ def process_dict(d: Dict[str, Any]):
57
+ if not d:
58
+ return
59
+ for k, v in d.items():
60
+ if not k:
61
+ continue
62
+
63
+ # 1. Check Alias Map first (e.g., class_name -> class)
64
+ key_name = ALIAS_MAP.get(k, k)
65
+
66
+ # 2. Process value to string
67
+ val = _to_attr_value(v)
68
+
69
+ if val is not None:
70
+ # 3. Check for mustache syntax to decide on casing
71
+ # If value is a binding (has { and }), force kebab-case on the key
72
+ if "{" in val and "}" in val:
73
+ final_key = camel_to_kebab(key_name)
74
+ else:
75
+ # Otherwise, keep original casing (e.g. standard attributes)
76
+ final_key = key_name
77
+
78
+ final_attrs[final_key] = val
79
+
80
+ # 1. Process base props
81
+ process_dict(props)
82
+
83
+ # 2. Process overrides (these will overwrite keys from step 1)
84
+ if overrides:
85
+ process_dict(overrides)
86
+
87
+ # 3. Build string
88
+ parts: list[str] = []
89
+ # Sorting keys is optional but helpful for consistent testing/output
90
+ for k, v in final_attrs.items():
91
+ parts.append(f'{k}="{html.escape(v, quote=True)}"')
92
+
93
+ return " ".join(parts)