shrd 0.3.0__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.
shard/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Shard — a Django-native component framework."""
2
+
3
+ from shard.actions import ActionResult
4
+ from shard.component import Component, action, computed, emits
5
+ from shard.props import Prop
6
+ from shard.render import mount, render_component
7
+ from shard.view_data import (
8
+ ViewNode,
9
+ commit_view_tree,
10
+ ensure_node_ids,
11
+ get_slot_nodes,
12
+ render_view_data,
13
+ set_slot_nodes,
14
+ )
15
+ from shard.view_tree import ViewTreeComponent
16
+
17
+ __all__ = [
18
+ "ActionResult",
19
+ "Component",
20
+ "Prop",
21
+ "ViewNode",
22
+ "ViewTreeComponent",
23
+ "action",
24
+ "commit_view_tree",
25
+ "computed",
26
+ "emits",
27
+ "ensure_node_ids",
28
+ "get_slot_nodes",
29
+ "mount",
30
+ "render_component",
31
+ "render_view_data",
32
+ "set_slot_nodes",
33
+ ]
34
+
35
+ __version__ = "0.3.0"
shard/actions.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class ActionResult:
9
+ """Optional return value from an ``@action`` method.
10
+
11
+ Use when an action needs to emit HTMX events or trigger a redirect in
12
+ addition to updating server state.
13
+ """
14
+
15
+ state: dict[str, Any] | None = None
16
+ events: dict[str, Any] = field(default_factory=dict)
17
+ redirect: str | None = None
18
+
19
+ @classmethod
20
+ def with_events(cls, state: dict[str, Any], **events: Any) -> ActionResult:
21
+ return cls(state=state, events=events)
shard/apps.py ADDED
@@ -0,0 +1,12 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class ShardConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "shard"
7
+ verbose_name = "Shard Component Framework"
8
+
9
+ def ready(self) -> None:
10
+ from shard.registry import autodiscover_components
11
+
12
+ autodiscover_components()
shard/component.py ADDED
@@ -0,0 +1,342 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from functools import wraps
5
+ from typing import Any, ClassVar
6
+ from uuid import uuid4
7
+
8
+ from django.template import Context, Template
9
+ from django.template.loader import render_to_string
10
+ from django.utils.safestring import SafeString, mark_safe
11
+
12
+ from shard.actions import ActionResult
13
+ from shard.exceptions import ActionNotFoundError, PropValidationError
14
+ from shard.props import Prop
15
+ from shard.slots import SlotContent
16
+ from shard.state import StateStore
17
+ from shard.styles import component_scope, load_scoped_styles
18
+
19
+ ACTION_MARKER = "__shard_action__"
20
+ COMPUTED_MARKER = "__shard_computed__"
21
+ EMITS_MARKER = "__shard_emits__"
22
+ IGNORED_PAYLOAD_KEYS = frozenset({"csrfmiddlewaretoken", "shard"})
23
+
24
+
25
+ def action(method: Callable) -> Callable:
26
+ """Mark a component method as an HTMX-callable server action."""
27
+
28
+ @wraps(method)
29
+ def wrapper(self, state: dict[str, Any], *args, **kwargs) -> Any:
30
+ return method(self, state, *args, **kwargs)
31
+
32
+ setattr(wrapper, ACTION_MARKER, True)
33
+ if hasattr(method, EMITS_MARKER):
34
+ setattr(wrapper, EMITS_MARKER, getattr(method, EMITS_MARKER))
35
+ return wrapper
36
+
37
+
38
+ def computed(method: Callable) -> Callable:
39
+ """Expose a method's return value as ``computed.<name>`` in templates."""
40
+
41
+ @wraps(method)
42
+ def wrapper(self) -> Any:
43
+ return method(self)
44
+
45
+ setattr(wrapper, COMPUTED_MARKER, True)
46
+ return wrapper
47
+
48
+
49
+ def emits(*event_names: str) -> Callable:
50
+ """Declare HTMX events fired after an action completes."""
51
+
52
+ def decorator(method: Callable) -> Callable:
53
+ setattr(method, EMITS_MARKER, tuple(event_names))
54
+ return method
55
+
56
+ return decorator
57
+
58
+
59
+ class ComponentMeta(type):
60
+ def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict[str, Any]):
61
+ prop_fields: dict[str, Prop] = {}
62
+ computed_fields: dict[str, Callable] = {}
63
+
64
+ for base in reversed(bases):
65
+ prop_fields.update(getattr(base, "_prop_fields", {}))
66
+ computed_fields.update(getattr(base, "_computed_fields", {}))
67
+
68
+ for key, value in list(namespace.items()):
69
+ if isinstance(value, Prop):
70
+ prop_fields[key] = value.bind(key)
71
+ namespace[key] = prop_fields[key]
72
+ elif callable(value) and getattr(value, COMPUTED_MARKER, False):
73
+ computed_fields[key] = value
74
+
75
+ cls = super().__new__(mcs, name, bases, namespace)
76
+ cls._prop_fields = prop_fields
77
+ cls._computed_fields = computed_fields
78
+ cls.component_name = namespace.get("component_name", name)
79
+ return cls
80
+
81
+
82
+ class Component(metaclass=ComponentMeta):
83
+ """Base class for Shard components."""
84
+
85
+ template_name: str = ""
86
+ component_name: str = ""
87
+ scope: str = ""
88
+ stylesheets: list[str] | None = None
89
+ styles: str = ""
90
+ scoped_styles: bool = True
91
+ _prop_fields: ClassVar[dict[str, Prop]] = {}
92
+ _computed_fields: ClassVar[dict[str, Callable]] = {}
93
+
94
+ def __init__(
95
+ self,
96
+ *,
97
+ instance_id: str | None = None,
98
+ props: dict[str, Any] | None = None,
99
+ state: dict[str, Any] | None = None,
100
+ slots: dict[str, str] | None = None,
101
+ ) -> None:
102
+ self.instance_id = instance_id or uuid4().hex
103
+ self._raw_props = props or {}
104
+ self._props = self._resolve_props(self._raw_props)
105
+ self._state = state if state is not None else self.get_initial_state()
106
+ self._slots = SlotContent.from_mapping(slots)
107
+ self._pending_events: dict[str, Any] = {}
108
+
109
+ @classmethod
110
+ def prop_names(cls) -> list[str]:
111
+ return list(cls._prop_fields.keys())
112
+
113
+ @classmethod
114
+ def action_names(cls) -> list[str]:
115
+ return [
116
+ name
117
+ for name in dir(cls)
118
+ if name != "dispatch_action"
119
+ and callable(getattr(cls, name))
120
+ and getattr(getattr(cls, name), ACTION_MARKER, False)
121
+ ]
122
+
123
+ def get_initial_state(self) -> dict[str, Any]:
124
+ return {}
125
+
126
+ def get_client_state(self) -> dict[str, Any]:
127
+ """Optional Alpine.js state. Override to seed ``x-data``."""
128
+
129
+ return {}
130
+
131
+ def before_action(self, action_name: str, payload: dict[str, Any]) -> None:
132
+ """Hook called before an action handler runs."""
133
+
134
+ def after_action(self, action_name: str, state: dict[str, Any]) -> None:
135
+ """Hook called after an action handler runs."""
136
+
137
+ @property
138
+ def shard_scope(self) -> str:
139
+ return component_scope(self.component_name, self.scope)
140
+
141
+ def get_computed_data(self) -> dict[str, Any]:
142
+ return {name: method(self) for name, method in type(self)._computed_fields.items()}
143
+
144
+ def get_context_data(self) -> dict[str, Any]:
145
+ return {
146
+ "component": self,
147
+ "props": self.props,
148
+ "state": self.state,
149
+ "computed": self.get_computed_data(),
150
+ "slots": self._slots.as_dict(),
151
+ "shard_id": self.instance_id,
152
+ "shard_scope": self.shard_scope,
153
+ "shard_actions": self.action_urls(),
154
+ }
155
+
156
+ def render(self, request=None) -> SafeString:
157
+ if not self.template_name:
158
+ raise ValueError(f"{self.__class__.__name__} is missing template_name.")
159
+
160
+ context = self.get_context_data()
161
+ if request is not None:
162
+ context["request"] = request
163
+
164
+ html = render_to_string(self.template_name, context, request=request)
165
+ styles = ""
166
+ if self._should_include_styles(request):
167
+ styles = load_scoped_styles(
168
+ scope=self.shard_scope,
169
+ template_name=self.template_name,
170
+ stylesheets=self.stylesheets,
171
+ inline_styles=self.styles,
172
+ )
173
+
174
+ return mark_safe(f"{styles}{html}")
175
+
176
+ def _should_include_styles(self, request) -> bool:
177
+ if not self.scoped_styles:
178
+ return False
179
+ if request is not None and request.headers.get("HX-Request"):
180
+ return False
181
+ return True
182
+
183
+ def render_child(
184
+ self,
185
+ component: type[Component] | str,
186
+ *,
187
+ props: dict[str, Any] | None = None,
188
+ slots: dict[str, str] | None = None,
189
+ request=None,
190
+ ) -> SafeString:
191
+ """Render a nested child component from Python or templates."""
192
+
193
+ from shard.registry import get_component
194
+ from shard.render import render_component
195
+
196
+ component_cls = get_component(component) if isinstance(component, str) else component
197
+ return render_component(
198
+ component_cls,
199
+ props=props,
200
+ slots=slots,
201
+ request=request,
202
+ persist=True,
203
+ )
204
+
205
+ def dispatch_action(
206
+ self, action_name: str, payload: dict[str, Any] | None = None
207
+ ) -> dict[str, Any]:
208
+ handler = getattr(type(self), action_name, None)
209
+ if handler is None or not getattr(handler, ACTION_MARKER, False):
210
+ raise ActionNotFoundError(
211
+ f"Action '{action_name}' is not defined on {self.__class__.__name__}."
212
+ )
213
+
214
+ clean_payload = {
215
+ key: value for key, value in (payload or {}).items() if key not in IGNORED_PAYLOAD_KEYS
216
+ }
217
+
218
+ self.before_action(action_name, clean_payload)
219
+ raw_result = handler(self, dict(self._state), **clean_payload)
220
+ next_state, events, redirect = self._normalize_action_result(raw_result)
221
+
222
+ declared_events = getattr(handler, EMITS_MARKER, ())
223
+ for event_name in declared_events:
224
+ events.setdefault(event_name, True)
225
+
226
+ self._pending_events = events
227
+ self._pending_redirect = redirect
228
+ self.after_action(action_name, next_state)
229
+ return next_state
230
+
231
+ @property
232
+ def pending_events(self) -> dict[str, Any]:
233
+ return dict(self._pending_events)
234
+
235
+ @property
236
+ def pending_redirect(self) -> str | None:
237
+ return getattr(self, "_pending_redirect", None)
238
+
239
+ def _normalize_action_result(
240
+ self,
241
+ raw_result: Any,
242
+ ) -> tuple[dict[str, Any], dict[str, Any], str | None]:
243
+ if isinstance(raw_result, ActionResult):
244
+ state = raw_result.state if raw_result.state is not None else dict(self._state)
245
+ return state, dict(raw_result.events), raw_result.redirect
246
+
247
+ if raw_result is None:
248
+ return dict(self._state), {}, None
249
+
250
+ if isinstance(raw_result, dict):
251
+ return raw_result, {}, None
252
+
253
+ raise TypeError(
254
+ f"Action handlers must return a state dict or ActionResult, not {type(raw_result)!r}."
255
+ )
256
+
257
+ def persist(self) -> None:
258
+ StateStore.save(
259
+ instance_id=self.instance_id,
260
+ component_name=self.component_name,
261
+ props=self._raw_props,
262
+ state=self._state,
263
+ slots=self._slots.as_dict(),
264
+ )
265
+
266
+ @classmethod
267
+ def from_storage(cls, instance_id: str) -> Component:
268
+ record = StateStore.load(instance_id)
269
+ component_cls = cls.registry_get(record.component_name)
270
+ return component_cls(
271
+ instance_id=instance_id,
272
+ props=record.props,
273
+ state=record.state,
274
+ slots=record.slots,
275
+ )
276
+
277
+ @classmethod
278
+ def registry_get(cls, component_name: str) -> type[Component]:
279
+ from shard.registry import get_component
280
+
281
+ return get_component(component_name)
282
+
283
+ def action_urls(self) -> dict[str, str]:
284
+ from django.urls import reverse
285
+
286
+ from shard.conf import get_setting
287
+
288
+ namespace = get_setting("URL_NAMESPACE")
289
+ return {
290
+ name: reverse(f"{namespace}:action", args=[self.instance_id, name])
291
+ for name in self.action_names()
292
+ }
293
+
294
+ @property
295
+ def props(self) -> dict[str, Any]:
296
+ return dict(self._props)
297
+
298
+ @property
299
+ def state(self) -> dict[str, Any]:
300
+ return dict(self._state)
301
+
302
+ @state.setter
303
+ def state(self, value: dict[str, Any]) -> None:
304
+ self._state = dict(value)
305
+
306
+ @property
307
+ def slots(self) -> dict[str, str]:
308
+ return self._slots.as_dict()
309
+
310
+ def _resolve_props(self, raw: dict[str, Any]) -> dict[str, Any]:
311
+ resolved: dict[str, Any] = {}
312
+ errors: list[str] = []
313
+
314
+ for name, prop in self._prop_fields.items():
315
+ try:
316
+ resolved[name] = prop.resolve(raw.get(name))
317
+ except PropValidationError as exc:
318
+ errors.append(str(exc))
319
+
320
+ for name in raw:
321
+ if name not in self._prop_fields:
322
+ errors.append(f"Unknown prop '{name}' for {self.__class__.__name__}.")
323
+
324
+ if errors:
325
+ raise PropValidationError("; ".join(errors))
326
+
327
+ return resolved
328
+
329
+
330
+ class TemplateComponent(Component):
331
+ """Component with an inline Django template string."""
332
+
333
+ inline_template: str = ""
334
+
335
+ def render(self, request=None) -> SafeString:
336
+ if self.inline_template:
337
+ template = Template(self.inline_template)
338
+ context = Context(self.get_context_data())
339
+ if request is not None:
340
+ context["request"] = request
341
+ return mark_safe(template.render(context))
342
+ return super().render(request=request)
shard/conf.py ADDED
@@ -0,0 +1,16 @@
1
+ from django.conf import settings
2
+
3
+ DEFAULTS = {
4
+ "STATE_BACKEND": "cache",
5
+ "STATE_TIMEOUT": 60 * 60 * 24,
6
+ "URL_NAMESPACE": "shard",
7
+ "AUTODISCOVER": True,
8
+ "LOAD_ALPINE": False,
9
+ "PRELOAD_SCRIPTS": True,
10
+ "MINIFY_CSS": True,
11
+ "VIEW_DATA_ALLOWED_COMPONENTS": None,
12
+ }
13
+
14
+
15
+ def get_setting(name: str):
16
+ return getattr(settings, f"SHARD_{name}", DEFAULTS[name])
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from django.conf import settings
6
+
7
+ from shard.conf import get_setting
8
+
9
+
10
+ def shard(request) -> dict[str, Any]:
11
+ """Expose Shard settings to every template."""
12
+
13
+ return {
14
+ "SHARD": {
15
+ "version": _framework_version(),
16
+ "url_namespace": get_setting("URL_NAMESPACE"),
17
+ "debug": getattr(settings, "DEBUG", False),
18
+ }
19
+ }
20
+
21
+
22
+ def _framework_version() -> str:
23
+ try:
24
+ from shard import __version__
25
+
26
+ return __version__
27
+ except Exception:
28
+ return "0.0.0"
shard/css_minify.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ _PUNCTUATION_RE = re.compile(r"\s*([{}:;,>+~])\s*")
6
+
7
+
8
+ def minify_css(css: str) -> str:
9
+ """Remove comments and collapse whitespace from component CSS."""
10
+
11
+ stripped = _strip_comments(css)
12
+ compact = re.sub(r"\s+", " ", stripped)
13
+ compact = _PUNCTUATION_RE.sub(r"\1", compact)
14
+ return compact.strip()
15
+
16
+
17
+ def _strip_comments(css: str) -> str:
18
+ parts: list[str] = []
19
+ index = 0
20
+ length = len(css)
21
+
22
+ while index < length:
23
+ if css[index] in "\"'":
24
+ index, chunk = _read_quoted(css, index)
25
+ parts.append(chunk)
26
+ continue
27
+
28
+ if css.startswith("/*", index):
29
+ end = css.find("*/", index + 2)
30
+ index = end + 2 if end != -1 else length
31
+ continue
32
+
33
+ parts.append(css[index])
34
+ index += 1
35
+
36
+ return "".join(parts)
37
+
38
+
39
+ def _read_quoted(css: str, start: int) -> tuple[int, str]:
40
+ quote = css[start]
41
+ index = start + 1
42
+ length = len(css)
43
+
44
+ while index < length:
45
+ if css[index] == "\\":
46
+ index += 2
47
+ continue
48
+ if css[index] == quote:
49
+ index += 1
50
+ break
51
+ index += 1
52
+
53
+ return index, css[start:index]
shard/exceptions.py ADDED
@@ -0,0 +1,22 @@
1
+ class ShardError(Exception):
2
+ """Base error for the Shard framework."""
3
+
4
+
5
+ class ComponentNotFoundError(ShardError):
6
+ """Raised when a component cannot be resolved from the registry."""
7
+
8
+
9
+ class PropValidationError(ShardError):
10
+ """Raised when component props fail validation."""
11
+
12
+
13
+ class ActionNotFoundError(ShardError):
14
+ """Raised when an HTMX action does not exist on a component."""
15
+
16
+
17
+ class StateNotFoundError(ShardError):
18
+ """Raised when component state cannot be loaded for an instance."""
19
+
20
+
21
+ class ViewDataError(ShardError):
22
+ """Raised when view data is invalid or references a disallowed component."""
shard/htmx.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from django.utils.html import escape
7
+ from django.utils.safestring import SafeString, mark_safe
8
+
9
+
10
+ def build_htmx_attrs(
11
+ component: Any,
12
+ action: str,
13
+ *,
14
+ swap: str = "outerHTML",
15
+ target: str | None = None,
16
+ trigger: str | None = None,
17
+ vals: dict[str, Any] | None = None,
18
+ include: str | None = None,
19
+ ) -> SafeString:
20
+ """Build HTMX attributes for a component action."""
21
+
22
+ url = component.action_urls().get(action)
23
+ if not url:
24
+ return mark_safe("")
25
+
26
+ target_id = target or f"#shard-{component.instance_id}"
27
+ attrs: list[str] = [
28
+ f'hx-post="{escape(url)}"',
29
+ f'hx-target="{escape(target_id)}"',
30
+ f'hx-swap="{escape(swap)}"',
31
+ ]
32
+
33
+ if trigger:
34
+ attrs.append(f'hx-trigger="{escape(trigger)}"')
35
+ if include:
36
+ attrs.append(f'hx-include="{escape(include)}"')
37
+ if vals:
38
+ attrs.append(f"hx-vals='{escape(json.dumps(vals))}'")
39
+
40
+ return mark_safe(" ".join(attrs))
41
+
42
+
43
+ def build_alpine_data(component: Any, extra: dict[str, Any] | None = None) -> SafeString:
44
+ """Serialize ``get_client_state()`` for an Alpine.js ``x-data`` attribute."""
45
+
46
+ data: dict[str, Any] = {}
47
+ if hasattr(component, "get_client_state"):
48
+ data.update(component.get_client_state())
49
+ if extra:
50
+ data.update(extra)
51
+
52
+ payload = json.dumps(data)
53
+ return mark_safe(f"x-data='{escape(payload)}'")
@@ -0,0 +1,26 @@
1
+ from django.core.management.base import BaseCommand
2
+
3
+ from shard.registry import get_all_components
4
+
5
+
6
+ class Command(BaseCommand):
7
+ help = "List registered Shard components."
8
+
9
+ def handle(self, *args, **options):
10
+ components = get_all_components()
11
+ if not components:
12
+ self.stdout.write("No components registered.")
13
+ return
14
+
15
+ name_width = max(len(name) for name in components)
16
+ self.stdout.write(f"{'NAME'.ljust(name_width)} CLASS")
17
+ self.stdout.write("-" * (name_width + 10))
18
+
19
+ for name, component_cls in sorted(components.items()):
20
+ props = ", ".join(component_cls.prop_names()) or "—"
21
+ actions = ", ".join(component_cls.action_names()) or "—"
22
+ self.stdout.write(
23
+ f"{name.ljust(name_width)} {component_cls.__module__}.{component_cls.__name__}"
24
+ )
25
+ self.stdout.write(f"{'':<{name_width}} props: {props}")
26
+ self.stdout.write(f"{'':<{name_width}} actions: {actions}")
@@ -0,0 +1,43 @@
1
+ import json
2
+ import sys
3
+
4
+ from django.core.management.base import BaseCommand
5
+
6
+ from shard.weight import build_report, check_budget, format_report, report_as_dict
7
+
8
+
9
+ class Command(BaseCommand):
10
+ help = "Report Shard framework size and page-load weight."
11
+
12
+ def add_arguments(self, parser):
13
+ parser.add_argument(
14
+ "--json",
15
+ action="store_true",
16
+ help="Output machine-readable JSON.",
17
+ )
18
+ parser.add_argument(
19
+ "--check-budget",
20
+ action="store_true",
21
+ help="Exit with code 1 if bundled assets exceed size budgets.",
22
+ )
23
+
24
+ def handle(self, *args, **options):
25
+ report = build_report()
26
+ violations = check_budget(report) if options["check_budget"] else []
27
+
28
+ if options["json"]:
29
+ payload = report_as_dict(report)
30
+ if options["check_budget"]:
31
+ payload["budget_violations"] = violations
32
+ self.stdout.write(json.dumps(payload, indent=2))
33
+ if violations:
34
+ sys.exit(1)
35
+ return
36
+
37
+ self.stdout.write(format_report(report))
38
+
39
+ if violations:
40
+ self.stderr.write(self.style.ERROR("\nSize budget violations:"))
41
+ for violation in violations:
42
+ self.stderr.write(self.style.ERROR(f" - {violation}"))
43
+ sys.exit(1)