citry 0.1.0__tar.gz
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.
- citry-0.1.0/PKG-INFO +27 -0
- citry-0.1.0/citry/__init__.py +172 -0
- citry-0.1.0/citry/__main__.py +73 -0
- citry-0.1.0/citry/assets.py +322 -0
- citry-0.1.0/citry/attrs.py +293 -0
- citry-0.1.0/citry/autodiscovery.py +185 -0
- citry-0.1.0/citry/cache.py +104 -0
- citry-0.1.0/citry/citry.py +565 -0
- citry-0.1.0/citry/citry_context.py +124 -0
- citry-0.1.0/citry/citry_element.py +121 -0
- citry-0.1.0/citry/citry_render.py +302 -0
- citry-0.1.0/citry/citry_template.py +64 -0
- citry-0.1.0/citry/command.py +286 -0
- citry-0.1.0/citry/commands/__init__.py +84 -0
- citry-0.1.0/citry/commands/create.py +95 -0
- citry-0.1.0/citry/commands/ext_list.py +22 -0
- citry-0.1.0/citry/commands/list.py +24 -0
- citry-0.1.0/citry/commands/watch.py +55 -0
- citry-0.1.0/citry/component.py +816 -0
- citry-0.1.0/citry/component_registry.py +280 -0
- citry-0.1.0/citry/component_render.py +969 -0
- citry-0.1.0/citry/components/__init__.py +36 -0
- citry-0.1.0/citry/components/dynamic.py +219 -0
- citry-0.1.0/citry/components/error_fallback.py +100 -0
- citry-0.1.0/citry/components/js_css.py +83 -0
- citry-0.1.0/citry/components/provide.py +60 -0
- citry-0.1.0/citry/constants.py +3 -0
- citry-0.1.0/citry/constness.py +833 -0
- citry-0.1.0/citry/contrib/__init__.py +18 -0
- citry-0.1.0/citry/contrib/asgi.py +132 -0
- citry-0.1.0/citry/contrib/caches.py +86 -0
- citry-0.1.0/citry/contrib/django.py +177 -0
- citry-0.1.0/citry/contrib/fastapi.py +36 -0
- citry-0.1.0/citry/contrib/flask.py +53 -0
- citry-0.1.0/citry/contrib/wsgi.py +57 -0
- citry-0.1.0/citry/extension.py +1058 -0
- citry-0.1.0/citry/extensions/__init__.py +6 -0
- citry-0.1.0/citry/extensions/dependencies/__init__.py +465 -0
- citry-0.1.0/citry/extensions/dependencies/client/citry.js +238 -0
- citry-0.1.0/citry/extensions/dependencies/emission.py +638 -0
- citry-0.1.0/citry/extensions/dependencies/routes.py +124 -0
- citry-0.1.0/citry/extensions/dependencies/scripts.py +246 -0
- citry-0.1.0/citry/extensions/dependencies/types.py +317 -0
- citry-0.1.0/citry/nodes/__init__.py +1280 -0
- citry-0.1.0/citry/provide.py +101 -0
- citry-0.1.0/citry/py.typed +0 -0
- citry-0.1.0/citry/reload.py +296 -0
- citry-0.1.0/citry/serialize.py +217 -0
- citry-0.1.0/citry/settings.py +87 -0
- citry-0.1.0/citry/slots.py +306 -0
- citry-0.1.0/citry/tag_rules.py +101 -0
- citry-0.1.0/citry/util/__init__.py +0 -0
- citry-0.1.0/citry/util/css.py +34 -0
- citry-0.1.0/citry/util/exception.py +176 -0
- citry-0.1.0/citry/util/html.py +73 -0
- citry-0.1.0/citry/util/id.py +71 -0
- citry-0.1.0/citry/util/misc.py +167 -0
- citry-0.1.0/citry/util/routing.py +141 -0
- citry-0.1.0/citry.egg-info/PKG-INFO +27 -0
- citry-0.1.0/citry.egg-info/SOURCES.txt +119 -0
- citry-0.1.0/citry.egg-info/dependency_links.txt +1 -0
- citry-0.1.0/citry.egg-info/entry_points.txt +2 -0
- citry-0.1.0/citry.egg-info/requires.txt +10 -0
- citry-0.1.0/citry.egg-info/top_level.txt +1 -0
- citry-0.1.0/pyproject.toml +79 -0
- citry-0.1.0/setup.cfg +4 -0
- citry-0.1.0/tests/test_assets.py +401 -0
- citry-0.1.0/tests/test_attrs.py +247 -0
- citry-0.1.0/tests/test_autodiscovery.py +281 -0
- citry-0.1.0/tests/test_benchmark_citry.py +3949 -0
- citry-0.1.0/tests/test_benchmark_citry_const.py +3958 -0
- citry-0.1.0/tests/test_benchmark_citry_small.py +292 -0
- citry-0.1.0/tests/test_benchmark_django.py +6443 -0
- citry-0.1.0/tests/test_benchmark_django_small.py +358 -0
- citry-0.1.0/tests/test_benchmark_djc.py +6067 -0
- citry-0.1.0/tests/test_benchmark_djc_small.py +369 -0
- citry-0.1.0/tests/test_benchmark_jinja2.py +3588 -0
- citry-0.1.0/tests/test_benchmark_jinja2_small.py +371 -0
- citry-0.1.0/tests/test_cache.py +98 -0
- citry-0.1.0/tests/test_citry.py +84 -0
- citry-0.1.0/tests/test_class_id.py +94 -0
- citry-0.1.0/tests/test_cli.py +193 -0
- citry-0.1.0/tests/test_command.py +155 -0
- citry-0.1.0/tests/test_component.py +624 -0
- citry-0.1.0/tests/test_component_dynamic.py +541 -0
- citry-0.1.0/tests/test_component_id.py +34 -0
- citry-0.1.0/tests/test_component_node.py +259 -0
- citry-0.1.0/tests/test_component_registry.py +218 -0
- citry-0.1.0/tests/test_const.py +1073 -0
- citry-0.1.0/tests/test_contrib_django.py +92 -0
- citry-0.1.0/tests/test_contrib_fastapi.py +109 -0
- citry-0.1.0/tests/test_contrib_hosts.py +200 -0
- citry-0.1.0/tests/test_control_flow.py +165 -0
- citry-0.1.0/tests/test_deferred_render.py +208 -0
- citry-0.1.0/tests/test_deps_emission.py +325 -0
- citry-0.1.0/tests/test_deps_fragments.py +208 -0
- citry-0.1.0/tests/test_deps_types.py +123 -0
- citry-0.1.0/tests/test_deps_urls.py +189 -0
- citry-0.1.0/tests/test_deps_vars.py +290 -0
- citry-0.1.0/tests/test_element_attrs.py +307 -0
- citry-0.1.0/tests/test_error_fallback.py +226 -0
- citry-0.1.0/tests/test_error_trace.py +585 -0
- citry-0.1.0/tests/test_exception.py +135 -0
- citry-0.1.0/tests/test_ext_dependencies.py +320 -0
- citry-0.1.0/tests/test_extension.py +492 -0
- citry-0.1.0/tests/test_id_generator.py +99 -0
- citry-0.1.0/tests/test_js_css_data.py +151 -0
- citry-0.1.0/tests/test_markers.py +129 -0
- citry-0.1.0/tests/test_nodes.py +113 -0
- citry-0.1.0/tests/test_on_render.py +639 -0
- citry-0.1.0/tests/test_provide.py +720 -0
- citry-0.1.0/tests/test_pydantic.py +183 -0
- citry-0.1.0/tests/test_raw.py +47 -0
- citry-0.1.0/tests/test_reload.py +316 -0
- citry-0.1.0/tests/test_render.py +111 -0
- citry-0.1.0/tests/test_sandbox_setting.py +106 -0
- citry-0.1.0/tests/test_slot_fills.py +584 -0
- citry-0.1.0/tests/test_slot_node.py +479 -0
- citry-0.1.0/tests/test_slots.py +239 -0
- citry-0.1.0/tests/test_tag_rules.py +455 -0
- citry-0.1.0/tests/test_template_globals.py +439 -0
citry-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: citry
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Framework-agnostic component engine for HTML templating
|
|
5
|
+
Author-email: Juro Oravec <juraj.oravec.josefson@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://citry.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/citry-dev/citry
|
|
9
|
+
Project-URL: Changelog, https://github.com/citry-dev/citry/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/citry-dev/citry/issues
|
|
11
|
+
Keywords: citry,components,frontend,html,web development,templating,template engine
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Requires-Python: <4.0,>=3.10
|
|
20
|
+
Requires-Dist: citry-core>=1.3.0
|
|
21
|
+
Requires-Dist: wrapt>=1.16
|
|
22
|
+
Requires-Dist: markupsafe>=2.1
|
|
23
|
+
Requires-Dist: typing-extensions>=4.4
|
|
24
|
+
Provides-Extra: watcher-watchfiles
|
|
25
|
+
Requires-Dist: watchfiles>=1.0; extra == "watcher-watchfiles"
|
|
26
|
+
Provides-Extra: watcher-watchdog
|
|
27
|
+
Requires-Dist: watchdog>=4.0; extra == "watcher-watchdog"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# citry - Framework-agnostic component engine for HTML templating
|
|
2
|
+
#
|
|
3
|
+
# This package provides the rendering runtime for Citry templates:
|
|
4
|
+
# component lifecycle, slots, rendering pipeline, and the node classes
|
|
5
|
+
# that the V3 compiler output instantiates.
|
|
6
|
+
#
|
|
7
|
+
# For the Rust-powered parser and compiler, see citry_core.
|
|
8
|
+
#
|
|
9
|
+
# API stability: the names exported HERE (listed in __all__) are the public
|
|
10
|
+
# API, and only these are promised not to break between releases. Submodules
|
|
11
|
+
# (citry.slots, citry.nodes, ...) may be imported from, but their contents
|
|
12
|
+
# are internal and free to change between releases.
|
|
13
|
+
|
|
14
|
+
from citry.attrs import (
|
|
15
|
+
format_attrs,
|
|
16
|
+
merge_attrs,
|
|
17
|
+
normalize_class,
|
|
18
|
+
normalize_style,
|
|
19
|
+
parse_string_style,
|
|
20
|
+
)
|
|
21
|
+
from citry.cache import CitryCache, InMemoryCache
|
|
22
|
+
from citry.citry import (
|
|
23
|
+
Citry,
|
|
24
|
+
citry,
|
|
25
|
+
)
|
|
26
|
+
from citry.citry_context import CitryContext
|
|
27
|
+
from citry.citry_element import CitryElement
|
|
28
|
+
from citry.citry_render import (
|
|
29
|
+
CitryRender,
|
|
30
|
+
DepsPosition,
|
|
31
|
+
DepsStrategy,
|
|
32
|
+
OnRenderGenerator,
|
|
33
|
+
Placeholder,
|
|
34
|
+
RenderReplacement,
|
|
35
|
+
)
|
|
36
|
+
from citry.citry_template import CitryTemplate
|
|
37
|
+
from citry.command import CommandArg, CommandArgGroup, CommandSubcommand
|
|
38
|
+
from citry.component import Component
|
|
39
|
+
from citry.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered
|
|
40
|
+
from citry.constness import Const
|
|
41
|
+
from citry.extension import (
|
|
42
|
+
Extension,
|
|
43
|
+
ExtensionCommand,
|
|
44
|
+
ExtensionConfig,
|
|
45
|
+
ExtensionManager,
|
|
46
|
+
OnAttrsResolvedContext,
|
|
47
|
+
OnComponentClassCreatedContext,
|
|
48
|
+
OnComponentClassDeletedContext,
|
|
49
|
+
OnComponentDataContext,
|
|
50
|
+
OnComponentInputContext,
|
|
51
|
+
OnComponentRegisteredContext,
|
|
52
|
+
OnComponentRenderedContext,
|
|
53
|
+
OnComponentUnregisteredContext,
|
|
54
|
+
OnCssLoadedContext,
|
|
55
|
+
OnExtensionCreatedContext,
|
|
56
|
+
OnFilesResetContext,
|
|
57
|
+
OnJsLoadedContext,
|
|
58
|
+
OnRenderContextMergeContext,
|
|
59
|
+
OnSerializeContext,
|
|
60
|
+
OnSlotRenderedContext,
|
|
61
|
+
OnTemplateCompiledContext,
|
|
62
|
+
OnTemplateLoadedContext,
|
|
63
|
+
)
|
|
64
|
+
from citry.extensions.dependencies import (
|
|
65
|
+
CitryDependencies,
|
|
66
|
+
Dependency,
|
|
67
|
+
DependencyRecord,
|
|
68
|
+
OnDependenciesContext,
|
|
69
|
+
Script,
|
|
70
|
+
Style,
|
|
71
|
+
)
|
|
72
|
+
from citry.nodes import (
|
|
73
|
+
ComponentNode,
|
|
74
|
+
ElementAttrsNode,
|
|
75
|
+
ExprHtmlAttr,
|
|
76
|
+
ExprNode,
|
|
77
|
+
FillNode,
|
|
78
|
+
ForNode,
|
|
79
|
+
HtmlAttr,
|
|
80
|
+
IfNode,
|
|
81
|
+
Node,
|
|
82
|
+
SlotNode,
|
|
83
|
+
StaticHtmlAttr,
|
|
84
|
+
TemplateHtmlAttr,
|
|
85
|
+
TemplateNode,
|
|
86
|
+
)
|
|
87
|
+
from citry.settings import CitrySettings
|
|
88
|
+
from citry.slots import (
|
|
89
|
+
Slot,
|
|
90
|
+
SlotContext,
|
|
91
|
+
SlotFunc,
|
|
92
|
+
SlotInput,
|
|
93
|
+
SlotResult,
|
|
94
|
+
)
|
|
95
|
+
from citry.util.routing import RouteResponse, URLRoute
|
|
96
|
+
|
|
97
|
+
__all__ = [
|
|
98
|
+
"AlreadyRegistered",
|
|
99
|
+
"Citry",
|
|
100
|
+
"CitryCache",
|
|
101
|
+
"CitryContext",
|
|
102
|
+
"CitryDependencies",
|
|
103
|
+
"CitryElement",
|
|
104
|
+
"CitryRender",
|
|
105
|
+
"CitrySettings",
|
|
106
|
+
"CitryTemplate",
|
|
107
|
+
"CommandArg",
|
|
108
|
+
"CommandArgGroup",
|
|
109
|
+
"CommandSubcommand",
|
|
110
|
+
"Component",
|
|
111
|
+
"ComponentNode",
|
|
112
|
+
"ComponentRegistry",
|
|
113
|
+
"Const",
|
|
114
|
+
"Dependency",
|
|
115
|
+
"DependencyRecord",
|
|
116
|
+
"DepsPosition",
|
|
117
|
+
"DepsStrategy",
|
|
118
|
+
"ElementAttrsNode",
|
|
119
|
+
"ExprHtmlAttr",
|
|
120
|
+
"ExprNode",
|
|
121
|
+
"Extension",
|
|
122
|
+
"ExtensionCommand",
|
|
123
|
+
"ExtensionConfig",
|
|
124
|
+
"ExtensionManager",
|
|
125
|
+
"FillNode",
|
|
126
|
+
"ForNode",
|
|
127
|
+
"HtmlAttr",
|
|
128
|
+
"IfNode",
|
|
129
|
+
"InMemoryCache",
|
|
130
|
+
"Node",
|
|
131
|
+
"NotRegistered",
|
|
132
|
+
"OnAttrsResolvedContext",
|
|
133
|
+
"OnComponentClassCreatedContext",
|
|
134
|
+
"OnComponentClassDeletedContext",
|
|
135
|
+
"OnComponentDataContext",
|
|
136
|
+
"OnComponentInputContext",
|
|
137
|
+
"OnComponentRegisteredContext",
|
|
138
|
+
"OnComponentRenderedContext",
|
|
139
|
+
"OnComponentUnregisteredContext",
|
|
140
|
+
"OnCssLoadedContext",
|
|
141
|
+
"OnDependenciesContext",
|
|
142
|
+
"OnExtensionCreatedContext",
|
|
143
|
+
"OnFilesResetContext",
|
|
144
|
+
"OnJsLoadedContext",
|
|
145
|
+
"OnRenderContextMergeContext",
|
|
146
|
+
"OnRenderGenerator",
|
|
147
|
+
"OnSerializeContext",
|
|
148
|
+
"OnSlotRenderedContext",
|
|
149
|
+
"OnTemplateCompiledContext",
|
|
150
|
+
"OnTemplateLoadedContext",
|
|
151
|
+
"Placeholder",
|
|
152
|
+
"RenderReplacement",
|
|
153
|
+
"RouteResponse",
|
|
154
|
+
"Script",
|
|
155
|
+
"Slot",
|
|
156
|
+
"SlotContext",
|
|
157
|
+
"SlotFunc",
|
|
158
|
+
"SlotInput",
|
|
159
|
+
"SlotNode",
|
|
160
|
+
"SlotResult",
|
|
161
|
+
"StaticHtmlAttr",
|
|
162
|
+
"Style",
|
|
163
|
+
"TemplateHtmlAttr",
|
|
164
|
+
"TemplateNode",
|
|
165
|
+
"URLRoute",
|
|
166
|
+
"citry",
|
|
167
|
+
"format_attrs",
|
|
168
|
+
"merge_attrs",
|
|
169
|
+
"normalize_class",
|
|
170
|
+
"normalize_style",
|
|
171
|
+
"parse_string_style",
|
|
172
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The ``citry`` command-line entry point.
|
|
3
|
+
|
|
4
|
+
Resolves which engine to run against, then builds and runs the command tree.
|
|
5
|
+
With no ``--app`` option the default global engine is used; a leading ``--app
|
|
6
|
+
module:attribute`` points the CLI at an explicitly constructed ``Citry`` (the
|
|
7
|
+
same ``module:object`` convention web-server entry points use). Registered as
|
|
8
|
+
the ``citry`` console script in ``pyproject.toml``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from importlib import import_module
|
|
15
|
+
from typing import TYPE_CHECKING, NoReturn
|
|
16
|
+
|
|
17
|
+
from citry.citry import Citry
|
|
18
|
+
from citry.citry import citry as default_engine
|
|
19
|
+
from citry.command import run
|
|
20
|
+
from citry.commands import build_cli
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fail(message: str) -> NoReturn:
|
|
27
|
+
"""Print a usage error to stderr and exit with code 2 (argparse's convention)."""
|
|
28
|
+
sys.stderr.write(f"citry: error: {message}\n")
|
|
29
|
+
raise SystemExit(2)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _import_engine(spec: str) -> Citry:
|
|
33
|
+
"""Resolve a ``module:attribute`` spec to the ``Citry`` engine it names."""
|
|
34
|
+
module_path, separator, attribute = spec.partition(":")
|
|
35
|
+
if not separator or not attribute:
|
|
36
|
+
_fail("--app must be 'module:attribute', e.g. 'myproject.app:engine'")
|
|
37
|
+
try:
|
|
38
|
+
engine = getattr(import_module(module_path), attribute)
|
|
39
|
+
except (ImportError, AttributeError) as exc:
|
|
40
|
+
_fail(f"could not import --app target {spec!r}: {exc}")
|
|
41
|
+
if not isinstance(engine, Citry):
|
|
42
|
+
_fail(f"--app target {spec!r} is a {type(engine).__name__}, not a Citry instance")
|
|
43
|
+
return engine
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_engine(argv: list[str]) -> tuple[Citry, list[str]]:
|
|
47
|
+
"""
|
|
48
|
+
Pick the engine to run against, consuming a leading ``--app module:attribute``.
|
|
49
|
+
|
|
50
|
+
``--app`` is recognized only as the first argument (as ``--app VALUE`` or
|
|
51
|
+
``--app=VALUE``), so it cannot be mistaken for an option of a nested command
|
|
52
|
+
further along the line. With no leading ``--app``, the default global engine
|
|
53
|
+
is used and the arguments are passed through unchanged.
|
|
54
|
+
"""
|
|
55
|
+
if argv and argv[0] == "--app":
|
|
56
|
+
if len(argv) < 2:
|
|
57
|
+
_fail("--app requires a value, e.g. --app myproject.app:engine")
|
|
58
|
+
return _import_engine(argv[1]), argv[2:]
|
|
59
|
+
if argv and argv[0].startswith("--app="):
|
|
60
|
+
return _import_engine(argv[0][len("--app=") :]), argv[1:]
|
|
61
|
+
return default_engine, argv
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
65
|
+
"""Run the citry CLI. Returns a process exit code."""
|
|
66
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
67
|
+
engine, rest = _resolve_engine(args)
|
|
68
|
+
root = build_cli(engine)
|
|
69
|
+
return run(root, rest, citry=engine)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
sys.exit(main())
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Asset loading: resolving and reading a component's template, JS, and CSS.
|
|
3
|
+
|
|
4
|
+
A component declares its primary assets as class fields, in three inline/file
|
|
5
|
+
pairs (``template``/``template_file``, ``js``/``js_file``, ``css``/``css_file``).
|
|
6
|
+
The fields are declarations and are never rewritten; the loaded values are read
|
|
7
|
+
through classmethods on ``Component`` (``Card.get_template()``,
|
|
8
|
+
``Card.get_js()``, ``Card.get_css()``), which delegate to this module's
|
|
9
|
+
``load_template`` / ``load_js`` / ``load_css``.
|
|
10
|
+
|
|
11
|
+
Resolution is lazy and cached once per class (in the class's own ``__dict__``).
|
|
12
|
+
File paths resolve relative to the directory of the component's own ``.py``
|
|
13
|
+
file first, then relative to each entry of ``Citry.settings.dirs``. Content
|
|
14
|
+
loading fires the ``on_template_loaded`` / ``on_js_loaded`` / ``on_css_loaded``
|
|
15
|
+
extension hooks, and every resolved file is registered in the Citry instance's
|
|
16
|
+
file-to-component index (the hot-reload seam).
|
|
17
|
+
|
|
18
|
+
Secondary assets (the nested ``Dependencies`` class) are owned by the built-in
|
|
19
|
+
``dependencies`` extension (``citry/extensions/dependencies.py``), which reuses
|
|
20
|
+
this module's path-resolution helpers.
|
|
21
|
+
|
|
22
|
+
The full design, including what diverges from django-components and why, is in
|
|
23
|
+
``docs/design/asset_loading.md``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
30
|
+
|
|
31
|
+
from citry.citry_template import CitryTemplate
|
|
32
|
+
from citry.util.misc import get_module_info
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Iterable, Mapping
|
|
36
|
+
|
|
37
|
+
from citry.component import Component
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class HasHtml(Protocol):
|
|
42
|
+
"""An object carrying a pre-rendered HTML tag (e.g. ``markupsafe.Markup``)."""
|
|
43
|
+
|
|
44
|
+
def __html__(self) -> str: ... # pragma: no cover - protocol
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def dedupe(items: Iterable[Any]) -> tuple[Any, ...]:
|
|
48
|
+
"""De-duplicate preserving first-seen order (never set-iteration order)."""
|
|
49
|
+
return tuple(dict.fromkeys(items))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
################################################
|
|
53
|
+
# DECLARATION LOOKUP (the inline/file pairs)
|
|
54
|
+
################################################
|
|
55
|
+
|
|
56
|
+
ASSET_PAIRS: tuple[tuple[str, str], ...] = (
|
|
57
|
+
("template", "template_file"),
|
|
58
|
+
("js", "js_file"),
|
|
59
|
+
("css", "css_file"),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Class-level cache attributes. Presence in the class's own __dict__ means
|
|
63
|
+
# "already resolved"; the cached value may be None (a valid result). The
|
|
64
|
+
# template cache holds the CitryTemplate, which also carries the compiled
|
|
65
|
+
# form once first rendered (one object, one invalidation).
|
|
66
|
+
_TEMPLATE_CACHE = "_citry_template"
|
|
67
|
+
_JS_CACHE = "_resolved_js"
|
|
68
|
+
_CSS_CACHE = "_resolved_css"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def validate_asset_pairs(class_name: str, attrs: Mapping[str, Any]) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Reject a class that sets both members of an inline/file pair.
|
|
74
|
+
|
|
75
|
+
Called by ``ComponentMeta.__new__`` with the class's own attributes, so the
|
|
76
|
+
error surfaces at class definition. Both members set to ``None`` is fine
|
|
77
|
+
(it means "explicitly no asset").
|
|
78
|
+
"""
|
|
79
|
+
for inline_attr, file_attr in ASSET_PAIRS:
|
|
80
|
+
if attrs.get(inline_attr) is not None and attrs.get(file_attr) is not None:
|
|
81
|
+
msg = (
|
|
82
|
+
f"Component {class_name} received non-empty values for both {inline_attr!r}"
|
|
83
|
+
f" and {file_attr!r}. Only one of the two may be set."
|
|
84
|
+
)
|
|
85
|
+
raise ValueError(msg)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _find_pair_declaration(
|
|
89
|
+
comp_cls: type[Component],
|
|
90
|
+
inline_attr: str,
|
|
91
|
+
file_attr: str,
|
|
92
|
+
) -> tuple[type, Any, Any]:
|
|
93
|
+
"""
|
|
94
|
+
Find the class in the MRO that owns this asset pair.
|
|
95
|
+
|
|
96
|
+
The pair is one inheritance unit: the first class whose own ``__dict__``
|
|
97
|
+
declares *either* member wins for both, so a child that sets only
|
|
98
|
+
``template_file`` fully shadows a parent's inline ``template``. An explicit
|
|
99
|
+
``None`` declaration stops the walk too ("no asset"); a class that does not
|
|
100
|
+
mention the pair is skipped. The base ``Component`` class declares both
|
|
101
|
+
members as ``None``, terminating the walk with the empty case.
|
|
102
|
+
|
|
103
|
+
Returns ``(owner, inline_value, file_value)``.
|
|
104
|
+
"""
|
|
105
|
+
for klass in comp_cls.__mro__:
|
|
106
|
+
attrs = klass.__dict__
|
|
107
|
+
if inline_attr in attrs or file_attr in attrs:
|
|
108
|
+
inline_val = attrs.get(inline_attr)
|
|
109
|
+
file_val = attrs.get(file_attr)
|
|
110
|
+
if inline_val is not None and file_val is not None:
|
|
111
|
+
msg = (
|
|
112
|
+
f"Component {klass.__name__} has non-empty values for both {inline_attr!r}"
|
|
113
|
+
f" and {file_attr!r}. Only one of the two may be set."
|
|
114
|
+
)
|
|
115
|
+
raise ValueError(msg)
|
|
116
|
+
return klass, inline_val, file_val
|
|
117
|
+
return comp_cls, None, None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
################################################
|
|
121
|
+
# FILE RESOLUTION
|
|
122
|
+
################################################
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def module_dir(comp_cls: type[Component]) -> Path | None:
|
|
126
|
+
"""The directory of the ``.py`` file where the class is defined, if any."""
|
|
127
|
+
_module, _module_name, module_file = get_module_info(comp_cls)
|
|
128
|
+
if module_file is None:
|
|
129
|
+
return None
|
|
130
|
+
return Path(module_file).parent
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_asset_file(filepath: str | Path, comp_cls: type[Component]) -> Path:
|
|
134
|
+
"""
|
|
135
|
+
Resolve an asset file path to an absolute ``Path``.
|
|
136
|
+
|
|
137
|
+
Lookup order (docs/design/asset_loading.md section 5.2):
|
|
138
|
+
|
|
139
|
+
1. An absolute path is used as-is (and must exist).
|
|
140
|
+
2. Relative to the directory of the component's ``.py`` file.
|
|
141
|
+
3. Relative to each entry of ``comp_cls.citry.settings.dirs``, in order.
|
|
142
|
+
|
|
143
|
+
Raises ``FileNotFoundError`` naming every location searched.
|
|
144
|
+
"""
|
|
145
|
+
path = Path(filepath)
|
|
146
|
+
searched: list[Path] = []
|
|
147
|
+
|
|
148
|
+
if path.is_absolute():
|
|
149
|
+
if path.exists():
|
|
150
|
+
return path
|
|
151
|
+
searched.append(path)
|
|
152
|
+
else:
|
|
153
|
+
comp_module_dir = module_dir(comp_cls)
|
|
154
|
+
if comp_module_dir is not None:
|
|
155
|
+
candidate = comp_module_dir / path
|
|
156
|
+
if candidate.exists():
|
|
157
|
+
return candidate.resolve()
|
|
158
|
+
searched.append(candidate)
|
|
159
|
+
for base_dir in comp_cls.citry.settings.dirs:
|
|
160
|
+
candidate = base_dir / path
|
|
161
|
+
if candidate.exists():
|
|
162
|
+
return candidate.resolve()
|
|
163
|
+
searched.append(candidate)
|
|
164
|
+
|
|
165
|
+
locations = ", ".join(str(loc) for loc in searched) if searched else "(no searchable locations)"
|
|
166
|
+
msg = (
|
|
167
|
+
f"Could not find file {str(filepath)!r} for component {comp_cls.__name__}."
|
|
168
|
+
f" Searched: {locations}. Set the file next to the component's .py file,"
|
|
169
|
+
f" under one of Citry(dirs=...), or pass an absolute path."
|
|
170
|
+
)
|
|
171
|
+
raise FileNotFoundError(msg)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _load_pair(
|
|
175
|
+
comp_cls: type[Component],
|
|
176
|
+
inline_attr: str,
|
|
177
|
+
file_attr: str,
|
|
178
|
+
) -> tuple[str | None, Path | None]:
|
|
179
|
+
"""
|
|
180
|
+
Resolve an asset pair to ``(content, filepath)``.
|
|
181
|
+
|
|
182
|
+
Inline content is returned as-is with ``filepath=None``. A file declaration
|
|
183
|
+
is resolved (section 5.2 chain), read with explicit utf8 encoding
|
|
184
|
+
(django-components #1074), and registered in the Citry file index for hot
|
|
185
|
+
reload. ``(None, None)`` when the pair declares no asset.
|
|
186
|
+
"""
|
|
187
|
+
_owner, inline_val, file_val = _find_pair_declaration(comp_cls, inline_attr, file_attr)
|
|
188
|
+
|
|
189
|
+
if inline_val is not None:
|
|
190
|
+
return inline_val, None
|
|
191
|
+
|
|
192
|
+
if file_val is not None:
|
|
193
|
+
path = resolve_asset_file(file_val, comp_cls)
|
|
194
|
+
comp_cls.citry._register_component_file(path, comp_cls)
|
|
195
|
+
return path.read_text(encoding="utf8"), path
|
|
196
|
+
|
|
197
|
+
return None, None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
################################################
|
|
201
|
+
# PRIMARY ASSET LOADERS
|
|
202
|
+
################################################
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def load_template(comp_cls: type[Component]) -> CitryTemplate | None:
|
|
206
|
+
"""
|
|
207
|
+
The component's loaded template, or ``None`` for a template-less component.
|
|
208
|
+
|
|
209
|
+
Resolves ``template`` / ``template_file`` once per class (cached on the
|
|
210
|
+
class), fires ``on_template_loaded`` with the content (inline or file), and
|
|
211
|
+
wraps the post-hook source in a ``CitryTemplate`` carrying its origin. The
|
|
212
|
+
render pipeline later fills the struct's compiled form in place; this
|
|
213
|
+
loader never does.
|
|
214
|
+
|
|
215
|
+
Users reach this through ``Card.get_template()``.
|
|
216
|
+
"""
|
|
217
|
+
if _TEMPLATE_CACHE in comp_cls.__dict__:
|
|
218
|
+
return comp_cls.__dict__[_TEMPLATE_CACHE] # type: ignore[no-any-return]
|
|
219
|
+
|
|
220
|
+
content, path = _load_pair(comp_cls, "template", "template_file")
|
|
221
|
+
|
|
222
|
+
result: CitryTemplate | None
|
|
223
|
+
if content is None:
|
|
224
|
+
result = None
|
|
225
|
+
else:
|
|
226
|
+
content = comp_cls.citry.extensions.on_template_loaded(comp_cls, content)
|
|
227
|
+
origin = str(path) if path is not None else _inline_origin(comp_cls)
|
|
228
|
+
result = CitryTemplate(source=content, origin=origin, filepath=path)
|
|
229
|
+
|
|
230
|
+
setattr(comp_cls, _TEMPLATE_CACHE, result)
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def load_js(comp_cls: type[Component]) -> str | None:
|
|
235
|
+
"""
|
|
236
|
+
The component's primary JS content, or ``None``.
|
|
237
|
+
|
|
238
|
+
Resolves ``js`` / ``js_file`` once per class (cached on the class) and
|
|
239
|
+
fires ``on_js_loaded`` with the content (inline or file). Users reach this
|
|
240
|
+
through ``Card.get_js()``.
|
|
241
|
+
"""
|
|
242
|
+
return _load_asset_content(comp_cls, "js", "js_file", _JS_CACHE)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def load_css(comp_cls: type[Component]) -> str | None:
|
|
246
|
+
"""
|
|
247
|
+
The component's primary CSS content, or ``None``.
|
|
248
|
+
|
|
249
|
+
Resolves ``css`` / ``css_file`` once per class (cached on the class) and
|
|
250
|
+
fires ``on_css_loaded`` with the content (inline or file). Users reach this
|
|
251
|
+
through ``Card.get_css()``.
|
|
252
|
+
"""
|
|
253
|
+
return _load_asset_content(comp_cls, "css", "css_file", _CSS_CACHE)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _load_asset_content(
|
|
257
|
+
comp_cls: type[Component],
|
|
258
|
+
inline_attr: str,
|
|
259
|
+
file_attr: str,
|
|
260
|
+
cache_attr: str,
|
|
261
|
+
) -> str | None:
|
|
262
|
+
if cache_attr in comp_cls.__dict__:
|
|
263
|
+
return comp_cls.__dict__[cache_attr] # type: ignore[no-any-return]
|
|
264
|
+
|
|
265
|
+
content, _path = _load_pair(comp_cls, inline_attr, file_attr)
|
|
266
|
+
if content is not None:
|
|
267
|
+
extensions = comp_cls.citry.extensions
|
|
268
|
+
if inline_attr == "js":
|
|
269
|
+
content = extensions.on_js_loaded(comp_cls, content)
|
|
270
|
+
else:
|
|
271
|
+
content = extensions.on_css_loaded(comp_cls, content)
|
|
272
|
+
|
|
273
|
+
setattr(comp_cls, cache_attr, content)
|
|
274
|
+
return content
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _inline_origin(comp_cls: type[Component]) -> str:
|
|
278
|
+
"""Origin string for an inline template: ``<module file>::<ClassName>``."""
|
|
279
|
+
_module, module_name, module_file = get_module_info(comp_cls)
|
|
280
|
+
prefix = module_file or module_name or "<unknown module>"
|
|
281
|
+
return f"{prefix}::{comp_cls.__name__}"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
################################################
|
|
285
|
+
# HOT RELOAD: RESETS
|
|
286
|
+
################################################
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def reset_template(comp_cls: type[Component]) -> None:
|
|
290
|
+
"""
|
|
291
|
+
Clear the class's loaded template so the next render re-reads it.
|
|
292
|
+
|
|
293
|
+
Drops the cached ``CitryTemplate`` (one object carrying the source and the
|
|
294
|
+
compiled form) and the class's cached ``Const`` optimization results
|
|
295
|
+
(template work that was pre-computed for inputs marked constant; see
|
|
296
|
+
citry/constness.py). The next access re-resolves the file, re-fires
|
|
297
|
+
``on_template_loaded``, and re-compiles.
|
|
298
|
+
|
|
299
|
+
Users reach this through ``Card.reset_template()``.
|
|
300
|
+
|
|
301
|
+
Note: a subclass that *inherits* this class's template caches its own copy;
|
|
302
|
+
clear it too (file-driven invalidation via
|
|
303
|
+
``Citry.get_components_for_file`` reaches all of them).
|
|
304
|
+
"""
|
|
305
|
+
if _TEMPLATE_CACHE in comp_cls.__dict__:
|
|
306
|
+
delattr(comp_cls, _TEMPLATE_CACHE)
|
|
307
|
+
comp_cls.citry._evict_component_cache(comp_cls)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def reset_files(comp_cls: type[Component]) -> None:
|
|
311
|
+
"""
|
|
312
|
+
Clear the class's loaded JS/CSS so the next access re-reads them.
|
|
313
|
+
|
|
314
|
+
Fires the ``on_files_reset`` hook so extensions evict their own per-class
|
|
315
|
+
state too: the built-in ``dependencies`` extension drops its merged
|
|
316
|
+
``CitryDependencies`` for this class there. Users reach this through
|
|
317
|
+
``Card.reset_files()``.
|
|
318
|
+
"""
|
|
319
|
+
for attr in (_JS_CACHE, _CSS_CACHE):
|
|
320
|
+
if attr in comp_cls.__dict__:
|
|
321
|
+
delattr(comp_cls, attr)
|
|
322
|
+
comp_cls.citry.extensions.on_files_reset(comp_cls)
|