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.
Files changed (121) hide show
  1. citry-0.1.0/PKG-INFO +27 -0
  2. citry-0.1.0/citry/__init__.py +172 -0
  3. citry-0.1.0/citry/__main__.py +73 -0
  4. citry-0.1.0/citry/assets.py +322 -0
  5. citry-0.1.0/citry/attrs.py +293 -0
  6. citry-0.1.0/citry/autodiscovery.py +185 -0
  7. citry-0.1.0/citry/cache.py +104 -0
  8. citry-0.1.0/citry/citry.py +565 -0
  9. citry-0.1.0/citry/citry_context.py +124 -0
  10. citry-0.1.0/citry/citry_element.py +121 -0
  11. citry-0.1.0/citry/citry_render.py +302 -0
  12. citry-0.1.0/citry/citry_template.py +64 -0
  13. citry-0.1.0/citry/command.py +286 -0
  14. citry-0.1.0/citry/commands/__init__.py +84 -0
  15. citry-0.1.0/citry/commands/create.py +95 -0
  16. citry-0.1.0/citry/commands/ext_list.py +22 -0
  17. citry-0.1.0/citry/commands/list.py +24 -0
  18. citry-0.1.0/citry/commands/watch.py +55 -0
  19. citry-0.1.0/citry/component.py +816 -0
  20. citry-0.1.0/citry/component_registry.py +280 -0
  21. citry-0.1.0/citry/component_render.py +969 -0
  22. citry-0.1.0/citry/components/__init__.py +36 -0
  23. citry-0.1.0/citry/components/dynamic.py +219 -0
  24. citry-0.1.0/citry/components/error_fallback.py +100 -0
  25. citry-0.1.0/citry/components/js_css.py +83 -0
  26. citry-0.1.0/citry/components/provide.py +60 -0
  27. citry-0.1.0/citry/constants.py +3 -0
  28. citry-0.1.0/citry/constness.py +833 -0
  29. citry-0.1.0/citry/contrib/__init__.py +18 -0
  30. citry-0.1.0/citry/contrib/asgi.py +132 -0
  31. citry-0.1.0/citry/contrib/caches.py +86 -0
  32. citry-0.1.0/citry/contrib/django.py +177 -0
  33. citry-0.1.0/citry/contrib/fastapi.py +36 -0
  34. citry-0.1.0/citry/contrib/flask.py +53 -0
  35. citry-0.1.0/citry/contrib/wsgi.py +57 -0
  36. citry-0.1.0/citry/extension.py +1058 -0
  37. citry-0.1.0/citry/extensions/__init__.py +6 -0
  38. citry-0.1.0/citry/extensions/dependencies/__init__.py +465 -0
  39. citry-0.1.0/citry/extensions/dependencies/client/citry.js +238 -0
  40. citry-0.1.0/citry/extensions/dependencies/emission.py +638 -0
  41. citry-0.1.0/citry/extensions/dependencies/routes.py +124 -0
  42. citry-0.1.0/citry/extensions/dependencies/scripts.py +246 -0
  43. citry-0.1.0/citry/extensions/dependencies/types.py +317 -0
  44. citry-0.1.0/citry/nodes/__init__.py +1280 -0
  45. citry-0.1.0/citry/provide.py +101 -0
  46. citry-0.1.0/citry/py.typed +0 -0
  47. citry-0.1.0/citry/reload.py +296 -0
  48. citry-0.1.0/citry/serialize.py +217 -0
  49. citry-0.1.0/citry/settings.py +87 -0
  50. citry-0.1.0/citry/slots.py +306 -0
  51. citry-0.1.0/citry/tag_rules.py +101 -0
  52. citry-0.1.0/citry/util/__init__.py +0 -0
  53. citry-0.1.0/citry/util/css.py +34 -0
  54. citry-0.1.0/citry/util/exception.py +176 -0
  55. citry-0.1.0/citry/util/html.py +73 -0
  56. citry-0.1.0/citry/util/id.py +71 -0
  57. citry-0.1.0/citry/util/misc.py +167 -0
  58. citry-0.1.0/citry/util/routing.py +141 -0
  59. citry-0.1.0/citry.egg-info/PKG-INFO +27 -0
  60. citry-0.1.0/citry.egg-info/SOURCES.txt +119 -0
  61. citry-0.1.0/citry.egg-info/dependency_links.txt +1 -0
  62. citry-0.1.0/citry.egg-info/entry_points.txt +2 -0
  63. citry-0.1.0/citry.egg-info/requires.txt +10 -0
  64. citry-0.1.0/citry.egg-info/top_level.txt +1 -0
  65. citry-0.1.0/pyproject.toml +79 -0
  66. citry-0.1.0/setup.cfg +4 -0
  67. citry-0.1.0/tests/test_assets.py +401 -0
  68. citry-0.1.0/tests/test_attrs.py +247 -0
  69. citry-0.1.0/tests/test_autodiscovery.py +281 -0
  70. citry-0.1.0/tests/test_benchmark_citry.py +3949 -0
  71. citry-0.1.0/tests/test_benchmark_citry_const.py +3958 -0
  72. citry-0.1.0/tests/test_benchmark_citry_small.py +292 -0
  73. citry-0.1.0/tests/test_benchmark_django.py +6443 -0
  74. citry-0.1.0/tests/test_benchmark_django_small.py +358 -0
  75. citry-0.1.0/tests/test_benchmark_djc.py +6067 -0
  76. citry-0.1.0/tests/test_benchmark_djc_small.py +369 -0
  77. citry-0.1.0/tests/test_benchmark_jinja2.py +3588 -0
  78. citry-0.1.0/tests/test_benchmark_jinja2_small.py +371 -0
  79. citry-0.1.0/tests/test_cache.py +98 -0
  80. citry-0.1.0/tests/test_citry.py +84 -0
  81. citry-0.1.0/tests/test_class_id.py +94 -0
  82. citry-0.1.0/tests/test_cli.py +193 -0
  83. citry-0.1.0/tests/test_command.py +155 -0
  84. citry-0.1.0/tests/test_component.py +624 -0
  85. citry-0.1.0/tests/test_component_dynamic.py +541 -0
  86. citry-0.1.0/tests/test_component_id.py +34 -0
  87. citry-0.1.0/tests/test_component_node.py +259 -0
  88. citry-0.1.0/tests/test_component_registry.py +218 -0
  89. citry-0.1.0/tests/test_const.py +1073 -0
  90. citry-0.1.0/tests/test_contrib_django.py +92 -0
  91. citry-0.1.0/tests/test_contrib_fastapi.py +109 -0
  92. citry-0.1.0/tests/test_contrib_hosts.py +200 -0
  93. citry-0.1.0/tests/test_control_flow.py +165 -0
  94. citry-0.1.0/tests/test_deferred_render.py +208 -0
  95. citry-0.1.0/tests/test_deps_emission.py +325 -0
  96. citry-0.1.0/tests/test_deps_fragments.py +208 -0
  97. citry-0.1.0/tests/test_deps_types.py +123 -0
  98. citry-0.1.0/tests/test_deps_urls.py +189 -0
  99. citry-0.1.0/tests/test_deps_vars.py +290 -0
  100. citry-0.1.0/tests/test_element_attrs.py +307 -0
  101. citry-0.1.0/tests/test_error_fallback.py +226 -0
  102. citry-0.1.0/tests/test_error_trace.py +585 -0
  103. citry-0.1.0/tests/test_exception.py +135 -0
  104. citry-0.1.0/tests/test_ext_dependencies.py +320 -0
  105. citry-0.1.0/tests/test_extension.py +492 -0
  106. citry-0.1.0/tests/test_id_generator.py +99 -0
  107. citry-0.1.0/tests/test_js_css_data.py +151 -0
  108. citry-0.1.0/tests/test_markers.py +129 -0
  109. citry-0.1.0/tests/test_nodes.py +113 -0
  110. citry-0.1.0/tests/test_on_render.py +639 -0
  111. citry-0.1.0/tests/test_provide.py +720 -0
  112. citry-0.1.0/tests/test_pydantic.py +183 -0
  113. citry-0.1.0/tests/test_raw.py +47 -0
  114. citry-0.1.0/tests/test_reload.py +316 -0
  115. citry-0.1.0/tests/test_render.py +111 -0
  116. citry-0.1.0/tests/test_sandbox_setting.py +106 -0
  117. citry-0.1.0/tests/test_slot_fills.py +584 -0
  118. citry-0.1.0/tests/test_slot_node.py +479 -0
  119. citry-0.1.0/tests/test_slots.py +239 -0
  120. citry-0.1.0/tests/test_tag_rules.py +455 -0
  121. 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)