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.
casp/layout.py ADDED
@@ -0,0 +1,474 @@
1
+ from __future__ import annotations
2
+ from typing import Callable
3
+ import os
4
+ import json
5
+ import hashlib
6
+ import importlib.util
7
+ import inspect
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+ from jinja2 import Environment, BaseLoader, select_autoescape
11
+ from markupsafe import Markup
12
+
13
+
14
+ class LayoutEngine:
15
+ """
16
+ Single source of truth for:
17
+ - Jinja environment (Caspian delimiters)
18
+ - Template loading (file + relative-to-py)
19
+ - Rendering (string + page + layout)
20
+ - Nested layout system (Next.js style layout discovery and wrapping)
21
+ """
22
+
23
+ def __init__(self) -> None:
24
+ self.env = Environment(
25
+ loader=BaseLoader(),
26
+ autoescape=select_autoescape(["html", "xml"]),
27
+ variable_start_string="[[",
28
+ variable_end_string="]]",
29
+ comment_start_string="[#",
30
+ comment_end_string="#]",
31
+ block_start_string="[%", # NOTE: matches your existing config
32
+ block_end_string="%]",
33
+ )
34
+ self.env.filters["json"] = self._to_safe_json
35
+ self.env.filters["dump"] = self._to_safe_json
36
+
37
+ # Legacy alias expected elsewhere
38
+ self.string_env = self.env
39
+
40
+ # ---------------------------------------------------------------------
41
+ # Jinja helpers
42
+ # ---------------------------------------------------------------------
43
+
44
+ @staticmethod
45
+ def _to_safe_json(value: Any) -> Markup:
46
+ """Safely serialize to JSON for HTML/JS embedding."""
47
+ s = json.dumps(value, ensure_ascii=False, default=str)
48
+ escaped = (
49
+ s.replace("</", "<\\/")
50
+ .replace("<!--", "<\\!--")
51
+ .replace("\u2028", "\\u2028")
52
+ .replace("\u2029", "\\u2029")
53
+ )
54
+ return Markup(escaped)
55
+
56
+ # ---------------------------------------------------------------------
57
+ # Core template loading & rendering (moved from render.py)
58
+ # ---------------------------------------------------------------------
59
+
60
+ def load_template_file(self, file_path: str) -> str:
61
+ """Load template file directly by path."""
62
+ if not os.path.exists(file_path):
63
+ raise FileNotFoundError(f"Template not found: {file_path}")
64
+ with open(file_path, "r", encoding="utf-8") as f:
65
+ return f.read()
66
+
67
+ def load_template(self, py_file: str, template_name: Optional[str] = None) -> str:
68
+ """
69
+ Load HTML template relative to a Python file.
70
+
71
+ Args:
72
+ py_file: Pass __file__ from calling module
73
+ template_name: Template filename (auto-detected if None)
74
+ """
75
+ dir_path = os.path.dirname(os.path.abspath(py_file))
76
+
77
+ if template_name is None:
78
+ template_name = os.path.basename(py_file).replace(".py", ".html")
79
+
80
+ html_path = os.path.join(dir_path, template_name)
81
+ return self.load_template_file(html_path)
82
+
83
+ def render(
84
+ self,
85
+ py_file: str,
86
+ context: Optional[Dict[str, Any]] = None,
87
+ template_name: Optional[str] = None,
88
+ ) -> str:
89
+ """Unified render function for pages and layouts."""
90
+ context = context or {}
91
+ template_str = self.load_template(py_file, template_name)
92
+ template = self.env.from_string(template_str)
93
+ return template.render(**context)
94
+
95
+ def render_page(self, py_file: str, context: Optional[Dict[str, Any]] = None) -> str:
96
+ """Render index.html with context."""
97
+ return self.render(py_file, context, "index.html")
98
+
99
+ def render_layout(self, py_file: str, context: Optional[Dict[str, Any]] = None) -> str:
100
+ """Render layout.html with context."""
101
+ if context is None:
102
+ # Optimization: No context? Just load the raw file (preserves all tags)
103
+ return self.load_template(py_file, "layout.html")
104
+
105
+ # Preserve [[ children ]] tag if caller didn't provide children
106
+ if "children" not in context:
107
+ context["children"] = Markup("[[ children ]]")
108
+
109
+ return self.render(py_file, context, "layout.html")
110
+
111
+ def render_string(self, template_str: str, context: Optional[Dict[str, Any]] = None) -> str:
112
+ """Render a template string directly."""
113
+ context = context or {}
114
+ template = self.env.from_string(template_str)
115
+ return template.render(**context)
116
+
117
+ # ---------------------------------------------------------------------
118
+ # Layout system (existing behavior, now using this engine as source)
119
+ # ---------------------------------------------------------------------
120
+
121
+ @dataclass
122
+ class LayoutInfo:
123
+ dir_path: str
124
+ has_py: bool
125
+ has_html: bool
126
+
127
+ @property
128
+ def layout_id(self) -> str:
129
+ return hashlib.md5(self.dir_path.encode()).hexdigest()[:8]
130
+
131
+ @property
132
+ def py_path(self) -> str:
133
+ return os.path.join(self.dir_path, "layout.py")
134
+
135
+ @property
136
+ def html_path(self) -> str:
137
+ return os.path.join(self.dir_path, "layout.html")
138
+
139
+ @dataclass
140
+ class LayoutResult:
141
+ content: str
142
+ props: Dict[str, Any] = field(default_factory=dict)
143
+
144
+ def discover_layouts(self, route_dir: str) -> List["LayoutEngine.LayoutInfo"]:
145
+ """Find all layouts from root to route (Next.js style)."""
146
+ route_dir = route_dir.replace("\\", "/")
147
+ rel_path = os.path.relpath(route_dir, "src/app").replace("\\", "/")
148
+
149
+ layouts: List[LayoutEngine.LayoutInfo] = []
150
+
151
+ def check_dir(dir_path: str) -> Optional[LayoutEngine.LayoutInfo]:
152
+ dir_path = dir_path.replace("\\", "/")
153
+ html_exists = os.path.exists(os.path.join(dir_path, "layout.html"))
154
+ py_exists = os.path.exists(os.path.join(dir_path, "layout.py"))
155
+
156
+ if html_exists or py_exists:
157
+ return LayoutEngine.LayoutInfo(
158
+ dir_path=dir_path, has_py=py_exists, has_html=html_exists
159
+ )
160
+ return None
161
+
162
+ # Root layout
163
+ root = check_dir("src/app")
164
+ if root:
165
+ layouts.append(root)
166
+
167
+ # Path segments
168
+ current = "src/app"
169
+ for segment in rel_path.split("/"):
170
+ if segment in (".", "src", "app", ""):
171
+ continue
172
+ current = os.path.join(current, segment).replace("\\", "/")
173
+ found = check_dir(current)
174
+ if found:
175
+ layouts.append(found)
176
+
177
+ return layouts
178
+
179
+ def load_layout_module(self, py_path: str):
180
+ """Dynamically load layout.py module."""
181
+ if not os.path.exists(py_path):
182
+ return None
183
+
184
+ module_name = f"layout_{hashlib.md5(py_path.encode()).hexdigest()}"
185
+ spec = importlib.util.spec_from_file_location(module_name, py_path)
186
+
187
+ if spec and spec.loader:
188
+ module = importlib.util.module_from_spec(spec)
189
+ spec.loader.exec_module(module)
190
+ return module
191
+ return None
192
+
193
+ def resolve_layout(self, layout: "LayoutEngine.LayoutInfo", context: Dict[str, Any]) -> "LayoutEngine.LayoutResult":
194
+ """
195
+ Resolve layout content:
196
+ 1. layout.py exists → execute it (MUST have layout() function)
197
+ 2. layout.html only → load it directly
198
+ 3. Neither → passthrough
199
+ """
200
+ passthrough = "[[ children | safe ]]"
201
+
202
+ # Priority 1: layout.py
203
+ if layout.has_py:
204
+ module = self.load_layout_module(layout.py_path)
205
+
206
+ if module and not hasattr(module, "layout"):
207
+ raise AttributeError(
208
+ f"The file '{layout.py_path}' is missing the required 'def layout():' function."
209
+ )
210
+
211
+ if module:
212
+ # Auto-extract module-level variables (Single Source of Truth)
213
+ module_props: Dict[str, Any] = {}
214
+
215
+ if hasattr(module, "title"):
216
+ module_props["title"] = getattr(module, "title")
217
+ if hasattr(module, "description"):
218
+ module_props["description"] = getattr(
219
+ module, "description")
220
+
221
+ if hasattr(module, "metadata") and isinstance(module.metadata, dict):
222
+ module_props.update(module.metadata)
223
+
224
+ sig = inspect.signature(module.layout)
225
+ result = module.layout(
226
+ context) if sig.parameters else module.layout()
227
+
228
+ if result is None:
229
+ if layout.has_html:
230
+ return LayoutEngine.LayoutResult(self.load_template_file(layout.html_path), module_props)
231
+ return LayoutEngine.LayoutResult(passthrough, module_props)
232
+
233
+ # Case A: User returns string (HTML)
234
+ if isinstance(result, str):
235
+ return LayoutEngine.LayoutResult(result, module_props)
236
+
237
+ # Case B: User returns tuple (HTML, props)
238
+ if isinstance(result, tuple) and len(result) == 2:
239
+ combined_props = {**module_props, **(result[1] or {})}
240
+ return LayoutEngine.LayoutResult(result[0], combined_props)
241
+
242
+ # Case C: User returns dict (Context for html file)
243
+ if isinstance(result, dict):
244
+ content = self.load_template_file(
245
+ layout.html_path) if layout.has_html else passthrough
246
+ return LayoutEngine.LayoutResult(content, {**module_props, **result})
247
+
248
+ if layout.has_html:
249
+ return LayoutEngine.LayoutResult(self.load_template_file(layout.html_path))
250
+ return LayoutEngine.LayoutResult(passthrough)
251
+
252
+ # Priority 2: layout.html only
253
+ if layout.has_html:
254
+ return LayoutEngine.LayoutResult(self.load_template_file(layout.html_path))
255
+
256
+ return LayoutEngine.LayoutResult(passthrough)
257
+
258
+ def load_layout(self, py_or_html_path: str) -> str:
259
+ """Load layout or page HTML file."""
260
+ if py_or_html_path.endswith(".html"):
261
+ return self.load_template_file(py_or_html_path)
262
+
263
+ dir_path = os.path.dirname(os.path.abspath(py_or_html_path))
264
+ base_name = os.path.splitext(os.path.basename(py_or_html_path))[0]
265
+ html_path = os.path.join(dir_path, f"{base_name}.html")
266
+
267
+ return self.load_template_file(html_path)
268
+
269
+ def get_loading_files(self) -> str:
270
+ """Get loading indicator HTML."""
271
+ try:
272
+ from .loading import get_loading_files as _get_loading
273
+ return _get_loading()
274
+ except ImportError:
275
+ return ""
276
+
277
+ def _inject_meta(self, html: str, layout_id: str) -> str:
278
+ """Inject root layout meta tag."""
279
+ meta = f'<meta name="pp-root-layout" content="{layout_id}">'
280
+ if "<head>" in html:
281
+ return html.replace("<head>", f"<head>\n {meta}")
282
+ return html
283
+
284
+ def render_with_nested_layouts(
285
+ self,
286
+ children: str,
287
+ route_dir: str,
288
+ title: Optional[str] = None,
289
+ description: Optional[str] = None,
290
+ context_data: Optional[Dict[str, Any]] = None,
291
+ transform_fn=None,
292
+ component_compiler: Optional[Callable] = None,
293
+ ) -> Tuple[str, str]:
294
+ """
295
+ Render page wrapped in nested layouts with strict precedence:
296
+ Page > Layout > Default
297
+ """
298
+ context = context_data or {}
299
+
300
+ layouts = self.discover_layouts(route_dir)
301
+ root_id = layouts[0].layout_id if layouts else "default"
302
+
303
+ if not layouts:
304
+ html = children + self.get_loading_files()
305
+ if transform_fn:
306
+ html = transform_fn(html)
307
+ return html, root_id
308
+
309
+ current_html = children
310
+ accumulated_props: Dict[str, Any] = {}
311
+
312
+ # Render leaf → root
313
+ for i, layout in enumerate(reversed(layouts)):
314
+ is_root = (i == len(layouts) - 1)
315
+
316
+ result = self.resolve_layout(layout, context)
317
+ layer_content = result.content
318
+
319
+ if component_compiler:
320
+ layer_content = component_compiler(
321
+ layer_content, base_dir=layout.dir_path)
322
+
323
+ # Child layouts override parent layouts
324
+ accumulated_props = {**result.props, **accumulated_props}
325
+
326
+ # Page > Layout > Default
327
+ effective_title = title if title is not None else accumulated_props.get(
328
+ "title", "App")
329
+ effective_desc = description if description is not None else accumulated_props.get(
330
+ "description", "")
331
+
332
+ layer_context = {
333
+ **accumulated_props,
334
+ "title": effective_title,
335
+ "description": effective_desc,
336
+ }
337
+
338
+ if is_root:
339
+ current_html += self.get_loading_files()
340
+ layer_content = self._inject_meta(layer_content, root_id)
341
+
342
+ template = self.env.from_string(layer_content)
343
+ current_html = template.render(
344
+ children=Markup(current_html),
345
+ layout=layer_context,
346
+ **context,
347
+ )
348
+
349
+ if transform_fn:
350
+ current_html = transform_fn(current_html)
351
+
352
+ return current_html, root_id
353
+
354
+
355
+ # -----------------------------------------------------------------------------
356
+ # Singleton + module-level backward-compatible API
357
+ # -----------------------------------------------------------------------------
358
+
359
+ _engine = LayoutEngine()
360
+
361
+ # Jinja env exports
362
+ env = _engine.env
363
+ string_env = _engine.string_env
364
+
365
+ # Render exports (moved here from render.py)
366
+
367
+
368
+ def load_template_file(file_path: str) -> str:
369
+ return _engine.load_template_file(file_path)
370
+
371
+
372
+ def load_template(py_file: str, template_name: Optional[str] = None) -> str:
373
+ return _engine.load_template(py_file, template_name)
374
+
375
+
376
+ def render(py_file: str, context: Optional[Dict[str, Any]] = None, template_name: Optional[str] = None) -> str:
377
+ return _engine.render(py_file, context, template_name)
378
+
379
+
380
+ def render_page(py_file: str, context: Optional[Dict[str, Any]] = None) -> str:
381
+ return _engine.render_page(py_file, context)
382
+
383
+
384
+ def render_layout(py_file: str, context: Optional[Dict[str, Any]] = None) -> str:
385
+ return _engine.render_layout(py_file, context)
386
+
387
+
388
+ def render_string(template_str: str, context: Optional[Dict[str, Any]] = None) -> str:
389
+ return _engine.render_string(template_str, context)
390
+
391
+
392
+ # Layout system exports
393
+ LayoutInfo = LayoutEngine.LayoutInfo
394
+ LayoutResult = LayoutEngine.LayoutResult
395
+
396
+
397
+ def discover_layouts(route_dir: str) -> List[LayoutInfo]:
398
+ return _engine.discover_layouts(route_dir)
399
+
400
+
401
+ def load_layout_module(py_path: str):
402
+ return _engine.load_layout_module(py_path)
403
+
404
+
405
+ def resolve_layout(layout: LayoutInfo, context: Dict[str, Any]) -> LayoutResult:
406
+ return _engine.resolve_layout(layout, context)
407
+
408
+
409
+ def get_loading_files() -> str:
410
+ return _engine.get_loading_files()
411
+
412
+
413
+ def render_with_nested_layouts(
414
+ children: str,
415
+ route_dir: str,
416
+ title: Optional[str] = None,
417
+ description: Optional[str] = None,
418
+ context_data: Optional[Dict[str, Any]] = None,
419
+ transform_fn=None,
420
+ component_compiler: Optional[Callable] = None,
421
+ ) -> Tuple[str, str]:
422
+ return _engine.render_with_nested_layouts(
423
+ children=children,
424
+ route_dir=route_dir,
425
+ title=title,
426
+ description=description,
427
+ context_data=context_data,
428
+ transform_fn=transform_fn,
429
+ component_compiler=component_compiler,
430
+ )
431
+
432
+
433
+ # Back-compat alias (your old __all__ referenced this name)
434
+ render_with_layouts = render_with_nested_layouts
435
+
436
+
437
+ def load_layout(py_or_html_path: str) -> str:
438
+ return _engine.load_layout(py_or_html_path)
439
+
440
+
441
+ def load_page(py_file_path):
442
+ """Load HTML file next to the Python file"""
443
+ dir_path = os.path.dirname(py_file_path)
444
+ html_path = os.path.join(dir_path, 'index.html')
445
+ with open(html_path, 'r', encoding='utf-8') as f:
446
+ return f.read()
447
+
448
+
449
+ __all__ = [
450
+ # Engine (optional export)
451
+ "LayoutEngine",
452
+
453
+ # Jinja env
454
+ "env",
455
+ "string_env",
456
+
457
+ # Render (single truth)
458
+ "load_template_file",
459
+ "load_template",
460
+ "render",
461
+ "render_page",
462
+ "render_layout",
463
+ "render_string",
464
+
465
+ # Layout system
466
+ "LayoutInfo",
467
+ "LayoutResult",
468
+ "discover_layouts",
469
+ "resolve_layout",
470
+ "load_layout_module",
471
+ "render_with_nested_layouts",
472
+ "render_with_layouts",
473
+ "load_layout",
474
+ ]
casp/loading.py ADDED
@@ -0,0 +1,25 @@
1
+ from .caspian_config import get_files_index
2
+ import os
3
+ import json
4
+
5
+ ROUTE_FILES_PATH = './settings/files-list.json'
6
+
7
+
8
+ def get_route_files():
9
+ if os.path.exists(ROUTE_FILES_PATH):
10
+ with open(ROUTE_FILES_PATH, 'r', encoding='utf-8') as f:
11
+ return json.load(f)
12
+ return []
13
+
14
+
15
+ def get_loading_files():
16
+ idx = get_files_index()
17
+ loading_content = ''
18
+
19
+ for loading in idx.loadings:
20
+ if os.path.exists(loading.file.lstrip('./')):
21
+ with open(loading.file.lstrip('./'), 'r', encoding='utf-8') as f:
22
+ content = f.read()
23
+ loading_content += f'<div pp-loading-url="{loading.url_scope}">{content}</div>'
24
+
25
+ return f'<div style="display: none;" id="loading-file-1B87E">{loading_content}</div>' if loading_content else ''