feat: expose _class_hash as class_id (#1094)

* feat: expose _class_hash as class_id

* refactor: fix linting
This commit is contained in:
Juro Oravec 2025-04-07 11:08:02 +02:00 committed by GitHub
parent a49f5e51dd
commit bb5de86b69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 141 additions and 82 deletions

View file

@ -4,6 +4,12 @@
#### Feat
- Each Component class now has a `class_id` attribute, which is unique to the component subclass.
NOTE: This is different from `Component.id`, which is unique to each rendered instance.
To look up a component class by its `class_id`, use `get_component_by_class_id()`.
- It's now easier to create URLs for component views.
Before, you had to call `Component.as_view()` and pass that to `urlpatterns`.

View file

@ -207,11 +207,11 @@ This is how we achieve that:
5. To be able to fetch component's inlined JS and CSS, django-components adds a URL path under:
`/components/cache/<str:comp_cls_hash>.<str:script_type>/`
`/components/cache/<str:comp_cls_id>.<str:script_type>/`
E.g. `/components/cache/my_table_10bc2c.js/`
E.g. `/components/cache/MyTable_10bc2c.js/`
This endpoint takes the component's unique hash, e.g. `my_table_10bc2c`, and looks up the component's inlined JS or CSS.
This endpoint takes the component's unique ID, e.g. `MyTable_10bc2c`, and looks up the component's inlined JS or CSS.
---

View file

@ -119,6 +119,10 @@
options:
show_if_no_docstring: true
::: django_components.get_component_by_class_id
options:
show_if_no_docstring: true
::: django_components.get_component_dirs
options:
show_if_no_docstring: true

View file

@ -22,6 +22,6 @@ urlpatterns = [
## List of URLs
- `components/cache/<str:comp_cls_hash>.<str:input_hash>.<str:script_type>`
- `components/cache/<str:comp_cls_id>.<str:input_hash>.<str:script_type>`
- `components/cache/<str:comp_cls_hash>.<str:script_type>`
- `components/cache/<str:comp_cls_id>.<str:script_type>`

View file

@ -201,6 +201,12 @@ def gen_reference_components():
# If the component classes define any extra methods, we want to show them.
# BUT, we don't to show the methods that belong to the base Component class.
unique_methods = _get_unique_methods(Component, obj)
# NOTE: `class_id` is declared on the `Component` class, only as a type,
# so it's not picked up by `_get_unique_methods`.
if "class_id" in unique_methods:
unique_methods.remove("class_id")
if unique_methods:
members = ", ".join(unique_methods)
members = f"[{unique_methods}]"
@ -456,7 +462,7 @@ def gen_reference_urls():
f.write(preface + "\n\n")
# Simply list all URLs, e.g.
# `- components/cache/<str:comp_cls_hash>.<str:script_type>/`
# `- components/cache/<str:comp_cls_id>.<str:script_type>/`
f.write("\n".join([f"- `{url_path}`\n" for url_path in all_urls]))

View file

@ -16,7 +16,7 @@ from django_components.util.command import (
CommandSubcommand,
ComponentCommand,
)
from django_components.component import Component, ComponentVars, all_components
from django_components.component import Component, ComponentVars, all_components, get_component_by_class_id
from django_components.component_media import ComponentMediaInput, ComponentMediaInputPath
from django_components.component_registry import (
AlreadyRegistered,
@ -96,6 +96,7 @@ __all__ = [
"EmptyTuple",
"EmptyDict",
"format_attributes",
"get_component_by_class_id",
"get_component_dirs",
"get_component_files",
"get_component_url",

View file

@ -22,7 +22,7 @@ from typing import (
Union,
cast,
)
from weakref import ReferenceType, finalize
from weakref import ReferenceType, WeakValueDictionary, finalize
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media as MediaCls
@ -46,7 +46,6 @@ from django_components.dependencies import (
cache_component_css_vars,
cache_component_js,
cache_component_js_vars,
comp_hash_mapping,
insert_component_dependencies_comment,
)
from django_components.dependencies import render_dependencies as _render_dependencies
@ -113,8 +112,10 @@ CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any])
# NOTE: `ReferenceType` is NOT a generic pre-3.9
if sys.version_info >= (3, 9):
AllComponents = List[ReferenceType[Type["Component"]]]
CompHashMapping = WeakValueDictionary[str, Type["Component"]]
else:
AllComponents = List[ReferenceType]
CompHashMapping = WeakValueDictionary
# Keep track of all the Component classes created, so we can clean up after tests
@ -131,6 +132,47 @@ def all_components() -> List[Type["Component"]]:
return components
# NOTE: Initially, we fetched components by their registered name, but that didn't work
# for multiple registries and unregistered components.
#
# To have unique identifiers that works across registries, we rely
# on component class' module import path (e.g. `path.to.my.MyComponent`).
#
# But we also don't want to expose the module import paths to the outside world, as
# that information could be potentially exploited. So, instead, each component is
# associated with a hash that's derived from its module import path, ensuring uniqueness,
# consistency and privacy.
#
# E.g. `path.to.my.secret.MyComponent` -> `ab01f32`
#
# For easier debugging, we then prepend the hash with the component class name, so that
# we can easily identify the component class by its hash.
#
# E.g. `path.to.my.secret.MyComponent` -> `MyComponent_ab01f32`
#
# The associations are defined as WeakValue map, so deleted components can be garbage
# collected and automatically deleted from the dict.
comp_cls_id_mapping: CompHashMapping = WeakValueDictionary()
def get_component_by_class_id(comp_cls_id: str) -> Type["Component"]:
"""
Get a component class by its unique ID.
Each component class is associated with a unique hash that's derived from its module import path.
E.g. `path.to.my.secret.MyComponent` -> `MyComponent_ab01f32`
This hash is available under [`class_id`](../api#django_components.Component.class_id)
on the component class.
Raises `KeyError` if the component class is not found.
NOTE: This is mainly intended for extensions.
"""
return comp_cls_id_mapping[comp_cls_id]
@dataclass(frozen=True)
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
context: Context
@ -589,7 +631,18 @@ class Component(
# MISC
# #####################################
_class_hash: ClassVar[str]
class_id: ClassVar[str]
"""
Unique ID of the component class, e.g. `MyComponent_ab01f2`.
This is derived from the component class' module import path, e.g. `path.to.my.MyComponent`.
"""
# TODO_V1 - Remove this in v1
@property
def _class_hash(self) -> str:
"""Deprecated. Use `Component.class_id` instead."""
return self.class_id
def __init__(
self,
@ -622,8 +675,8 @@ class Component(
extensions._init_component_instance(self)
def __init_subclass__(cls, **kwargs: Any) -> None:
cls._class_hash = hash_comp_cls(cls)
comp_hash_mapping[cls._class_hash] = cls
cls.class_id = hash_comp_cls(cls)
comp_cls_id_mapping[cls.class_id] = cls
ALL_COMPONENTS.append(cached_ref(cls)) # type: ignore[arg-type]
extensions._init_component_class(cls)

View file

@ -351,7 +351,7 @@ class ComponentRegistry:
```
"""
existing_component = self._registry.get(name)
if existing_component and existing_component.cls._class_hash != component._class_hash:
if existing_component and existing_component.cls.class_id != component.class_id:
raise AlreadyRegistered('The component "%s" has already been registered' % name)
entry = self._register_to_library(name, component)

View file

@ -3,7 +3,6 @@
import base64
import json
import re
import sys
from hashlib import md5
from typing import (
TYPE_CHECKING,
@ -20,7 +19,6 @@ from typing import (
Union,
cast,
)
from weakref import WeakValueDictionary
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
from django.forms import Media
@ -57,39 +55,18 @@ RenderType = Literal["document", "fragment"]
#########################################################
# NOTE: Initially, we fetched components by their registered name, but that didn't work
# for multiple registries and unregistered components.
#
# To have unique identifiers that works across registries, we rely
# on component class' module import path (e.g. `path.to.my.MyComponent`).
#
# But we also don't want to expose the module import paths to the outside world, as
# that information could be potentially exploited. So, instead, each component is
# associated with a hash that's derived from its module import path, ensuring uniqueness,
# consistency and privacy.
#
# E.g. `path.to.my.secret.MyComponent` -> `MyComponent_ab01f32`
#
# The associations are defined as WeakValue map, so deleted components can be garbage
# collected and automatically deleted from the dict.
if sys.version_info < (3, 9):
comp_hash_mapping: WeakValueDictionary = WeakValueDictionary()
else:
comp_hash_mapping: WeakValueDictionary[str, Type["Component"]] = WeakValueDictionary()
# Generate keys like
# `__components:MyButton_a78y37:js:df7c6d10`
# `__components:MyButton_a78y37:css`
def _gen_cache_key(
comp_cls_hash: str,
comp_cls_id: str,
script_type: ScriptType,
input_hash: Optional[str],
) -> str:
if input_hash:
return f"__components:{comp_cls_hash}:{script_type}:{input_hash}"
return f"__components:{comp_cls_id}:{script_type}:{input_hash}"
else:
return f"__components:{comp_cls_hash}:{script_type}"
return f"__components:{comp_cls_id}:{script_type}"
def _is_script_in_cache(
@ -97,7 +74,7 @@ def _is_script_in_cache(
script_type: ScriptType,
input_hash: Optional[str],
) -> bool:
cache_key = _gen_cache_key(comp_cls._class_hash, script_type, input_hash)
cache_key = _gen_cache_key(comp_cls.class_id, script_type, input_hash)
cache = get_component_media_cache()
return cache.has_key(cache_key)
@ -115,7 +92,7 @@ def _cache_script(
# E.g. `__components:MyButton:js:df7c6d10`
if script_type in ("js", "css"):
cache_key = _gen_cache_key(comp_cls._class_hash, script_type, input_hash)
cache_key = _gen_cache_key(comp_cls.class_id, script_type, input_hash)
else:
raise ValueError(f"Unexpected script_type '{script_type}'")
@ -333,7 +310,7 @@ def insert_component_dependencies_comment(
will be used by the ComponentDependencyMiddleware to collect all
declared JS / CSS scripts.
"""
data = f"{component_cls._class_hash},{component_id},{js_input_hash or ''},{css_input_hash or ''}"
data = f"{component_cls.class_id},{component_id},{js_input_hash or ''},{css_input_hash or ''}"
# NOTE: It's important that we put the comment BEFORE the content, so we can
# use the order of comments to evaluate components' instance JS code in the correct order.
@ -366,12 +343,12 @@ COMPONENT_DEPS_COMMENT = "<!-- _RENDERED {data} -->"
# E.g. `<!-- _RENDERED table,123,a92ef298,bd002c3 -->`
COMPONENT_COMMENT_REGEX = re.compile(rb"<!--\s+_RENDERED\s+(?P<data>[\w\-,/]+?)\s+-->")
# E.g. `table,123,a92ef298,bd002c3`
# - comp_cls_hash - Cache key of the component class that was rendered
# - comp_cls_id - Cache key of the component class that was rendered
# - id - Component render ID
# - js - Cache key for the JS data from `get_js_data()`
# - css - Cache key for the CSS data from `get_css_data()`
SCRIPT_NAME_REGEX = re.compile(
rb"^(?P<comp_cls_hash>[\w\-\./]+?),(?P<id>[\w]+?),(?P<js>[0-9a-f]*?),(?P<css>[0-9a-f]*?)$"
rb"^(?P<comp_cls_id>[\w\-\./]+?),(?P<id>[\w]+?),(?P<js>[0-9a-f]*?),(?P<css>[0-9a-f]*?)$"
)
# E.g. `data-djc-id-a1b2c3`
MAYBE_COMP_ID = r'(?: data-djc-id-\w{6}="")?'
@ -550,26 +527,26 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes,
if not part_match:
raise RuntimeError("Malformed dependencies data")
comp_cls_hash: str = part_match.group("comp_cls_hash").decode("utf-8")
comp_cls_id: str = part_match.group("comp_cls_id").decode("utf-8")
js_input_hash: Optional[str] = part_match.group("js").decode("utf-8") or None
css_input_hash: Optional[str] = part_match.group("css").decode("utf-8") or None
if comp_cls_hash in seen_comp_hashes:
if comp_cls_id in seen_comp_hashes:
continue
comp_hashes.append(comp_cls_hash)
seen_comp_hashes.add(comp_cls_hash)
comp_hashes.append(comp_cls_id)
seen_comp_hashes.add(comp_cls_id)
# Schedule to load the `<script>` / `<link>` tags for the JS / CSS from `Component.js/css`.
comp_data.append((comp_cls_hash, "js", None))
comp_data.append((comp_cls_hash, "css", None))
comp_data.append((comp_cls_id, "js", None))
comp_data.append((comp_cls_id, "css", None))
# Schedule to load the `<script>` / `<link>` tags for the JS / CSS variables.
# Skip if no variables are defined.
if js_input_hash is not None:
inputs_data.append((comp_cls_hash, "js", js_input_hash))
inputs_data.append((comp_cls_id, "js", js_input_hash))
if css_input_hash is not None:
inputs_data.append((comp_cls_hash, "css", css_input_hash))
inputs_data.append((comp_cls_id, "css", css_input_hash))
(
to_load_input_js_urls,
@ -589,15 +566,17 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes,
loaded_component_css_urls,
) = _prepare_tags_and_urls(comp_data, type)
def get_component_media(comp_cls_hash: str) -> Media:
comp_cls = comp_hash_mapping[comp_cls_hash]
def get_component_media(comp_cls_id: str) -> Media:
from django_components.component import get_component_by_class_id
comp_cls = get_component_by_class_id(comp_cls_id)
# NOTE: We instantiate the component classes so the `Media` are processed into `media`
comp = comp_cls()
return comp.media
all_medias = [
# JS / CSS files from Component.Media.js/css.
*[get_component_media(comp_cls_hash) for comp_cls_hash in comp_hashes],
*[get_component_media(comp_cls_id) for comp_cls_id in comp_hashes],
# All the inlined scripts that we plan to fetch / load
Media(
js=[*to_load_component_js_urls, *to_load_input_js_urls],
@ -747,6 +726,8 @@ def _prepare_tags_and_urls(
data: List[Tuple[str, ScriptType, Optional[str]]],
type: RenderType,
) -> Tuple[List[str], List[str], List[str], List[str], List[str], List[str]]:
from django_components.component import get_component_by_class_id
to_load_js_urls: List[str] = []
to_load_css_urls: List[str] = []
inlined_js_tags: List[str] = []
@ -758,12 +739,12 @@ def _prepare_tags_and_urls(
# But even in that case we still need to call `Components.manager.markScriptLoaded`,
# so the client knows NOT to fetch them again.
# So in that case we populate both `inlined` and `loaded` lists
for comp_cls_hash, script_type, input_hash in data:
for comp_cls_id, script_type, input_hash in data:
# NOTE: When CSS is scoped, then EVERY component instance will have different
# copy of the style, because each copy will have component's ID embedded.
# So, in that case we inline the style into the HTML (See `_link_dependencies_with_component_html`),
# which means that we are NOT going to load / inline it again.
comp_cls = comp_hash_mapping[comp_cls_hash]
comp_cls = get_component_by_class_id(comp_cls_id)
if type == "document":
# NOTE: Skip fetching of inlined JS/CSS if it's not defined or empty for given component
@ -805,7 +786,7 @@ def get_script_content(
input_hash: Optional[str],
) -> Optional[str]:
cache = get_component_media_cache()
cache_key = _gen_cache_key(comp_cls._class_hash, script_type, input_hash)
cache_key = _gen_cache_key(comp_cls.class_id, script_type, input_hash)
script = cache.get(cache_key)
return script
@ -819,8 +800,7 @@ def get_script_tag(
content = get_script_content(script_type, comp_cls, input_hash)
if content is None:
raise RuntimeError(
f"Could not find {script_type.upper()} for component '{comp_cls.__name__}' "
f"(hash: {comp_cls._class_hash})"
f"Could not find {script_type.upper()} for component '{comp_cls.__name__}' (id: {comp_cls.class_id})"
)
if script_type == "js":
@ -841,7 +821,7 @@ def get_script_url(
return reverse(
CACHE_ENDPOINT_NAME,
kwargs={
"comp_cls_hash": comp_cls._class_hash,
"comp_cls_id": comp_cls.class_id,
"script_type": script_type,
**({"input_hash": input_hash} if input_hash is not None else {}),
},
@ -963,15 +943,18 @@ def _get_content_types(script_type: ScriptType) -> str:
def cached_script_view(
req: HttpRequest,
comp_cls_hash: str,
comp_cls_id: str,
script_type: ScriptType,
input_hash: Optional[str] = None,
) -> HttpResponse:
from django_components.component import get_component_by_class_id
if req.method != "GET":
return HttpResponseNotAllowed(["GET"])
comp_cls = comp_hash_mapping.get(comp_cls_hash)
if comp_cls is None:
try:
comp_cls = get_component_by_class_id(comp_cls_id)
except KeyError:
return HttpResponseNotFound()
script = get_script_content(script_type, comp_cls, input_hash)
@ -983,9 +966,9 @@ def cached_script_view(
urlpatterns = [
# E.g. `/components/cache/table.js` or `/components/cache/table.0ab2c3.js`
path("cache/<str:comp_cls_hash>.<str:input_hash>.<str:script_type>", cached_script_view, name=CACHE_ENDPOINT_NAME),
path("cache/<str:comp_cls_hash>.<str:script_type>", cached_script_view, name=CACHE_ENDPOINT_NAME),
# E.g. `/components/cache/MyTable_a1b2c3.js` or `/components/cache/MyTable_a1b2c3.0ab2c3.js`
path("cache/<str:comp_cls_id>.<str:input_hash>.<str:script_type>", cached_script_view, name=CACHE_ENDPOINT_NAME),
path("cache/<str:comp_cls_id>.<str:script_type>", cached_script_view, name=CACHE_ENDPOINT_NAME),
]

View file

@ -24,7 +24,7 @@ else:
def _get_component_route_name(component: Union[Type["Component"], "Component"]) -> str:
return f"__component_url__{component._class_hash}"
return f"__component_url__{component.class_id}"
def get_component_url(component: Union[Type["Component"], "Component"]) -> str:
@ -162,7 +162,7 @@ class UrlExtension(ComponentExtension):
# Create a URL route like `components/MyTable_a1b2c3/`
# And since this is within the `url` extension, the full URL path will then be:
# `/components/ext/url/components/MyTable_a1b2c3/`
route_path = f"components/{ctx.component_cls._class_hash}/"
route_path = f"components/{ctx.component_cls.class_id}/"
route_name = _get_component_route_name(ctx.component_cls)
route = URLRoute(
path=route_path,

View file

@ -129,16 +129,16 @@ class TestComponentMediaCache:
TestMediaAndVarsComponent.render()
# Check that JS/CSS is cached for components that have them
assert test_cache.has_key(f"__components:{TestMediaAndVarsComponent._class_hash}:js")
assert test_cache.has_key(f"__components:{TestMediaAndVarsComponent._class_hash}:css")
assert test_cache.has_key(f"__components:{TestMediaNoVarsComponent._class_hash}:js")
assert test_cache.has_key(f"__components:{TestMediaNoVarsComponent._class_hash}:css")
assert not test_cache.has_key(f"__components:{TestSimpleComponent._class_hash}:js")
assert not test_cache.has_key(f"__components:{TestSimpleComponent._class_hash}:css")
assert test_cache.has_key(f"__components:{TestMediaAndVarsComponent.class_id}:js")
assert test_cache.has_key(f"__components:{TestMediaAndVarsComponent.class_id}:css")
assert test_cache.has_key(f"__components:{TestMediaNoVarsComponent.class_id}:js")
assert test_cache.has_key(f"__components:{TestMediaNoVarsComponent.class_id}:css")
assert not test_cache.has_key(f"__components:{TestSimpleComponent.class_id}:js")
assert not test_cache.has_key(f"__components:{TestSimpleComponent.class_id}:css")
# Check that we cache `Component.js` / `Component.css`
assert test_cache.get(f"__components:{TestMediaNoVarsComponent._class_hash}:js").strip() == "console.log('Hello from JS');" # noqa: E501
assert test_cache.get(f"__components:{TestMediaNoVarsComponent._class_hash}:css").strip() == ".novars-component { color: blue; }" # noqa: E501
assert test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:js").strip() == "console.log('Hello from JS');" # noqa: E501
assert test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:css").strip() == ".novars-component { color: blue; }" # noqa: E501
# Check that we cache JS / CSS scripts generated from `get_js_data` / `get_css_data`
# NOTE: The hashes is generated from the data.
@ -146,5 +146,5 @@ class TestComponentMediaCache:
css_vars_hash = "d039a3"
# TODO - Update once JS and CSS vars are enabled
assert test_cache.get(f"__components:{TestMediaAndVarsComponent._class_hash}:js:{js_vars_hash}").strip() == ""
assert test_cache.get(f"__components:{TestMediaAndVarsComponent._class_hash}:css:{css_vars_hash}").strip() == "" # noqa: E501
assert test_cache.get(f"__components:{TestMediaAndVarsComponent.class_id}:js:{js_vars_hash}").strip() == ""
assert test_cache.get(f"__components:{TestMediaAndVarsComponent.class_id}:css:{css_vars_hash}").strip() == "" # noqa: E501

View file

@ -16,7 +16,7 @@ from django.test import Client
from django.urls import path
from pytest_django.asserts import assertHTMLEqual, assertInHTML
from django_components import Component, ComponentView, all_components, register, types
from django_components import Component, ComponentView, all_components, get_component_by_class_id, register, types
from django_components.slots import SlotRef
from django_components.urls import urlpatterns as dc_urlpatterns
@ -356,6 +356,12 @@ class TestComponent:
):
Root.render()
def test_get_component_by_id(self):
class SimpleComponent(Component):
pass
assert get_component_by_class_id(SimpleComponent.class_id) == SimpleComponent
@djc_test
class TestComponentRender:

View file

@ -41,7 +41,7 @@ class TestComponentUrl:
# Check if the URL is correctly generated
component_url = get_component_url(TestComponent)
assert component_url == f"/components/ext/url/components/{TestComponent._class_hash}/"
assert component_url == f"/components/ext/url/components/{TestComponent.class_id}/"
client = Client()
response = client.get(component_url)
@ -79,7 +79,7 @@ class TestComponentUrl:
get_component_url(TestComponent)
# Even calling the URL directly should raise an error
component_url = f"/components/ext/url/components/{TestComponent._class_hash}/"
component_url = f"/components/ext/url/components/{TestComponent.class_id}/"
client = Client()
response = client.get(component_url)