diff --git a/CHANGELOG.md b/CHANGELOG.md index c0639391..b0fd7dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/docs/guides/devguides/dependency_mgmt.md b/docs/guides/devguides/dependency_mgmt.md index a165680b..c9c446a8 100644 --- a/docs/guides/devguides/dependency_mgmt.md +++ b/docs/guides/devguides/dependency_mgmt.md @@ -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/./` + `/components/cache/./` - 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. --- diff --git a/docs/reference/api.md b/docs/reference/api.md index ac42d912..3ce1b97c 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -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 diff --git a/docs/reference/urls.md b/docs/reference/urls.md index 3697d4f4..ad750cde 100644 --- a/docs/reference/urls.md +++ b/docs/reference/urls.md @@ -22,6 +22,6 @@ urlpatterns = [ ## List of URLs -- `components/cache/..` +- `components/cache/..` -- `components/cache/.` +- `components/cache/.` diff --git a/docs/scripts/reference.py b/docs/scripts/reference.py index 7929e013..e3f791b8 100644 --- a/docs/scripts/reference.py +++ b/docs/scripts/reference.py @@ -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/./` + # `- components/cache/./` f.write("\n".join([f"- `{url_path}`\n" for url_path in all_urls])) diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index 6b99e13d..59545f80 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -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", diff --git a/src/django_components/component.py b/src/django_components/component.py index 99ba98ca..0596df94 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -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) diff --git a/src/django_components/component_registry.py b/src/django_components/component_registry.py index 862eef90..86b4ea5f 100644 --- a/src/django_components/component_registry.py +++ b/src/django_components/component_registry.py @@ -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) diff --git a/src/django_components/dependencies.py b/src/django_components/dependencies.py index 7b022d95..d5a05d1d 100644 --- a/src/django_components/dependencies.py +++ b/src/django_components/dependencies.py @@ -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 = "" # E.g. `` COMPONENT_COMMENT_REGEX = re.compile(rb"") # 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[\w\-\./]+?),(?P[\w]+?),(?P[0-9a-f]*?),(?P[0-9a-f]*?)$" + rb"^(?P[\w\-\./]+?),(?P[\w]+?),(?P[0-9a-f]*?),(?P[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 `