diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 784cf69d..3ff02078 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,4 +11,4 @@ repos: rev: 7.0.0 hooks: - id: flake8 - + additional_dependencies: [flake8-pyproject] diff --git a/README.md b/README.md index 0dae5fab..ad42a5aa 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A way to create simple reusable template components in Django. It lets you create "template components", that contains both the template, the Javascript and the CSS needed to generate the front end code you need for a modern app. Components look like this: ```htmldjango -{% component "calendar" date="2015-06-19" %} +{% component "calendar" date="2015-06-19" %}{% endcomponent %} ``` And this is what gets rendered (plus the CSS and Javascript you've specified): @@ -20,17 +20,21 @@ Read on to learn about the details! ## Release notes -*Version 0.34* adds components as views, which allows you to handle requests and render responses from within a component. See the [documentation](#components-as-views) for more details. +🚨📢 **Version 0.5** CHANGES THE SYNTAX for components. `component_block` is now `component`, and `component` blocks need an ending `endcomponent` tag. The new `python manage.py upgradecomponent` command can be used to upgrade a directory (use --path argument to point to each dir) of components to the new syntax automatically. -*Version 0.28* introduces 'implicit' slot filling and the `default` option for `slot` tags. +This change is done to simplify the API in anticipation of a 1.0 release of django_components. After 1.0 we intend to be stricter with big changes like this in point releases. -*Version 0.27* adds a second installable app: *django_components.safer_staticfiles*. It provides the same behavior as *django.contrib.staticfiles* but with extra security guarantees (more info below in Security Notes). +**Version 0.34** adds components as views, which allows you to handle requests and render responses from within a component. See the [documentation](#components-as-views) for more details. -*Version 0.26* changes the syntax for `{% slot %}` tags. From now on, we separate defining a slot (`{% slot %}`) from filling a slot with content (`{% fill %}`). This means you will likely need to change a lot of slot tags to fill. We understand this is annoying, but it's the only way we can get support for nested slots that fill in other slots, which is a very nice feature to have access to. Hoping that this will feel worth it! +**Version 0.28** introduces 'implicit' slot filling and the `default` option for `slot` tags. -*Version 0.22* starts autoimporting all files inside components subdirectores, to simplify setup. An existing project might start to get AlreadyRegistered-errors because of this. To solve this, either remove your custom loading of components, or set "autodiscover": False in settings.COMPONENTS. +**Version 0.27** adds a second installable app: *django_components.safer_staticfiles*. It provides the same behavior as *django.contrib.staticfiles* but with extra security guarantees (more info below in Security Notes). -*Version 0.17* renames `Component.context` and `Component.template` to `get_context_data` and `get_template_name`. The old methods still work, but emit a deprecation warning. This change was done to sync naming with Django's class based views, and make using django-components more familiar to Django users. `Component.context` and `Component.template` will be removed when version 1.0 is released. +**Version 0.26** changes the syntax for `{% slot %}` tags. From now on, we separate defining a slot (`{% slot %}`) from filling a slot with content (`{% fill %}`). This means you will likely need to change a lot of slot tags to fill. We understand this is annoying, but it's the only way we can get support for nested slots that fill in other slots, which is a very nice featuPpre to have access to. Hoping that this will feel worth it! + +**Version 0.22** starts autoimporting all files inside components subdirectores, to simplify setup. An existing project might start to get AlreadyRegistered-errors because of this. To solve this, either remove your custom loading of components, or set "autodiscover": False in settings.COMPONENTS. + +**Version 0.17** renames `Component.context` and `Component.template` to `get_context_data` and `get_template_name`. The old methods still work, but emit a deprecation warning. This change was done to sync naming with Django's class based views, and make using django-components more familiar to Django users. `Component.context` and `Component.template` will be removed when version 1.0 is released. ## Security notes 🚨 @@ -220,7 +224,7 @@ First load the `component_tags` tag library, then use the `component_[js/css]_de {% component_css_dependencies %} - {% component "calendar" date="2015-06-19" %} + {% component "calendar" date="2015-06-19" %}{% endcomponent %} {% component_js_dependencies %} @@ -300,7 +304,7 @@ This mechanism makes components more reusable and composable. In the example below we introduce two block tags that work hand in hand to make this work. These are... - `{% slot %}`/`{% endslot %}`: Declares a new slot in the component template. -- `{% fill %}`/`{% endfill %}`: (Used inside a `component_block` tag pair.) Fills a declared slot with the specified content. +- `{% fill %}`/`{% endfill %}`: (Used inside a `component` tag pair.) Fills a declared slot with the specified content. Let's update our calendar component to support more customization. We'll add `slot` tag pairs to its template, _calendar.html_. @@ -318,9 +322,9 @@ Let's update our calendar component to support more customization. We'll add `sl When using the component, you specify which slots you want to fill and where you want to use the defaults from the template. It looks like this: ```htmldjango -{% component_block "calendar" date="2020-06-06" %} +{% component "calendar" date="2020-06-06" %} {% fill "body" %}Can you believe it's already {{ date }}??{% endfill %} -{% endcomponent_block %} +{% endcomponent %} ``` Since the header block is unspecified, it's taken from the base template. If you put this in a template, and pass in `date=2020-06-06`, this is what gets rendered: @@ -338,7 +342,7 @@ Since the header block is unspecified, it's taken from the base template. If you As you can see, component slots lets you write reusable containers that you fill in when you use a component. This makes for highly reusable components that can be used in different circumstances. -It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. When added to the end of the tag (as shown below), this option lets you pass filling content directly in the body of a `component_block` tag pair – without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`. +It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. When added to the end of the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair – without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`. Here's the same example as before, except with default slots and implicit filling. @@ -358,9 +362,9 @@ The template: Including the component (notice how the `fill` tag is omitted): ```htmldjango -{% component_block "calendar" date="2020-06-06" %} +{% component "calendar" date="2020-06-06" %} Can you believe it's already {{ date }}?? -{% endcomponent_block %} +{% endcomponent %} ``` The rendered result (exactly the same as before): @@ -380,32 +384,32 @@ You may be tempted to combine implicit fills with explicit `fill` tags. This wil ```htmldjango {# DON'T DO THIS #} -{% component_block "calendar" date="2020-06-06" %} +{% component "calendar" date="2020-06-06" %} {% fill "header" %}Totally new header!{% endfill %} Can you believe it's already {{ date }}?? -{% endcomponent_block %} +{% endcomponent %} ``` By contrast, it is permitted to use `fill` tags in nested components, e.g.: ```htmldjango -{% component_block "calendar" date="2020-06-06" %} - {% component_block "beautiful-box" %} +{% component "calendar" date="2020-06-06" %} + {% component "beautiful-box" %} {% fill "content" %} Can you believe it's already {{ date }}?? {% endfill %} - {% endcomponent_block %} -{% endcomponent_block %} + {% endcomponent %} +{% endcomponent %} ``` This is fine too: ```htmldjango -{% component_block "calendar" date="2020-06-06" %} +{% component "calendar" date="2020-06-06" %} {% fill "header" %} - {% component_block "calendar-header" %} + {% component "calendar-header" %} Super Special Calendar Header - {% endcomponent_block %} + {% endcomponent %} {% endfill %} -{% endcomponent_block %} +{% endcomponent %} ``` ### Components as views @@ -482,9 +486,9 @@ If you're planning on passing an HTML string, check Django's use of [`format_htm Certain properties of a slot can be accessed from within a 'fill' context. They are provided as attributes on a user-defined alias of the targeted slot. For instance, let's say you're filling a slot called 'body'. To access properties of this slot, alias it using the 'as' keyword to a new name -- or keep the original name. With the new slot alias, you can call `.default` to insert the default content. ```htmldjango -{% component_block "calendar" date="2020-06-06" %} +{% component "calendar" date="2020-06-06" %} {% fill "body" as "body" %}{{ body.default }}. Have a great day!{% endfill %} -{% endcomponent_block %} +{% endcomponent %} ``` Produces: @@ -617,10 +621,10 @@ COMPONENTS = { ## Component context and scope -By default, components can access context variables from the parent template, just like templates that are included with the `{% include %}` tag. Just like with `{% include %}`, if you don't want the component template to have access to the parent context, add `only` to the end of the `{% component %}` (or `{% component_block %}` tag): +By default, components can access context variables from the parent template, just like templates that are included with the `{% include %}` tag. Just like with `{% include %}`, if you don't want the component template to have access to the parent context, add `only` to the end of the `{% component %}` tag): ```htmldjango - {% component "calendar" date="2015-06-19" only %} + {% component "calendar" date="2015-06-19" only %}{% endcomponent %} ``` NOTE: `{% csrf_token %}` tags need access to the top-level context, and they will not function properly if they are rendered in a component that is called with the `only` modifier. diff --git a/benchmarks/component_rendering.py b/benchmarks/component_rendering.py index 2807e562..5d8f834c 100644 --- a/benchmarks/component_rendering.py +++ b/benchmarks/component_rendering.py @@ -4,10 +4,7 @@ from django.template import Context, Template from django.test import override_settings from django_components import component -from django_components.middleware import ( - CSS_DEPENDENCY_PLACEHOLDER, - JS_DEPENDENCY_PLACEHOLDER, -) +from django_components.middleware import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER from tests.django_test_setup import * # NOQA from tests.testutils import Django30CompatibleSimpleTestCase as SimpleTestCase from tests.testutils import create_and_process_template_response @@ -75,9 +72,7 @@ class RenderBenchmarks(SimpleTestCase): component.registry.clear() component.registry.register("test_component", SlottedComponent) component.registry.register("inner_component", SimpleComponent) - component.registry.register( - "breadcrumb_component", BreadcrumbComponent - ) + component.registry.register("breadcrumb_component", BreadcrumbComponent) @staticmethod def timed_loop(func, iterations=1000): @@ -91,22 +86,28 @@ class RenderBenchmarks(SimpleTestCase): def test_render_time_for_small_component(self): template = Template( - "{% load component_tags %}{% component_block 'test_component' %}" - "{% slot \"header\" %}{% component 'inner_component' variable='foo' %}{% endslot %}" - "{% endcomponent_block %}", - name="root", + """ + {% load component_tags %} + {% component 'test_component' %} + {% slot "header" %} + {% component 'inner_component' variable='foo' %}{% endcomponent %} + {% endslot %} + {% endcomponent %} + """ ) - print( - f"{self.timed_loop(lambda: template.render(Context({})))} ms per iteration" - ) + print(f"{self.timed_loop(lambda: template.render(Context({})))} ms per iteration") def test_middleware_time_with_dependency_for_small_page(self): template = Template( - "{% load component_tags %}{% component_dependencies %}" - "{% component_block 'test_component' %}{% slot \"header\" %}" - "{% component 'inner_component' variable='foo' %}{% endslot %}{% endcomponent_block %}", - name="root", + """ + {% load component_tags %}{% component_dependencies %} + {% component 'test_component' %} + {% slot "header" %} + {% component 'inner_component' variable='foo' %}{% endcomponent %} + {% endslot %} + {% endcomponent %} + """ ) # Sanity tests response_content = create_and_process_template_response(template) @@ -116,15 +117,9 @@ class RenderBenchmarks(SimpleTestCase): self.assertIn("script.js", response_content) without_middleware = self.timed_loop( - lambda: create_and_process_template_response( - template, use_middleware=False - ) - ) - with_middleware = self.timed_loop( - lambda: create_and_process_template_response( - template, use_middleware=True - ) + lambda: create_and_process_template_response(template, use_middleware=False) ) + with_middleware = self.timed_loop(lambda: create_and_process_template_response(template, use_middleware=True)) print("Small page middleware test") self.report_results(with_middleware, without_middleware) @@ -140,14 +135,10 @@ class RenderBenchmarks(SimpleTestCase): self.assertIn("test.js", response_content) without_middleware = self.timed_loop( - lambda: create_and_process_template_response( - template, {}, use_middleware=False - ) + lambda: create_and_process_template_response(template, {}, use_middleware=False) ) with_middleware = self.timed_loop( - lambda: create_and_process_template_response( - template, {}, use_middleware=True - ) + lambda: create_and_process_template_response(template, {}, use_middleware=True) ) print("Large page middleware test") @@ -156,15 +147,9 @@ class RenderBenchmarks(SimpleTestCase): @staticmethod def report_results(with_middleware, without_middleware): print(f"Middleware active\t\t{with_middleware:.3f} ms per iteration") - print( - f"Middleware inactive\t{without_middleware:.3f} ms per iteration" - ) + print(f"Middleware inactive\t{without_middleware:.3f} ms per iteration") time_difference = with_middleware - without_middleware if without_middleware > with_middleware: - print( - f"Decrease of {-100 * time_difference / with_middleware:.2f}%" - ) + print(f"Decrease of {-100 * time_difference / with_middleware:.2f}%") else: - print( - f"Increase of {100 * time_difference / without_middleware:.2f}%" - ) + print(f"Increase of {100 * time_difference / without_middleware:.2f}%") diff --git a/django_components/app_settings.py b/django_components/app_settings.py index 30daeab9..d4550e98 100644 --- a/django_components/app_settings.py +++ b/django_components/app_settings.py @@ -26,9 +26,7 @@ class AppSettings: @property def CONTEXT_BEHAVIOR(self): - raw_value = self.settings.setdefault( - "context_behavior", ContextBehavior.GLOBAL.value - ) + raw_value = self.settings.setdefault("context_behavior", ContextBehavior.GLOBAL.value) return self._validate_context_behavior(raw_value) def _validate_context_behavior(self, raw_value): @@ -36,9 +34,7 @@ class AppSettings: return ContextBehavior(raw_value) except ValueError: valid_values = [behavior.value for behavior in ContextBehavior] - raise ValueError( - f"Invalid context behavior: {raw_value}. Valid options are {valid_values}" - ) + raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}") app_settings = AppSettings() diff --git a/django_components/component.py b/django_components/component.py index 40d0fafe..2aa0ff1f 100644 --- a/django_components/component.py +++ b/django_components/component.py @@ -80,9 +80,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): self, registered_name: Optional[str] = None, outer_context: Optional[Context] = None, - fill_content: Union[ - DefaultFillContent, Iterable[NamedFillContent] - ] = (), + fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]] = (), ): self.registered_name: Optional[str] = registered_name self.outer_context: Context = outer_context or Context() @@ -152,14 +150,10 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): if slots_data: self._fill_slots(slots_data, escape_slots_content) - updated_filled_slots_context: FilledSlotsContext = ( - self._process_template_and_update_filled_slot_context( - context, template - ) + updated_filled_slots_context: FilledSlotsContext = self._process_template_and_update_filled_slot_context( + context, template ) - with context.update( - {FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context} - ): + with context.update({FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}): return template.render(context) def render_to_response( @@ -201,19 +195,14 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): named_fills_content = {} else: default_fill_content = None - named_fills_content = { - name: (nodelist, alias) - for name, nodelist, alias in self.fill_content - } + named_fills_content = {name: (nodelist, alias) for name, nodelist, alias in self.fill_content} # If value is `None`, then slot is unfilled. slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {} default_slot_encountered: bool = False required_slot_names: Set[str] = set() - for node in template.nodelist.get_nodes_by_type( - (SlotNode, IfSlotFilledConditionBranchNode) # type: ignore - ): + for node in template.nodelist.get_nodes_by_type((SlotNode, IfSlotFilledConditionBranchNode)): # type: ignore if isinstance(node, SlotNode): # Give slot node knowledge of its parent template. node.template = template @@ -225,9 +214,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): f"To fix, check template '{template.name}' " f"of component '{self.registered_name}'." ) - content_data: Optional[FillContent] = ( - None # `None` -> unfilled - ) + content_data: Optional[FillContent] = None # `None` -> unfilled if node.is_required: required_slot_names.add(node.name) if node.is_default: @@ -245,25 +232,19 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): elif isinstance(node, IfSlotFilledConditionBranchNode): node.template = template else: - raise RuntimeError( - f"Node of {type(node).__name__} does not require linking." - ) + raise RuntimeError(f"Node of {type(node).__name__} does not require linking.") # Check: Only component templates that include a 'default' slot # can be invoked with implicit filling. if default_fill_content and not default_slot_encountered: raise TemplateSyntaxError( - f"Component '{self.registered_name}' passed default fill content " + f"Component '{self.registered_name}' passed default fill content '{default_fill_content}'" f"(i.e. without explicit 'fill' tag), " f"even though none of its slots is marked as 'default'." ) - unfilled_slots: Set[str] = set( - k for k, v in slot_name2fill_content.items() if v is None - ) - unmatched_fills: Set[str] = ( - named_fills_content.keys() - slot_name2fill_content.keys() - ) + unfilled_slots: Set[str] = set(k for k, v in slot_name2fill_content.items() if v is None) + unmatched_fills: Set[str] = named_fills_content.keys() - slot_name2fill_content.keys() # Check that 'required' slots are filled. for slot_name in unfilled_slots: @@ -286,9 +267,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): # Higher values make matching stricter. This is probably preferable, as it # reduces false positives. for fill_name in unmatched_fills: - fuzzy_slot_name_matches = difflib.get_close_matches( - fill_name, unfilled_slots, n=1, cutoff=0.7 - ) + fuzzy_slot_name_matches = difflib.get_close_matches(fill_name, unfilled_slots, n=1, cutoff=0.7) msg = ( f"Component '{self.registered_name}' passed fill " f"that refers to undefined slot: '{fill_name}'." @@ -305,9 +284,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass): if content_data # Slots whose content is None (i.e. unfilled) are dropped. } try: - prev_context: FilledSlotsContext = context[ - FILLED_SLOTS_CONTENT_CONTEXT_KEY - ] + prev_context: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY] return prev_context.new_child(filled_slots_map) except KeyError: return ChainMap(filled_slots_map) diff --git a/django_components/component_registry.py b/django_components/component_registry.py index eb983276..209592d7 100644 --- a/django_components/component_registry.py +++ b/django_components/component_registry.py @@ -12,13 +12,8 @@ class ComponentRegistry(object): def register(self, name=None, component=None): existing_component = self._registry.get(name) - if ( - existing_component - and existing_component.class_hash != component.class_hash - ): - raise AlreadyRegistered( - 'The component "%s" has already been registered' % name - ) + if existing_component and existing_component.class_hash != component.class_hash: + raise AlreadyRegistered('The component "%s" has already been registered' % name) self._registry[name] = component def unregister(self, name): diff --git a/django_components/management/commands/startcomponent.py b/django_components/management/commands/startcomponent.py index 746c5e9c..6054ebbd 100644 --- a/django_components/management/commands/startcomponent.py +++ b/django_components/management/commands/startcomponent.py @@ -9,9 +9,7 @@ class Command(BaseCommand): help = "Creates a new component" def add_arguments(self, parser): - parser.add_argument( - "name", type=str, help="The name of the component to create" - ) + parser.add_argument("name", type=str, help="The name of the component to create") parser.add_argument( "--path", type=str, @@ -71,9 +69,7 @@ class Command(BaseCommand): elif base_dir: component_path = os.path.join(base_dir, "components", name) else: - raise CommandError( - "You must specify a path or set BASE_DIR in your django settings" - ) + raise CommandError("You must specify a path or set BASE_DIR in your django settings") if os.path.exists(component_path): if force: @@ -84,11 +80,7 @@ class Command(BaseCommand): ) ) else: - self.stdout.write( - self.style.WARNING( - f'The component "{name}" already exists. Overwriting...' - ) - ) + self.stdout.write(self.style.WARNING(f'The component "{name}" already exists. Overwriting...')) else: raise CommandError( f'The component "{name}" already exists at {component_path}. Use --force to overwrite.' @@ -107,9 +99,7 @@ class Command(BaseCommand): ) f.write(script_content.strip()) - with open( - os.path.join(component_path, css_filename), "w" - ) as f: + with open(os.path.join(component_path, css_filename), "w") as f: style_content = dedent( f""" .component-{name} {{ @@ -119,9 +109,7 @@ class Command(BaseCommand): ) f.write(style_content.strip()) - with open( - os.path.join(component_path, template_filename), "w" - ) as f: + with open(os.path.join(component_path, template_filename), "w") as f: template_content = dedent( f"""
@@ -133,9 +121,7 @@ class Command(BaseCommand): ) f.write(template_content.strip()) - with open( - os.path.join(component_path, f"{name}.py"), "w" - ) as f: + with open(os.path.join(component_path, f"{name}.py"), "w") as f: py_content = dedent( f""" from django_components import component @@ -157,16 +143,8 @@ class Command(BaseCommand): f.write(py_content.strip()) if verbose: - self.stdout.write( - self.style.SUCCESS( - f"Successfully created {name} component at {component_path}" - ) - ) + self.stdout.write(self.style.SUCCESS(f"Successfully created {name} component at {component_path}")) else: - self.stdout.write( - self.style.SUCCESS( - f"Successfully created {name} component" - ) - ) + self.stdout.write(self.style.SUCCESS(f"Successfully created {name} component")) else: raise CommandError("You must specify a component name") diff --git a/django_components/management/commands/upgradecomponent.py b/django_components/management/commands/upgradecomponent.py new file mode 100644 index 00000000..5c912e06 --- /dev/null +++ b/django_components/management/commands/upgradecomponent.py @@ -0,0 +1,65 @@ +import os +import re +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.template.engine import Engine + +from django_components.template_loader import Loader + + +class Command(BaseCommand): + help = "Updates component and component_block tags to the new syntax" + + def add_arguments(self, parser): + parser.add_argument("--path", type=str, help="Path to search for components") + + def handle(self, *args, **options): + current_engine = Engine.get_default() + loader = Loader(current_engine) + dirs = loader.get_dirs() + + if settings.BASE_DIR: + dirs.append(Path(settings.BASE_DIR) / "templates") + + if options["path"]: + dirs = [options["path"]] + + for dir_path in dirs: + self.stdout.write(f"Searching for components in {dir_path}...") + for root, _, files in os.walk(dir_path): + for file in files: + if file.endswith((".html", ".py")): + file_path = os.path.join(root, file) + with open(file_path, "r+", encoding="utf-8") as f: + content = f.read() + content_with_closed_components, step0_count = re.subn( + r'({%\s*component\s*"(\w+?)"(.*?)%})(?!.*?{%\s*endcomponent\s*%})', + r"\1{% endcomponent %}", + content, + flags=re.DOTALL, + ) + updated_content, step1_count_opening = re.subn( + r'{%\s*component_block\s*"(\w+?)"\s*(.*?)%}', + r'{% component "\1" \2%}', + content_with_closed_components, + flags=re.DOTALL, + ) + updated_content, step2_count_closing = re.subn( + r'{%\s*endcomponent_block\s*"(\w+?)"\s*%}', + r"{% endcomponent %}", + updated_content, + flags=re.DOTALL, + ) + updated_content, step2_count_closing_no_name = re.subn( + r"{%\s*endcomponent_block\s*%}", r"{% endcomponent %}", updated_content, flags=re.DOTALL + ) + total_updates = ( + step0_count + step1_count_opening + step2_count_closing + step2_count_closing_no_name + ) + if total_updates > 0: + f.seek(0) + f.write(updated_content) + f.truncate() + self.stdout.write(f"Updated {file_path}: {total_updates} changes made") diff --git a/django_components/middleware.py b/django_components/middleware.py index e230983a..4655398c 100644 --- a/django_components/middleware.py +++ b/django_components/middleware.py @@ -11,9 +11,7 @@ CSS_DEPENDENCY_PLACEHOLDER = '' JS_DEPENDENCY_PLACEHOLDER = '' SCRIPT_TAG_REGEX = re.compile("[\w\-/]+?) -->" -) +COMPONENT_COMMENT_REGEX = re.compile(rb"") PLACEHOLDER_REGEX = re.compile( rb"" rb'|' @@ -32,9 +30,7 @@ class ComponentDependencyMiddleware: def __call__(self, request): response = self.get_response(request) if ( - getattr(settings, "COMPONENTS", {}).get( - "RENDER_DEPENDENCIES", False - ) + getattr(settings, "COMPONENTS", {}).get("RENDER_DEPENDENCIES", False) and not isinstance(response, StreamingHttpResponse) and response.get("Content-Type", "").startswith("text/html") ): @@ -43,23 +39,12 @@ class ComponentDependencyMiddleware: def process_response_content(content): - component_names_seen = { - match.group("name") - for match in COMPONENT_COMMENT_REGEX.finditer(content) - } - all_components = [ - registry.get(name.decode("utf-8"))("") for name in component_names_seen - ] + component_names_seen = {match.group("name") for match in COMPONENT_COMMENT_REGEX.finditer(content)} + all_components = [registry.get(name.decode("utf-8"))("") for name in component_names_seen] all_media = join_media(all_components) - js_dependencies = b"".join( - media.encode("utf-8") for media in all_media.render_js() - ) - css_dependencies = b"".join( - media.encode("utf-8") for media in all_media.render_css() - ) - return PLACEHOLDER_REGEX.sub( - DependencyReplacer(css_dependencies, js_dependencies), content - ) + js_dependencies = b"".join(media.encode("utf-8") for media in all_media.render_js()) + css_dependencies = b"".join(media.encode("utf-8") for media in all_media.render_css()) + return PLACEHOLDER_REGEX.sub(DependencyReplacer(css_dependencies, js_dependencies), content) def add_module_attribute_to_scripts(scripts): diff --git a/django_components/safer_staticfiles/apps.py b/django_components/safer_staticfiles/apps.py index 28c3b623..e538d094 100644 --- a/django_components/safer_staticfiles/apps.py +++ b/django_components/safer_staticfiles/apps.py @@ -12,9 +12,7 @@ class SaferStaticFilesConfig(StaticFilesConfig): by the static file server. """ - default = ( - True # Ensure that _this_ app is registered, as opposed to parent cls. - ) + default = True # Ensure that _this_ app is registered, as opposed to parent cls. ignore_patterns = StaticFilesConfig.ignore_patterns + [ "*.py", "*.html", diff --git a/django_components/templatetags/component_tags.py b/django_components/templatetags/component_tags.py index ec795fb2..3256521c 100644 --- a/django_components/templatetags/component_tags.py +++ b/django_components/templatetags/component_tags.py @@ -9,13 +9,7 @@ else: import django.template from django.conf import settings from django.template import Context, Template -from django.template.base import ( - FilterExpression, - Node, - NodeList, - TextNode, - TokenType, -) +from django.template.base import FilterExpression, Node, NodeList, TextNode, TokenType from django.template.defaulttags import CommentNode from django.template.exceptions import TemplateSyntaxError from django.template.library import parse_bits @@ -24,10 +18,7 @@ from django.utils.safestring import mark_safe from django_components.app_settings import app_settings from django_components.component_registry import ComponentRegistry from django_components.component_registry import registry as component_registry -from django_components.middleware import ( - CSS_DEPENDENCY_PLACEHOLDER, - JS_DEPENDENCY_PLACEHOLDER, -) +from django_components.middleware import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER if TYPE_CHECKING: from django_components.component import Component @@ -88,16 +79,8 @@ def component_dependencies_tag(preload=""): if is_dependency_middleware_active(): preloaded_dependencies = [] for component in get_components_from_preload_str(preload): - preloaded_dependencies.append( - RENDERED_COMMENT_TEMPLATE.format( - name=component.registered_name - ) - ) - return mark_safe( - "\n".join(preloaded_dependencies) - + CSS_DEPENDENCY_PLACEHOLDER - + JS_DEPENDENCY_PLACEHOLDER - ) + preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name)) + return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER + JS_DEPENDENCY_PLACEHOLDER) else: rendered_dependencies = [] for component in get_components_from_registry(component_registry): @@ -113,14 +96,8 @@ def component_css_dependencies_tag(preload=""): if is_dependency_middleware_active(): preloaded_dependencies = [] for component in get_components_from_preload_str(preload): - preloaded_dependencies.append( - RENDERED_COMMENT_TEMPLATE.format( - name=component.registered_name - ) - ) - return mark_safe( - "\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER - ) + preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name)) + return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER) else: rendered_dependencies = [] for component in get_components_from_registry(component_registry): @@ -136,14 +113,8 @@ def component_js_dependencies_tag(preload=""): if is_dependency_middleware_active(): preloaded_dependencies = [] for component in get_components_from_preload_str(preload): - preloaded_dependencies.append( - RENDERED_COMMENT_TEMPLATE.format( - name=component.registered_name - ) - ) - return mark_safe( - "\n".join(preloaded_dependencies) + JS_DEPENDENCY_PLACEHOLDER - ) + preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name)) + return mark_safe("\n".join(preloaded_dependencies) + JS_DEPENDENCY_PLACEHOLDER) else: rendered_dependencies = [] for component in get_components_from_registry(component_registry): @@ -152,22 +123,6 @@ def component_js_dependencies_tag(preload=""): return mark_safe("\n".join(rendered_dependencies)) -@register.tag(name="component") -def do_component(parser, token): - bits = token.split_contents() - bits, isolated_context = check_for_isolated_context_keyword(bits) - - component_name, context_args, context_kwargs = parse_component_with_args( - parser, bits, "component" - ) - return ComponentNode( - FilterExpression(component_name, parser), - context_args, - context_kwargs, - isolated_context=isolated_context, - ) - - class UserSlotVar: """ Extensible mechanism for offering 'fill' blocks in template access to properties @@ -233,24 +188,17 @@ class SlotNode(Node, TemplateAwareNodeMixin): def render(self, context): try: - filled_slots_map: FilledSlotsContext = context[ - FILLED_SLOTS_CONTENT_CONTEXT_KEY - ] + filled_slots_map: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY] except KeyError: - raise TemplateSyntaxError( - f"Attempted to render SlotNode '{self.name}' outside a parent component." - ) + raise TemplateSyntaxError(f"Attempted to render SlotNode '{self.name}' outside a parent component.") extra_context = {} try: - slot_fill_content: Optional[FillContent] = filled_slots_map[ - (self.name, self.template) - ] + slot_fill_content: Optional[FillContent] = filled_slots_map[(self.name, self.template)] except KeyError: if self.is_required: raise TemplateSyntaxError( - f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), " - f"yet no fill is provided. " + f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), " f"yet no fill is provided. " ) nodelist = self.nodelist else: @@ -274,9 +222,7 @@ def do_slot(parser, token): if 1 <= len(args) <= 3: slot_name, *options = args if not is_wrapped_in_quotes(slot_name): - raise TemplateSyntaxError( - f"'{bits[0]}' name must be a string 'literal'." - ) + raise TemplateSyntaxError(f"'{bits[0]}' name must be a string 'literal'.") slot_name = strip_quotes(slot_name) modifiers_count = len(options) if SLOT_REQUIRED_OPTION_KEYWORD in options: @@ -290,9 +236,7 @@ def do_slot(parser, token): SLOT_REQUIRED_OPTION_KEYWORD, SLOT_DEFAULT_OPTION_KEYWORD, ] - raise TemplateSyntaxError( - f"Invalid options passed to 'slot' tag. Valid choices: {keywords}." - ) + raise TemplateSyntaxError(f"Invalid options passed to 'slot' tag. Valid choices: {keywords}.") else: raise TemplateSyntaxError( "'slot' tag does not match pattern " @@ -321,7 +265,7 @@ class BaseFillNode(Node): raise TemplateSyntaxError( "{% fill ... %} block cannot be rendered directly. " "You are probably seeing this because you have used one outside " - "a {% component_block %} context." + "a {% component %} context." ) @@ -342,7 +286,7 @@ class NamedFillNode(BaseFillNode): class ImplicitFillNode(BaseFillNode): """ - Instantiated when a `component_block` tag pair is passed template content that + Instantiated when a `component` tag pair is passed template content that excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked as 'default'. """ @@ -354,10 +298,10 @@ class ImplicitFillNode(BaseFillNode): @register.tag("fill") def do_fill(parser, token): """Block tag whose contents 'fill' (are inserted into) an identically named - 'slot'-block in the component template referred to by a parent component_block. + 'slot'-block in the component template referred to by a parent component. It exists to make component nesting easier. - This tag is available only within a {% component_block %}..{% endcomponent_block %} block. + This tag is available only within a {% component %}..{% endcomponent %} block. Runtime checks should prohibit other usages. """ bits = token.split_contents() @@ -371,14 +315,10 @@ def do_fill(parser, token): elif len(args) == 3: tgt_slot_name, as_keyword, alias = args if as_keyword.lower() != "as": - raise TemplateSyntaxError( - f"{tag} tag args do not conform to pattern ' as '" - ) + raise TemplateSyntaxError(f"{tag} tag args do not conform to pattern ' as '") alias_fexp = FilterExpression(alias, parser) else: - raise TemplateSyntaxError( - f"'{tag}' tag takes either 1 or 3 arguments: Received {len(args)}." - ) + raise TemplateSyntaxError(f"'{tag}' tag takes either 1 or 3 arguments: Received {len(args)}.") nodelist = parser.parse(parse_until=["endfill"]) parser.delete_first_token() @@ -414,27 +354,18 @@ class ComponentNode(Node): def __repr__(self): return "" % ( self.name_fexp, - getattr( - self, "nodelist", None - ), # 'nodelist' attribute only assigned later. + getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later. ) def render(self, context: Context): resolved_component_name = self.name_fexp.resolve(context) - component_cls: Type[Component] = component_registry.get( - resolved_component_name - ) + component_cls: Type[Component] = component_registry.get(resolved_component_name) # Resolve FilterExpressions and Variables that were passed as args to the # component, then call component's context method # to get values to insert into the context - resolved_context_args = [ - safe_resolve(arg, context) for arg in self.context_args - ] - resolved_context_kwargs = { - key: safe_resolve(kwarg, context) - for key, kwarg in self.context_kwargs.items() - } + resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args] + resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()} if isinstance(self.fill_nodes, ImplicitFillNode): fill_content = self.fill_nodes.nodelist @@ -454,9 +385,7 @@ class ComponentNode(Node): ) else: resolved_alias: None = None - fill_content.append( - (resolved_name, fill_node.nodelist, resolved_alias) - ) + fill_content.append((resolved_name, fill_node.nodelist, resolved_alias)) component: Component = component_cls( registered_name=resolved_component_name, @@ -464,9 +393,7 @@ class ComponentNode(Node): fill_content=fill_content, ) - component_context: dict = component.get_context_data( - *resolved_context_args, **resolved_context_kwargs - ) + component_context: dict = component.get_context_data(*resolved_context_args, **resolved_context_kwargs) if self.isolated_context: context = context.new() @@ -474,22 +401,19 @@ class ComponentNode(Node): rendered_component = component.render(context) if is_dependency_middleware_active(): - return ( - RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name) - + rendered_component - ) + return RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name) + rendered_component else: return rendered_component -@register.tag(name="component_block") -def do_component_block(parser, token): +@register.tag(name="component") +def do_component(parser, token): """ To give the component access to the template context: - {% component_block "name" positional_arg keyword_arg=value ... %} + {% component "name" positional_arg keyword_arg=value ... %} To render the component in an isolated context: - {% component_block "name" positional_arg keyword_arg=value ... only %} + {% component "name" positional_arg keyword_arg=value ... only %} Positional and keyword arguments can be literals or template variables. The component name must be a single- or double-quotes string and must @@ -499,10 +423,8 @@ def do_component_block(parser, token): bits = token.split_contents() bits, isolated_context = check_for_isolated_context_keyword(bits) - component_name, context_args, context_kwargs = parse_component_with_args( - parser, bits, "component_block" - ) - body: NodeList = parser.parse(parse_until=["endcomponent_block"]) + component_name, context_args, context_kwargs = parse_component_with_args(parser, bits, "component") + body: NodeList = parser.parse(parse_until=["endcomponent"]) parser.delete_first_token() fill_nodes = () if block_has_content(body): @@ -515,7 +437,7 @@ def do_component_block(parser, token): break else: raise TemplateSyntaxError( - "Illegal content passed to 'component_block' tag pair. " + "Illegal content passed to 'component' tag pair. " "Possible causes: 1) Explicit 'fill' tags cannot occur alongside other " "tags except comment tags; 2) Default (default slot-targeting) content " "is mixed with explict 'fill' tags." @@ -592,10 +514,7 @@ def is_whitespace_token(token): def is_block_tag_token(token, name): - return ( - token.token_type == TokenType.BLOCK - and token.split_contents()[0] == name - ) + return token.token_type == TokenType.BLOCK and token.split_contents()[0] == name @register.tag(name="if_filled") @@ -627,9 +546,7 @@ def do_if_filled_block(parser, token): slot_name, is_positive = parse_if_filled_bits(bits) nodelist = parser.parse(("elif_filled", "else_filled", "endif_filled")) branches: List[_IfSlotFilledBranchNode] = [ - IfSlotFilledConditionBranchNode( - slot_name=slot_name, nodelist=nodelist, is_positive=is_positive - ) + IfSlotFilledConditionBranchNode(slot_name=slot_name, nodelist=nodelist, is_positive=is_positive) ] token = parser.next_token() @@ -638,13 +555,9 @@ def do_if_filled_block(parser, token): while token.contents.startswith("elif_filled"): bits = token.split_contents() slot_name, is_positive = parse_if_filled_bits(bits) - nodelist: NodeList = parser.parse( - ("elif_filled", "else_filled", "endif_filled") - ) + nodelist: NodeList = parser.parse(("elif_filled", "else_filled", "endif_filled")) branches.append( - IfSlotFilledConditionBranchNode( - slot_name=slot_name, nodelist=nodelist, is_positive=is_positive - ) + IfSlotFilledConditionBranchNode(slot_name=slot_name, nodelist=nodelist, is_positive=is_positive) ) token = parser.next_token() @@ -673,9 +586,7 @@ def parse_if_filled_bits( tag, args = bits[0], bits[1:] if tag in ("else_filled", "endif_filled"): if len(args) != 0: - raise TemplateSyntaxError( - f"Tag '{tag}' takes no arguments. Received '{' '.join(args)}'" - ) + raise TemplateSyntaxError(f"Tag '{tag}' takes no arguments. Received '{' '.join(args)}'") else: return None, None if len(args) == 1: @@ -686,13 +597,10 @@ def parse_if_filled_bits( is_positive = bool_from_string(args[1]) else: raise TemplateSyntaxError( - f"{bits[0]} tag arguments '{' '.join(args)}' do not match pattern " - f"' ()'" + f"{bits[0]} tag arguments '{' '.join(args)}' do not match pattern " f"' ()'" ) if not is_wrapped_in_quotes(slot_name): - raise TemplateSyntaxError( - f"First argument of '{bits[0]}' must be a quoted string 'literal'." - ) + raise TemplateSyntaxError(f"First argument of '{bits[0]}' must be a quoted string 'literal'.") slot_name = strip_quotes(slot_name) return slot_name, is_positive @@ -708,9 +616,7 @@ class _IfSlotFilledBranchNode(Node): raise NotImplementedError -class IfSlotFilledConditionBranchNode( - _IfSlotFilledBranchNode, TemplateAwareNodeMixin -): +class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, TemplateAwareNodeMixin): def __init__( self, slot_name: str, @@ -723,9 +629,7 @@ class IfSlotFilledConditionBranchNode( def evaluate(self, context) -> bool: try: - filled_slots: FilledSlotsContext = context[ - FILLED_SLOTS_CONTENT_CONTEXT_KEY - ] + filled_slots: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY] except KeyError: raise TemplateSyntaxError( f"Attempted to render {type(self).__name__} outside a Component rendering context." @@ -794,9 +698,7 @@ def parse_component_with_args(parser, bits, tag_name): ) if tag_name != tag_args[0].token: - raise RuntimeError( - f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}" - ) + raise RuntimeError(f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}") if len(tag_args) > 1: # At least one position arg, so take the first as the component name component_name = tag_args[1].token @@ -808,9 +710,7 @@ def parse_component_with_args(parser, bits, tag_name): context_args = [] context_kwargs = tag_kwargs except IndexError: - raise TemplateSyntaxError( - f"Call the '{tag_name}' tag with a component name as the first parameter" - ) + raise TemplateSyntaxError(f"Call the '{tag_name}' tag with a component name as the first parameter") return component_name, context_args, context_kwargs @@ -818,11 +718,7 @@ def parse_component_with_args(parser, bits, tag_name): def safe_resolve(context_item, context): """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged.""" - return ( - context_item.resolve(context) - if hasattr(context_item, "resolve") - else context_item - ) + return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item def is_wrapped_in_quotes(s): @@ -830,9 +726,7 @@ def is_wrapped_in_quotes(s): def is_dependency_middleware_active(): - return getattr(settings, "COMPONENTS", {}).get( - "RENDER_DEPENDENCIES", False - ) + return getattr(settings, "COMPONENTS", {}).get("RENDER_DEPENDENCIES", False) def norm_and_validate_name(name: str, tag: str, context: Optional[str] = None): @@ -843,10 +737,7 @@ def norm_and_validate_name(name: str, tag: str, context: Optional[str] = None): name = strip_quotes(name) if not name.isidentifier(): context = f" in '{context}'" if context else "" - raise TemplateSyntaxError( - f"{tag} name '{name}'{context} " - "is not a valid Python identifier." - ) + raise TemplateSyntaxError(f"{tag} name '{name}'{context} " "is not a valid Python identifier.") return name diff --git a/pyproject.toml b/pyproject.toml index b77ea258..3291558c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.black] -line-length = 79 +line-length = 119 include = '\.pyi?$' exclude = ''' /( @@ -18,7 +18,21 @@ exclude = ''' [tool.isort] profile = "black" -line_length = 79 +line_length = 119 multi_line_output = 3 include_trailing_comma = "True" known_first_party = "django_components" + +[tool.flake8] +ignore = ['E302', 'W503'] +max-line-length = 119 +exclude = [ + 'migrations', + '__pycache__', + 'manage.py', + 'settings.py', + 'env', + '.env', + '.venv', + '.tox', +] \ No newline at end of file diff --git a/requirements-dev.in b/requirements-dev.in index b7968eb5..413f4750 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -2,5 +2,7 @@ django tox pytest flake8 +flake8-pyproject isort -pre-commit \ No newline at end of file +pre-commit +black \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 63f082a4..893becfb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,23 +6,31 @@ # asgiref==3.7.2 # via django +black==24.2.0 + # via -r requirements-dev.in cachetools==5.3.2 # via tox cfgv==3.4.0 # via pre-commit chardet==5.2.0 # via tox +click==8.1.7 + # via black colorama==0.4.6 # via tox distlib==0.3.8 # via virtualenv -django==5.0.2 +django==5.0.3 # via -r requirements-dev.in filelock==3.13.1 # via # tox # virtualenv flake8==7.0.0 + # via + # -r requirements-dev.in + # flake8-pyproject +flake8-pyproject==1.2.3 # via -r requirements-dev.in identify==2.5.33 # via pre-commit @@ -32,15 +40,21 @@ isort==5.13.2 # via -r requirements-dev.in mccabe==0.7.0 # via flake8 +mypy-extensions==1.0.0 + # via black nodeenv==1.8.0 # via pre-commit packaging==23.2 # via + # black # pyproject-api # pytest # tox +pathspec==0.12.1 + # via black platformdirs==4.1.0 # via + # black # tox # virtualenv pluggy==1.3.0 @@ -55,7 +69,7 @@ pyflakes==3.2.0 # via flake8 pyproject-api==1.6.1 # via tox -pytest==8.0.1 +pytest==8.0.2 # via -r requirements-dev.in pyyaml==6.0.1 # via pre-commit diff --git a/sampleproject/calendarapp/templates/calendarapp/calendar.html b/sampleproject/calendarapp/templates/calendarapp/calendar.html index 91a5683e..a763ece9 100644 --- a/sampleproject/calendarapp/templates/calendarapp/calendar.html +++ b/sampleproject/calendarapp/templates/calendarapp/calendar.html @@ -5,12 +5,12 @@ {% component_css_dependencies %} - {% component "calendar" date=date %} - {% component_block "greeting" name='Joe' %} + {% component "calendar" date=date %}{% endcomponent %} + {% component "greeting" name='Joe' %} {% fill "message" %} Howdy? {% endfill %} - {% endcomponent_block %} + {% endcomponent %} {% component_js_dependencies %} diff --git a/sampleproject/components/calendar/calendar.html b/sampleproject/components/calendar/calendar.html index ced9dcb6..4f6c908f 100644 --- a/sampleproject/components/calendar/calendar.html +++ b/sampleproject/components/calendar/calendar.html @@ -3,17 +3,17 @@
Your to-dos:
  • - {% component_block "todo" %} + {% component "todo" %} {% fill "todo_text" %} Stop forgetting the milk! {% endfill %} - {% endcomponent_block %} + {% endcomponent %}
  • - {% component_block "todo" %} + {% component "todo" %} {# As of v0.28, 'fill' tag optional for 1-slot filling if component template specifies a 'default' slot #} Wear all-white clothes to laser tag tournament. - {% endcomponent_block %} + {% endcomponent %}
\ No newline at end of file diff --git a/scripts/supported_versions.py b/scripts/supported_versions.py index 8f00999a..2d8f4061 100644 --- a/scripts/supported_versions.py +++ b/scripts/supported_versions.py @@ -30,9 +30,7 @@ def get_supported_versions(url): django_to_python = { version_to_tuple(python_version): [ version_to_tuple(version_string) - for version_string in re.findall( - r"(?

Document and website structure

- {% component 'breadcrumb_component' items=5 %} + {% component 'breadcrumb_component' items=5 %}{% endcomponent %}