refactor: replace isort, black and flake8 with ruff (#1346)
Some checks are pending
Docs - build & deploy / docs (push) Waiting to run
Run tests / build (ubuntu-latest, 3.10) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.11) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.12) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.13) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.8) (push) Waiting to run
Run tests / test_sampleproject (3.13) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.9) (push) Waiting to run
Run tests / build (windows-latest, 3.10) (push) Waiting to run
Run tests / build (windows-latest, 3.11) (push) Waiting to run
Run tests / build (windows-latest, 3.12) (push) Waiting to run
Run tests / build (windows-latest, 3.13) (push) Waiting to run
Run tests / build (windows-latest, 3.8) (push) Waiting to run
Run tests / build (windows-latest, 3.9) (push) Waiting to run
Run tests / test_docs (3.13) (push) Waiting to run

This commit is contained in:
Juro Oravec 2025-09-10 14:06:53 +02:00 committed by GitHub
parent 5279fd372a
commit f100cc1836
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
128 changed files with 3076 additions and 2599 deletions

View file

@ -8,7 +8,7 @@ Always reference these instructions first and fallback to search or bash command
### Initial Setup ### Initial Setup
- Install development dependencies: - Install development dependencies:
- `pip install -r requirements-dev.txt` -- installs all dev dependencies including pytest, black, flake8, etc. - `pip install -r requirements-dev.txt` -- installs all dev dependencies including pytest, ruff, etc.
- `pip install -e .` -- install the package in development mode - `pip install -e .` -- install the package in development mode
- Install Playwright for browser testing (optional, may timeout): - Install Playwright for browser testing (optional, may timeout):
- `playwright install chromium --with-deps` -- NEVER CANCEL: Can take 10+ minutes due to large download. Set timeout to 15+ minutes. - `playwright install chromium --with-deps` -- NEVER CANCEL: Can take 10+ minutes due to large download. Set timeout to 15+ minutes.
@ -20,14 +20,11 @@ Always reference these instructions first and fallback to search or bash command
- `python -m pytest tests/test_component.py` -- runs specific test file (~5 seconds) - `python -m pytest tests/test_component.py` -- runs specific test file (~5 seconds)
- `python -m pytest tests/test_templatetags*.py` -- runs template tag tests (~10 seconds, 349 tests) - `python -m pytest tests/test_templatetags*.py` -- runs template tag tests (~10 seconds, 349 tests)
- Run linting and code quality checks: - Run linting and code quality checks:
- `black --check src/django_components` -- check code formatting (~1 second) - `ruff check .` -- run linting, and import sorting (~2 seconds)
- `black src/django_components` -- format code - `ruff format .` -- format code
- `isort --check-only --diff src/django_components` -- check import sorting (~1 second)
- `flake8 .` -- run linting (~2 seconds)
- `mypy .` -- run type checking (~10 seconds, may show some errors in tests) - `mypy .` -- run type checking (~10 seconds, may show some errors in tests)
- Use tox for comprehensive testing (requires network access): - Use tox for comprehensive testing (requires network access):
- `tox -e black` -- run black in isolated environment - `tox -e ruff` -- run ruff in isolated environment
- `tox -e flake8` -- run flake8 in isolated environment
- `tox` -- run full test matrix (multiple Python/Django versions). NEVER CANCEL: Takes 10-30 minutes. - `tox` -- run full test matrix (multiple Python/Django versions). NEVER CANCEL: Takes 10-30 minutes.
### Sample Project Testing ### Sample Project Testing
@ -52,7 +49,7 @@ The package provides custom Django management commands:
## Validation ## Validation
- Always run linting before committing: `black src/django_components && isort src/django_components && flake8 .` - Always run linting before committing: `ruff check .`
- Always run at least basic tests: `python -m pytest tests/test_component.py` - Always run at least basic tests: `python -m pytest tests/test_component.py`
- Test sample project functionality: Start the sample project and make a request to verify components render correctly - Test sample project functionality: Start the sample project and make a request to verify components render correctly
- Check that imports work: `python -c "import django_components; print('OK')"` - Check that imports work: `python -c "import django_components; print('OK')"`
@ -80,7 +77,7 @@ The package provides custom Django management commands:
- Tests run on Python 3.8-3.13 with Django 4.2-5.2 - Tests run on Python 3.8-3.13 with Django 4.2-5.2
- Includes Playwright browser testing (requires `playwright install chromium --with-deps`) - Includes Playwright browser testing (requires `playwright install chromium --with-deps`)
- Documentation building uses mkdocs - Documentation building uses mkdocs
- Pre-commit hooks run black, isort, and flake8 - Pre-commit hooks run ruff
### Time Expectations ### Time Expectations
- Installing dependencies: 1-2 minutes - Installing dependencies: 1-2 minutes
@ -100,7 +97,7 @@ The package provides custom Django management commands:
1. Install dependencies: `pip install -r requirements-dev.txt && pip install -e .` 1. Install dependencies: `pip install -r requirements-dev.txt && pip install -e .`
2. Make changes to source code in `src/django_components/` 2. Make changes to source code in `src/django_components/`
3. Run tests: `python -m pytest tests/test_component.py` (or specific test files) 3. Run tests: `python -m pytest tests/test_component.py` (or specific test files)
4. Run linting: `black src/django_components && isort src/django_components && flake8 .` 4. Run linting: `ruff check .`
5. Test sample project: `cd sampleproject && python manage.py runserver` 5. Test sample project: `cd sampleproject && python manage.py runserver`
6. Validate with curl: `curl http://127.0.0.1:8000/` 6. Validate with curl: `curl http://127.0.0.1:8000/`
7. Run broader tests before final commit: `python -m pytest tests/test_templatetags*.py` 7. Run broader tests before final commit: `python -m pytest tests/test_templatetags*.py`

View file

@ -1,14 +1,10 @@
repos: repos:
- repo: https://github.com/pycqa/isort - repo: https://github.com/astral-sh/ruff-pre-commit
rev: 5.13.2 # Ruff version.
rev: v0.12.9
hooks: hooks:
- id: isort # Run the linter.
- repo: https://github.com/psf/black - id: ruff-check
rev: 24.10.0 args: [ --fix ]
hooks: # Run the formatter.
- id: black - id: ruff-format
- repo: https://github.com/pycqa/flake8
rev: 7.1.1
hooks:
- id: flake8
additional_dependencies: [flake8-pyproject]

View file

@ -8,10 +8,8 @@ from typing import Literal
# Fix for for https://github.com/airspeed-velocity/asv_runner/pull/44 # Fix for for https://github.com/airspeed-velocity/asv_runner/pull/44
import benchmarks.monkeypatch_asv # noqa: F401 import benchmarks.monkeypatch_asv # noqa: F401
from benchmarks.utils import benchmark, create_virtual_module from benchmarks.utils import benchmark, create_virtual_module
DJC_VS_DJ_GROUP = "Components vs Django" DJC_VS_DJ_GROUP = "Components vs Django"
DJC_ISOLATED_VS_NON_GROUP = "isolated vs django modes" DJC_ISOLATED_VS_NON_GROUP = "isolated vs django modes"
OTHER_GROUP = "Other" OTHER_GROUP = "Other"
@ -30,7 +28,7 @@ TemplatingTestType = Literal[
def _get_templating_filepath(renderer: TemplatingRenderer, size: TemplatingTestSize) -> Path: def _get_templating_filepath(renderer: TemplatingRenderer, size: TemplatingTestSize) -> Path:
if renderer == "none": if renderer == "none":
raise ValueError("Cannot get filepath for renderer 'none'") raise ValueError("Cannot get filepath for renderer 'none'")
elif renderer not in ["django", "django-components"]: if renderer not in ["django", "django-components"]:
raise ValueError(f"Invalid renderer: {renderer}") raise ValueError(f"Invalid renderer: {renderer}")
if size not in ("lg", "sm"): if size not in ("lg", "sm"):
@ -43,8 +41,7 @@ def _get_templating_filepath(renderer: TemplatingRenderer, size: TemplatingTestS
file_path = root / "tests" / "test_benchmark_django.py" file_path = root / "tests" / "test_benchmark_django.py"
else: else:
file_path = root / "tests" / "test_benchmark_django_small.py" file_path = root / "tests" / "test_benchmark_django_small.py"
else: elif size == "lg":
if size == "lg":
file_path = root / "tests" / "test_benchmark_djc.py" file_path = root / "tests" / "test_benchmark_djc.py"
else: else:
file_path = root / "tests" / "test_benchmark_djc_small.py" file_path = root / "tests" / "test_benchmark_djc_small.py"
@ -60,7 +57,7 @@ def _get_templating_script(
) -> str: ) -> str:
if renderer == "none": if renderer == "none":
return "" return ""
elif renderer not in ["django", "django-components"]: if renderer not in ["django", "django-components"]:
raise ValueError(f"Invalid renderer: {renderer}") raise ValueError(f"Invalid renderer: {renderer}")
# At this point, we know the renderer is either "django" or "django-components" # At this point, we know the renderer is either "django" or "django-components"
@ -119,7 +116,7 @@ def setup_templating_memory_benchmark(
context_mode: DjcContextMode, context_mode: DjcContextMode,
imports_only: bool = False, imports_only: bool = False,
): ):
global do_render global do_render # noqa: PLW0603
module = _get_templating_module(renderer, size, context_mode, imports_only) module = _get_templating_module(renderer, size, context_mode, imports_only)
data = module.gen_render_data() data = module.gen_render_data()
render = module.render render = module.render
@ -145,9 +142,8 @@ def prepare_templating_benchmark(
# If we're testing the startup time, then the setup is actually the tested code # If we're testing the startup time, then the setup is actually the tested code
if test_type == "startup": if test_type == "startup":
return setup_script return setup_script
else:
# Otherwise include also data generation as part of setup # Otherwise include also data generation as part of setup
setup_script += "\n\n" "render_data = gen_render_data()\n" setup_script += "\n\nrender_data = gen_render_data()\n"
# Do the first render as part of setup if we're testing the subsequent renders # Do the first render as part of setup if we're testing the subsequent renders
if test_type == "subsequent": if test_type == "subsequent":

View file

@ -1,8 +1,10 @@
from typing import Any
from asv_runner.benchmarks.timeraw import TimerawBenchmark, _SeparateProcessTimer from asv_runner.benchmarks.timeraw import TimerawBenchmark, _SeparateProcessTimer
# Fix for https://github.com/airspeed-velocity/asv_runner/pull/44 # Fix for https://github.com/airspeed-velocity/asv_runner/pull/44
def _get_timer(self, *param): def _get_timer(self: Any, *param: Any) -> _SeparateProcessTimer:
""" """
Returns a timer that runs the benchmark function in a separate process. Returns a timer that runs the benchmark function in a separate process.
@ -16,7 +18,7 @@ def _get_timer(self, *param):
""" """
if param: if param:
def func(): def func() -> Any:
# ---------- OUR CHANGES: ADDED RETURN STATEMENT ---------- # ---------- OUR CHANGES: ADDED RETURN STATEMENT ----------
return self.func(*param) return self.func(*param)
# ---------- OUR CHANGES END ---------- # ---------- OUR CHANGES END ----------

View file

@ -1,9 +1,9 @@
import os import os
import sys import sys
from importlib.abc import Loader from importlib.abc import Loader
from importlib.util import spec_from_loader, module_from_spec from importlib.util import module_from_spec, spec_from_loader
from types import ModuleType from types import ModuleType
from typing import Any, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
# NOTE: benchmark_name constraints: # NOTE: benchmark_name constraints:
@ -20,35 +20,35 @@ def benchmark(
number: Optional[int] = None, number: Optional[int] = None,
min_run_count: Optional[int] = None, min_run_count: Optional[int] = None,
include_in_quick_benchmark: bool = False, include_in_quick_benchmark: bool = False,
**kwargs, **kwargs: Any,
): ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func): def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
# For pull requests, we want to run benchmarks only for a subset of tests, # For pull requests, we want to run benchmarks only for a subset of tests,
# because the full set of tests takes about 10 minutes to run (5 min per commit). # because the full set of tests takes about 10 minutes to run (5 min per commit).
# This is done by setting DJC_BENCHMARK_QUICK=1 in the environment. # This is done by setting DJC_BENCHMARK_QUICK=1 in the environment.
if os.getenv("DJC_BENCHMARK_QUICK") and not include_in_quick_benchmark: if os.getenv("DJC_BENCHMARK_QUICK") and not include_in_quick_benchmark:
# By setting the benchmark name to something that does NOT start with # By setting the benchmark name to something that does NOT start with
# valid prefixes like `time_`, `mem_`, or `peakmem_`, this function will be ignored by asv. # valid prefixes like `time_`, `mem_`, or `peakmem_`, this function will be ignored by asv.
func.benchmark_name = "noop" func.benchmark_name = "noop" # type: ignore[attr-defined]
return func return func
# "group_name" is our custom field, which we actually convert to asv's "benchmark_name" # "group_name" is our custom field, which we actually convert to asv's "benchmark_name"
if group_name is not None: if group_name is not None:
benchmark_name = f"{group_name}.{func.__name__}" benchmark_name = f"{group_name}.{func.__name__}"
func.benchmark_name = benchmark_name func.benchmark_name = benchmark_name # type: ignore[attr-defined]
# Also "params" is custom, so we normalize it to "params" and "param_names" # Also "params" is custom, so we normalize it to "params" and "param_names"
if params is not None: if params is not None:
func.params, func.param_names = list(params.values()), list(params.keys()) func.params, func.param_names = list(params.values()), list(params.keys()) # type: ignore[attr-defined]
if pretty_name is not None: if pretty_name is not None:
func.pretty_name = pretty_name func.pretty_name = pretty_name # type: ignore[attr-defined]
if timeout is not None: if timeout is not None:
func.timeout = timeout func.timeout = timeout # type: ignore[attr-defined]
if number is not None: if number is not None:
func.number = number func.number = number # type: ignore[attr-defined]
if min_run_count is not None: if min_run_count is not None:
func.min_run_count = min_run_count func.min_run_count = min_run_count # type: ignore[attr-defined]
# Additional, untyped kwargs # Additional, untyped kwargs
for k, v in kwargs.items(): for k, v in kwargs.items():
@ -60,11 +60,11 @@ def benchmark(
class VirtualModuleLoader(Loader): class VirtualModuleLoader(Loader):
def __init__(self, code_string): def __init__(self, code_string: str) -> None:
self.code_string = code_string self.code_string = code_string
def exec_module(self, module): def exec_module(self, module: ModuleType) -> None:
exec(self.code_string, module.__dict__) exec(self.code_string, module.__dict__) # noqa: S102
def create_virtual_module(name: str, code_string: str, file_path: str) -> ModuleType: def create_virtual_module(name: str, code_string: str, file_path: str) -> ModuleType:

View file

@ -1,4 +1,4 @@
## Install locally and run the tests ## Local installation
Start by forking the project by clicking the **Fork button** up in the right corner in the [GitHub](https://github.com/django-components/django-components). Start by forking the project by clicking the **Fork button** up in the right corner in the [GitHub](https://github.com/django-components/django-components).
This makes a copy of the repository in your own name. Now you can clone this repository locally and start adding features: This makes a copy of the repository in your own name. Now you can clone this repository locally and start adding features:
@ -20,6 +20,8 @@ You also have to install this local django-components version. Use `-e` for [edi
pip install -e . pip install -e .
``` ```
## Running tests
Now you can run the tests to make sure everything works as expected: Now you can run the tests to make sure everything works as expected:
```sh ```sh
@ -47,15 +49,39 @@ tox -e py38
NOTE: See the available environments in `tox.ini`. NOTE: See the available environments in `tox.ini`.
And to run only linters, use: ## Linting and formatting
To check linting rules, run:
```sh ```sh
tox -e mypy,flake8,isort,black ruff check .
# Or to fix errors automatically:
ruff check --fix .
``` ```
## Running Playwright tests To format the code, run:
We use [Playwright](https://playwright.dev/python/docs/intro) for end-to-end tests. You will therefore need to install Playwright to be able to run these tests. ```sh
ruff format --check .
# Or to fix errors automatically:
ruff format .
```
To validate with Mypy, run:
```sh
mypy .
```
You can run these through `tox` as well:
```sh
tox -e mypy,ruff
```
## Playwright tests
We use [Playwright](https://playwright.dev/python/docs/intro) for end-to-end tests. You will need to install Playwright to run these tests.
Luckily, Playwright makes it very easy: Luckily, Playwright makes it very easy:
@ -64,13 +90,15 @@ pip install -r requirements-dev.txt
playwright install chromium --with-deps playwright install chromium --with-deps
``` ```
After Playwright is ready, simply run the tests with `tox`: After Playwright is ready, run the tests the same way as before:
```sh ```sh
tox pytest
# Or for specific Python version
tox -e py38
``` ```
## Developing against live Django app ## Dev server
How do you check that your changes to django-components project will work in an actual Django project? How do you check that your changes to django-components project will work in an actual Django project?
@ -96,9 +124,10 @@ Use the [sampleproject](https://github.com/django-components/django-components/t
!!! note !!! note
The path to the local version (in this case `..`) must point to the directory that has the `setup.py` file. The path to the local version (in this case `..`) must point to the directory that has the `pyproject.toml` file.
4. Start Django server:
4. Start Django server
```sh ```sh
python manage.py runserver python manage.py runserver
``` ```
@ -116,7 +145,7 @@ django_components uses a bit of JS code to:
When you make changes to this JS code, you also need to compile it: When you make changes to this JS code, you also need to compile it:
1. Make sure you are inside `src/django_components_js`: 1. Navigate to `src/django_components_js`:
```sh ```sh
cd src/django_components_js cd src/django_components_js

View file

@ -91,7 +91,7 @@ MyTable.render(
## Live examples ## Live examples
For live interactive examples, [start our demo project](../../community/development.md#developing-against-live-django-app) For live interactive examples, [start our demo project](../../community/development.md#dev-server)
(`sampleproject`). (`sampleproject`).
Then navigate to these URLs: Then navigate to these URLs:

View file

@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from typing import List, Optional, Type from typing import Any, List, Optional, Type
import griffe import griffe
from mkdocs_util import get_mkdocstrings_plugin_handler_options, import_object, load_config from mkdocs_util import get_mkdocstrings_plugin_handler_options, import_object, load_config
@ -18,7 +18,7 @@ is_skip_docstring: bool = mkdocstrings_config.get("show_if_no_docstring", "false
class RuntimeBasesExtension(griffe.Extension): class RuntimeBasesExtension(griffe.Extension):
"""Griffe extension that lists class bases.""" """Griffe extension that lists class bases."""
def on_class_instance(self, cls: griffe.Class, **kwargs) -> None: def on_class_instance(self, cls: griffe.Class, **_kwargs: Any) -> None:
if is_skip_docstring and cls.docstring is None: if is_skip_docstring and cls.docstring is None:
return return
@ -37,7 +37,7 @@ class RuntimeBasesExtension(griffe.Extension):
class SourceCodeExtension(griffe.Extension): class SourceCodeExtension(griffe.Extension):
"""Griffe extension that adds link to the source code at the end of the docstring.""" """Griffe extension that adds link to the source code at the end of the docstring."""
def on_instance(self, obj: griffe.Object, **kwargs) -> None: def on_instance(self, obj: griffe.Object, **_kwargs: Any) -> None:
if is_skip_docstring and obj.docstring is None: if is_skip_docstring and obj.docstring is None:
return return
@ -46,7 +46,7 @@ class SourceCodeExtension(griffe.Extension):
obj.docstring.value = html + obj.docstring.value obj.docstring.value = html + obj.docstring.value
def _format_source_code_html(relative_filepath: Path, lineno: Optional[int]): def _format_source_code_html(relative_filepath: Path, lineno: Optional[int]) -> str:
# Remove trailing slash and whitespace # Remove trailing slash and whitespace
repo_url = load_config()["repo_url"].strip("/ ") repo_url = load_config()["repo_url"].strip("/ ")
branch_path = f"tree/{SOURCE_CODE_GIT_BRANCH}" branch_path = f"tree/{SOURCE_CODE_GIT_BRANCH}"

View file

@ -9,7 +9,7 @@ from mkdocs_gen_files import Nav
ROOT = pathlib.Path(__file__).parent.parent.parent ROOT = pathlib.Path(__file__).parent.parent.parent
def generate_release_notes(): def generate_release_notes() -> None:
""" """
Reads CHANGELOG.md, splits it into per-version pages, Reads CHANGELOG.md, splits it into per-version pages,
and generates an index page with links to all versions. and generates an index page with links to all versions.
@ -20,7 +20,7 @@ def generate_release_notes():
# Create the output directory if it doesn't exist # Create the output directory if it doesn't exist
(ROOT / "docs" / releases_dir).mkdir(parents=True, exist_ok=True) (ROOT / "docs" / releases_dir).mkdir(parents=True, exist_ok=True)
with open(changelog_path, "r", encoding="utf-8") as f: with changelog_path.open("r", encoding="utf-8") as f:
changelog_content = f.read() changelog_content = f.read()
# Split the changelog by version headers (e.g., "## vX.Y.Z") # Split the changelog by version headers (e.g., "## vX.Y.Z")
@ -62,7 +62,7 @@ def generate_release_notes():
# Prepare title for navigation, e.g. "v0.140.0 (2024-09-11)" # Prepare title for navigation, e.g. "v0.140.0 (2024-09-11)"
nav_title = version_title_full nav_title = version_title_full
if date_str: if date_str:
parsed_date = datetime.strptime(date_str, "%d %b %Y") parsed_date = datetime.strptime(date_str, "%d %b %Y") # noqa: DTZ007
formatted_date = parsed_date.strftime("%Y-%m-%d") formatted_date = parsed_date.strftime("%Y-%m-%d")
nav_title += f" ({formatted_date})" nav_title += f" ({formatted_date})"

View file

@ -3,22 +3,22 @@
from functools import lru_cache from functools import lru_cache
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
import griffe import griffe
import yaml # type: ignore[import-untyped] import yaml # type: ignore[import-untyped]
@lru_cache() @lru_cache
def load_config() -> Dict: def load_config() -> Dict:
mkdocs_config_str = Path("mkdocs.yml").read_text() mkdocs_config_str = Path("mkdocs.yml").read_text()
# NOTE: Use BaseLoader to avoid resolving tags like `!ENV` # NOTE: Use BaseLoader to avoid resolving tags like `!ENV`
# See https://stackoverflow.com/questions/45966633/yaml-error-could-not-determine-a-constructor-for-the-tag # See https://stackoverflow.com/questions/45966633/yaml-error-could-not-determine-a-constructor-for-the-tag
mkdocs_config = yaml.load(mkdocs_config_str, yaml.BaseLoader) mkdocs_config = yaml.load(mkdocs_config_str, yaml.BaseLoader) # noqa: S506
return mkdocs_config return mkdocs_config
@lru_cache() @lru_cache
def find_plugin(name: str) -> Optional[Dict]: def find_plugin(name: str) -> Optional[Dict]:
config = load_config() config = load_config()
plugins: List[Union[str, Dict[str, Dict]]] = config.get("plugins", []) plugins: List[Union[str, Dict[str, Dict]]] = config.get("plugins", [])
@ -27,8 +27,8 @@ def find_plugin(name: str) -> Optional[Dict]:
for plugin in plugins: for plugin in plugins:
if isinstance(plugin, str): if isinstance(plugin, str):
plugin = {plugin: {}} plugin = {plugin: {}} # noqa: PLW2901
plugin_name, plugin_conf = list(plugin.items())[0] plugin_name, plugin_conf = next(iter(plugin.items()))
if plugin_name == name: if plugin_name == name:
return plugin_conf return plugin_conf
@ -43,7 +43,7 @@ def get_mkdocstrings_plugin_handler_options() -> Optional[Dict]:
return plugin.get("handlers", {}).get("python", {}).get("options", {}) return plugin.get("handlers", {}).get("python", {}).get("options", {})
def import_object(obj: griffe.Object): def import_object(obj: griffe.Object) -> Any:
module = import_module(obj.module.path) module = import_module(obj.module.path)
runtime_obj = getattr(module, obj.name) runtime_obj = getattr(module, obj.name)
return runtime_obj return runtime_obj

View file

@ -42,19 +42,21 @@ from argparse import ArgumentParser
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from textwrap import dedent from textwrap import dedent
from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple, Type, Union from typing import TYPE_CHECKING, Any, Dict, List, NamedTuple, Optional, Sequence, Tuple, Type, Union
import mkdocs_gen_files import mkdocs_gen_files
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand
from django.urls import URLPattern, URLResolver from django.urls import URLPattern, URLResolver
from django_components import Component, ComponentVars, ComponentCommand, TagFormatterABC from django_components import Component, ComponentCommand, ComponentVars, TagFormatterABC
from django_components.commands.components import ComponentsRootCommand from django_components.commands.components import ComponentsRootCommand
from django_components.node import BaseNode from django_components.node import BaseNode
from django_components.util.command import setup_parser_from_command from django_components.util.command import setup_parser_from_command
from django_components.util.misc import get_import_path from django_components.util.misc import get_import_path
if TYPE_CHECKING:
from django.core.management.base import BaseCommand
# NOTE: This file is an entrypoint for the `gen-files` plugin in `mkdocs.yml`. # NOTE: This file is an entrypoint for the `gen-files` plugin in `mkdocs.yml`.
# However, `gen-files` plugin runs this file as a script, NOT as a module. # However, `gen-files` plugin runs this file as a script, NOT as a module.
# That means that: # That means that:
@ -71,7 +73,7 @@ from extensions import _format_source_code_html # noqa: E402
root = Path(__file__).parent.parent.parent root = Path(__file__).parent.parent.parent
def gen_reference_api(): def gen_reference_api() -> None:
""" """
Generate documentation for the Python API of `django_components`. Generate documentation for the Python API of `django_components`.
@ -109,14 +111,14 @@ def gen_reference_api():
# options: # options:
# show_if_no_docstring: true # show_if_no_docstring: true
# ``` # ```
f.write(f"::: {module.__name__}.{name}\n" f" options:\n" f" show_if_no_docstring: true\n") f.write(f"::: {module.__name__}.{name}\n options:\n show_if_no_docstring: true\n")
f.write("\n") f.write("\n")
mkdocs_gen_files.set_edit_path(out_path, template_path) mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_testing_api(): def gen_reference_testing_api() -> None:
""" """
Generate documentation for the Python API of `django_components.testing`. Generate documentation for the Python API of `django_components.testing`.
@ -142,17 +144,15 @@ def gen_reference_testing_api():
# options: # options:
# show_if_no_docstring: true # show_if_no_docstring: true
# ``` # ```
f.write(f"::: {module.__name__}.{name}\n" f" options:\n" f" show_if_no_docstring: true\n") f.write(f"::: {module.__name__}.{name}\n options:\n show_if_no_docstring: true\n")
f.write("\n") f.write("\n")
mkdocs_gen_files.set_edit_path(out_path, template_path) mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_exceptions(): def gen_reference_exceptions() -> None:
""" """Generate documentation for the Exception classes included in the Python API of `django_components`."""
Generate documentation for the Exception classes included in the Python API of `django_components`.
"""
module = import_module("django_components") module = import_module("django_components")
preface = "<!-- Autogenerated by reference.py -->\n\n" preface = "<!-- Autogenerated by reference.py -->\n\n"
@ -178,14 +178,14 @@ def gen_reference_exceptions():
# options: # options:
# show_if_no_docstring: true # show_if_no_docstring: true
# ``` # ```
f.write(f"::: {module.__name__}.{name}\n" f" options:\n" f" show_if_no_docstring: true\n") f.write(f"::: {module.__name__}.{name}\n options:\n show_if_no_docstring: true\n")
f.write("\n") f.write("\n")
mkdocs_gen_files.set_edit_path(out_path, template_path) mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_components(): def gen_reference_components() -> None:
""" """
Generate documentation for the Component classes (AKA pre-defined components) included Generate documentation for the Component classes (AKA pre-defined components) included
in the Python API of `django_components`. in the Python API of `django_components`.
@ -200,7 +200,7 @@ def gen_reference_components():
with mkdocs_gen_files.open(out_path, "w", encoding="utf-8") as f: with mkdocs_gen_files.open(out_path, "w", encoding="utf-8") as f:
f.write(preface + "\n\n") f.write(preface + "\n\n")
for name, obj in inspect.getmembers(module): for _name, obj in inspect.getmembers(module):
if not _is_component_cls(obj): if not _is_component_cls(obj):
continue continue
@ -236,7 +236,7 @@ def gen_reference_components():
f" show_root_heading: true\n" f" show_root_heading: true\n"
f" show_signature: false\n" f" show_signature: false\n"
f" separate_signature: false\n" f" separate_signature: false\n"
f" members: {members}\n" f" members: {members}\n",
) )
f.write("\n") f.write("\n")
@ -244,10 +244,8 @@ def gen_reference_components():
mkdocs_gen_files.set_edit_path(out_path, template_path) mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_settings(): def gen_reference_settings() -> None:
""" """Generate documentation for the settings of django-components, as defined by the `ComponentsSettings` class."""
Generate documentation for the settings of django-components, as defined by the `ComponentsSettings` class.
"""
module = import_module("django_components.app_settings") module = import_module("django_components.app_settings")
preface = "<!-- Autogenerated by reference.py -->\n\n" preface = "<!-- Autogenerated by reference.py -->\n\n"
@ -293,7 +291,7 @@ def gen_reference_settings():
f" show_symbol_type_heading: false\n" f" show_symbol_type_heading: false\n"
f" show_symbol_type_toc: false\n" f" show_symbol_type_toc: false\n"
f" show_if_no_docstring: true\n" f" show_if_no_docstring: true\n"
f" show_labels: false\n" f" show_labels: false\n",
) )
f.write("\n") f.write("\n")
@ -301,7 +299,7 @@ def gen_reference_settings():
# Get attributes / methods that are unique to the subclass # Get attributes / methods that are unique to the subclass
def _get_unique_methods(base_class: Type, sub_class: Type): def _get_unique_methods(base_class: Type, sub_class: Type) -> List[str]:
base_methods = set(dir(base_class)) base_methods = set(dir(base_class))
subclass_methods = set(dir(sub_class)) subclass_methods = set(dir(sub_class))
unique_methods = subclass_methods - base_methods unique_methods = subclass_methods - base_methods
@ -332,25 +330,25 @@ def _gen_default_settings_section(app_settings_filepath: str) -> str:
# #
# However, for the documentation, we need to remove those. # However, for the documentation, we need to remove those.
dynamic_re = re.compile(r"Dynamic\(lambda\: (?P<code>.+)\)") dynamic_re = re.compile(r"Dynamic\(lambda\: (?P<code>.+)\)")
cleaned_snippet_lines = [] cleaned_snippet_lines: List[str] = []
for line in defaults_snippet_lines: for line in defaults_snippet_lines:
line = comment_re.split(line)[0].rstrip() curr_line = comment_re.split(line)[0].rstrip()
line = dynamic_re.sub( curr_line = dynamic_re.sub(
lambda m: m.group("code"), lambda m: m.group("code"),
line, curr_line,
) )
cleaned_snippet_lines.append(line) cleaned_snippet_lines.append(curr_line)
clean_defaults_snippet = "\n".join(cleaned_snippet_lines) clean_defaults_snippet = "\n".join(cleaned_snippet_lines)
return ( return (
"### Settings defaults\n\n" "### Settings defaults\n\n"
"Here's overview of all available settings and their defaults:\n\n" "Here's overview of all available settings and their defaults:\n\n"
+ f"```py\n{clean_defaults_snippet}\n```" f"```py\n{clean_defaults_snippet}\n```"
+ "\n\n" "\n\n"
) )
def gen_reference_tagformatters(): def gen_reference_tagformatters() -> None:
""" """
Generate documentation for all pre-defined TagFormatters included Generate documentation for all pre-defined TagFormatters included
in the Python API of `django_components`. in the Python API of `django_components`.
@ -387,7 +385,7 @@ def gen_reference_tagformatters():
formatted_instances = "\n".join(formatted_instances_lines) formatted_instances = "\n".join(formatted_instances_lines)
f.write("### Available tag formatters\n\n" + formatted_instances) f.write("### Available tag formatters\n\n" + formatted_instances)
for name, obj in tag_formatter_classes.items(): for obj in tag_formatter_classes.values():
class_name = get_import_path(obj) class_name = get_import_path(obj)
# Generate reference entry for each TagFormatter class. # Generate reference entry for each TagFormatter class.
@ -408,7 +406,7 @@ def gen_reference_tagformatters():
f" show_symbol_type_toc: false\n" f" show_symbol_type_toc: false\n"
f" show_if_no_docstring: true\n" f" show_if_no_docstring: true\n"
f" show_labels: false\n" f" show_labels: false\n"
f" members: false\n" f" members: false\n",
) )
f.write("\n") f.write("\n")
@ -416,10 +414,8 @@ def gen_reference_tagformatters():
mkdocs_gen_files.set_edit_path(out_path, template_path) mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_urls(): def gen_reference_urls() -> None:
""" """Generate documentation for all URLs (`urlpattern` entries) defined by django-components."""
Generate documentation for all URLs (`urlpattern` entries) defined by django-components.
"""
module = import_module("django_components.urls") module = import_module("django_components.urls")
preface = "<!-- Autogenerated by reference.py -->\n\n" preface = "<!-- Autogenerated by reference.py -->\n\n"
@ -437,7 +433,7 @@ def gen_reference_urls():
f.write("\n".join([f"- `{url_path}`\n" for url_path in all_urls])) f.write("\n".join([f"- `{url_path}`\n" for url_path in all_urls]))
def gen_reference_commands(): def gen_reference_commands() -> None:
""" """
Generate documentation for all Django admin commands defined by django-components. Generate documentation for all Django admin commands defined by django-components.
@ -474,7 +470,7 @@ def gen_reference_commands():
# becomes this: # becomes this:
# `usage: python manage.py components ext run [-h]` # `usage: python manage.py components ext run [-h]`
cmd_usage = cmd_usage[:7] + "python manage.py " + " ".join(cmd_path) + " " + cmd_usage[7:] cmd_usage = cmd_usage[:7] + "python manage.py " + " ".join(cmd_path) + " " + cmd_usage[7:]
formatted_args = _format_command_args(cmd_parser, cmd_path + (cmd_def_cls.name,)) formatted_args = _format_command_args(cmd_parser, (*cmd_path, cmd_def_cls.name))
# Add link to source code # Add link to source code
module_abs_path = import_module(cmd_def_cls.__module__).__file__ module_abs_path = import_module(cmd_def_cls.__module__).__file__
@ -483,7 +479,7 @@ def gen_reference_commands():
# NOTE: Raises `OSError` if the file is not found. # NOTE: Raises `OSError` if the file is not found.
try: try:
obj_lineno = inspect.findsource(cmd_def_cls)[1] obj_lineno = inspect.findsource(cmd_def_cls)[1]
except Exception: except Exception: # noqa: BLE001
obj_lineno = None obj_lineno = None
source_code_link = _format_source_code_html(module_rel_path, obj_lineno) source_code_link = _format_source_code_html(module_rel_path, obj_lineno)
@ -498,12 +494,12 @@ def gen_reference_commands():
f"{source_code_link}\n\n" f"{source_code_link}\n\n"
f"{cmd_summary}\n\n" f"{cmd_summary}\n\n"
f"{formatted_args}\n\n" f"{formatted_args}\n\n"
f"{cmd_desc}\n\n" f"{cmd_desc}\n\n",
) )
# Add subcommands # Add subcommands
for subcmd_cls in reversed(cmd_def_cls.subcommands): for subcmd_cls in reversed(cmd_def_cls.subcommands):
commands_stack.append((subcmd_cls, cmd_path + (cmd_def_cls.name,))) commands_stack.append((subcmd_cls, (*cmd_path, cmd_def_cls.name)))
# TODO_v1 - REMOVE - This this section as it only for legacy commands `startcomponent` and `upgradecomponent` # TODO_v1 - REMOVE - This this section as it only for legacy commands `startcomponent` and `upgradecomponent`
command_files = Path("./src/django_components/management/commands").glob("*.py") command_files = Path("./src/django_components/management/commands").glob("*.py")
@ -540,13 +536,13 @@ def gen_reference_commands():
f"{source_code_link}\n\n" f"{source_code_link}\n\n"
f"{cmd_summary}\n\n" f"{cmd_summary}\n\n"
f"{formatted_args}\n\n" f"{formatted_args}\n\n"
f"{cmd_desc}\n\n" f"{cmd_desc}\n\n",
) )
mkdocs_gen_files.set_edit_path(out_path, template_path) mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_template_tags(): def gen_reference_template_tags() -> None:
""" """
Generate documentation for all Django template tags defined by django-components, Generate documentation for all Django template tags defined by django-components,
like `{% slot %}`, `{% component %}`, etc. like `{% slot %}`, `{% component %}`, etc.
@ -573,7 +569,7 @@ def gen_reference_template_tags():
f.write( f.write(
f"All following template tags are defined in\n\n" f"All following template tags are defined in\n\n"
f"`{mod_path}`\n\n" f"`{mod_path}`\n\n"
f"Import as\n```django\n{{% load {mod_name} %}}\n```\n\n" f"Import as\n```django\n{{% load {mod_name} %}}\n```\n\n",
) )
for _, obj in inspect.getmembers(tags_module): for _, obj in inspect.getmembers(tags_module):
@ -597,19 +593,21 @@ def gen_reference_template_tags():
# {% component [arg, ...] **kwargs [only] %} # {% component [arg, ...] **kwargs [only] %}
# {% endcomponent %} # {% endcomponent %}
# ``` # ```
# fmt: off
f.write( f.write(
f"## {name}\n\n" f"## {name}\n\n"
f"```django\n" f"```django\n"
f"{tag_signature}\n" f"{tag_signature}\n"
f"```\n\n" f"```\n\n"
f"{source_code_link}\n\n" f"{source_code_link}\n\n"
f"{docstring}\n\n" f"{docstring}\n\n",
) )
# fmt: on
mkdocs_gen_files.set_edit_path(out_path, template_path) mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_template_variables(): def gen_reference_template_variables() -> None:
""" """
Generate documentation for all variables that are available inside the component templates Generate documentation for all variables that are available inside the component templates
under the `{{ component_vars }}` variable, as defined by `ComponentVars`. under the `{{ component_vars }}` variable, as defined by `ComponentVars`.
@ -628,10 +626,8 @@ def gen_reference_template_variables():
mkdocs_gen_files.set_edit_path(out_path, template_path) mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_extension_hooks(): def gen_reference_extension_hooks() -> None:
""" """Generate documentation for the hooks that are available to the extensions."""
Generate documentation for the hooks that are available to the extensions.
"""
module = import_module("django_components.extension") module = import_module("django_components.extension")
preface = "<!-- Autogenerated by reference.py -->\n\n" preface = "<!-- Autogenerated by reference.py -->\n\n"
@ -691,7 +687,7 @@ def gen_reference_extension_hooks():
f" show_symbol_type_heading: false\n" f" show_symbol_type_heading: false\n"
f" show_symbol_type_toc: false\n" f" show_symbol_type_toc: false\n"
f" show_if_no_docstring: true\n" f" show_if_no_docstring: true\n"
f" show_labels: false\n" f" show_labels: false\n",
) )
f.write("\n") f.write("\n")
f.write(available_data) f.write(available_data)
@ -714,7 +710,7 @@ def gen_reference_extension_hooks():
f"::: {module.__name__}.{name}\n" f"::: {module.__name__}.{name}\n"
f" options:\n" f" options:\n"
f" heading_level: 3\n" f" heading_level: 3\n"
f" show_if_no_docstring: true\n" f" show_if_no_docstring: true\n",
) )
f.write("\n") f.write("\n")
@ -722,10 +718,8 @@ def gen_reference_extension_hooks():
mkdocs_gen_files.set_edit_path(out_path, template_path) mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_extension_commands(): def gen_reference_extension_commands() -> None:
""" """Generate documentation for the objects related to defining extension commands."""
Generate documentation for the objects related to defining extension commands.
"""
module = import_module("django_components") module = import_module("django_components")
preface = "<!-- Autogenerated by reference.py -->\n\n" preface = "<!-- Autogenerated by reference.py -->\n\n"
@ -753,7 +747,7 @@ def gen_reference_extension_commands():
f"::: {module.__name__}.{name}\n" f"::: {module.__name__}.{name}\n"
f" options:\n" f" options:\n"
f" heading_level: 3\n" f" heading_level: 3\n"
f" show_if_no_docstring: true\n" f" show_if_no_docstring: true\n",
) )
f.write("\n") f.write("\n")
@ -761,10 +755,8 @@ def gen_reference_extension_commands():
mkdocs_gen_files.set_edit_path(out_path, template_path) mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_extension_urls(): def gen_reference_extension_urls() -> None:
""" """Generate documentation for the objects related to defining extension URLs."""
Generate documentation for the objects related to defining extension URLs.
"""
module = import_module("django_components") module = import_module("django_components")
preface = "<!-- Autogenerated by reference.py -->\n\n" preface = "<!-- Autogenerated by reference.py -->\n\n"
@ -792,7 +784,7 @@ def gen_reference_extension_urls():
f"::: {module.__name__}.{name}\n" f"::: {module.__name__}.{name}\n"
f" options:\n" f" options:\n"
f" heading_level: 3\n" f" heading_level: 3\n"
f" show_if_no_docstring: true\n" f" show_if_no_docstring: true\n",
) )
f.write("\n") f.write("\n")
@ -855,7 +847,7 @@ def _extract_property_docstrings(cls: Type) -> Dict[str, str]:
and that the body is indented with 4 spaces. and that the body is indented with 4 spaces.
""" """
lines, start_line_index = inspect.getsourcelines(cls) lines, start_line_index = inspect.getsourcelines(cls)
attrs_lines = [] attrs_lines: List[str] = []
ignore = True ignore = True
for line in lines: for line in lines:
if ignore: if ignore:
@ -863,9 +855,8 @@ def _extract_property_docstrings(cls: Type) -> Dict[str, str]:
ignore = False ignore = False
continue continue
# Ignore comments # Ignore comments
elif line.strip().startswith("#"): if line.strip().startswith("#"):
continue continue
else:
attrs_lines.append(line) attrs_lines.append(line)
attrs_docstrings = {} attrs_docstrings = {}
@ -886,7 +877,7 @@ def _extract_property_docstrings(cls: Type) -> Dict[str, str]:
attrs_docstrings[curr_attr] = "" attrs_docstrings[curr_attr] = ""
state = "before_attr_docstring" state = "before_attr_docstring"
elif state == "before_attr_docstring": elif state == "before_attr_docstring":
if not is_one_indent or not (line.startswith("'''") or line.startswith('"""')): if not is_one_indent or not line.startswith(("'''", '"""')):
continue continue
# Found start of docstring # Found start of docstring
docstring_delimiter = line[0:3] docstring_delimiter = line[0:3]
@ -909,7 +900,7 @@ def _extract_property_docstrings(cls: Type) -> Dict[str, str]:
# NOTE: Unlike other references, the API of Signals is not yet codified (AKA source of truth defined # NOTE: Unlike other references, the API of Signals is not yet codified (AKA source of truth defined
# as Python code). Instead, we manually list all signals that are sent by django-components. # as Python code). Instead, we manually list all signals that are sent by django-components.
def gen_reference_signals(): def gen_reference_signals() -> None:
""" """
Generate documentation for all [Django Signals](https://docs.djangoproject.com/en/5.2/ref/signals) that are Generate documentation for all [Django Signals](https://docs.djangoproject.com/en/5.2/ref/signals) that are
send by or during the use of django-components. send by or during the use of django-components.
@ -925,7 +916,7 @@ def gen_reference_signals():
mkdocs_gen_files.set_edit_path(out_path, template_path) mkdocs_gen_files.set_edit_path(out_path, template_path)
def _list_urls(urlpatterns: Sequence[Union[URLPattern, URLResolver]], prefix=""): def _list_urls(urlpatterns: Sequence[Union[URLPattern, URLResolver]], prefix: str = "") -> List[str]:
"""Recursively extract all URLs and their associated views from Django's urlpatterns""" """Recursively extract all URLs and their associated views from Django's urlpatterns"""
urls: List[str] = [] urls: List[str] = []
@ -1077,7 +1068,7 @@ def _parse_command_args(cmd_inputs: str) -> Dict[str, List[Dict]]:
return data return data
def _format_command_args(cmd_parser: ArgumentParser, cmd_path: Optional[Sequence[str]] = None): def _format_command_args(cmd_parser: ArgumentParser, cmd_path: Optional[Sequence[str]] = None) -> str:
cmd_inputs: str = _gen_command_args(cmd_parser) cmd_inputs: str = _gen_command_args(cmd_parser)
parsed_cmd_inputs = _parse_command_args(cmd_inputs) parsed_cmd_inputs = _parse_command_args(cmd_inputs)
@ -1131,9 +1122,8 @@ def _is_extension_url_api(obj: Any) -> bool:
return inspect.isclass(obj) and getattr(obj, "_extension_url_api", False) return inspect.isclass(obj) and getattr(obj, "_extension_url_api", False)
def gen_reference(): def gen_reference() -> None:
"""The entrypoint to generate all the reference documentation.""" """The entrypoint to generate all the reference documentation."""
# Set up Django settings so we can import `extensions` # Set up Django settings so we can import `extensions`
if not settings.configured: if not settings.configured:
settings.configure( settings.configure(

View file

@ -68,39 +68,117 @@ exclude = '''
)/ )/
''' '''
[tool.isort] [tool.ruff]
profile = "black" line-length = 119
line_length = 119 src = ["src", "tests"]
multi_line_output = 3
include_trailing_comma = "True"
known_first_party = "django_components"
[tool.flake8]
ignore = ['E302', 'W503']
max-line-length = 119
exclude = [ exclude = [
'migrations', "migrations",
'__pycache__', "manage.py",
'manage.py', "settings.py",
'settings.py', "env",
'env', ".env",
'.env', # From mypy
'.venv', "test_structures",
'.tox',
'build',
] ]
per-file-ignores = [
'tests/test_command_list.py:E501', # See https://docs.astral.sh/ruff/linter/#rule-selection
'tests/test_component_media.py:E501', [tool.ruff.lint]
'tests/test_dependency_rendering.py:E501', select = ["ALL"]
ignore = [
# Annotations
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `*args`
# Docstring
"D100", # Missing docstring in public module
"D101", # Missing docstring in public class
"D102", # Missing docstring in public method
"D103", # Missing docstring in public function
"D104", # Missing docstring in public package
"D105", # Missing docstring in magic method
"D106", # Missing docstring in public nested class
"D107", # Missing docstring in `__init__`
"D203", # Incorrect blank line before class
"D205", # 1 blank line required between summary line and description
"D212", # Multi-line docstring summary should start at the first line
"D400", # First line should end with a period
"D401", # First line of docstring should be in imperative mood
"D404", # First word of the docstring should not be "This"
"D412", # No blank lines allowed between a section header and its content ("Examples")
"D415", # First line should end with a period, question mark, or exclamation point
# Exceptions
"EM101", # Exception must not use a string literal, assign to variable first
"EM102", # Exception must not use an f-string literal, assign to variable first
# `TODO` comments
"FIX002", # Line contains TODO, consider resolving the issue
"TD002", # Missing author in TODO; try: `# TODO(<author_name>): ...` or `# TODO @<author_name>: ...`
"TD003", # Missing issue link for this TODO
"TD004", # Missing colon in TODO
# Code
"C901", # `test_result_interception` is too complex (36 > 10)
"COM812", # missing-trailing-comma (NOTE: Already handled by formatter)
"ERA001", # Found commented-out code (NOTE: Too many false positives)
"INP001", # File `...` is part of an implicit namespace package. Add an `__init__.py`.
"PLR0915", # Too many statements (64 > 50)
"PLR0911", # Too many return statements (7 > 6)
"PLR0912", # Too many branches (31 > 12)
"PLR0913", # Too many arguments in function definition (6 > 5)
"PLR2004", # Magic value used in comparison, consider replacing `123` with a constant variable
"RET504", # Unnecessary assignment to `collected` before `return` statement
"S308", # Use of `mark_safe` may expose cross-site scripting vulnerabilities
"S603", # `subprocess` call: check for execution of untrusted input
"SIM108", # Use ternary operator `...` instead of `if`-`else`-block
"SIM117", # Use a single `with` statement with multiple contexts instead of nested `with` statements
"SLF001", # Private member accessed: `_registry`
"TRY300", # Consider moving this statement to an `else` block
# TODO: Following could be useful to start using, but might require more changes.
"C420", # Unnecessary dict comprehension for iterable; use `dict.fromkeys` instead
"PERF401", # Use `list.extend` to create a transformed list
"PERF203", # `try`-`except` within a loop incurs performance overhead
"FBT001", # Boolean-typed positional argument in function definition
"FBT002", # Boolean default positional argument in function definition
"TRY003", # Avoid specifying long messages outside the exception class
# TODO - Enable FA100 once we drop support for Python 3.8
"FA100", # Add `from __future__ import annotations` to simplify `typing.Optional`
# TODO_V1 - Rename error to suffix with `Error` before v1?
"N818", # Exception name `NotRegistered` should be named with an Error suffix
] ]
[tool.ruff.lint.isort]
known-first-party = ["django_components"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"ARG002", # Unused method argument: `components_settings`
"ANN", # Annotations are not needed for tests
"N806", # Variable `SimpleComponent` in function should be lowercase
"PLC0415", # `import` should be at the top-level of a file
"RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
"S101", # Use of `assert` detected
"TRY002", # Create your own exception
]
"benchmarks/*" = [
"ARG002", # Unused method argument: `components_settings`
"ANN", # Annotations are not needed for tests
"N806", # Variable `SimpleComponent` in function should be lowercase
"PLC0415", # `import` should be at the top-level of a file
"RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
"S101", # Use of `assert` detected
"TRY002", # Create your own exception
]
"sampleproject/*" = [
"ARG002", # Unused method argument
"ANN", # Annotations are not needed for tests
"T201", # `print` found
"DTZ", # `datetime` found
]
[tool.mypy] [tool.mypy]
check_untyped_defs = true check_untyped_defs = true
ignore_missing_imports = true ignore_missing_imports = true
exclude = [ exclude = [
'test_structures', "test_structures",
'build', "build",
] ]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
@ -110,14 +188,14 @@ disallow_untyped_defs = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = [ testpaths = [
"tests" "tests",
] ]
asyncio_mode = "auto" asyncio_mode = "auto"
[tool.hatch.env] [tool.hatch.env]
requires = [ requires = [
"hatch-mkdocs", "hatch-mkdocs",
"hatch-pip-compile" "hatch-pip-compile",
] ]
[tool.hatch.envs.default] [tool.hatch.envs.default]
@ -126,11 +204,8 @@ dependencies = [
"djc-core-html-parser", "djc-core-html-parser",
"tox", "tox",
"pytest", "pytest",
"flake8", "ruff",
"flake8-pyproject",
"isort",
"pre-commit", "pre-commit",
"black",
"mypy", "mypy",
] ]
type = "pip-compile" type = "pip-compile"
@ -141,9 +216,7 @@ type = "pip-compile"
lock-filename = "requirements-docs.txt" lock-filename = "requirements-docs.txt"
detached = false detached = false
# Dependencies are fetched automatically from the mkdocs.yml file with hatch-mkdocs # Dependencies are fetched automatically from the mkdocs.yml file with hatch-mkdocs
# We only add black for formatting code in the docs
dependencies = [ dependencies = [
"black",
"pygments", "pygments",
"pygments-djc", "pygments-djc",
"mkdocs-awesome-nav", "mkdocs-awesome-nav",

View file

@ -6,11 +6,8 @@ pytest
pytest-asyncio pytest-asyncio
pytest-django pytest-django
syrupy syrupy
flake8 ruff
flake8-pyproject
isort
pre-commit pre-commit
black
mypy mypy
playwright playwright
requests requests

View file

@ -4,33 +4,29 @@
# #
# pip-compile requirements-dev.in # pip-compile requirements-dev.in
# #
asgiref==3.8.1 asgiref==3.9.1
# via django # via django
asv==0.6.4 asv==0.6.4
# via -r requirements-dev.in # via -r requirements-dev.in
asv-runner==0.2.1 asv-runner==0.2.1
# via asv # via asv
black==25.1.0 build==1.3.0
# via -r requirements-dev.in
build==1.2.2.post1
# via asv # via asv
cachetools==5.5.2 cachetools==6.1.0
# via tox # via tox
certifi==2025.1.31 certifi==2025.8.3
# via requests # via requests
cfgv==3.4.0 cfgv==3.4.0
# via pre-commit # via pre-commit
chardet==5.2.0 chardet==5.2.0
# via tox # via tox
charset-normalizer==3.4.1 charset-normalizer==3.4.3
# via requests # via requests
click==8.1.8
# via black
colorama==0.4.6 colorama==0.4.6
# via tox # via tox
distlib==0.3.9 distlib==0.4.0
# via virtualenv # via virtualenv
django==4.2.23 django==5.2.5
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# django-template-partials # django-template-partials
@ -38,47 +34,31 @@ django-template-partials==25.1
# via -r requirements-dev.in # via -r requirements-dev.in
djc-core-html-parser==1.0.2 djc-core-html-parser==1.0.2
# via -r requirements-dev.in # via -r requirements-dev.in
exceptiongroup==1.2.2 filelock==3.19.1
# via pytest
filelock==3.16.1
# via # via
# tox # tox
# virtualenv # virtualenv
flake8==7.3.0 greenlet==3.2.4
# via
# -r requirements-dev.in
# flake8-pyproject
flake8-pyproject==1.2.3
# via -r requirements-dev.in
greenlet==3.1.1
# via playwright # via playwright
identify==2.6.8 identify==2.6.13
# via pre-commit # via pre-commit
idna==3.10 idna==3.10
# via requests # via requests
importlib-metadata==8.5.0 importlib-metadata==8.7.0
# via # via asv-runner
# asv-runner iniconfig==2.1.0
# build
iniconfig==2.0.0
# via pytest # via pytest
isort==6.0.1 json5==0.12.1
# via -r requirements-dev.in
json5==0.10.0
# via asv # via asv
mccabe==0.7.0
# via flake8
mypy==1.17.1 mypy==1.17.1
# via -r requirements-dev.in # via -r requirements-dev.in
mypy-extensions==1.0.0 mypy-extensions==1.1.0
# via # via
# black
# mypy # mypy
nodeenv==1.9.1 nodeenv==1.9.1
# via pre-commit # via pre-commit
packaging==24.2 packaging==25.0
# via # via
# black
# build # build
# pyproject-api # pyproject-api
# pytest # pytest
@ -86,46 +66,41 @@ packaging==24.2
pathspec==0.12.1 pathspec==0.12.1
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# black
# mypy # mypy
platformdirs==4.3.6 platformdirs==4.3.8
# via # via
# black
# tox # tox
# virtualenv # virtualenv
playwright==1.48.0 playwright==1.54.0
# via -r requirements-dev.in # via -r requirements-dev.in
pluggy==1.5.0 pluggy==1.6.0
# via # via
# pytest # pytest
# tox # tox
pre-commit==4.3.0 pre-commit==4.3.0
# via -r requirements-dev.in # via -r requirements-dev.in
pycodestyle==2.14.0 pyee==13.0.0
# via flake8
pyee==12.0.0
# via playwright # via playwright
pyflakes==3.4.0 pygments==2.19.2
# via flake8
pygments==2.19.1
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# pygments-djc # pygments-djc
# pytest
pygments-djc==1.0.1 pygments-djc==1.0.1
# via -r requirements-dev.in # via -r requirements-dev.in
pympler==1.1 pympler==1.1
# via asv # via asv
pyproject-api==1.8.0 pyproject-api==1.9.1
# via tox # via tox
pyproject-hooks==1.2.0 pyproject-hooks==1.2.0
# via build # via build
pytest==8.3.5 pytest==8.4.1
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# pytest-asyncio # pytest-asyncio
# pytest-django # pytest-django
# syrupy # syrupy
pytest-asyncio==0.24.0 pytest-asyncio==1.1.0
# via -r requirements-dev.in # via -r requirements-dev.in
pytest-django==4.11.1 pytest-django==4.11.1
# via -r requirements-dev.in # via -r requirements-dev.in
@ -133,7 +108,9 @@ pyyaml==6.0.2
# via # via
# asv # asv
# pre-commit # pre-commit
requests==2.32.3 requests==2.32.4
# via -r requirements-dev.in
ruff==0.12.9
# via -r requirements-dev.in # via -r requirements-dev.in
sqlparse==0.5.3 sqlparse==0.5.3
# via django # via django
@ -141,30 +118,16 @@ syrupy==4.9.1
# via -r requirements-dev.in # via -r requirements-dev.in
tabulate==0.9.0 tabulate==0.9.0
# via asv # via asv
tomli==2.2.1 tox==4.28.4
# via
# asv
# black
# build
# flake8-pyproject
# mypy
# pyproject-api
# pytest
# tox
tox==4.25.0
# via -r requirements-dev.in # via -r requirements-dev.in
types-requests==2.32.0.20241016 types-requests==2.32.4.20250809
# via -r requirements-dev.in # via -r requirements-dev.in
typing-extensions==4.13.2 typing-extensions==4.14.1
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# asgiref
# black
# mypy # mypy
# pyee # pyee
# tox urllib3==2.5.0
# virtualenv
urllib3==2.2.3
# via # via
# requests # requests
# types-requests # types-requests
@ -174,7 +137,7 @@ virtualenv==20.34.0
# asv # asv
# pre-commit # pre-commit
# tox # tox
whitenoise==6.7.0 whitenoise==6.9.0
# via -r requirements-dev.in # via -r requirements-dev.in
zipp==3.20.2 zipp==3.23.0
# via importlib-metadata # via importlib-metadata

View file

@ -17,7 +17,6 @@
# - mkdocstrings # - mkdocstrings
# - mkdocstrings-python # - mkdocstrings-python
# - pymdown-extensions # - pymdown-extensions
# - black
# - pygments # - pygments
# - pygments-djc # - pygments-djc
# - django>=4.2 # - django>=4.2
@ -30,8 +29,6 @@ babel==2.17.0
# via # via
# mkdocs-git-revision-date-localized-plugin # mkdocs-git-revision-date-localized-plugin
# mkdocs-material # mkdocs-material
black==25.1.0
# via hatch.envs.docs
bracex==2.6 bracex==2.6
# via wcmatch # via wcmatch
cairocffi==1.7.1 cairocffi==1.7.1
@ -46,7 +43,6 @@ charset-normalizer==3.4.3
# via requests # via requests
click==8.1.8 click==8.1.8
# via # via
# black
# mkdocs # mkdocs
colorama==0.4.6 colorama==0.4.6
# via # via
@ -154,17 +150,13 @@ mkdocstrings==0.30.0
# mkdocstrings-python # mkdocstrings-python
mkdocstrings-python==1.17.0 mkdocstrings-python==1.17.0
# via hatch.envs.docs # via hatch.envs.docs
mypy-extensions==1.1.0
# via black
packaging==25.0 packaging==25.0
# via # via
# black
# mkdocs # mkdocs
paginate==0.5.7 paginate==0.5.7
# via mkdocs-material # via mkdocs-material
pathspec==0.12.1 pathspec==0.12.1
# via # via
# black
# mkdocs # mkdocs
pillow==11.3.0 pillow==11.3.0
# via # via
@ -172,7 +164,6 @@ pillow==11.3.0
# mkdocs-material # mkdocs-material
platformdirs==4.3.8 platformdirs==4.3.8
# via # via
# black
# mkdocs-get-deps # mkdocs-get-deps
pycparser==2.22 pycparser==2.22
# via cffi # via cffi

View file

@ -1,6 +1,7 @@
from calendarapp.views import calendar
from django.urls import path from django.urls import path
from calendarapp.views import calendar
urlpatterns = [ urlpatterns = [
path("", calendar, name="calendar"), path("", calendar, name="calendar"),
] ]

View file

@ -1,3 +1,5 @@
from django.http import HttpRequest, HttpResponse
from django_components import Component, register, types from django_components import Component, register, types
@ -26,7 +28,7 @@ class Greeting(Component):
return {"name": kwargs["name"]} return {"name": kwargs["name"]}
class View: class View:
def get(self, request, *args, **kwargs): def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
slots = {"message": "Hello, world!"} slots = {"message": "Hello, world!"}
return Greeting.render_to_response( return Greeting.render_to_response(
request=request, request=request,

View file

@ -1,4 +1,6 @@
from typing import NamedTuple from typing import Any, NamedTuple
from django.http import HttpRequest, HttpResponse
from django_components import Component, register from django_components import Component, register
@ -24,7 +26,7 @@ class CalendarNested(Component):
} }
class View: class View:
def get(self, request, *args, **kwargs): def get(self, request: HttpRequest, *_args: Any, **_kwargs: Any) -> HttpResponse:
return CalendarNested.render_to_response( return CalendarNested.render_to_response(
request=request, request=request,
kwargs={ kwargs={

View file

@ -1,6 +1,8 @@
import time import time
from typing import NamedTuple from typing import NamedTuple
from django.http import HttpRequest, HttpResponse
from django_components import Component, register, types from django_components import Component, register, types
@ -26,7 +28,7 @@ class Recursive(Component):
return {"depth": kwargs.depth + 1} return {"depth": kwargs.depth + 1}
class View: class View:
def get(self, request): def get(self, request: HttpRequest) -> HttpResponse:
time_before = time.time() time_before = time.time()
output = Recursive.render_to_response( output = Recursive.render_to_response(
request=request, request=request,

View file

@ -1,9 +1,10 @@
from django.urls import path
from components.calendar.calendar import Calendar, CalendarRelative from components.calendar.calendar import Calendar, CalendarRelative
from components.fragment import FragAlpine, FragJs, FragmentBaseAlpine, FragmentBaseHtmx, FragmentBaseJs from components.fragment import FragAlpine, FragJs, FragmentBaseAlpine, FragmentBaseHtmx, FragmentBaseJs
from components.greeting import Greeting from components.greeting import Greeting
from components.nested.calendar.calendar import CalendarNested from components.nested.calendar.calendar import CalendarNested
from components.recursive import Recursive from components.recursive import Recursive
from django.urls import path
urlpatterns = [ urlpatterns = [
path("greeting/", Greeting.as_view(), name="greeting"), path("greeting/", Greeting.as_view(), name="greeting"),

View file

@ -1,3 +1,4 @@
# ruff: noqa: T201, S310
import re import re
import textwrap import textwrap
from collections import defaultdict from collections import defaultdict
@ -8,21 +9,21 @@ Version = Tuple[int, ...]
VersionMapping = Dict[Version, List[Version]] VersionMapping = Dict[Version, List[Version]]
def cut_by_content(content: str, cut_from: str, cut_to: str): def cut_by_content(content: str, cut_from: str, cut_to: str) -> str:
return content.split(cut_from)[1].split(cut_to)[0] return content.split(cut_from)[1].split(cut_to)[0]
def keys_from_content(content: str): def keys_from_content(content: str) -> List[str]:
return re.findall(r"<td><p>(.*?)</p></td>", content) return re.findall(r"<td><p>(.*?)</p></td>", content)
def get_python_supported_version(url: str) -> list[Version]: def get_python_supported_version(url: str) -> List[Version]:
with request.urlopen(url) as response: with request.urlopen(url) as response:
response_content = response.read() response_content = response.read()
content = response_content.decode("utf-8") content = response_content.decode("utf-8")
def parse_supported_versions(content: str) -> list[Version]: def parse_supported_versions(content: str) -> List[Version]:
content = cut_by_content( content = cut_by_content(
content, content,
'<section id="supported-versions">', '<section id="supported-versions">',
@ -37,13 +38,13 @@ def get_python_supported_version(url: str) -> list[Version]:
return parse_supported_versions(content) return parse_supported_versions(content)
def get_django_to_pythoon_versions(url: str): def get_django_to_python_versions(url: str) -> VersionMapping:
with request.urlopen(url) as response: with request.urlopen(url) as response:
response_content = response.read() response_content = response.read()
content = response_content.decode("utf-8") content = response_content.decode("utf-8")
def parse_supported_versions(content): def parse_supported_versions(content: str) -> VersionMapping:
content = cut_by_content( content = cut_by_content(
content, content,
'<span id="what-python-version-can-i-use-with-django">', '<span id="what-python-version-can-i-use-with-django">',
@ -92,7 +93,7 @@ def get_django_supported_versions(url: str) -> List[Tuple[int, ...]]:
return versions return versions
def get_latest_version(url: str): def get_latest_version(url: str) -> Version:
with request.urlopen(url) as response: with request.urlopen(url) as response:
response_content = response.read() response_content = response.read()
@ -101,11 +102,11 @@ def get_latest_version(url: str):
return version_to_tuple(version_string) return version_to_tuple(version_string)
def version_to_tuple(version_string: str): def version_to_tuple(version_string: str) -> Version:
return tuple(int(num) for num in version_string.split(".")) return tuple(int(num) for num in version_string.split("."))
def build_python_to_django(django_to_python: VersionMapping, latest_version: Version): def build_python_to_django(django_to_python: VersionMapping, latest_version: Version) -> VersionMapping:
python_to_django: VersionMapping = defaultdict(list) python_to_django: VersionMapping = defaultdict(list)
for django_version, python_versions in django_to_python.items(): for django_version, python_versions in django_to_python.items():
for python_version in python_versions: for python_version in python_versions:
@ -116,11 +117,11 @@ def build_python_to_django(django_to_python: VersionMapping, latest_version: Ver
return python_to_django return python_to_django
def env_format(version_tuple, divider=""): def env_format(version_tuple: Version, divider: str = "") -> str:
return divider.join(str(num) for num in version_tuple) return divider.join(str(num) for num in version_tuple)
def build_tox_envlist(python_to_django: VersionMapping): def build_tox_envlist(python_to_django: VersionMapping) -> str:
lines_data = [ lines_data = [
( (
env_format(python_version), env_format(python_version),
@ -129,11 +130,11 @@ def build_tox_envlist(python_to_django: VersionMapping):
for python_version, django_versions in python_to_django.items() for python_version, django_versions in python_to_django.items()
] ]
lines = [f"py{a}-django{{{b}}}" for a, b in lines_data] lines = [f"py{a}-django{{{b}}}" for a, b in lines_data]
version_lines = "\n".join([version for version in lines]) version_lines = "\n".join(version for version in lines)
return "envlist = \n" + textwrap.indent(version_lines, prefix=" ") return "envlist = \n" + textwrap.indent(version_lines, prefix=" ")
def build_gh_actions_envlist(python_to_django: VersionMapping): def build_gh_actions_envlist(python_to_django: VersionMapping) -> str:
lines_data = [ lines_data = [
( (
env_format(python_version, divider="."), env_format(python_version, divider="."),
@ -143,11 +144,11 @@ def build_gh_actions_envlist(python_to_django: VersionMapping):
for python_version, django_versions in python_to_django.items() for python_version, django_versions in python_to_django.items()
] ]
lines = [f"{a}: py{b}-django{{{c}}}" for a, b, c in lines_data] lines = [f"{a}: py{b}-django{{{c}}}" for a, b, c in lines_data]
version_lines = "\n".join([version for version in lines]) version_lines = "\n".join(version for version in lines)
return "python = \n" + textwrap.indent(version_lines, prefix=" ") return "python = \n" + textwrap.indent(version_lines, prefix=" ")
def build_deps_envlist(python_to_django: VersionMapping): def build_deps_envlist(python_to_django: VersionMapping) -> str:
all_django_versions = set() all_django_versions = set()
for django_versions in python_to_django.values(): for django_versions in python_to_django.values():
for django_version in django_versions: for django_version in django_versions:
@ -165,7 +166,7 @@ def build_deps_envlist(python_to_django: VersionMapping):
return "deps = \n" + textwrap.indent("\n".join(lines), prefix=" ") return "deps = \n" + textwrap.indent("\n".join(lines), prefix=" ")
def build_pypi_classifiers(python_to_django: VersionMapping): def build_pypi_classifiers(python_to_django: VersionMapping) -> str:
classifiers = [] classifiers = []
all_python_versions = python_to_django.keys() all_python_versions = python_to_django.keys()
@ -183,14 +184,14 @@ def build_pypi_classifiers(python_to_django: VersionMapping):
return textwrap.indent("classifiers=[\n", prefix=" " * 4) + textwrap.indent("\n".join(classifiers), prefix=" " * 8) return textwrap.indent("classifiers=[\n", prefix=" " * 4) + textwrap.indent("\n".join(classifiers), prefix=" " * 8)
def build_readme(python_to_django: VersionMapping): def build_readme(python_to_django: VersionMapping) -> str:
print( print(
textwrap.dedent( textwrap.dedent(
"""\ """\
| Python version | Django version | | Python version | Django version |
|----------------|--------------------------| |----------------|--------------------------|
""".rstrip() """.rstrip(),
) ),
) )
lines_data = [ lines_data = [
( (
@ -200,24 +201,25 @@ def build_readme(python_to_django: VersionMapping):
for python_version, django_versions in python_to_django.items() for python_version, django_versions in python_to_django.items()
] ]
lines = [f"| {a: <14} | {b: <24} |" for a, b in lines_data] lines = [f"| {a: <14} | {b: <24} |" for a, b in lines_data]
version_lines = "\n".join([version for version in lines]) version_lines = "\n".join(version for version in lines)
return version_lines return version_lines
def build_pyenv(python_to_django: VersionMapping): def build_pyenv(python_to_django: VersionMapping) -> str:
lines = [] lines = []
all_python_versions = python_to_django.keys() all_python_versions = python_to_django.keys()
for python_version in all_python_versions: for python_version in all_python_versions:
lines.append(f'pyenv install -s {env_format(python_version, divider=".")}') lines.append(f"pyenv install -s {env_format(python_version, divider='.')}")
lines.append(f'pyenv local {" ".join(env_format(version, divider=".") for version in all_python_versions)}') versions_str = " ".join(env_format(version, divider=".") for version in all_python_versions)
lines.append(f"pyenv local {versions_str}")
lines.append("tox -p") lines.append("tox -p")
return "\n".join(lines) return "\n".join(lines)
def build_ci_python_versions(python_to_django: Dict[str, str]): def build_ci_python_versions(python_to_django: VersionMapping) -> str:
# Outputs python-version, like: ['3.8', '3.9', '3.10', '3.11', '3.12'] # Outputs python-version, like: ['3.8', '3.9', '3.10', '3.11', '3.12']
lines = [ lines = [
f"'{env_format(python_version, divider='.')}'" for python_version, django_versions in python_to_django.items() f"'{env_format(python_version, divider='.')}'" for python_version, django_versions in python_to_django.items()
@ -226,13 +228,13 @@ def build_ci_python_versions(python_to_django: Dict[str, str]):
return lines_formatted return lines_formatted
def filter_dict(d: Dict, filter_fn: Callable[[Any], bool]): def filter_dict(d: Dict, filter_fn: Callable[[Any], bool]) -> Dict:
return dict(filter(filter_fn, d.items())) return dict(filter(filter_fn, d.items()))
def main(): def main() -> None:
active_python = get_python_supported_version("https://devguide.python.org/versions/") active_python = get_python_supported_version("https://devguide.python.org/versions/")
django_to_python = get_django_to_pythoon_versions("https://docs.djangoproject.com/en/dev/faq/install/") django_to_python = get_django_to_python_versions("https://docs.djangoproject.com/en/dev/faq/install/")
django_supported_versions = get_django_supported_versions("https://www.djangoproject.com/download/") django_supported_versions = get_django_supported_versions("https://www.djangoproject.com/download/")
latest_version = get_latest_version("https://www.djangoproject.com/download/") latest_version = get_latest_version("https://www.djangoproject.com/download/")

View file

@ -35,19 +35,22 @@ Configuration:
See the code for more details and examples. See the code for more details and examples.
""" """
# ruff: noqa: T201,BLE001,PTH118
import argparse import argparse
import os import os
import re import re
import requests
import sys import sys
import time import time
from collections import defaultdict, deque from collections import defaultdict, deque
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import DefaultDict, Deque, Dict, List, Tuple, Union from typing import DefaultDict, Deque, Dict, List, Literal, Optional, Tuple, Union
from urllib.parse import urlparse from urllib.parse import urlparse
from bs4 import BeautifulSoup
import pathspec import pathspec
import requests
from bs4 import BeautifulSoup
from django_components.util.misc import format_as_ascii_table from django_components.util.misc import format_as_ascii_table
@ -77,7 +80,7 @@ IGNORED_PATHS = [
IGNORE_DOMAINS = [ IGNORE_DOMAINS = [
"127.0.0.1", "127.0.0.1",
"localhost", "localhost",
"0.0.0.0", "0.0.0.0", # noqa: S104
"example.com", "example.com",
] ]
@ -112,9 +115,35 @@ URL_VALIDATOR_REGEX = re.compile(
) )
@dataclass
class Link:
file: str
lineno: int
url: str
base_url: str # The URL without the fragment
fragment: Optional[str]
@dataclass
class LinkRewrite:
link: Link
new_url: str
mapping_key: Union[str, re.Pattern]
@dataclass
class LinkError:
link: Link
error_type: Literal["ERROR_FRAGMENT", "ERROR_HTTP", "ERROR_INVALID", "ERROR_OTHER"]
error_details: str
FetchedResults = Dict[str, Union[requests.Response, Exception, Literal["SKIPPED", "INVALID_URL"]]]
def is_binary_file(filepath: Path) -> bool: def is_binary_file(filepath: Path) -> bool:
try: try:
with open(filepath, "rb") as f: with filepath.open("rb") as f:
chunk = f.read(1024) chunk = f.read(1024)
if b"\0" in chunk: if b"\0" in chunk:
return True return True
@ -127,7 +156,7 @@ def load_gitignore(root: Path) -> pathspec.PathSpec:
gitignore = root / ".gitignore" gitignore = root / ".gitignore"
patterns = [] patterns = []
if gitignore.exists(): if gitignore.exists():
with open(gitignore) as f: with gitignore.open() as f:
patterns = f.read().splitlines() patterns = f.read().splitlines()
# Add additional ignored paths # Add additional ignored paths
patterns += IGNORED_PATHS patterns += IGNORED_PATHS
@ -153,29 +182,33 @@ def find_files(root: Path, spec: pathspec.PathSpec) -> List[Path]:
# Extract URLs from a file # Extract URLs from a file
def extract_urls_from_file(filepath: Path) -> List[Tuple[str, int, str, str]]: def extract_links_from_file(filepath: Path) -> List[Link]:
urls = [] urls: List[Link] = []
try: try:
with open(filepath, encoding="utf-8", errors="replace") as f: with filepath.open(encoding="utf-8", errors="replace") as f:
for i, line in enumerate(f, 1): for i, line in enumerate(f, 1):
for match in URL_REGEX.finditer(line): for match in URL_REGEX.finditer(line):
url = match.group(0) url = match.group(0)
urls.append((str(filepath), i, line.rstrip(), url)) if "#" in url:
base_url, fragment = url.split("#", 1)
else:
base_url, fragment = url, None
urls.append(Link(file=str(filepath), lineno=i, url=url, base_url=base_url, fragment=fragment))
except Exception as e: except Exception as e:
print(f"[WARN] Could not read {filepath}: {e}", file=sys.stderr) print(f"[WARN] Could not read {filepath}: {e}", file=sys.stderr)
return urls return urls
def get_base_url(url: str) -> str: # We validate the links by fetching them, reaching the (potentially 3rd party) servers.
"""Return the URL without the fragment.""" # This can be slow, because servers am have rate limiting policies.
return url.split("#", 1)[0] # So we group the URLs by domain - URLs pointing to different domains can be
# fetched in parallel. This way we can spread the load over the domains, and avoid hitting the rate limits.
# This function picks the next URL to fetch, respecting the cooldown.
def pick_next_url(domains, domain_to_urls, last_request_time): def pick_next_url(
""" domains: List[str],
Pick the next (domain, url) to fetch, respecting REQUEST_DELAY per domain. domain_to_urls: Dict[str, Deque[str]],
Returns (domain, url) or None if all are on cooldown or empty. last_request_time: Dict[str, float],
""" ) -> Optional[Tuple[str, str]]:
now = time.time() now = time.time()
for domain in domains: for domain in domains:
if not domain_to_urls[domain]: if not domain_to_urls[domain]:
@ -187,16 +220,23 @@ def pick_next_url(domains, domain_to_urls, last_request_time):
return None return None
def validate_urls(all_urls): def fetch_urls(links: List[Link]) -> FetchedResults:
""" """
For each unique base URL, make a GET request (with caching). For each unique URL, make a GET request (with caching).
Print progress for each request (including cache hits). Print progress for each request (including cache hits).
If a URL is invalid, print a warning and skip fetching. If a URL is invalid, print a warning and skip fetching.
Skip URLs whose netloc matches IGNORE_DOMAINS. Skip URLs whose netloc matches IGNORE_DOMAINS.
Use round-robin scheduling per domain, with cooldown. Use round-robin scheduling per domain, with cooldown.
""" """
url_cache: Dict[str, Union[requests.Response, Exception, str]] = {} all_url_results: FetchedResults = {}
unique_base_urls = sorted(set(get_base_url(url) for _, _, _, url in all_urls)) unique_base_urls = set()
base_urls_with_fragments = set()
for link in links:
unique_base_urls.add(link.base_url)
if link.fragment:
base_urls_with_fragments.add(link.base_url)
base_urls = sorted(unique_base_urls) # Ensure consistency
# NOTE: Originally we fetched the URLs one after another. But the issue with this was that # NOTE: Originally we fetched the URLs one after another. But the issue with this was that
# there is a few large domains like Github, MDN, Djagno docs, etc. And there's a lot of URLs # there is a few large domains like Github, MDN, Djagno docs, etc. And there's a lot of URLs
@ -208,10 +248,10 @@ def validate_urls(all_urls):
# Group URLs by domain # Group URLs by domain
domain_to_urls: DefaultDict[str, Deque[str]] = defaultdict(deque) domain_to_urls: DefaultDict[str, Deque[str]] = defaultdict(deque)
for url in unique_base_urls: for url in base_urls:
parsed = urlparse(url) parsed = urlparse(url)
if parsed.hostname and any(parsed.hostname == d for d in IGNORE_DOMAINS): if parsed.hostname and any(parsed.hostname == d for d in IGNORE_DOMAINS):
url_cache[url] = "SKIPPED" all_url_results[url] = "SKIPPED"
continue continue
domain_to_urls[parsed.netloc].append(url) domain_to_urls[parsed.netloc].append(url)
@ -236,37 +276,83 @@ def validate_urls(all_urls):
domain, url = pick domain, url = pick
# Classify and fetch # Classify and fetch
if url in url_cache: if url in all_url_results:
print(f"[done {done_count + 1}/{total_urls}] {url} (cache hit)") print(f"[done {done_count + 1}/{total_urls}] {url} (cache hit)")
done_count += 1 done_count += 1
continue continue
if not URL_VALIDATOR_REGEX.match(url): if not URL_VALIDATOR_REGEX.match(url):
url_cache[url] = "INVALID_URL" all_url_results[url] = "INVALID_URL"
print(f"[done {done_count + 1}/{total_urls}] {url} WARNING: Invalid URL format, not fetched.") print(f"[done {done_count + 1}/{total_urls}] {url} WARNING: Invalid URL format, not fetched.")
done_count += 1 done_count += 1
continue continue
print(f"[done {done_count + 1}/{total_urls}] {url} ...", end=" ") method = "GET" if url in base_urls_with_fragments else "HEAD"
print(f"[done {done_count + 1}/{total_urls}] {method:<4} {url} ...", end=" ")
try: try:
# If there is at least one URL that specifies a fragment in the URL,
# we will fetch the full HTML with GET.
# But if there isn't any, we can simply send HEAD request instead.
if method == "GET":
resp = requests.get( resp = requests.get(
url, timeout=REQUEST_TIMEOUT, headers={"User-Agent": "django-components-link-checker/0.1"} url,
allow_redirects=True,
timeout=REQUEST_TIMEOUT,
headers={"User-Agent": "django-components-link-checker/0.1"},
) )
url_cache[url] = resp else:
resp = requests.head(
url,
allow_redirects=True,
timeout=REQUEST_TIMEOUT,
headers={"User-Agent": "django-components-link-checker/0.1"},
)
all_url_results[url] = resp
print(f"{resp.status_code}") print(f"{resp.status_code}")
except Exception as err: except Exception as err:
url_cache[url] = err all_url_results[url] = err
print(f"ERROR: {err}") print(f"ERROR: {err}")
last_request_time[domain] = time.time() last_request_time[domain] = time.time()
done_count += 1 done_count += 1
return url_cache return all_url_results
def check_fragment_in_html(html: str, fragment: str) -> bool: def rewrite_links(links: List[Link], files: List[Path], dry_run: bool) -> None:
"""Return True if id=fragment exists in the HTML.""" # Group by file for efficient rewriting
print(f"Checking fragment {fragment} in HTML...") file_to_lines: Dict[str, List[str]] = {}
soup = BeautifulSoup(html, "html.parser") for filepath in files:
return bool(soup.find(id=fragment)) try:
with filepath.open(encoding="utf-8", errors="replace") as f:
file_to_lines[str(filepath)] = f.readlines()
except Exception as e:
print(f"[WARN] Could not read {filepath}: {e}", file=sys.stderr)
continue
rewrites: List[LinkRewrite] = []
for link in links:
new_url, mapping_key = rewrite_url(link.url)
if not new_url or new_url == link.url or mapping_key is None:
continue
# Rewrite in memory, so we can have dry-run mode
lines = file_to_lines[link.file]
idx = link.lineno - 1
old_line = lines[idx]
new_line = old_line.replace(link.url, new_url)
if old_line != new_line:
lines[idx] = new_line
rewrites.append(LinkRewrite(link=link, new_url=new_url, mapping_key=mapping_key))
# Write back or dry-run
if dry_run:
for rewrite in rewrites:
print(f"[DRY-RUN] {rewrite.link.file}#{rewrite.link.lineno}: {rewrite.link.url} -> {rewrite.new_url}")
else:
for rewrite in rewrites:
# Write only once per file
lines = file_to_lines[rewrite.link.file]
Path(rewrite.link.file).write_text("".join(lines), encoding="utf-8")
print(f"[REWRITE] {rewrite.link.file}#{rewrite.link.lineno}: {rewrite.link.url} -> {rewrite.new_url}")
def rewrite_url(url: str) -> Union[Tuple[None, None], Tuple[str, Union[str, re.Pattern]]]: def rewrite_url(url: str) -> Union[Tuple[None, None], Tuple[str, Union[str, re.Pattern]]]:
@ -279,16 +365,82 @@ def rewrite_url(url: str) -> Union[Tuple[None, None], Tuple[str, Union[str, re.P
if key.search(url): if key.search(url):
return key.sub(repl, url), key return key.sub(repl, url), key
else: else:
raise ValueError(f"Invalid key type: {type(key)}") raise TypeError(f"Invalid key type: {type(key)}")
return None, None return None, None
def output_summary(errors: List[Tuple[str, int, str, str, str]], output: str): def check_links_for_errors(all_urls: List[Link], all_url_results: FetchedResults) -> List[LinkError]:
errors: List[LinkError] = []
for link in all_urls:
cache_val = all_url_results.get(link.base_url)
if cache_val == "SKIPPED":
continue
if cache_val == "INVALID_URL":
link_error = LinkError(link=link, error_type="ERROR_INVALID", error_details="Invalid URL format")
errors.append(link_error)
continue
if isinstance(cache_val, Exception):
link_error = LinkError(link=link, error_type="ERROR_OTHER", error_details=str(cache_val))
errors.append(link_error)
continue
if isinstance(cache_val, requests.Response):
# Error response
if hasattr(cache_val, "status_code") and getattr(cache_val, "status_code", 0) != 200:
link_error = LinkError(
link=link,
error_type="ERROR_HTTP",
error_details=f"Status {getattr(cache_val, 'status_code', '?')}",
)
errors.append(link_error)
continue
# Success response
if cache_val and hasattr(cache_val, "text") and link.fragment:
content_type = cache_val.headers.get("Content-Type", "")
if "html" not in content_type:
# The specified URL does NOT point to an HTML page, so the fragment is not valid.
link_error = LinkError(link=link, error_type="ERROR_FRAGMENT", error_details="Not HTML content")
errors.append(link_error)
continue
fragment_in_html = check_fragment_in_html(cache_val.text, link.fragment)
if not fragment_in_html:
# The specified URL points to an HTML page, but the fragment is not valid.
link_error = LinkError(
link=link,
error_type="ERROR_FRAGMENT",
error_details=f"Fragment '#{link.fragment}' not found",
)
errors.append(link_error)
continue
else:
raise TypeError(f"Unknown cache value type: {type(cache_val)}")
return errors
def check_fragment_in_html(html: str, fragment: str) -> bool:
"""Return True if id=fragment exists in the HTML."""
print(f"Checking fragment {fragment} in HTML...")
soup = BeautifulSoup(html, "html.parser")
return bool(soup.find(id=fragment))
def output_summary(errors: List[LinkError], output: Optional[str]) -> None:
# Format the errors into a table # Format the errors into a table
headers = ["Type", "Details", "File", "URL"] headers = ["Type", "Details", "File", "URL"]
data = [ data = [
{"File": file + "#" + str(lineno), "Type": errtype, "URL": url, "Details": details} {
for file, lineno, errtype, url, details in errors "File": link_error.link.file + "#" + str(link_error.link.lineno),
"Type": link_error.error_type,
"URL": link_error.link.url,
"Details": link_error.error_details,
}
for link_error in errors
] ]
table = format_as_ascii_table(data, headers, include_headers=True) table = format_as_ascii_table(data, headers, include_headers=True)
@ -300,106 +452,59 @@ def output_summary(errors: List[Tuple[str, int, str, str, str]], output: str):
print(table + "\n") print(table + "\n")
# TODO: Run this as a test in CI? def parse_args() -> argparse.Namespace:
# NOTE: At v0.140 there was ~800 URL instances total, ~300 unique URLs, and the script took 4 min.
def main():
parser = argparse.ArgumentParser(description="Validate links and fragments in the codebase.") parser = argparse.ArgumentParser(description="Validate links and fragments in the codebase.")
parser.add_argument( parser.add_argument(
"-o", "--output", type=str, help="Output summary table to file (suppress stdout except errors)" "-o",
"--output",
type=str,
help="Output summary table to file (suppress stdout except errors)",
) )
parser.add_argument("--rewrite", action="store_true", help="Rewrite URLs using URL_REWRITE_MAP and update files") parser.add_argument("--rewrite", action="store_true", help="Rewrite URLs using URL_REWRITE_MAP and update files")
parser.add_argument( parser.add_argument(
"--dry-run", action="store_true", help="Show what would be changed by --rewrite, but do not write files" "--dry-run",
action="store_true",
help="Show what would be changed by --rewrite, but do not write files",
) )
args = parser.parse_args() return parser.parse_args()
root = Path(os.getcwd())
# TODO: Run this as a test in CI?
# NOTE: At v0.140 there was ~800 URL instances total, ~300 unique URLs, and the script took 4 min.
def main() -> None:
args = parse_args()
# Find all relevant files
root = Path.cwd()
spec = load_gitignore(root) spec = load_gitignore(root)
files = find_files(root, spec) files = find_files(root, spec)
print(f"Scanning {len(files)} files...") print(f"Scanning {len(files)} files...")
all_urls: List[Tuple[str, int, str, str]] = [] # Find links in those files
for f in files: all_links: List[Link] = []
if is_binary_file(f): for filepath in files:
if is_binary_file(filepath):
continue continue
all_urls.extend(extract_urls_from_file(f)) all_links.extend(extract_links_from_file(filepath))
# HTTP request and caching step # Rewrite links in those files if requested
url_cache = validate_urls(all_urls)
# --- URL rewriting logic ---
if args.rewrite: if args.rewrite:
# Group by file for efficient rewriting rewrite_links(all_links, files, dry_run=args.dry_run)
file_to_lines: Dict[str, List[str]] = {}
for f in files:
try:
with open(f, encoding="utf-8", errors="replace") as fh:
file_to_lines[str(f)] = fh.readlines()
except Exception:
continue
rewrites = []
for file, lineno, line, url in all_urls:
new_url, mapping_key = rewrite_url(url)
if not new_url or new_url == url:
continue
# Rewrite in memory, so we can have dry-run mode
lines = file_to_lines[file]
idx = lineno - 1
old_line = lines[idx]
new_line = old_line.replace(url, new_url)
if old_line != new_line:
lines[idx] = new_line
rewrites.append((file, lineno, url, new_url, mapping_key))
# Write back or dry-run
if args.dry_run:
for file, lineno, old, new, _ in rewrites:
print(f"[DRY-RUN] {file}#{lineno}: {old} -> {new}")
else:
for file, _, _, _, _ in rewrites:
# Write only once per file
lines = file_to_lines[file]
Path(file).write_text("".join(lines), encoding="utf-8")
for file, lineno, old, new, _ in rewrites:
print(f"[REWRITE] {file}#{lineno}: {old} -> {new}")
return # After rewriting, skip error reporting return # After rewriting, skip error reporting
# --- Categorize the results / errors --- # Otherwise proceed to validation of the URLs and fragments
errors = [] # by first fetching the HTTP requests.
for file, lineno, line, url in all_urls: all_url_results = fetch_urls(all_links)
base_url = get_base_url(url)
fragment = url.split("#", 1)[1] if "#" in url else None
cache_val = url_cache.get(base_url)
if cache_val == "SKIPPED":
continue
elif cache_val == "INVALID_URL":
errors.append((file, lineno, "INVALID", url, "Invalid URL format"))
continue
elif isinstance(cache_val, Exception):
errors.append((file, lineno, "ERROR", url, str(cache_val)))
continue
elif hasattr(cache_val, "status_code") and getattr(cache_val, "status_code", 0) != 200:
errors.append((file, lineno, "ERROR_HTTP", url, f"Status {getattr(cache_val, 'status_code', '?')}"))
continue
elif fragment and hasattr(cache_val, "text"):
content_type = cache_val.headers.get("Content-Type", "")
if "html" not in content_type:
errors.append((file, lineno, "ERROR_FRAGMENT", url, "Not HTML content"))
continue
if not check_fragment_in_html(cache_val.text, fragment):
errors.append((file, lineno, "ERROR_FRAGMENT", url, f"Fragment '#{fragment}' not found"))
# After everything's fetched, check for errors.
errors = check_links_for_errors(all_links, all_url_results)
if not errors: if not errors:
print("\nAll links and fragments are valid!") print("\nAll links and fragments are valid!")
return return
# Format the errors into a table # Format the errors into a table
output_summary(errors, args.output) output_summary(errors, args.output or None)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -76,7 +76,7 @@ from django_components.tag_formatter import (
component_shorthand_formatter, component_shorthand_formatter,
) )
from django_components.template import cached_template from django_components.template import cached_template
import django_components.types as types import django_components.types as types # noqa: PLR0402
from django_components.util.loader import ComponentFileEntry, get_component_dirs, get_component_files from django_components.util.loader import ComponentFileEntry, get_component_dirs, get_component_files
from django_components.util.routing import URLRoute, URLRouteHandler from django_components.util.routing import URLRoute, URLRouteHandler
from django_components.util.types import Empty from django_components.util.types import Empty
@ -85,12 +85,8 @@ from django_components.util.types import Empty
__all__ = [ __all__ = [
"all_components",
"all_registries",
"AlreadyRegistered", "AlreadyRegistered",
"autodiscover",
"BaseNode", "BaseNode",
"cached_template",
"CommandArg", "CommandArg",
"CommandArgGroup", "CommandArgGroup",
"CommandHandler", "CommandHandler",
@ -113,8 +109,6 @@ __all__ = [
"ComponentVars", "ComponentVars",
"ComponentView", "ComponentView",
"ComponentsSettings", "ComponentsSettings",
"component_formatter",
"component_shorthand_formatter",
"ContextBehavior", "ContextBehavior",
"Default", "Default",
"DependenciesStrategy", "DependenciesStrategy",
@ -122,13 +116,6 @@ __all__ = [
"Empty", "Empty",
"ExtensionComponentConfig", "ExtensionComponentConfig",
"FillNode", "FillNode",
"format_attributes",
"get_component_by_class_id",
"get_component_dirs",
"get_component_files",
"get_component_url",
"import_libraries",
"merge_attributes",
"NotRegistered", "NotRegistered",
"OnComponentClassCreatedContext", "OnComponentClassCreatedContext",
"OnComponentClassDeletedContext", "OnComponentClassDeletedContext",
@ -140,10 +127,7 @@ __all__ = [
"OnRegistryDeletedContext", "OnRegistryDeletedContext",
"OnRenderGenerator", "OnRenderGenerator",
"ProvideNode", "ProvideNode",
"register",
"registry",
"RegistrySettings", "RegistrySettings",
"render_dependencies",
"ShorthandComponentFormatter", "ShorthandComponentFormatter",
"Slot", "Slot",
"SlotContent", "SlotContent",
@ -157,8 +141,24 @@ __all__ = [
"TagFormatterABC", "TagFormatterABC",
"TagProtectedError", "TagProtectedError",
"TagResult", "TagResult",
"template_tag",
"types",
"URLRoute", "URLRoute",
"URLRouteHandler", "URLRouteHandler",
"all_components",
"all_registries",
"autodiscover",
"cached_template",
"component_formatter",
"component_shorthand_formatter",
"format_attributes",
"get_component_by_class_id",
"get_component_dirs",
"get_component_files",
"get_component_url",
"import_libraries",
"merge_attributes",
"register",
"registry",
"render_dependencies",
"template_tag",
"types",
] ]

View file

@ -1,3 +1,4 @@
# ruff: noqa: N802, PLC0415
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
@ -440,7 +441,7 @@ class ComponentsSettings(NamedTuple):
reload_on_template_change: Optional[bool] = None reload_on_template_change: Optional[bool] = None
"""Deprecated. Use """Deprecated. Use
[`COMPONENTS.reload_on_file_change`](./settings.md#django_components.app_settings.ComponentsSettings.reload_on_file_change) [`COMPONENTS.reload_on_file_change`](./settings.md#django_components.app_settings.ComponentsSettings.reload_on_file_change)
instead.""" # noqa: E501 instead."""
reload_on_file_change: Optional[bool] = None reload_on_file_change: Optional[bool] = None
""" """
@ -515,7 +516,7 @@ class ComponentsSettings(NamedTuple):
forbidden_static_files: Optional[List[Union[str, re.Pattern]]] = None forbidden_static_files: Optional[List[Union[str, re.Pattern]]] = None
"""Deprecated. Use """Deprecated. Use
[`COMPONENTS.static_files_forbidden`](./settings.md#django_components.app_settings.ComponentsSettings.static_files_forbidden) [`COMPONENTS.static_files_forbidden`](./settings.md#django_components.app_settings.ComponentsSettings.static_files_forbidden)
instead.""" # noqa: E501 instead."""
static_files_forbidden: Optional[List[Union[str, re.Pattern]]] = None static_files_forbidden: Optional[List[Union[str, re.Pattern]]] = None
""" """
@ -693,7 +694,7 @@ class Dynamic(Generic[T]):
# for `COMPONENTS.dirs`, we do it lazily. # for `COMPONENTS.dirs`, we do it lazily.
# NOTE 2: We show the defaults in the documentation, together with the comments # NOTE 2: We show the defaults in the documentation, together with the comments
# (except for the `Dynamic` instances and comments like `type: ignore`). # (except for the `Dynamic` instances and comments like `type: ignore`).
# So `fmt: off` turns off Black formatting and `snippet:defaults` allows # So `fmt: off` turns off Black/Ruff formatting and `snippet:defaults` allows
# us to extract the snippet from the file. # us to extract the snippet from the file.
# #
# fmt: off # fmt: off
@ -757,7 +758,7 @@ class InternalSettings:
# For DIRS setting, we use a getter for the default value, because the default value # For DIRS setting, we use a getter for the default value, because the default value
# uses Django settings, which may not yet be initialized at the time these settings are generated. # uses Django settings, which may not yet be initialized at the time these settings are generated.
dirs_default_fn = cast(Dynamic[Sequence[Union[str, Tuple[str, str]]]], defaults.dirs) dirs_default_fn = cast("Dynamic[Sequence[Union[str, Tuple[str, str]]]]", defaults.dirs)
dirs_default = dirs_default_fn.getter() dirs_default = dirs_default_fn.getter()
self._settings = ComponentsSettings( self._settings = ComponentsSettings(
@ -766,11 +767,13 @@ class InternalSettings:
dirs=default(components_settings.dirs, dirs_default), dirs=default(components_settings.dirs, dirs_default),
app_dirs=default(components_settings.app_dirs, defaults.app_dirs), app_dirs=default(components_settings.app_dirs, defaults.app_dirs),
debug_highlight_components=default( debug_highlight_components=default(
components_settings.debug_highlight_components, defaults.debug_highlight_components components_settings.debug_highlight_components,
defaults.debug_highlight_components,
), ),
debug_highlight_slots=default(components_settings.debug_highlight_slots, defaults.debug_highlight_slots), debug_highlight_slots=default(components_settings.debug_highlight_slots, defaults.debug_highlight_slots),
dynamic_component_name=default( dynamic_component_name=default(
components_settings.dynamic_component_name, defaults.dynamic_component_name components_settings.dynamic_component_name,
defaults.dynamic_component_name,
), ),
libraries=default(components_settings.libraries, defaults.libraries), libraries=default(components_settings.libraries, defaults.libraries),
# NOTE: Internally we store the extensions as a list of instances, but the user # NOTE: Internally we store the extensions as a list of instances, but the user
@ -789,11 +792,12 @@ class InternalSettings:
def _get_settings(self) -> ComponentsSettings: def _get_settings(self) -> ComponentsSettings:
if self._settings is None: if self._settings is None:
self._load_settings() self._load_settings()
return cast(ComponentsSettings, self._settings) return cast("ComponentsSettings", self._settings)
def _prepare_extensions(self, new_settings: ComponentsSettings) -> List["ComponentExtension"]: def _prepare_extensions(self, new_settings: ComponentsSettings) -> List["ComponentExtension"]:
extensions: Sequence[Union[Type["ComponentExtension"], str]] = default( extensions: Sequence[Union[Type[ComponentExtension], str]] = default(
new_settings.extensions, cast(List[str], defaults.extensions) new_settings.extensions,
cast("List[str]", defaults.extensions),
) )
# Prepend built-in extensions # Prepend built-in extensions
@ -804,7 +808,7 @@ class InternalSettings:
from django_components.extensions.view import ViewExtension from django_components.extensions.view import ViewExtension
extensions = cast( extensions = cast(
List[Type["ComponentExtension"]], "List[Type[ComponentExtension]]",
[ [
CacheExtension, CacheExtension,
DefaultsExtension, DefaultsExtension,
@ -815,12 +819,12 @@ class InternalSettings:
) + list(extensions) ) + list(extensions)
# Extensions may be passed in either as classes or import strings. # Extensions may be passed in either as classes or import strings.
extension_instances: List["ComponentExtension"] = [] extension_instances: List[ComponentExtension] = []
for extension in extensions: for extension in extensions:
if isinstance(extension, str): if isinstance(extension, str):
import_path, class_name = extension.rsplit(".", 1) import_path, class_name = extension.rsplit(".", 1)
extension_module = import_module(import_path) extension_module = import_module(import_path)
extension = cast(Type["ComponentExtension"], getattr(extension_module, class_name)) extension = cast("Type[ComponentExtension]", getattr(extension_module, class_name)) # noqa: PLW2901
if isinstance(extension, type): if isinstance(extension, type):
extension_instance = extension() extension_instance = extension()
@ -837,7 +841,7 @@ class InternalSettings:
if val is None: if val is None:
val = new_settings.reload_on_template_change val = new_settings.reload_on_template_change
return default(val, cast(bool, defaults.reload_on_file_change)) return default(val, cast("bool", defaults.reload_on_file_change))
def _prepare_static_files_forbidden(self, new_settings: ComponentsSettings) -> List[Union[str, re.Pattern]]: def _prepare_static_files_forbidden(self, new_settings: ComponentsSettings) -> List[Union[str, re.Pattern]]:
val = new_settings.static_files_forbidden val = new_settings.static_files_forbidden
@ -845,18 +849,18 @@ class InternalSettings:
if val is None: if val is None:
val = new_settings.forbidden_static_files val = new_settings.forbidden_static_files
return default(val, cast(List[Union[str, re.Pattern]], defaults.static_files_forbidden)) return default(val, cast("List[Union[str, re.Pattern]]", defaults.static_files_forbidden))
def _prepare_context_behavior(self, new_settings: ComponentsSettings) -> Literal["django", "isolated"]: def _prepare_context_behavior(self, new_settings: ComponentsSettings) -> Literal["django", "isolated"]:
raw_value = cast( raw_value = cast(
Literal["django", "isolated"], "Literal['django', 'isolated']",
default(new_settings.context_behavior, defaults.context_behavior), default(new_settings.context_behavior, defaults.context_behavior),
) )
try: try:
ContextBehavior(raw_value) ContextBehavior(raw_value)
except ValueError: except ValueError as err:
valid_values = [behavior.value for behavior in ContextBehavior] 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}") from err
return raw_value return raw_value

View file

@ -1,3 +1,4 @@
# ruff: noqa: PLC0415
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -76,7 +77,7 @@ def _watch_component_files_for_autoreload() -> None:
component_dirs = set(get_component_dirs()) component_dirs = set(get_component_dirs())
def template_changed(sender: Any, file_path: Path, **kwargs: Any) -> None: def template_changed(sender: Any, file_path: Path, **kwargs: Any) -> None: # noqa: ARG001
# Reload dev server if any of the files within `COMPONENTS.dirs` or `COMPONENTS.app_dirs` changed # Reload dev server if any of the files within `COMPONENTS.dirs` or `COMPONENTS.app_dirs` changed
for dir_path in file_path.parents: for dir_path in file_path.parents:
if dir_path in component_dirs: if dir_path in component_dirs:

View file

@ -70,11 +70,11 @@ class HtmlAttrsNode(BaseNode):
tag = "html_attrs" tag = "html_attrs"
end_tag = None # inline-only end_tag = None # inline-only
allowed_flags = [] allowed_flags = ()
def render( def render(
self, self,
context: Context, context: Context, # noqa: ARG002
attrs: Optional[Dict] = None, attrs: Optional[Dict] = None,
defaults: Optional[Dict] = None, defaults: Optional[Dict] = None,
**kwargs: Any, **kwargs: Any,
@ -269,7 +269,7 @@ def normalize_class(value: ClassValue) -> str:
res: Dict[str, bool] = {} res: Dict[str, bool] = {}
if isinstance(value, str): if isinstance(value, str):
return value.strip() return value.strip()
elif isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
# List items may be strings, dicts, or other lists/tuples # List items may be strings, dicts, or other lists/tuples
for item in value: for item in value:
# NOTE: One difference from Vue is that if a class is given multiple times, # NOTE: One difference from Vue is that if a class is given multiple times,
@ -287,7 +287,7 @@ def normalize_class(value: ClassValue) -> str:
# `{"class": True, "extra": True}` will result in `class="class extra"` # `{"class": True, "extra": True}` will result in `class="class extra"`
res = value res = value
else: else:
raise ValueError(f"Invalid class value: {value}") raise TypeError(f"Invalid class value: {value}")
res_str = "" res_str = ""
for key, val in res.items(): for key, val in res.items():
@ -313,7 +313,7 @@ def _normalize_class(value: ClassValue) -> Dict[str, bool]:
elif isinstance(value, dict): elif isinstance(value, dict):
res = value res = value
else: else:
raise ValueError(f"Invalid class value: {value}") raise TypeError(f"Invalid class value: {value}")
return res return res
@ -360,7 +360,7 @@ def normalize_style(value: StyleValue) -> str:
res: StyleDict = {} res: StyleDict = {}
if isinstance(value, str): if isinstance(value, str):
return value.strip() return value.strip()
elif isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
# List items may be strings, dicts, or other lists/tuples # List items may be strings, dicts, or other lists/tuples
for item in value: for item in value:
normalized = _normalize_style(item) normalized = _normalize_style(item)
@ -369,7 +369,7 @@ def normalize_style(value: StyleValue) -> str:
# Remove entries with `None` value # Remove entries with `None` value
res = _normalize_style(value) res = _normalize_style(value)
else: else:
raise ValueError(f"Invalid style value: {value}") raise TypeError(f"Invalid style value: {value}")
# By the time we get here, all `None` values have been removed. # By the time we get here, all `None` values have been removed.
# If the final dict has `None` or `False` values, they are removed, so those # If the final dict has `None` or `False` values, they are removed, so those
@ -398,7 +398,7 @@ def _normalize_style(value: StyleValue) -> StyleDict:
if val is not None: if val is not None:
res[key] = val res[key] = val
else: else:
raise ValueError(f"Invalid style value: {value}") raise TypeError(f"Invalid style value: {value}")
return res return res

View file

@ -40,6 +40,7 @@ def autodiscover(
modules = get_component_files(".py") modules = get_component_files(".py")
``` ```
""" """
modules = get_component_files(".py") modules = get_component_files(".py")
logger.debug(f"Autodiscover found {len(modules)} files in component directories.") logger.debug(f"Autodiscover found {len(modules)} files in component directories.")
@ -80,8 +81,9 @@ def import_libraries(
import_libraries(lambda path: path.replace("tests.", "myapp.")) import_libraries(lambda path: path.replace("tests.", "myapp."))
``` ```
""" """
from django_components.app_settings import app_settings from django_components.app_settings import app_settings # noqa: PLC0415
return _import_modules(app_settings.LIBRARIES, map_module) return _import_modules(app_settings.LIBRARIES, map_module)
@ -93,7 +95,7 @@ def _import_modules(
imported_modules: List[str] = [] imported_modules: List[str] = []
for module_name in modules: for module_name in modules:
if map_module: if map_module:
module_name = map_module(module_name) module_name = map_module(module_name) # noqa: PLW2901
# This imports the file and runs it's code. So if the file defines any # This imports the file and runs it's code. So if the file defines any
# django components, they will be registered. # django components, they will be registered.

View file

@ -20,7 +20,7 @@ component_media_cache: Optional[BaseCache] = None
# TODO_V1 - Remove, won't be needed once we remove `get_template_string()`, `get_template_name()`, `get_template()` # TODO_V1 - Remove, won't be needed once we remove `get_template_string()`, `get_template_name()`, `get_template()`
def get_template_cache() -> LRUCache: def get_template_cache() -> LRUCache:
global template_cache global template_cache # noqa: PLW0603
if template_cache is None: if template_cache is None:
template_cache = LRUCache(maxsize=app_settings.TEMPLATE_CACHE_SIZE) template_cache = LRUCache(maxsize=app_settings.TEMPLATE_CACHE_SIZE)
@ -32,7 +32,7 @@ def get_component_media_cache() -> BaseCache:
return caches[app_settings.CACHE] return caches[app_settings.CACHE]
# If no cache is set, use a local memory cache. # If no cache is set, use a local memory cache.
global component_media_cache global component_media_cache # noqa: PLW0603
if component_media_cache is None: if component_media_cache is None:
component_media_cache = LocMemCache( component_media_cache = LocMemCache(
"django-components-media", "django-components-media",

View file

@ -24,9 +24,9 @@ class ComponentsRootCommand(ComponentCommand):
name = "components" name = "components"
help = "The entrypoint for the 'components' commands." help = "The entrypoint for the 'components' commands."
subcommands = [ subcommands = (
CreateCommand, CreateCommand,
UpgradeCommand, UpgradeCommand,
ExtCommand, ExtCommand,
ComponentListCommand, ComponentListCommand,
] )

View file

@ -1,5 +1,5 @@
import os
import sys import sys
from pathlib import Path
from textwrap import dedent from textwrap import dedent
from typing import Any from typing import Any
@ -69,7 +69,7 @@ class CreateCommand(ComponentCommand):
name = "create" name = "create"
help = "Create a new django component." help = "Create a new django component."
arguments = [ arguments = (
CommandArg( CommandArg(
name_or_flags="name", name_or_flags="name",
help="The name of the component to create. This is a required argument.", help="The name of the component to create. This is a required argument.",
@ -118,9 +118,9 @@ class CreateCommand(ComponentCommand):
), ),
action="store_true", action="store_true",
), ),
] )
def handle(self, *args: Any, **kwargs: Any) -> None: def handle(self, *_args: Any, **kwargs: Any) -> None:
name = kwargs["name"] name = kwargs["name"]
if not name: if not name:
@ -138,16 +138,16 @@ class CreateCommand(ComponentCommand):
dry_run = kwargs["dry_run"] dry_run = kwargs["dry_run"]
if path: if path:
component_path = os.path.join(path, name) component_path = Path(path) / name
elif base_dir: elif base_dir:
component_path = os.path.join(base_dir, "components", name) component_path = Path(base_dir) / "components" / name
else: 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 component_path.exists():
if not force: if not force:
raise CommandError( raise CommandError(
f'The component "{name}" already exists at {component_path}. Use --force to overwrite.' f'The component "{name}" already exists at {component_path}. Use --force to overwrite.',
) )
if verbose: if verbose:
@ -158,29 +158,32 @@ class CreateCommand(ComponentCommand):
sys.stdout.write(style_warning(msg) + "\n") sys.stdout.write(style_warning(msg) + "\n")
if not dry_run: if not dry_run:
os.makedirs(component_path, exist_ok=force) component_path.mkdir(parents=True, exist_ok=force)
with open(os.path.join(component_path, js_filename), "w") as f: js_path = component_path / js_filename
with js_path.open("w") as f:
script_content = dedent( script_content = dedent(
f""" f"""
window.addEventListener('load', (event) => {{ window.addEventListener('load', (event) => {{
console.log("{name} component is fully loaded"); console.log("{name} component is fully loaded");
}}); }});
""" """,
) )
f.write(script_content.strip()) f.write(script_content.strip())
with open(os.path.join(component_path, css_filename), "w") as f: css_path = component_path / css_filename
with css_path.open("w") as f:
style_content = dedent( style_content = dedent(
f""" f"""
.component-{name} {{ .component-{name} {{
background: red; background: red;
}} }}
""" """,
) )
f.write(style_content.strip()) f.write(style_content.strip())
with open(os.path.join(component_path, template_filename), "w") as f: template_path = component_path / template_filename
with template_path.open("w") as f:
template_content = dedent( template_content = dedent(
f""" f"""
<div class="component-{name}"> <div class="component-{name}">
@ -188,11 +191,12 @@ class CreateCommand(ComponentCommand):
<br> <br>
This is {{ param }} context value. This is {{ param }} context value.
</div> </div>
""" """,
) )
f.write(template_content.strip()) f.write(template_content.strip())
with open(os.path.join(component_path, f"{name}.py"), "w") as f: py_path = component_path / f"{name}.py"
with py_path.open("w") as f:
py_content = dedent( py_content = dedent(
f""" f"""
from django_components import Component, register from django_components import Component, register
@ -213,7 +217,7 @@ class CreateCommand(ComponentCommand):
return {{ return {{
"param": kwargs.param, "param": kwargs.param,
}} }}
""" """,
) )
f.write(py_content.strip()) f.write(py_content.strip())

View file

@ -16,7 +16,7 @@ class ExtCommand(ComponentCommand):
name = "ext" name = "ext"
help = "Run extension commands." help = "Run extension commands."
subcommands = [ subcommands = (
ExtListCommand, ExtListCommand,
ExtRunCommand, ExtRunCommand,
] )

View file

@ -60,8 +60,8 @@ class ExtListCommand(ListCommand):
name = "list" name = "list"
help = "List all extensions." help = "List all extensions."
columns = ["name"] columns = ("name",)
default_columns = ["name"] default_columns = ("name",)
def get_data(self) -> List[Dict[str, Any]]: def get_data(self) -> List[Dict[str, Any]]:
data: List[Dict[str, Any]] = [] data: List[Dict[str, Any]] = []
@ -69,6 +69,6 @@ class ExtListCommand(ListCommand):
data.append( data.append(
{ {
"name": extension.name, "name": extension.name,
} },
) )
return data return data

View file

@ -22,7 +22,7 @@ def _gen_subcommands() -> List[Type[ComponentCommand]]:
if not extension.commands: if not extension.commands:
continue continue
ExtCommand = type( ExtCommand = type( # noqa: N806
"ExtRunSubcommand_" + extension.name, "ExtRunSubcommand_" + extension.name,
(ComponentCommand,), (ComponentCommand,),
{ {
@ -113,4 +113,4 @@ class ExtRunCommand(ComponentCommand):
name = "run" name = "run"
help = "Run a command added by an extension." help = "Run a command added by an extension."
subcommands = SubcommandsDescriptor() # type: ignore subcommands = SubcommandsDescriptor() # type: ignore[assignment]

View file

@ -1,5 +1,6 @@
import os # ruff: noqa: T201
from typing import Any, Dict, List, Optional, Type from pathlib import Path
from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple, Type, Union
from django_components.component import all_components from django_components.component import all_components
from django_components.util.command import CommandArg, ComponentCommand from django_components.util.command import CommandArg, ComponentCommand
@ -48,8 +49,8 @@ class ListCommand(ComponentCommand):
# SUBCLASS API # SUBCLASS API
#################### ####################
columns: List[str] columns: ClassVar[Union[List[str], Tuple[str, ...], Set[str]]]
default_columns: List[str] default_columns: ClassVar[Union[List[str], Tuple[str, ...], Set[str]]]
def get_data(self) -> List[Dict[str, Any]]: def get_data(self) -> List[Dict[str, Any]]:
return [] return []
@ -60,7 +61,7 @@ class ListCommand(ComponentCommand):
arguments = ListArgumentsDescriptor() # type: ignore[assignment] arguments = ListArgumentsDescriptor() # type: ignore[assignment]
def handle(self, *args: Any, **kwargs: Any) -> None: def handle(self, *_args: Any, **kwargs: Any) -> None:
""" """
This runs when the "list" command is called. This handler delegates to subclasses This runs when the "list" command is called. This handler delegates to subclasses
to define how to get the data with the `get_data` method and formats the results to define how to get the data with the `get_data` method and formats the results
@ -146,8 +147,8 @@ class ComponentListCommand(ListCommand):
name = "list" name = "list"
help = "List all components created in this project." help = "List all components created in this project."
columns = ["name", "full_name", "path"] columns = ("name", "full_name", "path")
default_columns = ["full_name", "path"] default_columns = ("full_name", "path")
def get_data(self) -> List[Dict[str, Any]]: def get_data(self) -> List[Dict[str, Any]]:
components = all_components() components = all_components()
@ -158,13 +159,13 @@ class ComponentListCommand(ListCommand):
# Make paths relative to CWD # Make paths relative to CWD
if module_file_path: if module_file_path:
module_file_path = os.path.relpath(module_file_path, os.getcwd()) module_file_path = str(Path(module_file_path).relative_to(Path.cwd()))
data.append( data.append(
{ {
"name": component.__name__, "name": component.__name__,
"full_name": full_name, "full_name": full_name,
"path": module_file_path, "path": module_file_path,
} },
) )
return data return data

View file

@ -3,9 +3,7 @@ from django_components.commands.create import CreateCommand
# TODO_REMOVE_IN_V1 - Superseded by `components create` # TODO_REMOVE_IN_V1 - Superseded by `components create`
class StartComponentCommand(CreateCommand): class StartComponentCommand(CreateCommand):
""" """**Deprecated**. Use [`components create`](../commands#components-create) instead."""
**Deprecated**. Use [`components create`](../commands#components-create) instead.
"""
name = "startcomponent" name = "startcomponent"
help = "Deprecated. Use `components create` instead." help = "Deprecated. Use `components create` instead."

View file

@ -1,7 +1,8 @@
# ruff: noqa: T201
import os import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, List
from django.conf import settings from django.conf import settings
from django.template.engine import Engine from django.template.engine import Engine
@ -15,14 +16,14 @@ class UpgradeCommand(ComponentCommand):
name = "upgrade" name = "upgrade"
help = "Upgrade django components syntax from '{%% component_block ... %%}' to '{%% component ... %%}'." help = "Upgrade django components syntax from '{%% component_block ... %%}' to '{%% component ... %%}'."
arguments = [ arguments = (
CommandArg( CommandArg(
name_or_flags="--path", name_or_flags="--path",
help="Path to search for components", help="Path to search for components",
), ),
] )
def handle(self, *args: Any, **options: Any) -> None: def handle(self, *_args: Any, **options: Any) -> None:
current_engine = Engine.get_default() current_engine = Engine.get_default()
loader = DjcLoader(current_engine) loader = DjcLoader(current_engine)
dirs = loader.get_dirs(include_apps=False) dirs = loader.get_dirs(include_apps=False)
@ -33,7 +34,7 @@ class UpgradeCommand(ComponentCommand):
if options["path"]: if options["path"]:
dirs = [options["path"]] dirs = [options["path"]]
all_files = [] all_files: List[Path] = []
for dir_path in dirs: for dir_path in dirs:
print(f"Searching for components in {dir_path}...") print(f"Searching for components in {dir_path}...")
@ -41,11 +42,11 @@ class UpgradeCommand(ComponentCommand):
for file in files: for file in files:
if not file.endswith((".html", ".py")): if not file.endswith((".html", ".py")):
continue continue
file_path = os.path.join(root, file) file_path = Path(root) / file
all_files.append(file_path) all_files.append(file_path)
for file_path in all_files: for file_path in all_files:
with open(file_path, "r+", encoding="utf-8") as f: with file_path.open("r+", encoding="utf-8") as f:
content = f.read() content = f.read()
content_with_closed_components, step0_count = re.subn( content_with_closed_components, step0_count = re.subn(
r'({%\s*component\s*"(\w+?)"(.*?)%})(?!.*?{%\s*endcomponent\s*%})', r'({%\s*component\s*"(\w+?)"(.*?)%})(?!.*?{%\s*endcomponent\s*%})',

View file

@ -3,9 +3,7 @@ from django_components.commands.upgrade import UpgradeCommand
# TODO_REMOVE_IN_V1 - No longer needed? # TODO_REMOVE_IN_V1 - No longer needed?
class UpgradeComponentCommand(UpgradeCommand): class UpgradeComponentCommand(UpgradeCommand):
""" """**Deprecated**. Use [`components upgrade`](../commands#components-upgrade) instead."""
**Deprecated**. Use [`components upgrade`](../commands#components-upgrade) instead.
"""
name = "upgradecomponent" name = "upgradecomponent"
help = "Deprecated. Use `components upgrade` instead." help = "Deprecated. Use `components upgrade` instead."

View file

@ -32,7 +32,7 @@ DJANGO_COMMAND_ARGS = [
default=1, default=1,
type=int, type=int,
choices=[0, 1, 2, 3], choices=[0, 1, 2, 3],
help=("Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, " "3=very verbose output"), help=("Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output"),
), ),
CommandArg( CommandArg(
"--settings", "--settings",
@ -44,7 +44,7 @@ DJANGO_COMMAND_ARGS = [
), ),
CommandArg( CommandArg(
"--pythonpath", "--pythonpath",
help=("A directory to add to the Python path, e.g. " '"/home/djangoprojects/myproject".'), help=('A directory to add to the Python path, e.g. "/home/djangoprojects/myproject".'),
), ),
CommandArg( CommandArg(
"--traceback", "--traceback",
@ -93,7 +93,7 @@ def load_as_django_command(command: Type[ComponentCommand]) -> Type[DjangoComman
def __init__(self) -> None: def __init__(self) -> None:
self._command = command() self._command = command()
def create_parser(self, prog_name: str, subcommand: str, **kwargs: Any) -> ArgumentParser: def create_parser(self, *_args: Any, **_kwargs: Any) -> ArgumentParser:
parser = setup_parser_from_command(command) parser = setup_parser_from_command(command)
for arg in DJANGO_COMMAND_ARGS: for arg in DJANGO_COMMAND_ARGS:
_setup_command_arg(parser, arg.asdict()) _setup_command_arg(parser, arg.asdict())
@ -104,13 +104,13 @@ def load_as_django_command(command: Type[ComponentCommand]) -> Type[DjangoComman
# this is where we forward the args to the command handler. # this is where we forward the args to the command handler.
def handle(self, *args: Any, **options: Any) -> None: def handle(self, *args: Any, **options: Any) -> None:
# Case: (Sub)command matched and it HAS handler # Case: (Sub)command matched and it HAS handler
resolved_command: Optional[ComponentCommand] = options.get("_command", None) resolved_command: Optional[ComponentCommand] = options.get("_command")
if resolved_command and resolved_command.handle: if resolved_command and resolved_command.handle:
resolved_command.handle(*args, **options) resolved_command.handle(*args, **options)
return return
# Case: (Sub)command matched and it DOES NOT have handler (e.g. subcommand used for routing) # Case: (Sub)command matched and it DOES NOT have handler (e.g. subcommand used for routing)
cmd_parser: Optional[ArgumentParser] = options.get("_parser", None) cmd_parser: Optional[ArgumentParser] = options.get("_parser")
if cmd_parser: if cmd_parser:
cmd_parser.print_help() cmd_parser.print_help()
return return

View file

@ -1,8 +1,10 @@
# ruff: noqa: ARG002, N804, N805
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from inspect import signature from inspect import signature
from types import MethodType from types import MethodType
from typing import ( from typing import (
TYPE_CHECKING,
Any, Any,
Callable, Callable,
ClassVar, ClassVar,
@ -25,7 +27,6 @@ from django.template.base import NodeList, Parser, Template, Token
from django.template.context import Context, RequestContext from django.template.context import Context, RequestContext
from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
from django.test.signals import template_rendered from django.test.signals import template_rendered
from django.views import View
from django_components.app_settings import ContextBehavior from django_components.app_settings import ContextBehavior
from django_components.component_media import ComponentMediaInput, ComponentMediaMeta from django_components.component_media import ComponentMediaInput, ComponentMediaMeta
@ -40,11 +41,9 @@ from django_components.dependencies import (
cache_component_js, cache_component_js,
cache_component_js_vars, cache_component_js_vars,
insert_component_dependencies_comment, insert_component_dependencies_comment,
)
from django_components.dependencies import render_dependencies as _render_dependencies
from django_components.dependencies import (
set_component_attrs_for_js_and_css, set_component_attrs_for_js_and_css,
) )
from django_components.dependencies import render_dependencies as _render_dependencies
from django_components.extension import ( from django_components.extension import (
OnComponentClassCreatedContext, OnComponentClassCreatedContext,
OnComponentClassDeletedContext, OnComponentClassDeletedContext,
@ -85,14 +84,17 @@ from django_components.util.weakref import cached_ref
# TODO_REMOVE_IN_V1 - Users should use top-level import instead # TODO_REMOVE_IN_V1 - Users should use top-level import instead
# isort: off # isort: off
from django_components.component_registry import AlreadyRegistered as AlreadyRegistered # NOQA from django_components.component_registry import AlreadyRegistered as AlreadyRegistered # noqa: PLC0414
from django_components.component_registry import ComponentRegistry as ComponentRegistry # NOQA from django_components.component_registry import ComponentRegistry as ComponentRegistry # noqa: PLC0414,F811
from django_components.component_registry import NotRegistered as NotRegistered # NOQA from django_components.component_registry import NotRegistered as NotRegistered # noqa: PLC0414
from django_components.component_registry import register as register # NOQA from django_components.component_registry import register as register # noqa: PLC0414
from django_components.component_registry import registry as registry # NOQA from django_components.component_registry import registry as registry # noqa: PLC0414
# isort: on # isort: on
if TYPE_CHECKING:
from django.views import View
COMP_ONLY_FLAG = "only" COMP_ONLY_FLAG = "only"
@ -160,7 +162,7 @@ ALL_COMPONENTS: AllComponents = []
def all_components() -> List[Type["Component"]]: def all_components() -> List[Type["Component"]]:
"""Get a list of all created [`Component`](../api#django_components.Component) classes.""" """Get a list of all created [`Component`](../api#django_components.Component) classes."""
components: List[Type["Component"]] = [] components: List[Type[Component]] = []
for comp_ref in ALL_COMPONENTS: for comp_ref in ALL_COMPONENTS:
comp = comp_ref() comp = comp_ref()
if comp is not None: if comp is not None:
@ -473,13 +475,13 @@ class ComponentMeta(ComponentMediaMeta):
attrs["template_file"] = attrs.pop("template_name") attrs["template_file"] = attrs.pop("template_name")
attrs["template_name"] = ComponentTemplateNameDescriptor() attrs["template_name"] = ComponentTemplateNameDescriptor()
cls = cast(Type["Component"], super().__new__(mcs, name, bases, attrs)) cls = cast("Type[Component]", super().__new__(mcs, name, bases, attrs))
# If the component defined `template_file`, then associate this Component class # If the component defined `template_file`, then associate this Component class
# with that template file path. # with that template file path.
# This way, when we will be instantiating `Template` in order to load the Component's template, # This way, when we will be instantiating `Template` in order to load the Component's template,
# and its template_name matches this path, then we know that the template belongs to this Component class. # and its template_name matches this path, then we know that the template belongs to this Component class.
if "template_file" in attrs and attrs["template_file"]: if attrs.get("template_file"):
cache_component_template_file(cls) cache_component_template_file(cls)
# TODO_V1 - Remove. This is only for backwards compatibility with v0.139 and earlier, # TODO_V1 - Remove. This is only for backwards compatibility with v0.139 and earlier,
@ -493,7 +495,7 @@ class ComponentMeta(ComponentMediaMeta):
context: Context, context: Context,
template: Template, template: Template,
result: str, result: str,
error: Optional[Exception], _error: Optional[Exception],
) -> Optional[SlotResult]: ) -> Optional[SlotResult]:
return orig_on_render_after(self, context, template, result) # type: ignore[call-arg] return orig_on_render_after(self, context, template, result) # type: ignore[call-arg]
@ -507,7 +509,7 @@ class ComponentMeta(ComponentMediaMeta):
if not extensions: if not extensions:
return return
comp_cls = cast(Type["Component"], cls) comp_cls = cast("Type[Component]", cls)
extensions.on_component_class_deleted(OnComponentClassDeletedContext(comp_cls)) extensions.on_component_class_deleted(OnComponentClassDeletedContext(comp_cls))
@ -826,6 +828,7 @@ class Component(metaclass=ComponentMeta):
Returns: Returns:
Optional[str]: The filepath to the template. Optional[str]: The filepath to the template.
""" """
return None return None
@ -915,11 +918,12 @@ class Component(metaclass=ComponentMeta):
Returns: Returns:
Optional[Union[str, Template]]: The inlined Django template string or\ Optional[Union[str, Template]]: The inlined Django template string or\
a [`Template`](https://docs.djangoproject.com/en/5.1/topics/templates/#template) instance. a [`Template`](https://docs.djangoproject.com/en/5.1/topics/templates/#template) instance.
""" """
return None return None
# TODO_V2 - Remove this in v2 # TODO_V2 - Remove this in v2
def get_context_data(self, *args: Any, **kwargs: Any) -> Optional[Mapping]: def get_context_data(self, *_args: Any, **_kwargs: Any) -> Optional[Mapping]:
""" """
DEPRECATED: Use [`get_template_data()`](../api#django_components.Component.get_template_data) instead. DEPRECATED: Use [`get_template_data()`](../api#django_components.Component.get_template_data) instead.
Will be removed in v2. Will be removed in v2.
@ -1788,7 +1792,7 @@ class Component(metaclass=ComponentMeta):
media_class = MyMediaClass media_class = MyMediaClass
``` ```
""" # noqa: E501 """
Media: ClassVar[Optional[Type[ComponentMediaInput]]] = None Media: ClassVar[Optional[Type[ComponentMediaInput]]] = None
""" """
@ -1832,7 +1836,7 @@ class Component(metaclass=ComponentMeta):
"print": ["path/to/style2.css"], "print": ["path/to/style2.css"],
} }
``` ```
""" # noqa: E501 """
response_class: ClassVar[Type[HttpResponse]] = HttpResponse response_class: ClassVar[Type[HttpResponse]] = HttpResponse
""" """
@ -1911,8 +1915,8 @@ class Component(metaclass=ComponentMeta):
Since this hook is called for every component, this means that the template would be modified Since this hook is called for every component, this means that the template would be modified
every time a component is rendered. every time a component is rendered.
""" """
pass
def on_render(self, context: Context, template: Optional[Template]) -> Union[SlotResult, OnRenderGenerator, None]: def on_render(self, context: Context, template: Optional[Template]) -> Union[SlotResult, OnRenderGenerator, None]:
""" """
@ -2026,11 +2030,14 @@ class Component(metaclass=ComponentMeta):
""" """
if template is None: if template is None:
return None return None
else:
return template.render(context) return template.render(context)
def on_render_after( def on_render_after(
self, context: Context, template: Optional[Template], result: Optional[str], error: Optional[Exception] self,
context: Context,
template: Optional[Template],
result: Optional[str],
error: Optional[Exception],
) -> Optional[SlotResult]: ) -> Optional[SlotResult]:
""" """
Hook that runs when the component was fully rendered, Hook that runs when the component was fully rendered,
@ -2099,7 +2106,6 @@ class Component(metaclass=ComponentMeta):
print(f"Error: {error}") print(f"Error: {error}")
``` ```
""" """
pass
# ##################################### # #####################################
# BUILT-IN EXTENSIONS # BUILT-IN EXTENSIONS
@ -2225,7 +2231,7 @@ class Component(metaclass=ComponentMeta):
self, self,
registered_name: Optional[str] = None, registered_name: Optional[str] = None,
outer_context: Optional[Context] = None, outer_context: Optional[Context] = None,
registry: Optional[ComponentRegistry] = None, # noqa F811 registry: Optional[ComponentRegistry] = None, # noqa: F811
context: Optional[Context] = None, context: Optional[Context] = None,
args: Optional[Any] = None, args: Optional[Any] = None,
kwargs: Optional[Any] = None, kwargs: Optional[Any] = None,
@ -2233,8 +2239,8 @@ class Component(metaclass=ComponentMeta):
deps_strategy: Optional[DependenciesStrategy] = None, deps_strategy: Optional[DependenciesStrategy] = None,
request: Optional[HttpRequest] = None, request: Optional[HttpRequest] = None,
node: Optional["ComponentNode"] = None, node: Optional["ComponentNode"] = None,
id: Optional[str] = None, id: Optional[str] = None, # noqa: A002
): ) -> None:
# TODO_v1 - Remove this whole block in v1. This is for backwards compatibility with pre-v0.140 # TODO_v1 - Remove this whole block in v1. This is for backwards compatibility with pre-v0.140
# where one could do: # where one could do:
# `MyComp("my_comp").render(kwargs={"a": 1})`. # `MyComp("my_comp").render(kwargs={"a": 1})`.
@ -2272,10 +2278,10 @@ class Component(metaclass=ComponentMeta):
}, },
) )
self.render_to_response = MethodType(primed_render_to_response, self) # type: ignore self.render_to_response = MethodType(primed_render_to_response, self) # type: ignore[method-assign]
self.render = MethodType(primed_render, self) # type: ignore self.render = MethodType(primed_render, self) # type: ignore[method-assign]
deps_strategy = cast(DependenciesStrategy, default(deps_strategy, "document")) deps_strategy = cast("DependenciesStrategy", default(deps_strategy, "document"))
self.id = default(id, _gen_component_id, factory=True) # type: ignore[arg-type] self.id = default(id, _gen_component_id, factory=True) # type: ignore[arg-type]
self.name = _get_component_name(self.__class__, registered_name) self.name = _get_component_name(self.__class__, registered_name)
@ -2293,9 +2299,9 @@ class Component(metaclass=ComponentMeta):
self.input = ComponentInput( self.input = ComponentInput(
context=self.context, context=self.context,
# NOTE: Convert args / kwargs / slots to plain lists / dicts # NOTE: Convert args / kwargs / slots to plain lists / dicts
args=cast(List, args if isinstance(self.args, list) else list(self.args)), args=cast("List", args if isinstance(self.args, list) else list(self.args)),
kwargs=cast(Dict, kwargs if isinstance(self.kwargs, dict) else to_dict(self.kwargs)), kwargs=cast("Dict", kwargs if isinstance(self.kwargs, dict) else to_dict(self.kwargs)),
slots=cast(Dict, slots if isinstance(self.slots, dict) else to_dict(self.slots)), slots=cast("Dict", slots if isinstance(self.slots, dict) else to_dict(self.slots)),
deps_strategy=deps_strategy, deps_strategy=deps_strategy,
# TODO_v1 - Remove, superseded by `deps_strategy` # TODO_v1 - Remove, superseded by `deps_strategy`
type=deps_strategy, type=deps_strategy,
@ -2804,7 +2810,7 @@ class Component(metaclass=ComponentMeta):
[`{{ component_vars.is_filled.slot_name }}`](../template_vars#django_components.component.ComponentVars.is_filled) [`{{ component_vars.is_filled.slot_name }}`](../template_vars#django_components.component.ComponentVars.is_filled)
""" # noqa: E501 """
request: Optional[HttpRequest] request: Optional[HttpRequest]
""" """
@ -2882,7 +2888,6 @@ class Component(metaclass=ComponentMeta):
if request is None: if request is None:
return {} return {}
else:
return gen_context_processors_data(self.context, request) return gen_context_processors_data(self.context, request)
# ##################################### # #####################################
@ -2950,7 +2955,7 @@ class Component(metaclass=ComponentMeta):
def outer_view(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def outer_view(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# `view` is a built-in extension defined in `extensions.view`. It subclasses # `view` is a built-in extension defined in `extensions.view`. It subclasses
# from Django's `View` class, and adds the `component` attribute to it. # from Django's `View` class, and adds the `component` attribute to it.
view_cls = cast(View, cls.View) # type: ignore[attr-defined] view_cls = cast("View", cls.View) # type: ignore[attr-defined]
# TODO_v1 - Remove `component` and use only `component_cls` instead. # TODO_v1 - Remove `component` and use only `component_cls` instead.
inner_view = view_cls.as_view(**initkwargs, component=cls(), component_cls=cls) inner_view = view_cls.as_view(**initkwargs, component=cls(), component_cls=cls)
@ -2971,13 +2976,13 @@ class Component(metaclass=ComponentMeta):
slots: Optional[Any] = None, slots: Optional[Any] = None,
deps_strategy: DependenciesStrategy = "document", deps_strategy: DependenciesStrategy = "document",
# TODO_v1 - Remove, superseded by `deps_strategy` # TODO_v1 - Remove, superseded by `deps_strategy`
type: Optional[DependenciesStrategy] = None, type: Optional[DependenciesStrategy] = None, # noqa: A002
# TODO_v1 - Remove, superseded by `deps_strategy="ignore"` # TODO_v1 - Remove, superseded by `deps_strategy="ignore"`
render_dependencies: bool = True, render_dependencies: bool = True,
request: Optional[HttpRequest] = None, request: Optional[HttpRequest] = None,
outer_context: Optional[Context] = None, outer_context: Optional[Context] = None,
# TODO_v2 - Remove `registered_name` and `registry` # TODO_v2 - Remove `registered_name` and `registry`
registry: Optional[ComponentRegistry] = None, registry: Optional[ComponentRegistry] = None, # noqa: F811
registered_name: Optional[str] = None, registered_name: Optional[str] = None,
node: Optional["ComponentNode"] = None, node: Optional["ComponentNode"] = None,
**response_kwargs: Any, **response_kwargs: Any,
@ -3061,13 +3066,13 @@ class Component(metaclass=ComponentMeta):
slots: Optional[Any] = None, slots: Optional[Any] = None,
deps_strategy: DependenciesStrategy = "document", deps_strategy: DependenciesStrategy = "document",
# TODO_v1 - Remove, superseded by `deps_strategy` # TODO_v1 - Remove, superseded by `deps_strategy`
type: Optional[DependenciesStrategy] = None, type: Optional[DependenciesStrategy] = None, # noqa: A002
# TODO_v1 - Remove, superseded by `deps_strategy="ignore"` # TODO_v1 - Remove, superseded by `deps_strategy="ignore"`
render_dependencies: bool = True, render_dependencies: bool = True,
request: Optional[HttpRequest] = None, request: Optional[HttpRequest] = None,
outer_context: Optional[Context] = None, outer_context: Optional[Context] = None,
# TODO_v2 - Remove `registered_name` and `registry` # TODO_v2 - Remove `registered_name` and `registry`
registry: Optional[ComponentRegistry] = None, registry: Optional[ComponentRegistry] = None, # noqa: F811
registered_name: Optional[str] = None, registered_name: Optional[str] = None,
node: Optional["ComponentNode"] = None, node: Optional["ComponentNode"] = None,
) -> str: ) -> str:
@ -3253,13 +3258,12 @@ class Component(metaclass=ComponentMeta):
) )
``` ```
""" # noqa: E501 """ # noqa: E501
# TODO_v1 - Remove, superseded by `deps_strategy` # TODO_v1 - Remove, superseded by `deps_strategy`
if type is not None: if type is not None:
if deps_strategy != "document": if deps_strategy != "document":
raise ValueError( raise ValueError(
"Component.render() received both `type` and `deps_strategy` arguments. " "Component.render() received both `type` and `deps_strategy` arguments. "
"Only one should be given. The `type` argument is deprecated. Use `deps_strategy` instead." "Only one should be given. The `type` argument is deprecated. Use `deps_strategy` instead.",
) )
deps_strategy = type deps_strategy = type
@ -3293,7 +3297,7 @@ class Component(metaclass=ComponentMeta):
request: Optional[HttpRequest] = None, request: Optional[HttpRequest] = None,
outer_context: Optional[Context] = None, outer_context: Optional[Context] = None,
# TODO_v2 - Remove `registered_name` and `registry` # TODO_v2 - Remove `registered_name` and `registry`
registry: Optional[ComponentRegistry] = None, registry: Optional[ComponentRegistry] = None, # noqa: F811
registered_name: Optional[str] = None, registered_name: Optional[str] = None,
node: Optional["ComponentNode"] = None, node: Optional["ComponentNode"] = None,
) -> str: ) -> str:
@ -3326,7 +3330,7 @@ class Component(metaclass=ComponentMeta):
request: Optional[HttpRequest] = None, request: Optional[HttpRequest] = None,
outer_context: Optional[Context] = None, outer_context: Optional[Context] = None,
# TODO_v2 - Remove `registered_name` and `registry` # TODO_v2 - Remove `registered_name` and `registry`
registry: Optional[ComponentRegistry] = None, registry: Optional[ComponentRegistry] = None, # noqa: F811
registered_name: Optional[str] = None, registered_name: Optional[str] = None,
node: Optional["ComponentNode"] = None, node: Optional["ComponentNode"] = None,
) -> str: ) -> str:
@ -3394,7 +3398,7 @@ class Component(metaclass=ComponentMeta):
kwargs=kwargs_dict, kwargs=kwargs_dict,
slots=slots_dict, slots=slots_dict,
context=context, context=context,
) ),
) )
# The component rendering was short-circuited by an extension, skipping # The component rendering was short-circuited by an extension, skipping
@ -3415,7 +3419,7 @@ class Component(metaclass=ComponentMeta):
# Required for compatibility with Django's {% extends %} tag # Required for compatibility with Django's {% extends %} tag
# See https://github.com/django-components/django-components/pull/859 # See https://github.com/django-components/django-components/pull/859
context.render_context.push( # type: ignore[union-attr] context.render_context.push( # type: ignore[union-attr]
{BLOCK_CONTEXT_KEY: context.render_context.get(BLOCK_CONTEXT_KEY, BlockContext())} # type: ignore {BLOCK_CONTEXT_KEY: context.render_context.get(BLOCK_CONTEXT_KEY, BlockContext())}, # type: ignore[union-attr]
) )
# We pass down the components the info about the component's parent. # We pass down the components the info about the component's parent.
@ -3485,7 +3489,7 @@ class Component(metaclass=ComponentMeta):
template_data=template_data, template_data=template_data,
js_data=js_data, js_data=js_data,
css_data=css_data, css_data=css_data,
) ),
) )
# Cache component's JS and CSS scripts, in case they have been evicted from the cache. # Cache component's JS and CSS scripts, in case they have been evicted from the cache.
@ -3549,7 +3553,7 @@ class Component(metaclass=ComponentMeta):
# `{% if variable > 8 and component_vars.is_filled.header %}` # `{% if variable > 8 and component_vars.is_filled.header %}`
is_filled=component.is_filled, is_filled=component.is_filled,
), ),
} },
): ):
# Make a "snapshot" of the context as it was at the time of the render call. # Make a "snapshot" of the context as it was at the time of the render call.
# #
@ -3600,7 +3604,7 @@ class Component(metaclass=ComponentMeta):
if maybe_output is not None: if maybe_output is not None:
html = maybe_output html = maybe_output
error = None error = None
except Exception as new_error: except Exception as new_error: # noqa: BLE001
error = new_error error = new_error
html = None html = None
@ -3619,7 +3623,7 @@ class Component(metaclass=ComponentMeta):
component_id=render_id, component_id=render_id,
result=html, result=html,
error=error, error=error,
) ),
) )
if result is not None: if result is not None:
@ -3769,7 +3773,7 @@ class Component(metaclass=ComponentMeta):
if legacy_template_data and new_template_data: if legacy_template_data and new_template_data:
raise RuntimeError( raise RuntimeError(
f"Component {self.name} has both `get_context_data()` and `get_template_data()` methods. " f"Component {self.name} has both `get_context_data()` and `get_template_data()` methods. "
"Please remove one of them." "Please remove one of them.",
) )
template_data = new_template_data or legacy_template_data template_data = new_template_data or legacy_template_data
@ -3896,13 +3900,13 @@ class ComponentNode(BaseNode):
tag = "component" tag = "component"
end_tag = "endcomponent" end_tag = "endcomponent"
allowed_flags = [COMP_ONLY_FLAG] allowed_flags = (COMP_ONLY_FLAG,)
def __init__( def __init__(
self, self,
# ComponentNode inputs # ComponentNode inputs
name: str, name: str,
registry: ComponentRegistry, # noqa F811 registry: ComponentRegistry, # noqa: F811
# BaseNode inputs # BaseNode inputs
params: List[TagAttr], params: List[TagAttr],
flags: Optional[Dict[str, bool]] = None, flags: Optional[Dict[str, bool]] = None,
@ -3930,7 +3934,7 @@ class ComponentNode(BaseNode):
cls, cls,
parser: Parser, parser: Parser,
token: Token, token: Token,
registry: ComponentRegistry, # noqa F811 registry: ComponentRegistry, # noqa: F811
name: str, name: str,
start_tag: str, start_tag: str,
end_tag: str, end_tag: str,
@ -3952,11 +3956,11 @@ class ComponentNode(BaseNode):
if cached_registry is not registry: if cached_registry is not registry:
raise RuntimeError( raise RuntimeError(
f"Detected two Components from different registries using the same start tag '{start_tag}'" f"Detected two Components from different registries using the same start tag '{start_tag}'",
) )
elif cached_subcls.end_tag != end_tag: if cached_subcls.end_tag != end_tag:
raise RuntimeError( raise RuntimeError(
f"Detected two Components using the same start tag '{start_tag}' but with different end tags" f"Detected two Components using the same start tag '{start_tag}' but with different end tags",
) )
# Call `BaseNode.parse()` as if with the context of subcls. # Call `BaseNode.parse()` as if with the context of subcls.

View file

@ -1,3 +1,4 @@
# ruff: noqa: PTH100, PTH118, PTH120, PTH207
import glob import glob
import os import os
import sys import sys
@ -254,7 +255,7 @@ class ComponentMediaInput(Protocol):
print(MyComponent.media._js) # ["script.js", "other1.js", "other2.js"] print(MyComponent.media._js) # ["script.js", "other1.js", "other2.js"]
``` ```
""" # noqa: E501 """
@dataclass @dataclass
@ -294,7 +295,7 @@ class ComponentMedia:
if (inlined_val is not UNSET and file_val is not UNSET) and not (inlined_val is None and file_val is None): if (inlined_val is not UNSET and file_val is not UNSET) and not (inlined_val is None and file_val is None):
raise ImproperlyConfigured( raise ImproperlyConfigured(
f"Received non-empty value from both '{inlined_attr}' and '{file_attr}' in" f"Received non-empty value from both '{inlined_attr}' and '{file_attr}' in"
f" Component {self.comp_cls.__name__}. Only one of the two must be set." f" Component {self.comp_cls.__name__}. Only one of the two must be set.",
) )
# Make a copy of the original state, so we can reset it in tests # Make a copy of the original state, so we can reset it in tests
self._original = copy(self) self._original = copy(self)
@ -338,7 +339,7 @@ class ComponentMediaMeta(type):
_normalize_media(attrs["Media"]) _normalize_media(attrs["Media"])
cls = super().__new__(mcs, name, bases, attrs) cls = super().__new__(mcs, name, bases, attrs)
comp_cls = cast(Type["Component"], cls) comp_cls = cast("Type[Component]", cls)
_setup_lazy_media_resolve(comp_cls, attrs) _setup_lazy_media_resolve(comp_cls, attrs)
@ -358,9 +359,9 @@ class ComponentMediaMeta(type):
if name in COMP_MEDIA_LAZY_ATTRS: if name in COMP_MEDIA_LAZY_ATTRS:
comp_media: Optional[ComponentMedia] = getattr(cls, "_component_media", None) comp_media: Optional[ComponentMedia] = getattr(cls, "_component_media", None)
if comp_media is not None and comp_media.resolved: if comp_media is not None and comp_media.resolved:
print( print( # noqa: T201
f"WARNING: Setting attribute '{name}' on component '{cls.__name__}' after the media files were" f"WARNING: Setting attribute '{name}' on component '{cls.__name__}' after the media files were"
" already resolved. This may lead to unexpected behavior." " already resolved. This may lead to unexpected behavior.",
) )
# NOTE: When a metaclass specifies a `__setattr__` method, this overrides the normal behavior of # NOTE: When a metaclass specifies a `__setattr__` method, this overrides the normal behavior of
@ -393,7 +394,6 @@ def _setup_lazy_media_resolve(comp_cls: Type["Component"], attrs: Dict[str, Any]
def get_comp_media_attr(attr: str) -> Any: def get_comp_media_attr(attr: str) -> Any:
if attr == "media": if attr == "media":
return _get_comp_cls_media(comp_cls) return _get_comp_cls_media(comp_cls)
else:
return _get_comp_cls_attr(comp_cls, attr) return _get_comp_cls_attr(comp_cls, attr)
# Because of the lazy resolution, we want to know when the user tries to access the media attributes. # Because of the lazy resolution, we want to know when the user tries to access the media attributes.
@ -432,26 +432,25 @@ def _get_comp_cls_attr(comp_cls: Type["Component"], attr: str) -> Any:
# For each of the pairs of inlined_content + file (e.g. `js` + `js_file`), if at least one of the two # For each of the pairs of inlined_content + file (e.g. `js` + `js_file`), if at least one of the two
# is defined, we interpret it such that this (sub)class has overridden what was set by the parent class(es), # is defined, we interpret it such that this (sub)class has overridden what was set by the parent class(es),
# and we won't search further up the MRO. # and we won't search further up the MRO.
def resolve_pair(inline_attr: str, file_attr: str) -> Any: def is_pair_empty(inline_attr: str, file_attr: str) -> bool:
inline_attr_empty = getattr(comp_media, inline_attr, UNSET) is UNSET inline_attr_empty = getattr(comp_media, inline_attr, UNSET) is UNSET # noqa: B023
file_attr_empty = getattr(comp_media, file_attr, UNSET) is UNSET file_attr_empty = getattr(comp_media, file_attr, UNSET) is UNSET # noqa: B023
is_pair_empty = inline_attr_empty and file_attr_empty return inline_attr_empty and file_attr_empty
if is_pair_empty:
return UNSET
else:
return value
if attr in ("js", "js_file"): if attr in ("js", "js_file"):
value = resolve_pair("js", "js_file") is_empty_pair = is_pair_empty("js", "js_file")
elif attr in ("css", "css_file"): elif attr in ("css", "css_file"):
value = resolve_pair("css", "css_file") is_empty_pair = is_pair_empty("css", "css_file")
elif attr in ("template", "template_file"): elif attr in ("template", "template_file"):
value = resolve_pair("template", "template_file") is_empty_pair = is_pair_empty("template", "template_file")
else:
is_empty_pair = False
value = UNSET if is_empty_pair else value
if value is UNSET: if value is UNSET:
continue continue
else:
return value return value
return None return None
@ -509,7 +508,7 @@ def _get_comp_cls_media(comp_cls: Type["Component"]) -> Any:
# pass # pass
# ``` # ```
media_input = getattr(curr_cls, "Media", UNSET) media_input = getattr(curr_cls, "Media", UNSET)
default_extend = True if media_input is not None else False default_extend = media_input is not None
media_extend = getattr(media_input, "extend", default_extend) media_extend = getattr(media_input, "extend", default_extend)
# This ensures the same behavior as Django's Media class, where: # This ensures the same behavior as Django's Media class, where:
@ -520,7 +519,7 @@ def _get_comp_cls_media(comp_cls: Type["Component"]) -> Any:
if media_extend is True: if media_extend is True:
bases = curr_cls.__bases__ bases = curr_cls.__bases__
elif media_extend is False: elif media_extend is False:
bases = tuple() bases = ()
else: else:
bases = media_extend bases = media_extend
@ -716,17 +715,17 @@ def _normalize_media(media: Type[ComponentMediaInput]) -> None:
for media_type, path_or_list in media.css.items(): for media_type, path_or_list in media.css.items():
# {"all": "style.css"} # {"all": "style.css"}
if _is_media_filepath(path_or_list): if _is_media_filepath(path_or_list):
media.css[media_type] = [path_or_list] # type: ignore media.css[media_type] = [path_or_list] # type: ignore[misc]
# {"all": ["style.css"]} # {"all": ["style.css"]}
else: else:
media.css[media_type] = path_or_list # type: ignore media.css[media_type] = path_or_list # type: ignore[misc]
else: else:
raise ValueError(f"Media.css must be str, list, or dict, got {type(media.css)}") raise ValueError(f"Media.css must be str, list, or dict, got {type(media.css)}")
if hasattr(media, "js") and media.js: if hasattr(media, "js") and media.js:
# Allow: class Media: js = "script.js" # Allow: class Media: js = "script.js"
if _is_media_filepath(media.js): if _is_media_filepath(media.js):
media.js = [media.js] # type: ignore media.js = [media.js] # type: ignore[misc]
# Allow: class Media: js = ["script.js"] # Allow: class Media: js = ["script.js"]
else: else:
# JS is already a list, no action needed # JS is already a list, no action needed
@ -759,29 +758,31 @@ def _map_media_filepaths(media: Type[ComponentMediaInput], map_fn: Callable[[Seq
def _is_media_filepath(filepath: Any) -> bool: def _is_media_filepath(filepath: Any) -> bool:
# Case callable
if callable(filepath): if callable(filepath):
return True return True
# Case SafeString
if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"): if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"):
return True return True
elif isinstance(filepath, (Path, os.PathLike)) or hasattr(filepath, "__fspath__"): # Case PathLike
if isinstance(filepath, (Path, os.PathLike)) or hasattr(filepath, "__fspath__"):
return True return True
# Case bytes
if isinstance(filepath, bytes): if isinstance(filepath, bytes):
return True return True
if isinstance(filepath, str): # Case str
return True return isinstance(filepath, str)
return False
def _normalize_media_filepath(filepaths: Sequence[ComponentMediaInputPath]) -> List[Union[str, SafeData]]: def _normalize_media_filepath(filepaths: Sequence[ComponentMediaInputPath]) -> List[Union[str, SafeData]]:
normalized: List[Union[str, SafeData]] = [] normalized: List[Union[str, SafeData]] = []
for filepath in filepaths: for filepath in filepaths:
if callable(filepath): if callable(filepath):
filepath = filepath() filepath = filepath() # noqa: PLW2901
if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"): if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"):
normalized.append(filepath) normalized.append(filepath)
@ -789,10 +790,10 @@ def _normalize_media_filepath(filepaths: Sequence[ComponentMediaInputPath]) -> L
if isinstance(filepath, (Path, os.PathLike)) or hasattr(filepath, "__fspath__"): if isinstance(filepath, (Path, os.PathLike)) or hasattr(filepath, "__fspath__"):
# In case of Windows OS, convert to forward slashes # In case of Windows OS, convert to forward slashes
filepath = Path(filepath.__fspath__()).as_posix() filepath = Path(filepath.__fspath__()).as_posix() # noqa: PLW2901
if isinstance(filepath, bytes): if isinstance(filepath, bytes):
filepath = filepath.decode("utf-8") filepath = filepath.decode("utf-8") # noqa: PLW2901
if isinstance(filepath, str): if isinstance(filepath, str):
normalized.append(filepath) normalized.append(filepath)
@ -800,14 +801,16 @@ def _normalize_media_filepath(filepaths: Sequence[ComponentMediaInputPath]) -> L
raise ValueError( raise ValueError(
f"Unknown filepath {filepath} of type {type(filepath)}. Must be str, bytes, PathLike, SafeString," f"Unknown filepath {filepath} of type {type(filepath)}. Must be str, bytes, PathLike, SafeString,"
" or a function that returns one of the former" " or a function that returns one of the former",
) )
return normalized return normalized
def _resolve_component_relative_files( def _resolve_component_relative_files(
comp_cls: Type["Component"], comp_media: ComponentMedia, comp_dirs: List[Path] comp_cls: Type["Component"],
comp_media: ComponentMedia,
comp_dirs: List[Path],
) -> None: ) -> None:
""" """
Check if component's HTML, JS and CSS files refer to files in the same directory Check if component's HTML, JS and CSS files refer to files in the same directory
@ -825,7 +828,8 @@ def _resolve_component_relative_files(
if is_set(comp_media.template_file) or is_set(comp_media.js_file) or is_set(comp_media.css_file): if is_set(comp_media.template_file) or is_set(comp_media.js_file) or is_set(comp_media.css_file):
will_resolve_files = True will_resolve_files = True
elif not will_resolve_files and is_set(comp_media.Media): elif not will_resolve_files and is_set(comp_media.Media):
if getattr(comp_media.Media, "css", None) or getattr(comp_media.Media, "js", None): has_media_files = getattr(comp_media.Media, "css", None) or getattr(comp_media.Media, "js", None)
if has_media_files:
will_resolve_files = True will_resolve_files = True
if not will_resolve_files: if not will_resolve_files:
@ -837,7 +841,7 @@ def _resolve_component_relative_files(
if not module_file_path: if not module_file_path:
logger.debug( logger.debug(
f"Could not resolve the path to the file for component '{component_name}'." f"Could not resolve the path to the file for component '{component_name}'."
" Paths for HTML, JS or CSS templates will NOT be resolved relative to the component file." " Paths for HTML, JS or CSS templates will NOT be resolved relative to the component file.",
) )
return return
@ -851,7 +855,7 @@ def _resolve_component_relative_files(
f"No component directory found for component '{component_name}' in {module_file_path}" f"No component directory found for component '{component_name}' in {module_file_path}"
" If this component defines HTML, JS or CSS templates relatively to the component file," " If this component defines HTML, JS or CSS templates relatively to the component file,"
" then check that the component's directory is accessible from one of the paths" " then check that the component's directory is accessible from one of the paths"
" specified in the Django's 'COMPONENTS.dirs' settings." " specified in the Django's 'COMPONENTS.dirs' settings.",
) )
return return
@ -876,12 +880,11 @@ def _resolve_component_relative_files(
# NOTE: It's important to use `repr`, so we don't trigger __str__ on SafeStrings # NOTE: It's important to use `repr`, so we don't trigger __str__ on SafeStrings
if has_matched: if has_matched:
logger.debug( logger.debug(
f"Interpreting file '{repr(filepath)}' of component '{module_name}'" " relatively to component file" f"Interpreting file '{filepath!r}' of component '{module_name}' relatively to component file",
) )
else: else:
logger.debug( logger.debug(
f"Interpreting file '{repr(filepath)}' of component '{module_name}'" f"Interpreting file '{filepath!r}' of component '{module_name}' relatively to components directory",
" relatively to components directory"
) )
return resolved_filepaths return resolved_filepaths
@ -904,18 +907,18 @@ def _resolve_component_relative_files(
# Check if template name is a local file or not # Check if template name is a local file or not
if is_set(comp_media.template_file): if is_set(comp_media.template_file):
comp_media.template_file = resolve_relative_media_file(comp_media.template_file, False)[0] comp_media.template_file = resolve_relative_media_file(comp_media.template_file, allow_glob=False)[0]
if is_set(comp_media.js_file): if is_set(comp_media.js_file):
comp_media.js_file = resolve_relative_media_file(comp_media.js_file, False)[0] comp_media.js_file = resolve_relative_media_file(comp_media.js_file, allow_glob=False)[0]
if is_set(comp_media.css_file): if is_set(comp_media.css_file):
comp_media.css_file = resolve_relative_media_file(comp_media.css_file, False)[0] comp_media.css_file = resolve_relative_media_file(comp_media.css_file, allow_glob=False)[0]
if is_set(comp_media.Media): if is_set(comp_media.Media):
_map_media_filepaths( _map_media_filepaths(
comp_media.Media, comp_media.Media,
# Media files can be defined as a glob patterns that match multiple files. # Media files can be defined as a glob patterns that match multiple files.
# Thus, flatten the list of lists returned by `resolve_relative_media_file`. # Thus, flatten the list of lists returned by `resolve_relative_media_file`.
lambda filepaths: flatten(resolve_relative_media_file(f, True) for f in filepaths), lambda filepaths: flatten(resolve_relative_media_file(f, allow_glob=True) for f in filepaths),
) )
# Go over the JS / CSS media files again, but this time, if there are still any globs, # Go over the JS / CSS media files again, but this time, if there are still any globs,
@ -925,7 +928,7 @@ def _resolve_component_relative_files(
comp_media.Media, comp_media.Media,
# Media files can be defined as a glob patterns that match multiple files. # Media files can be defined as a glob patterns that match multiple files.
# Thus, flatten the list of lists returned by `resolve_static_media_file`. # Thus, flatten the list of lists returned by `resolve_static_media_file`.
lambda filepaths: flatten(resolve_static_media_file(f, True) for f in filepaths), lambda filepaths: flatten(resolve_static_media_file(f, allow_glob=True) for f in filepaths),
) )
@ -957,9 +960,8 @@ def resolve_media_file(
if allow_glob and is_glob(filepath_abs_or_glob): if allow_glob and is_glob(filepath_abs_or_glob):
# Since globs are matched against the files, then we know that these files exist. # Since globs are matched against the files, then we know that these files exist.
matched_abs_filepaths = glob.glob(filepath_abs_or_glob) matched_abs_filepaths = glob.glob(filepath_abs_or_glob)
else:
# But if we were given non-glob file path, then we need to check if it exists. # But if we were given non-glob file path, then we need to check if it exists.
if Path(filepath_abs_or_glob).exists(): elif Path(filepath_abs_or_glob).exists():
matched_abs_filepaths = [filepath_abs_or_glob] matched_abs_filepaths = [filepath_abs_or_glob]
else: else:
matched_abs_filepaths = [] matched_abs_filepaths = []
@ -1082,7 +1084,7 @@ def _get_asset(
if asset_content is not UNSET and asset_file is not UNSET: if asset_content is not UNSET and asset_file is not UNSET:
raise ValueError( raise ValueError(
f"Received both '{inlined_attr}' and '{file_attr}' in Component {comp_cls.__qualname__}." f"Received both '{inlined_attr}' and '{file_attr}' in Component {comp_cls.__qualname__}."
" Only one of the two must be set." " Only one of the two must be set.",
) )
# At this point we can tell that only EITHER `asset_content` OR `asset_file` is set. # At this point we can tell that only EITHER `asset_content` OR `asset_file` is set.
@ -1108,7 +1110,7 @@ def _get_asset(
if asset_file is None: if asset_file is None:
return None, None return None, None
asset_file = cast(str, asset_file) asset_file = cast("str", asset_file)
if inlined_attr == "template": if inlined_attr == "template":
# NOTE: `load_component_template()` applies `on_template_loaded()` and `on_template_compiled()` hooks. # NOTE: `load_component_template()` applies `on_template_loaded()` and `on_template_compiled()` hooks.
@ -1139,14 +1141,14 @@ def _get_asset(
OnJsLoadedContext( OnJsLoadedContext(
component_cls=comp_cls, component_cls=comp_cls,
content=content, content=content,
) ),
) )
elif inlined_attr == "css": elif inlined_attr == "css":
content = extensions.on_css_loaded( content = extensions.on_css_loaded(
OnCssLoadedContext( OnCssLoadedContext(
component_cls=comp_cls, component_cls=comp_cls,
content=content, content=content,
) ),
) )
return content, None return content, None

View file

@ -38,8 +38,6 @@ class AlreadyRegistered(Exception):
[ComponentRegistry](./api.md#django_components.ComponentRegistry). [ComponentRegistry](./api.md#django_components.ComponentRegistry).
""" """
pass
class NotRegistered(Exception): class NotRegistered(Exception):
""" """
@ -48,8 +46,6 @@ class NotRegistered(Exception):
[ComponentRegistry](./api.md#django_components.ComponentRegistry). [ComponentRegistry](./api.md#django_components.ComponentRegistry).
""" """
pass
# Why do we store the tags with the components? # Why do we store the tags with the components?
# #
@ -146,10 +142,8 @@ ALL_REGISTRIES: AllRegistries = []
def all_registries() -> List["ComponentRegistry"]: def all_registries() -> List["ComponentRegistry"]:
""" """Get a list of all created [`ComponentRegistry`](./api.md#django_components.ComponentRegistry) instances."""
Get a list of all created [`ComponentRegistry`](./api.md#django_components.ComponentRegistry) instances. registries: List[ComponentRegistry] = []
"""
registries: List["ComponentRegistry"] = []
for reg_ref in ALL_REGISTRIES: for reg_ref in ALL_REGISTRIES:
reg = reg_ref() reg = reg_ref()
if reg is not None: if reg is not None:
@ -238,6 +232,7 @@ class ComponentRegistry:
{% component "button" %} {% component "button" %}
{% endcomponent %} {% endcomponent %}
``` ```
""" """
def __init__( def __init__(
@ -255,7 +250,7 @@ class ComponentRegistry:
extensions.on_registry_created( extensions.on_registry_created(
OnRegistryCreatedContext( OnRegistryCreatedContext(
registry=self, registry=self,
) ),
) )
def __del__(self) -> None: def __del__(self) -> None:
@ -266,7 +261,7 @@ class ComponentRegistry:
extensions.on_registry_deleted( extensions.on_registry_deleted(
OnRegistryDeletedContext( OnRegistryDeletedContext(
registry=self, registry=self,
) ),
) )
# Unregister all components when the registry is deleted # Unregister all components when the registry is deleted
@ -288,7 +283,7 @@ class ComponentRegistry:
if self._library is not None: if self._library is not None:
lib = self._library lib = self._library
else: else:
from django_components.templatetags.component_tags import register as tag_library from django_components.templatetags.component_tags import register as tag_library # noqa: PLC0415
# For the default library, we want to protect our template tags from # For the default library, we want to protect our template tags from
# being overriden. # being overriden.
@ -301,9 +296,7 @@ class ComponentRegistry:
@property @property
def settings(self) -> InternalRegistrySettings: def settings(self) -> InternalRegistrySettings:
""" """[Registry settings](./api.md#django_components.RegistrySettings) configured for this registry."""
[Registry settings](./api.md#django_components.RegistrySettings) configured for this registry.
"""
# NOTE: We allow the settings to be given as a getter function # NOTE: We allow the settings to be given as a getter function
# so the settings can respond to changes. # so the settings can respond to changes.
if callable(self._settings): if callable(self._settings):
@ -348,10 +341,11 @@ class ComponentRegistry:
```python ```python
registry.register("button", ButtonComponent) registry.register("button", ButtonComponent)
``` ```
""" """
existing_component = self._registry.get(name) existing_component = self._registry.get(name)
if existing_component and existing_component.cls.class_id != component.class_id: if existing_component and existing_component.cls.class_id != component.class_id:
raise AlreadyRegistered('The component "%s" has already been registered' % name) raise AlreadyRegistered(f'The component "{name}" has already been registered')
entry = self._register_to_library(name, component) entry = self._register_to_library(name, component)
@ -372,7 +366,7 @@ class ComponentRegistry:
registry=self, registry=self,
name=name, name=name,
component_cls=entry.cls, component_cls=entry.cls,
) ),
) )
def unregister(self, name: str) -> None: def unregister(self, name: str) -> None:
@ -398,6 +392,7 @@ class ComponentRegistry:
# Then unregister # Then unregister
registry.unregister("button") registry.unregister("button")
``` ```
""" """
# Validate # Validate
self.get(name) self.get(name)
@ -420,9 +415,8 @@ class ComponentRegistry:
# Only unregister a tag if it's NOT protected # Only unregister a tag if it's NOT protected
is_protected = is_tag_protected(self.library, tag) is_protected = is_tag_protected(self.library, tag)
if not is_protected:
# Unregister the tag from library if this was the last component using this tag # Unregister the tag from library if this was the last component using this tag
if is_tag_empty and tag in self.library.tags: if not is_protected and is_tag_empty and tag in self.library.tags:
self.library.tags.pop(tag, None) self.library.tags.pop(tag, None)
entry = self._registry[name] entry = self._registry[name]
@ -433,7 +427,7 @@ class ComponentRegistry:
registry=self, registry=self,
name=name, name=name,
component_cls=entry.cls, component_cls=entry.cls,
) ),
) )
def get(self, name: str) -> Type["Component"]: def get(self, name: str) -> Type["Component"]:
@ -461,9 +455,10 @@ class ComponentRegistry:
registry.get("button") registry.get("button")
# > ButtonComponent # > ButtonComponent
``` ```
""" """
if name not in self._registry: if name not in self._registry:
raise NotRegistered('The component "%s" is not registered' % name) raise NotRegistered(f'The component "{name}" is not registered')
return self._registry[name].cls return self._registry[name].cls
@ -487,6 +482,7 @@ class ComponentRegistry:
registry.has("button") registry.has("button")
# > True # > True
``` ```
""" """
return name in self._registry return name in self._registry
@ -510,6 +506,7 @@ class ComponentRegistry:
# > "card": CardComponent, # > "card": CardComponent,
# > } # > }
``` ```
""" """
comps = {key: entry.cls for key, entry in self._registry.items()} comps = {key: entry.cls for key, entry in self._registry.items()}
return comps return comps
@ -530,6 +527,7 @@ class ComponentRegistry:
registry.all() registry.all()
# > {} # > {}
``` ```
""" """
all_comp_names = list(self._registry.keys()) all_comp_names = list(self._registry.keys())
for comp_name in all_comp_names: for comp_name in all_comp_names:
@ -544,7 +542,7 @@ class ComponentRegistry:
component: Type["Component"], component: Type["Component"],
) -> ComponentRegistryEntry: ) -> ComponentRegistryEntry:
# Lazily import to avoid circular dependencies # Lazily import to avoid circular dependencies
from django_components.component import ComponentNode from django_components.component import ComponentNode # noqa: PLC0415
registry = self registry = self
@ -613,7 +611,10 @@ registry.clear()
_the_registry = registry _the_registry = registry
def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callable[ def register(
name: str,
registry: Optional[ComponentRegistry] = None,
) -> Callable[
[Type[TComponent]], [Type[TComponent]],
Type[TComponent], Type[TComponent],
]: ]:
@ -656,6 +657,7 @@ def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callabl
class MyComponent(Component): class MyComponent(Component):
... ...
``` ```
""" """
if registry is None: if registry is None:
registry = _the_registry registry = _the_registry

View file

@ -95,6 +95,7 @@ class DynamicComponent(Component):
{% endfill %} {% endfill %}
{% endcomponent %} {% endcomponent %}
``` ```
""" """
_is_dynamic_component = True _is_dynamic_component = True
@ -105,8 +106,8 @@ class DynamicComponent(Component):
# will know that it's a child of this component. # will know that it's a child of this component.
def on_render( def on_render(
self, self,
context: Context, context: Context, # noqa: ARG002
template: Optional[Template], template: Optional[Template], # noqa: ARG002
) -> str: ) -> str:
# Make a copy of kwargs so we pass to the child only the kwargs that are # Make a copy of kwargs so we pass to the child only the kwargs that are
# actually used by the child component. # actually used by the child component.
@ -146,10 +147,9 @@ class DynamicComponent(Component):
if inspect.isclass(comp_name_or_class): if inspect.isclass(comp_name_or_class):
component_cls = comp_name_or_class component_cls = comp_name_or_class
else: else:
component_cls = cast(Type[Component], comp_name_or_class.__class__) component_cls = cast("Type[Component]", comp_name_or_class.__class__)
else: elif registry:
if registry:
component_cls = registry.get(comp_name_or_class) component_cls = registry.get(comp_name_or_class)
else: else:
# Search all registries for the first match # Search all registries for the first match

View file

@ -70,7 +70,6 @@ def _gen_cache_key(
) -> str: ) -> str:
if input_hash: if input_hash:
return f"__components:{comp_cls_id}:{script_type}:{input_hash}" return f"__components:{comp_cls_id}:{script_type}:{input_hash}"
else:
return f"__components:{comp_cls_id}:{script_type}" return f"__components:{comp_cls_id}:{script_type}"
@ -94,7 +93,6 @@ def _cache_script(
Given a component and it's inlined JS or CSS, store the JS/CSS in a cache, Given a component and it's inlined JS or CSS, store the JS/CSS in a cache,
so it can be retrieved via URL endpoint. so it can be retrieved via URL endpoint.
""" """
# E.g. `__components:MyButton:js:df7c6d10` # E.g. `__components:MyButton:js:df7c6d10`
if script_type in ("js", "css"): if script_type in ("js", "css"):
cache_key = _gen_cache_key(comp_cls.class_id, script_type, input_hash) cache_key = _gen_cache_key(comp_cls.class_id, script_type, input_hash)
@ -114,10 +112,10 @@ def cache_component_js(comp_cls: Type["Component"], force: bool) -> None:
times, this JS is loaded only once. times, this JS is loaded only once.
""" """
if not comp_cls.js or not is_nonempty_str(comp_cls.js): if not comp_cls.js or not is_nonempty_str(comp_cls.js):
return None return
if not force and _is_script_in_cache(comp_cls, "js", None): if not force and _is_script_in_cache(comp_cls, "js", None):
return None return
_cache_script( _cache_script(
comp_cls=comp_cls, comp_cls=comp_cls,
@ -147,7 +145,7 @@ def cache_component_js_vars(comp_cls: Type["Component"], js_vars: Mapping) -> Op
# The hash for the file that holds the JS variables is derived from the variables themselves. # The hash for the file that holds the JS variables is derived from the variables themselves.
json_data = json.dumps(js_vars) json_data = json.dumps(js_vars)
input_hash = md5(json_data.encode()).hexdigest()[0:6] input_hash = md5(json_data.encode()).hexdigest()[0:6] # noqa: S324
# Generate and cache a JS script that contains the JS variables. # Generate and cache a JS script that contains the JS variables.
if not _is_script_in_cache(comp_cls, "js", input_hash): if not _is_script_in_cache(comp_cls, "js", input_hash):
@ -165,7 +163,7 @@ def wrap_component_js(comp_cls: Type["Component"], content: str) -> str:
if "</script" in content: if "</script" in content:
raise RuntimeError( raise RuntimeError(
f"Content of `Component.js` for component '{comp_cls.__name__}' contains '</script>' end tag. " f"Content of `Component.js` for component '{comp_cls.__name__}' contains '</script>' end tag. "
"This is not allowed, as it would break the HTML." "This is not allowed, as it would break the HTML.",
) )
return f"<script>{content}</script>" return f"<script>{content}</script>"
@ -177,10 +175,10 @@ def cache_component_css(comp_cls: Type["Component"], force: bool) -> None:
times, this CSS is loaded only once. times, this CSS is loaded only once.
""" """
if not comp_cls.css or not is_nonempty_str(comp_cls.css): if not comp_cls.css or not is_nonempty_str(comp_cls.css):
return None return
if not force and _is_script_in_cache(comp_cls, "css", None): if not force and _is_script_in_cache(comp_cls, "css", None):
return None return
_cache_script( _cache_script(
comp_cls=comp_cls, comp_cls=comp_cls,
@ -200,7 +198,7 @@ def cache_component_css_vars(comp_cls: Type["Component"], css_vars: Mapping) ->
# The hash for the file that holds the CSS variables is derived from the variables themselves. # The hash for the file that holds the CSS variables is derived from the variables themselves.
json_data = json.dumps(css_vars) json_data = json.dumps(css_vars)
input_hash = md5(json_data.encode()).hexdigest()[0:6] input_hash = md5(json_data.encode()).hexdigest()[0:6] # noqa: S324
# Generate and cache a CSS stylesheet that contains the CSS variables. # Generate and cache a CSS stylesheet that contains the CSS variables.
if not _is_script_in_cache(comp_cls, "css", input_hash): if not _is_script_in_cache(comp_cls, "css", input_hash):
@ -218,7 +216,7 @@ def wrap_component_css(comp_cls: Type["Component"], content: str) -> str:
if "</style" in content: if "</style" in content:
raise RuntimeError( raise RuntimeError(
f"Content of `Component.css` for component '{comp_cls.__name__}' contains '</style>' end tag. " f"Content of `Component.css` for component '{comp_cls.__name__}' contains '</style>' end tag. "
"This is not allowed, as it would break the HTML." "This is not allowed, as it would break the HTML.",
) )
return f"<style>{content}</style>" return f"<style>{content}</style>"
@ -347,10 +345,10 @@ COMPONENT_COMMENT_REGEX = re.compile(rb"<!--\s+_RENDERED\s+(?P<data>[\w\-,/]+?)\
# - js - Cache key for the JS data from `get_js_data()` # - js - Cache key for the JS data from `get_js_data()`
# - css - Cache key for the CSS data from `get_css_data()` # - css - Cache key for the CSS data from `get_css_data()`
SCRIPT_NAME_REGEX = re.compile( SCRIPT_NAME_REGEX = re.compile(
rb"^(?P<comp_cls_id>[\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-ca1b2c3` # E.g. `data-djc-id-ca1b2c3`
MAYBE_COMP_ID = r'(?: data-djc-id-\w{{{COMP_ID_LENGTH}}}="")?'.format(COMP_ID_LENGTH=COMP_ID_LENGTH) MAYBE_COMP_ID = r'(?: data-djc-id-\w{{{COMP_ID_LENGTH}}}="")?'.format(COMP_ID_LENGTH=COMP_ID_LENGTH) # noqa: UP032
# E.g. `data-djc-css-99914b` # E.g. `data-djc-css-99914b`
MAYBE_COMP_CSS_ID = r'(?: data-djc-css-\w{6}="")?' MAYBE_COMP_CSS_ID = r'(?: data-djc-css-\w{6}="")?'
@ -358,7 +356,7 @@ PLACEHOLDER_REGEX = re.compile(
r"{css_placeholder}|{js_placeholder}".format( r"{css_placeholder}|{js_placeholder}".format(
css_placeholder=f'<link name="{CSS_PLACEHOLDER_NAME}"{MAYBE_COMP_CSS_ID}{MAYBE_COMP_ID}/?>', css_placeholder=f'<link name="{CSS_PLACEHOLDER_NAME}"{MAYBE_COMP_CSS_ID}{MAYBE_COMP_ID}/?>',
js_placeholder=f'<script name="{JS_PLACEHOLDER_NAME}"{MAYBE_COMP_CSS_ID}{MAYBE_COMP_ID}></script>', js_placeholder=f'<script name="{JS_PLACEHOLDER_NAME}"{MAYBE_COMP_CSS_ID}{MAYBE_COMP_ID}></script>',
).encode() ).encode(),
) )
@ -400,10 +398,11 @@ def render_dependencies(content: TContent, strategy: DependenciesStrategy = "doc
return HttpResponse(processed_html) return HttpResponse(processed_html)
``` ```
""" """
if strategy not in DEPS_STRATEGIES: if strategy not in DEPS_STRATEGIES:
raise ValueError(f"Invalid strategy '{strategy}'") raise ValueError(f"Invalid strategy '{strategy}'")
elif strategy == "ignore": if strategy == "ignore":
return content return content
is_safestring = isinstance(content, SafeString) is_safestring = isinstance(content, SafeString)
@ -411,7 +410,7 @@ def render_dependencies(content: TContent, strategy: DependenciesStrategy = "doc
if isinstance(content, str): if isinstance(content, str):
content_ = content.encode() content_ = content.encode()
else: else:
content_ = cast(bytes, content) content_ = cast("bytes", content)
content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, strategy) content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, strategy)
@ -438,7 +437,7 @@ def render_dependencies(content: TContent, strategy: DependenciesStrategy = "doc
else: else:
raise RuntimeError( raise RuntimeError(
"Unexpected error: Regex for component dependencies processing" "Unexpected error: Regex for component dependencies processing"
f" matched unknown string '{match[0].decode()}'" f" matched unknown string '{match[0].decode()}'",
) )
return replacement return replacement
@ -469,7 +468,7 @@ def render_dependencies(content: TContent, strategy: DependenciesStrategy = "doc
# Return the same type as we were given # Return the same type as we were given
output = content_.decode() if isinstance(content, str) else content_ output = content_.decode() if isinstance(content, str) else content_
output = mark_safe(output) if is_safestring else output output = mark_safe(output) if is_safestring else output
return cast(TContent, output) return cast("TContent", output)
# Renamed so we can access use this function where there's kwarg of the same name # Renamed so we can access use this function where there's kwarg of the same name
@ -531,7 +530,7 @@ def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) ->
`<!-- _RENDERED table_10bac31,123,a92ef298,bd002c3 -->` `<!-- _RENDERED table_10bac31,123,a92ef298,bd002c3 -->`
""" """
# Extract all matched instances of `<!-- _RENDERED ... -->` while also removing them from the text # Extract all matched instances of `<!-- _RENDERED ... -->` while also removing them from the text
all_parts: List[bytes] = list() all_parts: List[bytes] = []
def on_replace_match(match: "re.Match[bytes]") -> bytes: def on_replace_match(match: "re.Match[bytes]") -> bytes:
all_parts.append(match.group("data")) all_parts.append(match.group("data"))
@ -595,7 +594,7 @@ def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) ->
) = _prepare_tags_and_urls(comp_data, strategy) ) = _prepare_tags_and_urls(comp_data, strategy)
def get_component_media(comp_cls_id: str) -> Media: def get_component_media(comp_cls_id: str) -> Media:
from django_components.component import get_component_by_class_id from django_components.component import get_component_by_class_id # noqa: PLC0415
comp_cls = get_component_by_class_id(comp_cls_id) comp_cls = get_component_by_class_id(comp_cls_id)
return comp_cls.media return comp_cls.media
@ -639,7 +638,7 @@ def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) ->
# to avoid a flash of unstyled content. In such case, the "CSS to load" is actually already # to avoid a flash of unstyled content. In such case, the "CSS to load" is actually already
# loaded, so we have to mark those scripts as loaded in the dependency manager. # loaded, so we have to mark those scripts as loaded in the dependency manager.
*(media_css_urls if strategy == "document" else []), *(media_css_urls if strategy == "document" else []),
] ],
) )
loaded_js_urls = sorted( loaded_js_urls = sorted(
[ [
@ -649,7 +648,7 @@ def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) ->
# so the scripts are executed at proper order. In such case, the "JS to load" is actually already # so the scripts are executed at proper order. In such case, the "JS to load" is actually already
# loaded, so we have to mark those scripts as loaded in the dependency manager. # loaded, so we have to mark those scripts as loaded in the dependency manager.
*(media_js_urls if strategy == "document" else []), *(media_js_urls if strategy == "document" else []),
] ],
) )
# NOTE: No exec script for the "simple" mode, as that one is NOT using the dependency manager # NOTE: No exec script for the "simple" mode, as that one is NOT using the dependency manager
@ -686,7 +685,7 @@ def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) ->
final_script_tags = "".join( final_script_tags = "".join(
[ [
# JS by us # JS by us
*[tag for tag in core_script_tags], *core_script_tags,
# Make calls to the JS dependency manager # Make calls to the JS dependency manager
# Loads JS from `Media.js` and `Component.js` if fragment # Loads JS from `Media.js` and `Component.js` if fragment
*([exec_script] if exec_script else []), *([exec_script] if exec_script else []),
@ -696,10 +695,10 @@ def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) ->
# we only mark those scripts as loaded. # we only mark those scripts as loaded.
*(media_js_tags if strategy in ("document", "simple", "prepend", "append") else []), *(media_js_tags if strategy in ("document", "simple", "prepend", "append") else []),
# JS variables # JS variables
*[tag for tag in js_variables_tags], *js_variables_tags,
# JS from `Component.js` (if not fragment) # JS from `Component.js` (if not fragment)
*[tag for tag in component_js_tags], *component_js_tags,
] ],
) )
final_css_tags = "".join( final_css_tags = "".join(
@ -707,14 +706,14 @@ def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) ->
# CSS by us # CSS by us
# <NONE> # <NONE>
# CSS from `Component.css` (if not fragment) # CSS from `Component.css` (if not fragment)
*[tag for tag in component_css_tags], *component_css_tags,
# CSS variables # CSS variables
*[tag for tag in css_variables_tags], *css_variables_tags,
# CSS from `Media.css` (plus from `Component.css` if fragment) # CSS from `Media.css` (plus from `Component.css` if fragment)
# NOTE: Similarly to JS, the initial CSS is loaded outside of the dependency # NOTE: Similarly to JS, the initial CSS is loaded outside of the dependency
# manager, and only marked as loaded, to avoid a flash of unstyled content. # manager, and only marked as loaded, to avoid a flash of unstyled content.
*[tag for tag in media_css_tags], *media_css_tags,
] ],
) )
return (content, final_script_tags.encode("utf-8"), final_css_tags.encode("utf-8")) return (content, final_script_tags.encode("utf-8"), final_css_tags.encode("utf-8"))
@ -748,10 +747,10 @@ def _postprocess_media_tags(
raise RuntimeError( raise RuntimeError(
f"One of entries for `Component.Media.{script_type}` media is missing a " f"One of entries for `Component.Media.{script_type}` media is missing a "
f"value for attribute '{attr}'. If there is content inlined inside the `<{attr}>` tags, " f"value for attribute '{attr}'. If there is content inlined inside the `<{attr}>` tags, "
f"you must move the content to a `.{script_type}` file and reference it via '{attr}'.\nGot:\n{tag}" f"you must move the content to a `.{script_type}` file and reference it via '{attr}'.\nGot:\n{tag}",
) )
url = cast(str, maybe_url) url = cast("str", maybe_url)
# Skip duplicates # Skip duplicates
if url in tags_by_url: if url in tags_by_url:
@ -770,7 +769,7 @@ def _prepare_tags_and_urls(
data: List[Tuple[str, ScriptType, Optional[str]]], data: List[Tuple[str, ScriptType, Optional[str]]],
strategy: DependenciesStrategy, strategy: DependenciesStrategy,
) -> Tuple[List[str], List[str], List[str], List[str], List[str], List[str]]: ) -> Tuple[List[str], List[str], List[str], List[str], List[str], List[str]]:
from django_components.component import get_component_by_class_id from django_components.component import get_component_by_class_id # noqa: PLC0415
# JS / CSS that we should insert into the HTML # JS / CSS that we should insert into the HTML
inlined_js_tags: List[str] = [] inlined_js_tags: List[str] = []
@ -859,7 +858,7 @@ def get_script_tag(
content = get_script_content(script_type, comp_cls, input_hash) content = get_script_content(script_type, comp_cls, input_hash)
if content is None: if content is None:
raise RuntimeError( raise RuntimeError(
f"Could not find {script_type.upper()} for component '{comp_cls.__name__}' (id: {comp_cls.class_id})" f"Could not find {script_type.upper()} for component '{comp_cls.__name__}' (id: {comp_cls.class_id})",
) )
if script_type == "js": if script_type == "js":
@ -979,7 +978,7 @@ def _insert_js_css_to_default_locations(
if did_modify_html: if did_modify_html:
return updated_html return updated_html
else:
return None # No changes made return None # No changes made
@ -1006,7 +1005,7 @@ def cached_script_view(
script_type: ScriptType, script_type: ScriptType,
input_hash: Optional[str] = None, input_hash: Optional[str] = None,
) -> HttpResponse: ) -> HttpResponse:
from django_components.component import get_component_by_class_id from django_components.component import get_component_by_class_id # noqa: PLC0415
if req.method != "GET": if req.method != "GET":
return HttpResponseNotAllowed(["GET"]) return HttpResponseNotAllowed(["GET"])
@ -1036,15 +1035,15 @@ urlpatterns = [
######################################################### #########################################################
def _component_dependencies(type: Literal["js", "css"]) -> SafeString: def _component_dependencies(dep_type: Literal["js", "css"]) -> SafeString:
"""Marks location where CSS link and JS script tags should be rendered.""" """Marks location where CSS link and JS script tags should be rendered."""
if type == "css": if dep_type == "css":
placeholder = CSS_DEPENDENCY_PLACEHOLDER placeholder = CSS_DEPENDENCY_PLACEHOLDER
elif type == "js": elif dep_type == "js":
placeholder = JS_DEPENDENCY_PLACEHOLDER placeholder = JS_DEPENDENCY_PLACEHOLDER
else: else:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {type}" f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {dep_type}",
) )
return mark_safe(placeholder) return mark_safe(placeholder)
@ -1066,9 +1065,9 @@ class ComponentCssDependenciesNode(BaseNode):
tag = "component_css_dependencies" tag = "component_css_dependencies"
end_tag = None # inline-only end_tag = None # inline-only
allowed_flags = [] allowed_flags = ()
def render(self, context: Context) -> str: def render(self, context: Context) -> str: # noqa: ARG002
return _component_dependencies("css") return _component_dependencies("css")
@ -1088,7 +1087,7 @@ class ComponentJsDependenciesNode(BaseNode):
tag = "component_js_dependencies" tag = "component_js_dependencies"
end_tag = None # inline-only end_tag = None # inline-only
allowed_flags = [] allowed_flags = ()
def render(self, context: Context) -> str: def render(self, context: Context) -> str: # noqa: ARG002
return _component_dependencies("js") return _component_dependencies("js")

View file

@ -81,11 +81,11 @@ class DynamicFilterExpression:
# to avoid it being stringified # to avoid it being stringified
if isinstance(node, VariableNode): if isinstance(node, VariableNode):
return node.filter_expression.resolve(context) return node.filter_expression.resolve(context)
else:
# For any other tags `{% %}`, we're at a mercy of the authors, and # For any other tags `{% %}`, we're at a mercy of the authors, and
# we don't know if the result comes out stringified or not. # we don't know if the result comes out stringified or not.
return node.render(context) return node.render(context)
else:
# Lastly, if there's multiple nodes, we render it to a string # Lastly, if there's multiple nodes, we render it to a string
# #
# NOTE: When rendering a NodeList, it expects that each node is a string. # NOTE: When rendering a NodeList, it expects that each node is a string.
@ -127,23 +127,20 @@ DYNAMIC_EXPR_RE = re.compile(
comment_tag=r"(?:\{#.*?#\})", comment_tag=r"(?:\{#.*?#\})",
start_quote=r"(?P<quote>['\"])", # NOTE: Capture group so we check for the same quote at the end start_quote=r"(?P<quote>['\"])", # NOTE: Capture group so we check for the same quote at the end
end_quote=r"(?P=quote)", end_quote=r"(?P=quote)",
) ),
) )
def is_dynamic_expression(value: Any) -> bool: def is_dynamic_expression(value: Any) -> bool:
# NOTE: Currently dynamic expression need at least 6 characters # NOTE: Currently dynamic expression need at least 6 characters
# for the opening and closing tags, and quotes, e.g. `"`, `{%`, `%}` in `" some text {% ... %}"` # for the opening and closing tags, and quotes, e.g. `"`, `{%`, `%}` in `" some text {% ... %}"`
MIN_EXPR_LEN = 6 MIN_EXPR_LEN = 6 # noqa: N806
if not isinstance(value, str) or not value or len(value) < MIN_EXPR_LEN: if not isinstance(value, str) or not value or len(value) < MIN_EXPR_LEN:
return False return False
# Is not wrapped in quotes, or does not contain any tags # Is not wrapped in quotes, or does not contain any tags
if not DYNAMIC_EXPR_RE.match(value): return bool(DYNAMIC_EXPR_RE.match(value))
return False
return True
# TODO - Move this out into a plugin? # TODO - Move this out into a plugin?
@ -200,8 +197,9 @@ def process_aggregate_kwargs(params: List["TagParam"]) -> List["TagParam"]:
This provides sufficient flexiblity to make it easy for component users to provide This provides sufficient flexiblity to make it easy for component users to provide
"fallthrough attributes", and sufficiently easy for component authors to process "fallthrough attributes", and sufficiently easy for component authors to process
that input while still being able to provide their own keys. that input while still being able to provide their own keys.
""" """
from django_components.util.template_tag import TagParam from django_components.util.template_tag import TagParam # noqa: PLC0415
_check_kwargs_for_agg_conflict(params) _check_kwargs_for_agg_conflict(params)
@ -233,7 +231,7 @@ def process_aggregate_kwargs(params: List["TagParam"]) -> List["TagParam"]:
if key in seen_keys: if key in seen_keys:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Received argument '{key}' both as a regular input ({key}=...)" f"Received argument '{key}' both as a regular input ({key}=...)"
f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two" f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two",
) )
processed_params.append(TagParam(key=key, value=val)) processed_params.append(TagParam(key=key, value=val))
@ -256,7 +254,7 @@ def _check_kwargs_for_agg_conflict(params: List["TagParam"]) -> None:
): # fmt: skip ): # fmt: skip
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Received argument '{param.key}' both as a regular input ({param.key}=...)" f"Received argument '{param.key}' both as a regular input ({param.key}=...)"
f" and as an aggregate dict ('{param.key}:key=...'). Must be only one of the two" f" and as an aggregate dict ('{param.key}:key=...'). Must be only one of the two",
) )
if is_agg_kwarg: if is_agg_kwarg:

View file

@ -555,7 +555,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
ctx.component_cls.my_attr = "my_value" ctx.component_cls.my_attr = "my_value"
``` ```
""" """
pass
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None: def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
""" """
@ -577,7 +576,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
self.cache.pop(ctx.component_cls, None) self.cache.pop(ctx.component_cls, None)
``` ```
""" """
pass
def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None: def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
""" """
@ -599,7 +597,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
ctx.registry.my_attr = "my_value" ctx.registry.my_attr = "my_value"
``` ```
""" """
pass
def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None: def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
""" """
@ -621,7 +618,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
self.cache.pop(ctx.registry, None) self.cache.pop(ctx.registry, None)
``` ```
""" """
pass
def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None: def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
""" """
@ -641,7 +637,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
print(f"Component {ctx.component_cls} registered to {ctx.registry} as '{ctx.name}'") print(f"Component {ctx.component_cls} registered to {ctx.registry} as '{ctx.name}'")
``` ```
""" """
pass
def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None: def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
""" """
@ -661,7 +656,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
print(f"Component {ctx.component_cls} unregistered from {ctx.registry} as '{ctx.name}'") print(f"Component {ctx.component_cls} unregistered from {ctx.registry} as '{ctx.name}'")
``` ```
""" """
pass
########################### ###########################
# Component render hooks # Component render hooks
@ -712,7 +706,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
[`Component.slots`](./api.md#django_components.Component.slots) [`Component.slots`](./api.md#django_components.Component.slots)
are plain `list` / `dict` objects. are plain `list` / `dict` objects.
""" """
pass
def on_component_data(self, ctx: OnComponentDataContext) -> None: def on_component_data(self, ctx: OnComponentDataContext) -> None:
""" """
@ -739,7 +732,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
ctx.template_data["my_template_var"] = "my_value" ctx.template_data["my_template_var"] = "my_value"
``` ```
""" """
pass
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]: def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
""" """
@ -797,7 +789,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
print(f"Result: {ctx.result}") print(f"Result: {ctx.result}")
``` ```
""" """
pass
########################## ##########################
# Template / JS / CSS hooks # Template / JS / CSS hooks
@ -826,7 +817,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
return ctx.content.replace("Hello", "Hi") return ctx.content.replace("Hello", "Hi")
``` ```
""" """
pass
def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None: def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None:
""" """
@ -849,7 +839,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
print(f"Template origin: {ctx.template.origin.name}") print(f"Template origin: {ctx.template.origin.name}")
``` ```
""" """
pass
def on_css_loaded(self, ctx: OnCssLoadedContext) -> Optional[str]: def on_css_loaded(self, ctx: OnCssLoadedContext) -> Optional[str]:
""" """
@ -874,7 +863,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
return ctx.content.replace("Hello", "Hi") return ctx.content.replace("Hello", "Hi")
``` ```
""" """
pass
def on_js_loaded(self, ctx: OnJsLoadedContext) -> Optional[str]: def on_js_loaded(self, ctx: OnJsLoadedContext) -> Optional[str]:
""" """
@ -899,7 +887,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
return ctx.content.replace("Hello", "Hi") return ctx.content.replace("Hello", "Hi")
``` ```
""" """
pass
########################## ##########################
# Tags lifecycle hooks # Tags lifecycle hooks
@ -944,7 +931,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
print(f"Slot owner: {slot_owner}") print(f"Slot owner: {slot_owner}")
``` ```
""" """
pass
# Decorator to store events in `ExtensionManager._events` when django_components is not yet initialized. # Decorator to store events in `ExtensionManager._events` when django_components is not yet initialized.
@ -955,7 +941,7 @@ def store_events(func: TCallable) -> TCallable:
def wrapper(self: "ExtensionManager", ctx: Any) -> Any: def wrapper(self: "ExtensionManager", ctx: Any) -> Any:
if not self._initialized: if not self._initialized:
self._events.append((fn_name, ctx)) self._events.append((fn_name, ctx))
return return None
return func(self, ctx) return func(self, ctx)
@ -1051,13 +1037,13 @@ class ExtensionManager:
extension_defaults = all_extensions_defaults.get(extension.name, None) extension_defaults = all_extensions_defaults.get(extension.name, None)
if extension_defaults: if extension_defaults:
# Create dummy class that holds the extension defaults # Create dummy class that holds the extension defaults
defaults_class = type(f"{ext_class_name}Defaults", tuple(), extension_defaults.copy()) defaults_class = type(f"{ext_class_name}Defaults", (), extension_defaults.copy())
bases_list.insert(0, defaults_class) bases_list.insert(0, defaults_class)
if component_ext_subclass: if component_ext_subclass:
bases_list.insert(0, component_ext_subclass) bases_list.insert(0, component_ext_subclass)
bases: tuple[Type, ...] = tuple(bases_list) bases: Tuple[Type, ...] = tuple(bases_list)
# Allow component-level extension class to access the owner `Component` class that via # Allow component-level extension class to access the owner `Component` class that via
# `component_cls`. # `component_cls`.
@ -1118,7 +1104,7 @@ class ExtensionManager:
urls: List[URLResolver] = [] urls: List[URLResolver] = []
seen_names: Set[str] = set() seen_names: Set[str] = set()
from django_components import Component from django_components import Component # noqa: PLC0415
for extension in self.extensions: for extension in self.extensions:
# Ensure that the extension name won't conflict with existing Component class API # Ensure that the extension name won't conflict with existing Component class API
@ -1297,7 +1283,7 @@ class ExtensionManager:
for extension in self.extensions: for extension in self.extensions:
try: try:
result = extension.on_component_rendered(ctx) result = extension.on_component_rendered(ctx)
except Exception as error: except Exception as error: # noqa: BLE001
# Error from `on_component_rendered()` - clear HTML and set error # Error from `on_component_rendered()` - clear HTML and set error
ctx = ctx._replace(result=None, error=error) ctx = ctx._replace(result=None, error=error)
else: else:

View file

@ -120,7 +120,7 @@ class ComponentCache(ExtensionComponentConfig):
if self.include_slots: if self.include_slots:
cache_key += ":" + self.hash_slots(slots) cache_key += ":" + self.hash_slots(slots)
cache_key = self.component._class_hash + ":" + cache_key cache_key = self.component._class_hash + ":" + cache_key
cache_key = CACHE_KEY_PREFIX + md5(cache_key.encode()).hexdigest() cache_key = CACHE_KEY_PREFIX + md5(cache_key.encode()).hexdigest() # noqa: S324
return cache_key return cache_key
def hash(self, args: List, kwargs: Dict) -> str: def hash(self, args: List, kwargs: Dict) -> str:
@ -141,10 +141,10 @@ class ComponentCache(ExtensionComponentConfig):
hash_parts = [] hash_parts = []
for key, slot in sorted_items: for key, slot in sorted_items:
if callable(slot.contents): if callable(slot.contents):
raise ValueError( raise TypeError(
f"Cannot hash slot '{key}' of component '{self.component.name}' - Slot functions are unhashable." f"Cannot hash slot '{key}' of component '{self.component.name}' - Slot functions are unhashable."
" Instead define the slot as a string or `{% fill %}` tag, or disable slot caching" " Instead define the slot as a string or `{% fill %}` tag, or disable slot caching"
" with `Cache.include_slots=False`." " with `Cache.include_slots=False`.",
) )
hash_parts.append(f"{key}-{slot.contents}") hash_parts.append(f"{key}-{slot.contents}")
return ",".join(hash_parts) return ",".join(hash_parts)
@ -175,8 +175,8 @@ class CacheExtension(ComponentExtension):
ComponentConfig = ComponentCache ComponentConfig = ComponentCache
def __init__(self, *args: Any, **kwargs: Any): def __init__(self, *_args: Any, **_kwargs: Any) -> None:
self.render_id_to_cache_key: dict[str, str] = {} self.render_id_to_cache_key: Dict[str, str] = {}
def on_component_input(self, ctx: OnComponentInputContext) -> Optional[Any]: def on_component_input(self, ctx: OnComponentInputContext) -> Optional[Any]:
cache_instance = ctx.component.cache cache_instance = ctx.component.cache
@ -196,7 +196,7 @@ class CacheExtension(ComponentExtension):
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> None: def on_component_rendered(self, ctx: OnComponentRenderedContext) -> None:
cache_instance = ctx.component.cache cache_instance = ctx.component.cache
if not cache_instance.enabled: if not cache_instance.enabled:
return None return
if ctx.error is not None: if ctx.error is not None:
return return

View file

@ -21,14 +21,14 @@ COLORS = {
} }
def apply_component_highlight(type: Literal["component", "slot"], output: str, name: str) -> str: def apply_component_highlight(highlight_type: Literal["component", "slot"], output: str, name: str) -> str:
""" """
Wrap HTML (string) in a div with a border and a highlight color. Wrap HTML (string) in a div with a border and a highlight color.
This is part of the component / slot highlighting feature. User can toggle on This is part of the component / slot highlighting feature. User can toggle on
to see the component / slot boundaries. to see the component / slot boundaries.
""" """
color = COLORS[type] color = COLORS[highlight_type]
# Because the component / slot name is set via styling as a `::before` pseudo-element, # Because the component / slot name is set via styling as a `::before` pseudo-element,
# we need to generate a unique ID for each component / slot to avoid conflicts. # we need to generate a unique ID for each component / slot to avoid conflicts.
@ -36,13 +36,13 @@ def apply_component_highlight(type: Literal["component", "slot"], output: str, n
output = f""" output = f"""
<style> <style>
.{type}-highlight-{highlight_id}::before {{ .{highlight_type}-highlight-{highlight_id}::before {{
content: "{name}: "; content: "{name}: ";
font-weight: bold; font-weight: bold;
color: {color.text_color}; color: {color.text_color};
}} }}
</style> </style>
<div class="{type}-highlight-{highlight_id}" style="border: 1px solid {color.border_color}"> <div class="{highlight_type}-highlight-{highlight_id}" style="border: 1px solid {color.border_color}">
{output} {output}
</div> </div>
""" """

View file

@ -145,8 +145,6 @@ class ComponentDefaults(ExtensionComponentConfig):
``` ```
""" """
pass
class DefaultsExtension(ComponentExtension): class DefaultsExtension(ComponentExtension):
""" """

View file

@ -27,7 +27,7 @@ else:
class ViewFn(Protocol): class ViewFn(Protocol):
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704 def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ...
def _get_component_route_name(component: Union[Type["Component"], "Component"]) -> str: def _get_component_route_name(component: Union[Type["Component"], "Component"]) -> str:
@ -143,7 +143,7 @@ class ComponentView(ExtensionComponentConfig, View):
``` ```
""" """
component_cls = cast(Type["Component"], None) component_cls = cast("Type[Component]", None)
""" """
The parent component class. The parent component class.
@ -220,28 +220,28 @@ class ComponentView(ExtensionComponentConfig, View):
# `return self.component_cls.render_to_response(request, *args, **kwargs)` or similar # `return self.component_cls.render_to_response(request, *args, **kwargs)` or similar
# or raise NotImplementedError. # or raise NotImplementedError.
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component_cls(), "get")(request, *args, **kwargs) return self.component_cls().get(request, *args, **kwargs) # type: ignore[attr-defined]
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component_cls(), "post")(request, *args, **kwargs) return self.component_cls().post(request, *args, **kwargs) # type: ignore[attr-defined]
def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component_cls(), "put")(request, *args, **kwargs) return self.component_cls().put(request, *args, **kwargs) # type: ignore[attr-defined]
def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component_cls(), "patch")(request, *args, **kwargs) return self.component_cls().patch(request, *args, **kwargs) # type: ignore[attr-defined]
def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component_cls(), "delete")(request, *args, **kwargs) return self.component_cls().delete(request, *args, **kwargs) # type: ignore[attr-defined]
def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component_cls(), "head")(request, *args, **kwargs) return self.component_cls().head(request, *args, **kwargs) # type: ignore[attr-defined]
def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component_cls(), "options")(request, *args, **kwargs) return self.component_cls().options(request, *args, **kwargs) # type: ignore[attr-defined]
def trace(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def trace(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return getattr(self.component_cls(), "trace")(request, *args, **kwargs) return self.component_cls().trace(request, *args, **kwargs) # type: ignore[attr-defined]
class ViewExtension(ComponentExtension): class ViewExtension(ComponentExtension):

View file

@ -1,11 +1,12 @@
import os import os
import re import re
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
from django import VERSION as DJANGO_VERSION from django import VERSION as DJANGO_VERSION
from django.contrib.staticfiles.finders import BaseFinder from django.contrib.staticfiles.finders import BaseFinder
from django.contrib.staticfiles.utils import get_files from django.contrib.staticfiles.utils import get_files
from django.core.checks import CheckMessage, Error, Warning from django.core import checks
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from django.utils._os import safe_join from django.utils._os import safe_join
@ -34,7 +35,7 @@ class ComponentsFileSystemFinder(BaseFinder):
- If `COMPONENTS.dirs` is not set, defaults to `settings.BASE_DIR / "components"` - If `COMPONENTS.dirs` is not set, defaults to `settings.BASE_DIR / "components"`
""" """
def __init__(self, app_names: Any = None, *args: Any, **kwargs: Any) -> None: def __init__(self, app_names: Any = None, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
component_dirs = [str(p) for p in get_component_dirs()] component_dirs = [str(p) for p in get_component_dirs()]
# NOTE: The rest of the __init__ is the same as `django.contrib.staticfiles.finders.FileSystemFinder`, # NOTE: The rest of the __init__ is the same as `django.contrib.staticfiles.finders.FileSystemFinder`,
@ -47,7 +48,7 @@ class ComponentsFileSystemFinder(BaseFinder):
self.storages: Dict[str, FileSystemStorage] = {} self.storages: Dict[str, FileSystemStorage] = {}
for root in component_dirs: for root in component_dirs:
if isinstance(root, (list, tuple)): if isinstance(root, (list, tuple)):
prefix, root = root prefix, root = root # noqa: PLW2901
else: else:
prefix = "" prefix = ""
if (prefix, root) not in self.locations: if (prefix, root) not in self.locations:
@ -60,41 +61,39 @@ class ComponentsFileSystemFinder(BaseFinder):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# NOTE: Based on `FileSystemFinder.check` # NOTE: Based on `FileSystemFinder.check`
def check(self, **kwargs: Any) -> List[CheckMessage]: def check(self, **_kwargs: Any) -> List[checks.CheckMessage]:
errors: List[CheckMessage] = [] errors: List[checks.CheckMessage] = []
if not isinstance(app_settings.DIRS, (list, tuple)): if not isinstance(app_settings.DIRS, (list, tuple)):
errors.append( errors.append(
Error( checks.Error(
"The COMPONENTS.dirs setting is not a tuple or list.", "The COMPONENTS.dirs setting is not a tuple or list.",
hint="Perhaps you forgot a trailing comma?", hint="Perhaps you forgot a trailing comma?",
id="components.E001", id="components.E001",
) ),
) )
return errors return errors
for root in app_settings.DIRS: for root in app_settings.DIRS:
if isinstance(root, (list, tuple)): if isinstance(root, (list, tuple)):
prefix, root = root prefix, root = root # noqa: PLW2901
if prefix.endswith("/"): if prefix.endswith("/"):
errors.append( errors.append(
Error( checks.Error(
"The prefix %r in the COMPONENTS.dirs setting must not end with a slash." % prefix, f"The prefix {prefix!r} in the COMPONENTS.dirs setting must not end with a slash.",
id="staticfiles.E003", id="staticfiles.E003",
),
) )
) elif not Path(root).is_dir():
elif not os.path.isdir(root):
errors.append( errors.append(
Warning( checks.Warning(
f"The directory '{root}' in the COMPONENTS.dirs setting does not exist.", f"The directory '{root}' in the COMPONENTS.dirs setting does not exist.",
id="components.W004", id="components.W004",
) ),
) )
return errors return errors
# NOTE: Same as `FileSystemFinder.find` # NOTE: Same as `FileSystemFinder.find`
def find(self, path: str, **kwargs: Any) -> Union[List[str], str]: def find(self, path: str, **kwargs: Any) -> Union[List[str], str]:
""" """Look for files in the extra locations as defined in COMPONENTS.dirs."""
Look for files in the extra locations as defined in COMPONENTS.dirs.
"""
# Handle deprecated `all` parameter: # Handle deprecated `all` parameter:
# - In Django 5.2, the `all` parameter was deprecated in favour of `find_all`. # - In Django 5.2, the `all` parameter was deprecated in favour of `find_all`.
# - Between Django 5.2 (inclusive) and 6.1 (exclusive), the `all` parameter was still # - Between Django 5.2 (inclusive) and 6.1 (exclusive), the `all` parameter was still
@ -104,7 +103,7 @@ class ComponentsFileSystemFinder(BaseFinder):
# See https://github.com/django/django/blob/5.2/django/contrib/staticfiles/finders.py#L58C9-L58C37 # See https://github.com/django/django/blob/5.2/django/contrib/staticfiles/finders.py#L58C9-L58C37
# And https://github.com/django-components/django-components/issues/1119 # And https://github.com/django-components/django-components/issues/1119
if DJANGO_VERSION >= (5, 2) and DJANGO_VERSION < (6, 1): if DJANGO_VERSION >= (5, 2) and DJANGO_VERSION < (6, 1):
find_all = self._check_deprecated_find_param(**kwargs) # type: ignore find_all = self._check_deprecated_find_param(**kwargs)
elif DJANGO_VERSION >= (6, 1): elif DJANGO_VERSION >= (6, 1):
find_all = kwargs.get("find_all", False) find_all = kwargs.get("find_all", False)
else: else:
@ -128,28 +127,26 @@ class ComponentsFileSystemFinder(BaseFinder):
absolute path (or ``None`` if no match). absolute path (or ``None`` if no match).
""" """
if prefix: if prefix:
prefix = "%s%s" % (prefix, os.sep) prefix = f"{prefix}{os.sep}"
if not path.startswith(prefix): if not path.startswith(prefix):
return None return None
path = path.removeprefix(prefix) path = path.removeprefix(prefix)
path = safe_join(root, path) path = safe_join(root, path)
if os.path.exists(path) and self._is_path_valid(path): if Path(path).exists() and self._is_path_valid(path):
return path return path
return None return None
# `Finder.list` is called from `collectstatic` command, # `Finder.list` is called from `collectstatic` command,
# see https://github.com/django/django/blob/bc9b6251e0b54c3b5520e3c66578041cc17e4a28/django/contrib/staticfiles/management/commands/collectstatic.py#L126C23-L126C30 # noqa E501 # see https://github.com/django/django/blob/bc9b6251e0b54c3b5520e3c66578041cc17e4a28/django/contrib/staticfiles/management/commands/collectstatic.py#L126C23-L126C30
# #
# NOTE: This is same as `FileSystemFinder.list`, but we exclude Python/HTML files # NOTE: This is same as `FileSystemFinder.list`, but we exclude Python/HTML files
# NOTE 2: Yield can be annotated as Iterable, see https://stackoverflow.com/questions/38419654 # NOTE 2: Yield can be annotated as Iterable, see https://stackoverflow.com/questions/38419654
def list(self, ignore_patterns: List[str]) -> Iterable[Tuple[str, FileSystemStorage]]: def list(self, ignore_patterns: List[str]) -> Iterable[Tuple[str, FileSystemStorage]]:
""" """List all files in all locations."""
List all files in all locations. for _prefix, root in self.locations:
"""
for prefix, root in self.locations:
# Skip nonexistent directories. # Skip nonexistent directories.
if os.path.isdir(root): if Path(root).is_dir():
storage = self.storages[root] storage = self.storages[root]
for path in get_files(storage, ignore_patterns): for path in get_files(storage, ignore_patterns):
if self._is_path_valid(path): if self._is_path_valid(path):

View file

@ -31,9 +31,7 @@ class TagProtectedError(Exception):
Thus, this exception is raised when a component is attempted to be registered under Thus, this exception is raised when a component is attempted to be registered under
a forbidden name, such that it would overwrite one of django_component's own template tags. a forbidden name, such that it would overwrite one of django_component's own template tags.
""" # noqa: E501 """
pass
PROTECTED_TAGS = [ PROTECTED_TAGS = [
@ -57,8 +55,7 @@ def register_tag(
) -> None: ) -> None:
# Register inline tag # Register inline tag
if is_tag_protected(library, tag): if is_tag_protected(library, tag):
raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag) raise TagProtectedError(f'Cannot register tag "{tag}", this tag name is protected')
else:
library.tag(tag, tag_fn) library.tag(tag, tag_fn)

View file

@ -1,7 +1,7 @@
import functools import functools
import inspect import inspect
import keyword import keyword
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, List, Optional, Tuple, Type, cast from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Iterable, List, Optional, Tuple, Type, cast
from django.template import Context, Library from django.template import Context, Library
from django.template.base import Node, NodeList, Parser, Token from django.template.base import Node, NodeList, Parser, Token
@ -50,10 +50,10 @@ class NodeMeta(type):
bases: Tuple[Type, ...], bases: Tuple[Type, ...],
attrs: Dict[str, Any], attrs: Dict[str, Any],
) -> Type["BaseNode"]: ) -> Type["BaseNode"]:
cls = cast(Type["BaseNode"], super().__new__(mcs, name, bases, attrs)) cls = cast("Type[BaseNode]", super().__new__(mcs, name, bases, attrs))
# Ignore the `BaseNode` class itself # Ignore the `BaseNode` class itself
if attrs.get("__module__", None) == "django_components.node": if attrs.get("__module__") == "django_components.node":
return cls return cls
if not hasattr(cls, "tag"): if not hasattr(cls, "tag"):
@ -195,8 +195,8 @@ class NodeMeta(type):
# Wrap cls.render() so we resolve the args and kwargs and pass them to the # Wrap cls.render() so we resolve the args and kwargs and pass them to the
# actual render method. # actual render method.
cls.render = wrapper_render # type: ignore cls.render = wrapper_render # type: ignore[assignment]
cls.render._djc_wrapped = True # type: ignore cls.render._djc_wrapped = True # type: ignore[attr-defined]
return cls return cls
@ -210,8 +210,7 @@ class BaseNode(Node, metaclass=NodeMeta):
1. It declares how a particular template tag should be parsed - By setting the 1. It declares how a particular template tag should be parsed - By setting the
[`tag`](../api#django_components.BaseNode.tag), [`tag`](../api#django_components.BaseNode.tag),
[`end_tag`](../api#django_components.BaseNode.end_tag), [`end_tag`](../api#django_components.BaseNode.end_tag),
and [`allowed_flags`](../api#django_components.BaseNode.allowed_flags) and [`allowed_flags`](../api#django_components.BaseNode.allowed_flags) attributes:
attributes:
```python ```python
class SlotNode(BaseNode): class SlotNode(BaseNode):
@ -306,7 +305,7 @@ class BaseNode(Node, metaclass=NodeMeta):
``` ```
""" """
allowed_flags: ClassVar[Optional[List[str]]] = None allowed_flags: ClassVar[Optional[Iterable[str]]] = None
""" """
The list of all *possible* flags for this tag. The list of all *possible* flags for this tag.
@ -328,7 +327,7 @@ class BaseNode(Node, metaclass=NodeMeta):
``` ```
""" """
def render(self, context: Context, *args: Any, **kwargs: Any) -> str: def render(self, context: Context, *_args: Any, **_kwargs: Any) -> str:
""" """
Render the node. This method is meant to be overridden by subclasses. Render the node. This method is meant to be overridden by subclasses.
@ -491,7 +490,7 @@ class BaseNode(Node, metaclass=NodeMeta):
contents: Optional[str] = None, contents: Optional[str] = None,
template_name: Optional[str] = None, template_name: Optional[str] = None,
template_component: Optional[Type["Component"]] = None, template_component: Optional[Type["Component"]] = None,
): ) -> None:
self.params = params self.params = params
self.flags = flags or {flag: False for flag in self.allowed_flags or []} self.flags = flags or {flag: False for flag in self.allowed_flags or []}
self.nodelist = nodelist or NodeList() self.nodelist = nodelist or NodeList()
@ -501,10 +500,7 @@ class BaseNode(Node, metaclass=NodeMeta):
self.template_component = template_component self.template_component = template_component
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return f"<{self.__class__.__name__}: {self.node_id}. Contents: {self.contents}. Flags: {self.active_flags}>"
f"<{self.__class__.__name__}: {self.node_id}. Contents: {repr(self.nodelist)}."
f" Flags: {self.active_flags}>"
)
@property @property
def active_flags(self) -> List[str]: def active_flags(self) -> List[str]:
@ -541,7 +537,7 @@ class BaseNode(Node, metaclass=NodeMeta):
To register the tag, you can use [`BaseNode.register()`](../api#django_components.BaseNode.register). To register the tag, you can use [`BaseNode.register()`](../api#django_components.BaseNode.register).
""" """
# NOTE: Avoids circular import # NOTE: Avoids circular import
from django_components.template import get_component_from_origin from django_components.template import get_component_from_origin # noqa: PLC0415
tag_id = gen_id() tag_id = gen_id()
tag = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token) tag = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token)
@ -650,7 +646,7 @@ def template_tag(
{ {
"tag": tag, "tag": tag,
"end_tag": end_tag, "end_tag": end_tag,
"allowed_flags": allowed_flags or [], "allowed_flags": allowed_flags or (),
"render": fn, "render": fn,
}, },
) )

View file

@ -77,10 +77,10 @@ component_renderer_cache: Dict[str, Tuple[ComponentRenderer, str]] = {}
child_component_attrs: Dict[str, List[str]] = {} child_component_attrs: Dict[str, List[str]] = {}
nested_comp_pattern = re.compile( nested_comp_pattern = re.compile(
r'<template [^>]*?djc-render-id="\w{{{COMP_ID_LENGTH}}}"[^>]*?></template>'.format(COMP_ID_LENGTH=COMP_ID_LENGTH) r'<template [^>]*?djc-render-id="\w{{{COMP_ID_LENGTH}}}"[^>]*?></template>'.format(COMP_ID_LENGTH=COMP_ID_LENGTH), # noqa: UP032
) )
render_id_pattern = re.compile( render_id_pattern = re.compile(
r'djc-render-id="(?P<render_id>\w{{{COMP_ID_LENGTH}}})"'.format(COMP_ID_LENGTH=COMP_ID_LENGTH) r'djc-render-id="(?P<render_id>\w{{{COMP_ID_LENGTH}}})"'.format(COMP_ID_LENGTH=COMP_ID_LENGTH), # noqa: UP032
) )
@ -135,7 +135,8 @@ def component_post_render(
component_name: str, component_name: str,
parent_id: Optional[str], parent_id: Optional[str],
on_component_rendered_callbacks: Dict[ on_component_rendered_callbacks: Dict[
str, Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult] str,
Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult],
], ],
on_html_rendered: Callable[[str], str], on_html_rendered: Callable[[str], str],
) -> str: ) -> str:
@ -345,11 +346,11 @@ def component_post_render(
continue continue
# Skip parts of errored components # Skip parts of errored components
elif curr_item.parent_id in ignored_ids: if curr_item.parent_id in ignored_ids:
continue continue
# Process text parts # Process text parts
elif isinstance(curr_item, TextPart): if isinstance(curr_item, TextPart):
parent_html_parts = get_html_parts(curr_item.parent_id) parent_html_parts = get_html_parts(curr_item.parent_id)
parent_html_parts.append(curr_item.text) parent_html_parts.append(curr_item.text)
@ -388,7 +389,7 @@ def component_post_render(
# - Rendering of component's template # - Rendering of component's template
# #
# In all cases, we want to mark the component as errored, and let the parent handle it. # In all cases, we want to mark the component as errored, and let the parent handle it.
except Exception as err: except Exception as err: # noqa: BLE001
handle_error(component_id=component_id, error=err) handle_error(component_id=component_id, error=err)
continue continue
@ -416,7 +417,7 @@ def component_post_render(
last_index = 0 last_index = 0
parts_to_process: List[Union[TextPart, ComponentPart]] = [] parts_to_process: List[Union[TextPart, ComponentPart]] = []
for match in nested_comp_pattern.finditer(comp_content): for match in nested_comp_pattern.finditer(comp_content):
part_before_component = comp_content[last_index : match.start()] # noqa: E203 part_before_component = comp_content[last_index : match.start()]
last_index = match.end() last_index = match.end()
comp_part = match[0] comp_part = match[0]
@ -490,7 +491,7 @@ def _call_generator_before_callback(
# Catch if `Component.on_render()` raises an exception, in which case this becomes # Catch if `Component.on_render()` raises an exception, in which case this becomes
# the new error. # the new error.
except Exception as new_error: except Exception as new_error: # noqa: BLE001
error = new_error error = new_error
html = None html = None
# This raises if `StopIteration` was not raised, which may be if `Component.on_render()` # This raises if `StopIteration` was not raised, which may be if `Component.on_render()`

View file

@ -1,6 +1,4 @@
""" """This module contains optimizations for the `{% provide %}` feature."""
This module contains optimizations for the `{% provide %}` feature.
"""
from contextlib import contextmanager from contextlib import contextmanager
from typing import Dict, Generator, NamedTuple, Set from typing import Dict, Generator, NamedTuple, Set

View file

@ -1,5 +1,4 @@
from collections import namedtuple from typing import Any, Dict, NamedTuple, Optional
from typing import Any, Dict, Optional
from django.template import Context, TemplateSyntaxError from django.template import Context, TemplateSyntaxError
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
@ -85,7 +84,7 @@ class ProvideNode(BaseNode):
tag = "provide" tag = "provide"
end_tag = "endprovide" end_tag = "endprovide"
allowed_flags = [] allowed_flags = ()
def render(self, context: Context, name: str, **kwargs: Any) -> SafeString: def render(self, context: Context, name: str, **kwargs: Any) -> SafeString:
# NOTE: The "provided" kwargs are meant to be shared privately, meaning that components # NOTE: The "provided" kwargs are meant to be shared privately, meaning that components
@ -130,7 +129,7 @@ def get_injected_context_var(
raise KeyError( raise KeyError(
f"Component '{component_name}' tried to inject a variable '{key}' before it was provided." f"Component '{component_name}' tried to inject a variable '{key}' before it was provided."
f" To fix this, make sure that at least one ancestor of component '{component_name}' has" f" To fix this, make sure that at least one ancestor of component '{component_name}' has"
f" the variable '{key}' in their 'provide' attribute." f" the variable '{key}' in their 'provide' attribute.",
) )
@ -148,17 +147,18 @@ def set_provided_context_var(
# within template. # within template.
if not key: if not key:
raise TemplateSyntaxError( raise TemplateSyntaxError(
"Provide tag received an empty string. Key must be non-empty and a valid identifier." "Provide tag received an empty string. Key must be non-empty and a valid identifier.",
) )
if not key.isidentifier(): if not key.isidentifier():
raise TemplateSyntaxError( raise TemplateSyntaxError(
"Provide tag received a non-identifier string. Key must be non-empty and a valid identifier." "Provide tag received a non-identifier string. Key must be non-empty and a valid identifier.",
) )
# We turn the kwargs into a NamedTuple so that the object that's "provided" # We turn the kwargs into a NamedTuple so that the object that's "provided"
# is immutable. This ensures that the data returned from `inject` will always # is immutable. This ensures that the data returned from `inject` will always
# have all the keys that were passed to the `provide` tag. # have all the keys that were passed to the `provide` tag.
tuple_cls = namedtuple("DepInject", provided_kwargs.keys()) # type: ignore[misc] fields = [(field, Any) for field in provided_kwargs]
tuple_cls = NamedTuple("DepInject", fields) # type: ignore[misc]
payload = tuple_cls(**provided_kwargs) payload = tuple_cls(**provided_kwargs)
# Instead of storing the provided data on the Context object, we store it # Instead of storing the provided data on the Context object, we store it

View file

@ -174,9 +174,10 @@ class SlotFunc(Protocol, Generic[TSlotData]):
}, },
) )
``` ```
""" """
def __call__(self, ctx: SlotContext[TSlotData]) -> SlotResult: ... # noqa E704 def __call__(self, ctx: SlotContext[TSlotData]) -> SlotResult: ...
@dataclass @dataclass
@ -238,7 +239,7 @@ class Slot(Generic[TSlotData]):
Read more about [Slot contents](../../concepts/fundamentals/slots#slot-contents). Read more about [Slot contents](../../concepts/fundamentals/slots#slot-contents).
""" """
content_func: SlotFunc[TSlotData] = cast(SlotFunc[TSlotData], None) content_func: SlotFunc[TSlotData] = cast("SlotFunc[TSlotData]", None) # noqa: RUF009
""" """
The actual slot function. The actual slot function.
@ -319,7 +320,7 @@ class Slot(Generic[TSlotData]):
# Raise if Slot received another Slot instance as `contents`, # Raise if Slot received another Slot instance as `contents`,
# because this leads to ambiguity about how to handle the metadata. # because this leads to ambiguity about how to handle the metadata.
if isinstance(self.contents, Slot): if isinstance(self.contents, Slot):
raise ValueError("Slot received another Slot instance as `contents`") raise TypeError("Slot received another Slot instance as `contents`")
if self.content_func is None: if self.content_func is None:
self.contents, new_nodelist, self.content_func = self._resolve_contents(self.contents) self.contents, new_nodelist, self.content_func = self._resolve_contents(self.contents)
@ -327,7 +328,7 @@ class Slot(Generic[TSlotData]):
self.nodelist = new_nodelist self.nodelist = new_nodelist
if not callable(self.content_func): if not callable(self.content_func):
raise ValueError(f"Slot 'content_func' must be a callable, got: {self.content_func}") raise TypeError(f"Slot 'content_func' must be a callable, got: {self.content_func}")
# Allow to treat the instances as functions # Allow to treat the instances as functions
def __call__( def __call__(
@ -463,9 +464,9 @@ class SlotFallback:
def slot_function(self, ctx: SlotContext): def slot_function(self, ctx: SlotContext):
return f"Hello, {ctx.fallback}!" return f"Hello, {ctx.fallback}!"
``` ```
""" # noqa: E501 """
def __init__(self, slot: "SlotNode", context: Context): def __init__(self, slot: "SlotNode", context: Context) -> None:
self._slot = slot self._slot = slot
self._context = context self._context = context
@ -486,12 +487,10 @@ name_escape_re = re.compile(r"[^\w]")
# TODO_v1 - Remove, superseded by `Component.slots` and `component_vars.slots` # TODO_v1 - Remove, superseded by `Component.slots` and `component_vars.slots`
class SlotIsFilled(dict): class SlotIsFilled(dict):
""" """Dictionary that returns `True` if the slot is filled (key is found), `False` otherwise."""
Dictionary that returns `True` if the slot is filled (key is found), `False` otherwise.
"""
def __init__(self, fills: Dict, *args: Any, **kwargs: Any) -> None: def __init__(self, fills: Dict, *args: Any, **kwargs: Any) -> None:
escaped_fill_names = {self._escape_slot_name(fill_name): True for fill_name in fills.keys()} escaped_fill_names = {self._escape_slot_name(fill_name): True for fill_name in fills}
super().__init__(escaped_fill_names, *args, **kwargs) super().__init__(escaped_fill_names, *args, **kwargs)
def __missing__(self, key: Any) -> bool: def __missing__(self, key: Any) -> bool:
@ -641,7 +640,7 @@ class SlotNode(BaseNode):
tag = "slot" tag = "slot"
end_tag = "endslot" end_tag = "endslot"
allowed_flags = [SLOT_DEFAULT_FLAG, SLOT_REQUIRED_FLAG] allowed_flags = (SLOT_DEFAULT_FLAG, SLOT_REQUIRED_FLAG)
# NOTE: # NOTE:
# In the current implementation, the slots are resolved only at the render time. # In the current implementation, the slots are resolved only at the render time.
@ -675,7 +674,7 @@ class SlotNode(BaseNode):
raise TemplateSyntaxError( raise TemplateSyntaxError(
"Encountered a SlotNode outside of a Component context. " "Encountered a SlotNode outside of a Component context. "
"Make sure that all {% slot %} tags are nested within {% component %} tags.\n" "Make sure that all {% slot %} tags are nested within {% component %} tags.\n"
f"SlotNode: {self.__repr__()}" f"SlotNode: {self.__repr__()}",
) )
# Component info # Component info
@ -715,7 +714,7 @@ class SlotNode(BaseNode):
"Only one component slot may be marked as 'default', " "Only one component slot may be marked as 'default', "
f"found '{default_slot_name}' and '{slot_name}'. " f"found '{default_slot_name}' and '{slot_name}'. "
f"To fix, check template '{component_ctx.template_name}' " f"To fix, check template '{component_ctx.template_name}' "
f"of component '{component_name}'." f"of component '{component_name}'.",
) )
if default_slot_name is None: if default_slot_name is None:
@ -730,7 +729,7 @@ class SlotNode(BaseNode):
): ):
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Slot '{slot_name}' of component '{component_name}' was filled twice: " f"Slot '{slot_name}' of component '{component_name}' was filled twice: "
"once explicitly and once implicitly as 'default'." "once explicitly and once implicitly as 'default'.",
) )
# If slot is marked as 'default', we use the name 'default' for the fill, # If slot is marked as 'default', we use the name 'default' for the fill,
@ -798,7 +797,8 @@ class SlotNode(BaseNode):
# To achieve that, we first find the left-most `hui3q2` (index 2), and then find the `ax3c89` # To achieve that, we first find the left-most `hui3q2` (index 2), and then find the `ax3c89`
# in the list of dicts before it (index 1). # in the list of dicts before it (index 1).
curr_index = get_index( curr_index = get_index(
context.dicts, lambda d: _COMPONENT_CONTEXT_KEY in d and d[_COMPONENT_CONTEXT_KEY] == component_id context.dicts,
lambda d: _COMPONENT_CONTEXT_KEY in d and d[_COMPONENT_CONTEXT_KEY] == component_id,
) )
parent_index = get_last_index(context.dicts[:curr_index], lambda d: _COMPONENT_CONTEXT_KEY in d) parent_index = get_last_index(context.dicts[:curr_index], lambda d: _COMPONENT_CONTEXT_KEY in d)
@ -808,7 +808,8 @@ class SlotNode(BaseNode):
# Looking left finds nothing. In this case, look for the first component layer to the right. # Looking left finds nothing. In this case, look for the first component layer to the right.
if parent_index is None and curr_index + 1 < len(context.dicts): if parent_index is None and curr_index + 1 < len(context.dicts):
parent_index = get_index( parent_index = get_index(
context.dicts[curr_index + 1 :], lambda d: _COMPONENT_CONTEXT_KEY in d # noqa: E203 context.dicts[curr_index + 1 :],
lambda d: _COMPONENT_CONTEXT_KEY in d,
) )
if parent_index is not None: if parent_index is not None:
parent_index = parent_index + curr_index + 1 parent_index = parent_index + curr_index + 1
@ -914,7 +915,7 @@ class SlotNode(BaseNode):
# {% endprovide %} # {% endprovide %}
for key, value in context.flatten().items(): for key, value in context.flatten().items():
if key.startswith(_INJECT_CONTEXT_KEY_PREFIX): if key.startswith(_INJECT_CONTEXT_KEY_PREFIX):
extra_context[key] = value extra_context[key] = value # noqa: PERF403
fallback = SlotFallback(self, context) fallback = SlotFallback(self, context)
@ -982,9 +983,8 @@ class SlotNode(BaseNode):
registry_settings = component.registry.settings registry_settings = component.registry.settings
if registry_settings.context_behavior == ContextBehavior.DJANGO: if registry_settings.context_behavior == ContextBehavior.DJANGO:
return context return context
elif registry_settings.context_behavior == ContextBehavior.ISOLATED: if registry_settings.context_behavior == ContextBehavior.ISOLATED:
return outer_context if outer_context is not None else Context() return outer_context if outer_context is not None else Context()
else:
raise ValueError(f"Unknown value for context_behavior: '{registry_settings.context_behavior}'") raise ValueError(f"Unknown value for context_behavior: '{registry_settings.context_behavior}'")
@ -1138,7 +1138,7 @@ class FillNode(BaseNode):
tag = "fill" tag = "fill"
end_tag = "endfill" end_tag = "endfill"
allowed_flags = [] allowed_flags = ()
def render( def render(
self, self,
@ -1155,15 +1155,15 @@ class FillNode(BaseNode):
if fallback is not None and default is not None: if fallback is not None and default is not None:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Fill tag received both 'default' and '{FILL_FALLBACK_KWARG}' kwargs. " f"Fill tag received both 'default' and '{FILL_FALLBACK_KWARG}' kwargs. "
f"Use '{FILL_FALLBACK_KWARG}' instead." f"Use '{FILL_FALLBACK_KWARG}' instead.",
) )
elif fallback is None and default is not None: if fallback is None and default is not None:
fallback = default fallback = default
if not _is_extracting_fill(context): if not _is_extracting_fill(context):
raise TemplateSyntaxError( raise TemplateSyntaxError(
"FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. " "FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. "
"Make sure that the {% fill %} tags are nested within {% component %} tags." "Make sure that the {% fill %} tags are nested within {% component %} tags.",
) )
# Validate inputs # Validate inputs
@ -1175,31 +1175,31 @@ class FillNode(BaseNode):
raise TemplateSyntaxError(f"Fill tag '{FILL_DATA_KWARG}' kwarg must resolve to a string, got {data}") raise TemplateSyntaxError(f"Fill tag '{FILL_DATA_KWARG}' kwarg must resolve to a string, got {data}")
if not is_identifier(data): if not is_identifier(data):
raise RuntimeError( raise RuntimeError(
f"Fill tag kwarg '{FILL_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'" f"Fill tag kwarg '{FILL_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'",
) )
if fallback is not None: if fallback is not None:
if not isinstance(fallback, str): if not isinstance(fallback, str):
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Fill tag '{FILL_FALLBACK_KWARG}' kwarg must resolve to a string, got {fallback}" f"Fill tag '{FILL_FALLBACK_KWARG}' kwarg must resolve to a string, got {fallback}",
) )
if not is_identifier(fallback): if not is_identifier(fallback):
raise RuntimeError( raise RuntimeError(
f"Fill tag kwarg '{FILL_FALLBACK_KWARG}' does not resolve to a valid Python identifier," f"Fill tag kwarg '{FILL_FALLBACK_KWARG}' does not resolve to a valid Python identifier,"
f" got '{fallback}'" f" got '{fallback}'",
) )
# data and fallback cannot be bound to the same variable # data and fallback cannot be bound to the same variable
if data and fallback and data == fallback: if data and fallback and data == fallback:
raise RuntimeError( raise RuntimeError(
f"Fill '{name}' received the same string for slot fallback ({FILL_FALLBACK_KWARG}=...)" f"Fill '{name}' received the same string for slot fallback ({FILL_FALLBACK_KWARG}=...)"
f" and slot data ({FILL_DATA_KWARG}=...)" f" and slot data ({FILL_DATA_KWARG}=...)",
) )
if body is not None and self.contents: if body is not None and self.contents:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Fill '{name}' received content both through '{FILL_BODY_KWARG}' kwarg and '{{% fill %}}' body. " f"Fill '{name}' received content both through '{FILL_BODY_KWARG}' kwarg and '{{% fill %}}' body. "
f"Use only one method." f"Use only one method.",
) )
fill_data = FillWithData( fill_data = FillWithData(
@ -1229,7 +1229,7 @@ class FillNode(BaseNode):
if captured_fills is None: if captured_fills is None:
raise RuntimeError( raise RuntimeError(
"FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. " "FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. "
"Make sure that the {% fill %} tags are nested within {% component %} tags." "Make sure that the {% fill %} tags are nested within {% component %} tags.",
) )
# To allow using variables which were defined within the template and to which # To allow using variables which were defined within the template and to which
@ -1282,9 +1282,9 @@ class FillNode(BaseNode):
# ] # ]
for layer in context.dicts: for layer in context.dicts:
if "forloop" in layer: if "forloop" in layer:
layer = layer.copy() layer_copy = layer.copy()
layer["forloop"] = layer["forloop"].copy() layer_copy["forloop"] = layer_copy["forloop"].copy()
data.extra_context.update(layer) data.extra_context.update(layer_copy)
captured_fills.append(data) captured_fills.append(data)
@ -1472,11 +1472,11 @@ def _extract_fill_content(
if not captured_fills: if not captured_fills:
return False return False
elif content: if content:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Illegal content passed to component '{component_name}'. " f"Illegal content passed to component '{component_name}'. "
"Explicit 'fill' tags cannot occur alongside other text. " "Explicit 'fill' tags cannot occur alongside other text. "
"The component body rendered content: {content}" "The component body rendered content: {content}",
) )
# Check for any duplicates # Check for any duplicates
@ -1485,7 +1485,7 @@ def _extract_fill_content(
if fill.name in seen_names: if fill.name in seen_names:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name in component '{component_name}': " f"Multiple fill tags cannot target the same slot name in component '{component_name}': "
f"Detected duplicate fill tag name '{fill.name}'." f"Detected duplicate fill tag name '{fill.name}'.",
) )
seen_names.add(fill.name) seen_names.add(fill.name)
@ -1550,7 +1550,7 @@ def normalize_slot_fills(
if content is None: if content is None:
continue continue
# Case: Content is a string / non-slot / non-callable # Case: Content is a string / non-slot / non-callable
elif not callable(content): if not callable(content):
# NOTE: `Slot.content_func` and `Slot.nodelist` will be set in `Slot.__init__()` # NOTE: `Slot.content_func` and `Slot.nodelist` will be set in `Slot.__init__()`
slot: Slot = Slot(contents=content, component_name=component_name, slot_name=slot_name) slot: Slot = Slot(contents=content, component_name=component_name, slot_name=slot_name)
# Case: Content is a callable, so either a plain function or a `Slot` instance. # Case: Content is a callable, so either a plain function or a `Slot` instance.
@ -1573,16 +1573,14 @@ def _nodelist_to_slot(
fill_node: Optional[Union[FillNode, "ComponentNode"]] = None, fill_node: Optional[Union[FillNode, "ComponentNode"]] = None,
extra: Optional[Dict[str, Any]] = None, extra: Optional[Dict[str, Any]] = None,
) -> Slot: ) -> Slot:
if data_var: if data_var and not data_var.isidentifier():
if not data_var.isidentifier():
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Slot data alias in fill '{slot_name}' must be a valid identifier. Got '{data_var}'" f"Slot data alias in fill '{slot_name}' must be a valid identifier. Got '{data_var}'",
) )
if fallback_var: if fallback_var and not fallback_var.isidentifier():
if not fallback_var.isidentifier():
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Slot fallback alias in fill '{slot_name}' must be a valid identifier. Got '{fallback_var}'" f"Slot fallback alias in fill '{slot_name}' must be a valid identifier. Got '{fallback_var}'",
) )
# We use Template.render() to render the nodelist, so that Django correctly sets up # We use Template.render() to render the nodelist, so that Django correctly sets up
@ -1655,7 +1653,7 @@ def _nodelist_to_slot(
return rendered return rendered
return Slot( return Slot(
content_func=cast(SlotFunc, render_func), content_func=cast("SlotFunc", render_func),
component_name=component_name, component_name=component_name,
slot_name=slot_name, slot_name=slot_name,
nodelist=nodelist, nodelist=nodelist,

View file

@ -13,7 +13,7 @@ if TYPE_CHECKING:
# Require the start / end tags to contain NO spaces and only these characters # Require the start / end tags to contain NO spaces and only these characters
TAG_CHARS = r"\w\-\:\@\.\#/" TAG_CHARS = r"\w\-\:\@\.\#/"
TAG_RE = re.compile(r"^[{chars}]+$".format(chars=TAG_CHARS)) TAG_RE = re.compile(rf"^[{TAG_CHARS}]+$")
class TagResult(NamedTuple): class TagResult(NamedTuple):
@ -106,6 +106,7 @@ class TagFormatterABC(abc.ABC):
Returns: Returns:
str: The formatted start tag. str: The formatted start tag.
""" """
... ...
@ -119,6 +120,7 @@ class TagFormatterABC(abc.ABC):
Returns: Returns:
str: The formatted end tag. str: The formatted end tag.
""" """
... ...
@ -131,7 +133,7 @@ class TagFormatterABC(abc.ABC):
which is a tuple of `(component_name, remaining_tokens)`. which is a tuple of `(component_name, remaining_tokens)`.
Args: Args:
tokens [List(str]): List of tokens passed to the component tag. tokens (List[str]): List of tokens passed to the component tag.
Returns: Returns:
TagResult: Parsed component name and remaining tokens. TagResult: Parsed component name and remaining tokens.
@ -160,16 +162,15 @@ class TagFormatterABC(abc.ABC):
```python ```python
TagResult('my_comp', ['key=val', 'key2=val2']) TagResult('my_comp', ['key=val', 'key2=val2'])
``` ```
""" """
... ...
class InternalTagFormatter: class InternalTagFormatter:
""" """Internal wrapper around user-provided TagFormatters, so that we validate the outputs."""
Internal wrapper around user-provided TagFormatters, so that we validate the outputs.
"""
def __init__(self, tag_formatter: TagFormatterABC): def __init__(self, tag_formatter: TagFormatterABC) -> None:
self.tag_formatter = tag_formatter self.tag_formatter = tag_formatter
def start_tag(self, name: str) -> str: def start_tag(self, name: str) -> str:
@ -192,13 +193,13 @@ class InternalTagFormatter:
if not tag: if not tag:
raise ValueError( raise ValueError(
f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'." f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'."
f" Tag cannot be empty" f" Tag cannot be empty",
) )
if not TAG_RE.match(tag): if not TAG_RE.match(tag):
raise ValueError( raise ValueError(
f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'." f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'."
f" Tag must contain only following chars: {TAG_CHARS}" f" Tag must contain only following chars: {TAG_CHARS}",
) )
@ -222,13 +223,13 @@ class ComponentFormatter(TagFormatterABC):
``` ```
""" """
def __init__(self, tag: str): def __init__(self, tag: str) -> None:
self.tag = tag self.tag = tag
def start_tag(self, name: str) -> str: def start_tag(self, _name: str) -> str:
return self.tag return self.tag
def end_tag(self, name: str) -> str: def end_tag(self, _name: str) -> str:
return f"end{self.tag}" return f"end{self.tag}"
def parse(self, tokens: List[str]) -> TagResult: def parse(self, tokens: List[str]) -> TagResult:
@ -238,10 +239,7 @@ class ComponentFormatter(TagFormatterABC):
raise TemplateSyntaxError(f"{self.__class__.__name__}: Component tag did not receive tag name") raise TemplateSyntaxError(f"{self.__class__.__name__}: Component tag did not receive tag name")
# If the first arg is a kwarg, then clearly the component name is not set. # If the first arg is a kwarg, then clearly the component name is not set.
if "=" in args[0]: comp_name = None if "=" in args[0] else args.pop(0)
comp_name = None
else:
comp_name = args.pop(0)
if not comp_name: if not comp_name:
raise TemplateSyntaxError("Component name must be a non-empty quoted string, e.g. 'my_comp'") raise TemplateSyntaxError("Component name must be a non-empty quoted string, e.g. 'my_comp'")

View file

@ -60,7 +60,8 @@ def cached_template(
engine=... engine=...
) )
``` ```
""" # noqa: E501
"""
template_cache = get_template_cache() template_cache = get_template_cache()
template_cls = template_cls or Template template_cls = template_cls or Template
@ -103,7 +104,7 @@ def prepare_component_template(
"Django-components received a Template instance which was not patched." "Django-components received a Template instance which was not patched."
"If you are using Django's Template class, check if you added django-components" "If you are using Django's Template class, check if you added django-components"
"to INSTALLED_APPS. If you are using a custom template class, then you need to" "to INSTALLED_APPS. If you are using a custom template class, then you need to"
"manually patch the class." "manually patch the class.",
) )
with _maybe_bind_template(context, template): with _maybe_bind_template(context, template):
@ -223,7 +224,7 @@ def _get_component_template(component: "Component") -> Optional[Template]:
# TODO_V1 - Remove `get_template_string()` in v1 # TODO_V1 - Remove `get_template_string()` in v1
if hasattr(component, "get_template_string"): if hasattr(component, "get_template_string"):
template_string_getter = getattr(component, "get_template_string") template_string_getter = component.get_template_string
template_body_from_getter = template_string_getter(component.context) template_body_from_getter = template_string_getter(component.context)
else: else:
template_body_from_getter = None template_body_from_getter = None
@ -244,8 +245,7 @@ def _get_component_template(component: "Component") -> Optional[Template]:
sources_with_values = [k for k, v in template_sources.items() if v is not None] sources_with_values = [k for k, v in template_sources.items() if v is not None]
if len(sources_with_values) > 1: if len(sources_with_values) > 1:
raise ImproperlyConfigured( raise ImproperlyConfigured(
f"Component template was set multiple times in Component {component.name}." f"Component template was set multiple times in Component {component.name}. Sources: {sources_with_values}",
f"Sources: {sources_with_values}"
) )
# Load the template based on the source # Load the template based on the source
@ -281,7 +281,7 @@ def _get_component_template(component: "Component") -> Optional[Template]:
if template is not None: if template is not None:
return template return template
# Create the template from the string # Create the template from the string
elif template_string is not None: if template_string is not None:
return _create_template_from_string(component.__class__, template_string) return _create_template_from_string(component.__class__, template_string)
# Otherwise, Component has no template - this is valid, as it may be instead rendered # Otherwise, Component has no template - this is valid, as it may be instead rendered
@ -384,7 +384,12 @@ def cache_component_template_file(component_cls: Type["Component"]) -> None:
return return
# NOTE: Avoids circular import # NOTE: Avoids circular import
from django_components.component_media import ComponentMedia, Unset, _resolve_component_relative_files, is_set from django_components.component_media import ( # noqa: PLC0415
ComponentMedia,
Unset,
_resolve_component_relative_files,
is_set,
)
# If we access the `Component.template_file` attribute, then this triggers media resolution if it was not done yet. # If we access the `Component.template_file` attribute, then this triggers media resolution if it was not done yet.
# The problem is that this also causes the loading of the Template, if Component has defined `template_file`. # The problem is that this also causes the loading of the Template, if Component has defined `template_file`.
@ -422,12 +427,12 @@ def get_component_by_template_file(template_file: str) -> Optional[Type["Compone
# #
# So at this point we want to call `cache_component_template_file()` for all Components for which # So at this point we want to call `cache_component_template_file()` for all Components for which
# we skipped it earlier. # we skipped it earlier.
global component_template_file_cache_initialized global component_template_file_cache_initialized # noqa: PLW0603
if not component_template_file_cache_initialized: if not component_template_file_cache_initialized:
component_template_file_cache_initialized = True component_template_file_cache_initialized = True
# NOTE: Avoids circular import # NOTE: Avoids circular import
from django_components.component import all_components from django_components.component import all_components # noqa: PLC0415
components = all_components() components = all_components()
for component in components: for component in components:
@ -462,10 +467,10 @@ def get_component_by_template_file(template_file: str) -> Optional[Type["Compone
# NOTE: Used by `@djc_test` to reset the component template file cache # NOTE: Used by `@djc_test` to reset the component template file cache
def _reset_component_template_file_cache() -> None: def _reset_component_template_file_cache() -> None:
global component_template_file_cache global component_template_file_cache # noqa: PLW0603
component_template_file_cache = {} component_template_file_cache = {}
global component_template_file_cache_initialized global component_template_file_cache_initialized # noqa: PLW0603
component_template_file_cache_initialized = False component_template_file_cache_initialized = False

View file

@ -1,6 +1,4 @@
""" """Template loader that loads templates from each Django app's "components" directory."""
Template loader that loads templates from each Django app's "components" directory.
"""
from pathlib import Path from pathlib import Path
from typing import List from typing import List

View file

@ -10,7 +10,7 @@ urlpatterns = [
[ [
*dependencies_urlpatterns, *dependencies_urlpatterns,
*extension_urlpatterns, *extension_urlpatterns,
] ],
), ),
), ),
] ]

View file

@ -7,17 +7,17 @@ T = TypeVar("T")
class CacheNode(Generic[T]): class CacheNode(Generic[T]):
"""A node in the doubly linked list.""" """A node in the doubly linked list."""
def __init__(self, key: Hashable, value: T): def __init__(self, key: Hashable, value: T) -> None:
self.key = key self.key = key
self.value = value self.value = value
self.prev: Optional["CacheNode"] = None self.prev: Optional[CacheNode] = None
self.next: Optional["CacheNode"] = None self.next: Optional[CacheNode] = None
class LRUCache(Generic[T]): class LRUCache(Generic[T]):
"""A simple LRU Cache implementation.""" """A simple LRU Cache implementation."""
def __init__(self, maxsize: Optional[int] = None): def __init__(self, maxsize: Optional[int] = None) -> None:
""" """
Initialize the LRU cache. Initialize the LRU cache.
@ -26,8 +26,8 @@ class LRUCache(Generic[T]):
self.maxsize = maxsize self.maxsize = maxsize
self.cache: Dict[Hashable, CacheNode[T]] = {} # Maps keys to nodes in the doubly linked list self.cache: Dict[Hashable, CacheNode[T]] = {} # Maps keys to nodes in the doubly linked list
# Dummy head and tail nodes to simplify operations # Dummy head and tail nodes to simplify operations
self.head = CacheNode[T]("", cast(T, None)) # Most recently used self.head = CacheNode[T]("", cast("T", None)) # Most recently used
self.tail = CacheNode[T]("", cast(T, None)) # Least recently used self.tail = CacheNode[T]("", cast("T", None)) # Least recently used
self.head.next = self.tail self.head.next = self.tail
self.tail.prev = self.head self.tail.prev = self.head
@ -44,7 +44,7 @@ class LRUCache(Generic[T]):
self._remove(node) self._remove(node)
self._add_to_front(node) self._add_to_front(node)
return node.value return node.value
else:
return None # Key not found return None # Key not found
def has(self, key: Hashable) -> bool: def has(self, key: Hashable) -> bool:

View file

@ -35,7 +35,15 @@ def mark_extension_command_api(obj: TClass) -> TClass:
############################# #############################
CommandLiteralAction = Literal[ CommandLiteralAction = Literal[
"append", "append_const", "count", "extend", "store", "store_const", "store_true", "store_false", "version" "append",
"append_const",
"count",
"extend",
"store",
"store_const",
"store_true",
"store_false",
"version",
] ]
""" """
The basic type of action to be taken when this argument is encountered at the command line. The basic type of action to be taken when this argument is encountered at the command line.
@ -43,7 +51,7 @@ The basic type of action to be taken when this argument is encountered at the co
This is a subset of the values for `action` in This is a subset of the values for `action` in
[`ArgumentParser.add_argument()`](https://docs.python.org/3/library/argparse.html#the-add-argument-method). [`ArgumentParser.add_argument()`](https://docs.python.org/3/library/argparse.html#the-add-argument-method).
""" """
mark_extension_command_api(CommandLiteralAction) # type: ignore mark_extension_command_api(CommandLiteralAction) # type: ignore[type-var]
@mark_extension_command_api @mark_extension_command_api
@ -54,7 +62,7 @@ class CommandArg:
Fields on this class correspond to the arguments for Fields on this class correspond to the arguments for
[`ArgumentParser.add_argument()`](https://docs.python.org/3/library/argparse.html#the-add-argument-method) [`ArgumentParser.add_argument()`](https://docs.python.org/3/library/argparse.html#the-add-argument-method)
""" # noqa: E501 """
name_or_flags: Union[str, Sequence[str]] name_or_flags: Union[str, Sequence[str]]
"""Either a name or a list of option strings, e.g. 'foo' or '-f', '--foo'.""" """Either a name or a list of option strings, e.g. 'foo' or '-f', '--foo'."""
@ -111,7 +119,7 @@ class CommandArgGroup:
Fields on this class correspond to the arguments for Fields on this class correspond to the arguments for
[`ArgumentParser.add_argument_group()`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument_group) [`ArgumentParser.add_argument_group()`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument_group)
""" # noqa: E501 """
title: Optional[str] = None title: Optional[str] = None
""" """
@ -137,7 +145,7 @@ class CommandSubcommand:
Fields on this class correspond to the arguments for Fields on this class correspond to the arguments for
[`ArgumentParser.add_subparsers.add_parser()`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_subparsers) [`ArgumentParser.add_subparsers.add_parser()`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_subparsers)
""" # noqa: E501 """
title: Optional[str] = None title: Optional[str] = None
""" """
@ -208,7 +216,7 @@ class CommandParserInput:
formatter_class: Optional[Type["_FormatterClass"]] = None formatter_class: Optional[Type["_FormatterClass"]] = None
"""A class for customizing the help output""" """A class for customizing the help output"""
prefix_chars: Optional[str] = None prefix_chars: Optional[str] = None
"""The set of characters that prefix optional arguments (default: -)""" """The set of characters that prefix optional arguments (default: `-`)"""
fromfile_prefix_chars: Optional[str] = None fromfile_prefix_chars: Optional[str] = None
"""The set of characters that prefix files from which additional arguments should be read (default: `None`)""" """The set of characters that prefix files from which additional arguments should be read (default: `None`)"""
argument_default: Optional[Any] = None argument_default: Optional[Any] = None
@ -234,7 +242,7 @@ class CommandParserInput:
@mark_extension_command_api @mark_extension_command_api
class CommandHandler(Protocol): class CommandHandler(Protocol):
def __call__(self, *args: Any, **kwargs: Any) -> None: ... # noqa: E704 def __call__(self, *args: Any, **kwargs: Any) -> None: ...
@mark_extension_command_api @mark_extension_command_api
@ -367,7 +375,7 @@ def setup_parser_from_command(command: Type[ComponentCommand]) -> ArgumentParser
# Recursively setup the parser and its subcommands # Recursively setup the parser and its subcommands
def _setup_parser_from_command( def _setup_parser_from_command(
parser: ArgumentParser, parser: ArgumentParser,
command: Union[Type[ComponentCommand], Type[ComponentCommand]], command: Type[ComponentCommand],
) -> ArgumentParser: ) -> ArgumentParser:
# Attach the command to the data returned by `parser.parse_args()`, so we know # Attach the command to the data returned by `parser.parse_args()`, so we know
# which command was matched. # which command was matched.
@ -383,9 +391,9 @@ def _setup_parser_from_command(
# NOTE: Seems that dataclass's `asdict()` calls `asdict()` also on the # NOTE: Seems that dataclass's `asdict()` calls `asdict()` also on the
# nested dataclass fields. Thus we need to apply `_remove_none_values()` # nested dataclass fields. Thus we need to apply `_remove_none_values()`
# to the nested dataclass fields. # to the nested dataclass fields.
group_arg = _remove_none_values(group_arg) cleaned_group_arg = _remove_none_values(group_arg)
_setup_command_arg(arg_group, group_arg) _setup_command_arg(arg_group, cleaned_group_arg)
else: else:
_setup_command_arg(parser, arg.asdict()) _setup_command_arg(parser, arg.asdict())
@ -421,11 +429,7 @@ def _setup_command_arg(parser: Union[ArgumentParser, "_ArgumentGroup"], arg: dic
def _remove_none_values(data: dict) -> dict: def _remove_none_values(data: dict) -> dict:
new_data = {} return {key: val for key, val in data.items() if val is not None}
for key, val in data.items():
if val is not None:
new_data[key] = val
return new_data
def style_success(message: str) -> str: def style_success(message: str) -> str:

View file

@ -20,8 +20,6 @@ else:
class CopiedDict(dict): class CopiedDict(dict):
"""Dict subclass to identify dictionaries that have been copied with `snapshot_context`""" """Dict subclass to identify dictionaries that have been copied with `snapshot_context`"""
pass
def snapshot_context(context: Context) -> Context: def snapshot_context(context: Context) -> Context:
""" """
@ -151,7 +149,7 @@ def gen_context_processors_data(context: BaseContext, request: HttpRequest) -> D
try: try:
processors_data.update(data) processors_data.update(data)
except TypeError as e: except TypeError as e:
raise TypeError(f"Context processor {processor.__qualname__} didn't return a " "dictionary.") from e raise TypeError(f"Context processor {processor.__qualname__} didn't return a dictionary.") from e
context_processors_data[request] = processors_data context_processors_data[request] = processors_data

View file

@ -29,7 +29,7 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None:
# NOTE: Function signature of Template.__init__ hasn't changed in 11 years, so we can safely patch it. # NOTE: Function signature of Template.__init__ hasn't changed in 11 years, so we can safely patch it.
# See https://github.com/django/django/blame/main/django/template/base.py#L139 # See https://github.com/django/django/blame/main/django/template/base.py#L139
def __init__( def __init__( # noqa: N807
self: Template, self: Template,
template_string: Any, template_string: Any,
origin: Optional[Origin] = None, origin: Optional[Origin] = None,
@ -38,7 +38,7 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None:
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
# NOTE: Avoids circular import # NOTE: Avoids circular import
from django_components.template import ( from django_components.template import ( # noqa: PLC0415
get_component_by_template_file, get_component_by_template_file,
get_component_from_origin, get_component_from_origin,
set_component_to_origin, set_component_to_origin,
@ -70,7 +70,7 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None:
content=template_string, content=template_string,
origin=origin, origin=origin,
name=name, name=name,
) ),
) )
# Calling original `Template.__init__` should also compile the template into a Nodelist # Calling original `Template.__init__` should also compile the template into a Nodelist
@ -82,7 +82,7 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None:
OnTemplateCompiledContext( OnTemplateCompiledContext(
component_cls=component_cls, component_cls=component_cls,
template=self, template=self,
) ),
) )
template_cls.__init__ = __init__ template_cls.__init__ = __init__
@ -129,7 +129,7 @@ def monkeypatch_template_compile_nodelist(template_cls: Type[Template]) -> None:
return nodelist return nodelist
except Exception as e: except Exception as e:
if self.engine.debug: if self.engine.debug:
e.template_debug = self.get_exception_info(e, e.token) # type: ignore e.template_debug = self.get_exception_info(e, e.token) # type: ignore[attr-defined]
raise raise
template_cls.compile_nodelist = _compile_nodelist template_cls.compile_nodelist = _compile_nodelist
@ -162,7 +162,7 @@ def monkeypatch_template_render(template_cls: Type[Template]) -> None:
# NOTE: This implementation is based on Django v5.1.3) # NOTE: This implementation is based on Django v5.1.3)
def _template_render(self: Template, context: Context, *args: Any, **kwargs: Any) -> str: def _template_render(self: Template, context: Context, *args: Any, **kwargs: Any) -> str:
"Display stage -- can be called many times" """Display stage -- can be called many times"""
# We parametrized `isolated_context`, which was `True` in the original method. # We parametrized `isolated_context`, which was `True` in the original method.
if COMPONENT_IS_NESTED_KEY not in context: if COMPONENT_IS_NESTED_KEY not in context:
isolated_context = True isolated_context = True
@ -254,7 +254,7 @@ def monkeypatch_template_proxy_cls() -> None:
# Patch TemplateProxy if template_partials is installed # Patch TemplateProxy if template_partials is installed
# See https://github.com/django-components/django-components/issues/1323#issuecomment-3164224042 # See https://github.com/django-components/django-components/issues/1323#issuecomment-3164224042
try: try:
from template_partials.templatetags.partials import TemplateProxy from template_partials.templatetags.partials import TemplateProxy # noqa: PLC0415
except ImportError: except ImportError:
# template_partials is in INSTALLED_APPS but not actually installed # template_partials is in INSTALLED_APPS but not actually installed
# This is fine, just skip the patching # This is fine, just skip the patching
@ -270,7 +270,7 @@ def monkeypatch_template_proxy_cls() -> None:
def monkeypatch_template_proxy_render(template_proxy_cls: Type[Any]) -> None: def monkeypatch_template_proxy_render(template_proxy_cls: Type[Any]) -> None:
# NOTE: TemplateProxy.render() is same logic as Template.render(), just duplicated. # NOTE: TemplateProxy.render() is same logic as Template.render(), just duplicated.
# So we can instead reuse Template.render() # So we can instead reuse Template.render()
def _template_proxy_render(self: Any, context: Context, *args: Any, **kwargs: Any) -> str: def _template_proxy_render(self: Any, context: Context, *_args: Any, **_kwargs: Any) -> str:
return Template.render(self, context) return Template.render(self, context)
template_proxy_cls.render = _template_proxy_render template_proxy_cls.render = _template_proxy_render

View file

@ -1,4 +1,3 @@
import glob
import os import os
from pathlib import Path, PurePosixPath, PureWindowsPath from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import List, NamedTuple, Optional, Set, Union from typing import List, NamedTuple, Optional, Set, Union
@ -41,6 +40,7 @@ def get_component_dirs(include_apps: bool = True) -> List[Path]:
- The paths in [`COMPONENTS.dirs`](../settings#django_components.app_settings.ComponentsSettings.dirs) - The paths in [`COMPONENTS.dirs`](../settings#django_components.app_settings.ComponentsSettings.dirs)
must be absolute paths. must be absolute paths.
""" """
# Allow to configure from settings which dirs should be checked for components # Allow to configure from settings which dirs should be checked for components
component_dirs = app_settings.DIRS component_dirs = app_settings.DIRS
@ -56,9 +56,7 @@ def get_component_dirs(include_apps: bool = True) -> List[Path]:
is_component_dirs_set = raw_dirs_value is not None is_component_dirs_set = raw_dirs_value is not None
is_legacy_paths = ( is_legacy_paths = (
# Use value of `STATICFILES_DIRS` ONLY if `COMPONENT.dirs` not set # Use value of `STATICFILES_DIRS` ONLY if `COMPONENT.dirs` not set
not is_component_dirs_set not is_component_dirs_set and getattr(settings, "STATICFILES_DIRS", None)
and hasattr(settings, "STATICFILES_DIRS")
and settings.STATICFILES_DIRS
) )
if is_legacy_paths: if is_legacy_paths:
# NOTE: For STATICFILES_DIRS, we use the defaults even for empty list. # NOTE: For STATICFILES_DIRS, we use the defaults even for empty list.
@ -70,7 +68,7 @@ def get_component_dirs(include_apps: bool = True) -> List[Path]:
logger.debug( logger.debug(
"get_component_dirs will search for valid dirs from following options:\n" "get_component_dirs will search for valid dirs from following options:\n"
+ "\n".join([f" - {str(d)}" for d in component_dirs]) + "\n".join([f" - {d!s}" for d in component_dirs]),
) )
# Add `[app]/[APP_DIR]` to the directories. This is, by default `[app]/components` # Add `[app]/[APP_DIR]` to the directories. This is, by default `[app]/components`
@ -89,23 +87,22 @@ def get_component_dirs(include_apps: bool = True) -> List[Path]:
# Consider tuples for STATICFILES_DIRS (See #489) # Consider tuples for STATICFILES_DIRS (See #489)
# See https://docs.djangoproject.com/en/5.2/ref/settings/#prefixes-optional # See https://docs.djangoproject.com/en/5.2/ref/settings/#prefixes-optional
if isinstance(component_dir, (tuple, list)): if isinstance(component_dir, (tuple, list)):
component_dir = component_dir[1] component_dir = component_dir[1] # noqa: PLW2901
try: try:
Path(component_dir) Path(component_dir)
except TypeError: except TypeError:
logger.warning( logger.warning(
f"{source} expected str, bytes or os.PathLike object, or tuple/list of length 2. " f"{source} expected str, bytes or os.PathLike object, or tuple/list of length 2. "
f"See Django documentation for STATICFILES_DIRS. Got {type(component_dir)} : {component_dir}" f"See Django documentation for STATICFILES_DIRS. Got {type(component_dir)} : {component_dir}",
) )
continue continue
if not Path(component_dir).is_absolute(): if not Path(component_dir).is_absolute():
raise ValueError(f"{source} must contain absolute paths, got '{component_dir}'") raise ValueError(f"{source} must contain absolute paths, got '{component_dir}'")
else:
directories.add(Path(component_dir).resolve()) directories.add(Path(component_dir).resolve())
logger.debug( logger.debug(
"get_component_dirs matched following template dirs:\n" + "\n".join([f" - {str(d)}" for d in directories]) "get_component_dirs matched following template dirs:\n" + "\n".join([f" - {d!s}" for d in directories]),
) )
return list(directories) return list(directories)
@ -143,6 +140,7 @@ def get_component_files(suffix: Optional[str] = None) -> List[ComponentFileEntry
modules = get_component_files(".py") modules = get_component_files(".py")
``` ```
""" """
search_glob = f"**/*{suffix}" if suffix else "**/*" search_glob = f"**/*{suffix}" if suffix else "**/*"
@ -150,10 +148,10 @@ def get_component_files(suffix: Optional[str] = None) -> List[ComponentFileEntry
component_filepaths = _search_dirs(dirs, search_glob) component_filepaths = _search_dirs(dirs, search_glob)
if hasattr(settings, "BASE_DIR") and settings.BASE_DIR: if hasattr(settings, "BASE_DIR") and settings.BASE_DIR:
project_root = str(settings.BASE_DIR) project_root = settings.BASE_DIR
else: else:
# Fallback for getting the root dir, see https://stackoverflow.com/a/16413955/9788634 # Fallback for getting the root dir, see https://stackoverflow.com/a/16413955/9788634
project_root = os.path.abspath(os.path.dirname(__name__)) project_root = Path(__name__).parent.resolve()
# NOTE: We handle dirs from `COMPONENTS.dirs` and from individual apps separately. # NOTE: We handle dirs from `COMPONENTS.dirs` and from individual apps separately.
modules: List[ComponentFileEntry] = [] modules: List[ComponentFileEntry] = []
@ -212,6 +210,7 @@ def _filepath_to_python_module(
- And file_path is `/path/to/project/app/components/mycomp.py` - And file_path is `/path/to/project/app/components/mycomp.py`
- Then the path relative to project root is `app/components/mycomp.py` - Then the path relative to project root is `app/components/mycomp.py`
- Which we then turn into python import path `app.components.mycomp` - Which we then turn into python import path `app.components.mycomp`
""" """
path_cls = PureWindowsPath if os.name == "nt" else PurePosixPath path_cls = PureWindowsPath if os.name == "nt" else PurePosixPath
@ -234,8 +233,7 @@ def _search_dirs(dirs: List[Path], search_glob: str) -> List[Path]:
""" """
matched_files: List[Path] = [] matched_files: List[Path] = []
for directory in dirs: for directory in dirs:
for path_str in glob.iglob(str(Path(directory) / search_glob), recursive=True): for path in Path(directory).rglob(search_glob):
path = Path(path_str)
# Skip any subdirectory or file (under the top-level directory) that starts with an underscore # Skip any subdirectory or file (under the top-level directory) that starts with an underscore
rel_dir_parts = list(path.relative_to(directory).parts) rel_dir_parts = list(path.relative_to(directory).parts)
name_part = rel_dir_parts.pop() name_part = rel_dir_parts.pop()

View file

@ -11,7 +11,7 @@ actual_trace_level_num = -1
def setup_logging() -> None: def setup_logging() -> None:
# Check if "TRACE" level was already defined. And if so, use its log level. # Check if "TRACE" level was already defined. And if so, use its log level.
# See https://docs.python.org/3/howto/logging.html#custom-levels # See https://docs.python.org/3/howto/logging.html#custom-levels
global actual_trace_level_num global actual_trace_level_num # noqa: PLW0603
log_levels = _get_log_levels() log_levels = _get_log_levels()
if "TRACE" in log_levels: if "TRACE" in log_levels:
@ -25,7 +25,6 @@ def _get_log_levels() -> Dict[str, int]:
# Use official API if possible # Use official API if possible
if sys.version_info >= (3, 11): if sys.version_info >= (3, 11):
return logging.getLevelNamesMapping() return logging.getLevelNamesMapping()
else:
return logging._nameToLevel.copy() return logging._nameToLevel.copy()
@ -54,6 +53,7 @@ def trace(message: str, *args: Any, **kwargs: Any) -> None:
}, },
} }
``` ```
""" """
if actual_trace_level_num == -1: if actual_trace_level_num == -1:
setup_logging() setup_logging()
@ -96,26 +96,10 @@ def trace_component_msg(
`"RENDER_SLOT COMPONENT 'component_name' SLOT: 'slot_name' FILLS: 'fill_name' PATH: Root > Child > Grandchild "` `"RENDER_SLOT COMPONENT 'component_name' SLOT: 'slot_name' FILLS: 'fill_name' PATH: Root > Child > Grandchild "`
""" """
component_id_str = f"ID {component_id}" if component_id else ""
if component_id: slot_name_str = f"SLOT: '{slot_name}'" if slot_name else ""
component_id_str = f"ID {component_id}" component_path_str = "PATH: " + " > ".join(component_path) if component_path else ""
else: slot_fills_str = "FILLS: " + ", ".join(slot_fills.keys()) if slot_fills else ""
component_id_str = ""
if slot_name:
slot_name_str = f"SLOT: '{slot_name}'"
else:
slot_name_str = ""
if component_path:
component_path_str = "PATH: " + " > ".join(component_path)
else:
component_path_str = ""
if slot_fills:
slot_fills_str = "FILLS: " + ", ".join(slot_fills.keys())
else:
slot_fills_str = ""
full_msg = f"{action} COMPONENT: '{component_name}' {component_id_str} {slot_name_str} {slot_fills_str} {component_path_str} {extra}" # noqa: E501 full_msg = f"{action} COMPONENT: '{component_name}' {component_id_str} {slot_name_str} {slot_fills_str} {component_path_str} {extra}" # noqa: E501

View file

@ -5,7 +5,7 @@ from hashlib import md5
from importlib import import_module from importlib import import_module
from itertools import chain from itertools import chain
from types import ModuleType from types import ModuleType
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, TypeVar, Union, cast from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Type, TypeVar, Union, cast
from urllib import parse from urllib import parse
from django_components.constants import UID_LENGTH from django_components.constants import UID_LENGTH
@ -43,9 +43,7 @@ def snake_to_pascal(name: str) -> str:
def is_identifier(value: Any) -> bool: def is_identifier(value: Any) -> bool:
if not isinstance(value, str): if not isinstance(value, str):
return False return False
if not value.isidentifier(): return value.isidentifier()
return False
return True
def any_regex_match(string: str, patterns: List[re.Pattern]) -> bool: def any_regex_match(string: str, patterns: List[re.Pattern]) -> bool:
@ -58,9 +56,7 @@ def no_regex_match(string: str, patterns: List[re.Pattern]) -> bool:
# See https://stackoverflow.com/a/2020083/9788634 # See https://stackoverflow.com/a/2020083/9788634
def get_import_path(cls_or_fn: Type[Any]) -> str: def get_import_path(cls_or_fn: Type[Any]) -> str:
""" """Get the full import path for a class or a function, e.g. `"path.to.MyClass"`"""
Get the full import path for a class or a function, e.g. `"path.to.MyClass"`
"""
module = cls_or_fn.__module__ module = cls_or_fn.__module__
if module == "builtins": if module == "builtins":
return cls_or_fn.__qualname__ # avoid outputs like 'builtins.str' return cls_or_fn.__qualname__ # avoid outputs like 'builtins.str'
@ -79,7 +75,7 @@ def get_module_info(
else: else:
try: try:
module = import_module(module_name) module = import_module(module_name)
except Exception: except Exception: # noqa: BLE001
module = None module = None
else: else:
module = None module = None
@ -96,9 +92,9 @@ def default(val: Optional[T], default: Union[U, Callable[[], U], Type[T]], facto
if val is not None: if val is not None:
return val return val
if factory: if factory:
default_func = cast(Callable[[], U], default) default_func = cast("Callable[[], U]", default)
return default_func() return default_func()
return cast(U, default) return cast("U", default)
def get_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]: def get_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]:
@ -124,7 +120,7 @@ def is_nonempty_str(txt: Optional[str]) -> bool:
# Convert Component class to something like `TableComp_a91d03` # Convert Component class to something like `TableComp_a91d03`
def hash_comp_cls(comp_cls: Type["Component"]) -> str: def hash_comp_cls(comp_cls: Type["Component"]) -> str:
full_name = get_import_path(comp_cls) full_name = get_import_path(comp_cls)
name_hash = md5(full_name.encode()).hexdigest()[0:6] name_hash = md5(full_name.encode()).hexdigest()[0:6] # noqa: S324
return comp_cls.__name__ + "_" + name_hash return comp_cls.__name__ + "_" + name_hash
@ -148,9 +144,9 @@ def to_dict(data: Any) -> dict:
""" """
if isinstance(data, dict): if isinstance(data, dict):
return data return data
elif hasattr(data, "_asdict"): # Case: NamedTuple if hasattr(data, "_asdict"): # Case: NamedTuple
return data._asdict() return data._asdict()
elif is_dataclass(data): # Case: dataclass if is_dataclass(data): # Case: dataclass
return asdict(data) # type: ignore[arg-type] return asdict(data) # type: ignore[arg-type]
return dict(data) return dict(data)
@ -176,7 +172,11 @@ def format_url(url: str, query: Optional[Dict] = None, fragment: Optional[str] =
return parse.urlunsplit(parts._replace(query=encoded_qs, fragment=fragment_enc)) return parse.urlunsplit(parts._replace(query=encoded_qs, fragment=fragment_enc))
def format_as_ascii_table(data: List[Dict[str, Any]], headers: List[str], include_headers: bool = True) -> str: def format_as_ascii_table(
data: List[Dict[str, Any]],
headers: Union[List[str], Tuple[str, ...], Set[str]],
include_headers: bool = True,
) -> str:
""" """
Format a list of dictionaries as an ASCII table. Format a list of dictionaries as an ASCII table.
@ -201,6 +201,7 @@ def format_as_ascii_table(data: List[Dict[str, Any]], headers: List[str], includ
ProjectDashboard project.components.dashboard.ProjectDashboard ./project/components/dashboard ProjectDashboard project.components.dashboard.ProjectDashboard ./project/components/dashboard
ProjectDashboardAction project.components.dashboard_action.ProjectDashboardAction ./project/components/dashboard_action ProjectDashboardAction project.components.dashboard_action.ProjectDashboardAction ./project/components/dashboard_action
``` ```
""" # noqa: E501 """ # noqa: E501
# Calculate the width of each column # Calculate the width of each column
column_widths = {header: len(header) for header in headers} column_widths = {header: len(header) for header in headers}
@ -221,8 +222,5 @@ def format_as_ascii_table(data: List[Dict[str, Any]], headers: List[str], includ
data_rows.append(data_row) data_rows.append(data_row)
# Combine all parts into the final table # Combine all parts into the final table
if include_headers: table = "\n".join([header_row, separator, *data_rows]) if include_headers else "\n".join(data_rows)
table = "\n".join([header_row, separator] + data_rows)
else:
table = "\n".join(data_rows)
return table return table

View file

@ -13,17 +13,16 @@ def generate(alphabet: str, size: int) -> str:
mask = 1 mask = 1
if alphabet_len > 1: if alphabet_len > 1:
mask = (2 << int(log(alphabet_len - 1) / log(2))) - 1 mask = (2 << int(log(alphabet_len - 1) / log(2))) - 1
step = int(ceil(1.6 * mask * size / alphabet_len)) step = int(ceil(1.6 * mask * size / alphabet_len)) # noqa: RUF046
id = "" id_str = ""
while True: while True:
random_bytes = bytearray(urandom(step)) random_bytes = bytearray(urandom(step))
for i in range(step): for i in range(step):
random_byte = random_bytes[i] & mask random_byte = random_bytes[i] & mask
if random_byte < alphabet_len: if random_byte < alphabet_len and alphabet[random_byte]:
if alphabet[random_byte]: id_str += alphabet[random_byte]
id += alphabet[random_byte]
if len(id) == size: if len(id_str) == size:
return id return id_str

View file

@ -15,7 +15,7 @@ def mark_extension_url_api(obj: TClass) -> TClass:
class URLRouteHandler(Protocol): class URLRouteHandler(Protocol):
"""Framework-agnostic 'view' function for routes""" """Framework-agnostic 'view' function for routes"""
def __call__(self, request: Any, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704 def __call__(self, request: Any, *args: Any, **kwargs: Any) -> Any: ...
@mark_extension_url_api @mark_extension_url_api

View file

@ -1,3 +1,4 @@
# ruff: noqa: S105
""" """
Parser for Django template tags. Parser for Django template tags.
@ -180,7 +181,7 @@ class TagValuePart:
# Create a hash based on the attributes that define object equality # Create a hash based on the attributes that define object equality
return hash((self.value, self.quoted, self.spread, self.translation, self.filter)) return hash((self.value, self.quoted, self.spread, self.translation, self.filter))
def __eq__(self, other: Any) -> bool: def __eq__(self, other: object) -> bool:
if not isinstance(other, TagValuePart): if not isinstance(other, TagValuePart):
return False return False
return ( return (
@ -231,16 +232,15 @@ class TagValueStruct:
def render_value(value: Union[TagValue, TagValueStruct]) -> str: def render_value(value: Union[TagValue, TagValueStruct]) -> str:
if isinstance(value, TagValue): if isinstance(value, TagValue):
return value.serialize() return value.serialize()
else:
return value.serialize() return value.serialize()
if self.type == "simple": if self.type == "simple":
value = self.entries[0] value = self.entries[0]
return render_value(value) return render_value(value)
elif self.type == "list": if self.type == "list":
prefix = self.spread or "" prefix = self.spread or ""
return prefix + "[" + ", ".join([render_value(entry) for entry in self.entries]) + "]" return prefix + "[" + ", ".join([render_value(entry) for entry in self.entries]) + "]"
elif self.type == "dict": if self.type == "dict":
prefix = self.spread or "" prefix = self.spread or ""
dict_pairs = [] dict_pairs = []
dict_pair: List[str] = [] dict_pair: List[str] = []
@ -255,8 +255,7 @@ class TagValueStruct:
dict_pairs.append(rendered) dict_pairs.append(rendered)
else: else:
dict_pair.append(rendered) dict_pair.append(rendered)
else: elif entry.is_spread:
if entry.is_spread:
if dict_pair: if dict_pair:
raise TemplateSyntaxError("Malformed dict: spread operator cannot be used as a dict key") raise TemplateSyntaxError("Malformed dict: spread operator cannot be used as a dict key")
dict_pairs.append(rendered) dict_pairs.append(rendered)
@ -267,6 +266,8 @@ class TagValueStruct:
dict_pair = [] dict_pair = []
return prefix + "{" + ", ".join(dict_pairs) + "}" return prefix + "{" + ", ".join(dict_pairs) + "}"
raise ValueError(f"Invalid type: {self.type}")
# When we want to render the TagValueStruct, which may contain nested lists and dicts, # When we want to render the TagValueStruct, which may contain nested lists and dicts,
# we need to find all leaf nodes (the "simple" types) and compile them to FilterExpression. # we need to find all leaf nodes (the "simple" types) and compile them to FilterExpression.
# #
@ -308,7 +309,7 @@ class TagValueStruct:
raise TemplateSyntaxError("Malformed tag: simple value is not a TagValue") raise TemplateSyntaxError("Malformed tag: simple value is not a TagValue")
return value.resolve(context) return value.resolve(context)
elif self.type == "list": if self.type == "list":
resolved_list: List[Any] = [] resolved_list: List[Any] = []
for entry in self.entries: for entry in self.entries:
resolved = entry.resolve(context) resolved = entry.resolve(context)
@ -325,7 +326,7 @@ class TagValueStruct:
resolved_list.append(resolved) resolved_list.append(resolved)
return resolved_list return resolved_list
elif self.type == "dict": if self.type == "dict":
resolved_dict: Dict = {} resolved_dict: Dict = {}
dict_pair: List = [] dict_pair: List = []
@ -336,14 +337,14 @@ class TagValueStruct:
if isinstance(entry, TagValueStruct) and entry.spread: if isinstance(entry, TagValueStruct) and entry.spread:
if dict_pair: if dict_pair:
raise TemplateSyntaxError( raise TemplateSyntaxError(
"Malformed dict: spread operator cannot be used on the position of a dict value" "Malformed dict: spread operator cannot be used on the position of a dict value",
) )
# Case: Spreading a literal dict: { **{"key": val2} } # Case: Spreading a literal dict: { **{"key": val2} }
resolved_dict.update(resolved) resolved_dict.update(resolved)
elif isinstance(entry, TagValue) and entry.is_spread: elif isinstance(entry, TagValue) and entry.is_spread:
if dict_pair: if dict_pair:
raise TemplateSyntaxError( raise TemplateSyntaxError(
"Malformed dict: spread operator cannot be used on the position of a dict value" "Malformed dict: spread operator cannot be used on the position of a dict value",
) )
# Case: Spreading a variable: { **val } # Case: Spreading a variable: { **val }
resolved_dict.update(resolved) resolved_dict.update(resolved)
@ -358,6 +359,8 @@ class TagValueStruct:
dict_pair = [] dict_pair = []
return resolved_dict return resolved_dict
raise ValueError(f"Invalid type: {self.type}")
def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]: def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
""" """
@ -447,7 +450,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
return False return False
def taken_n(n: int) -> str: def taken_n(n: int) -> str:
result = text[index : index + n] # noqa: E203 result = text[index : index + n]
add_token(result) add_token(result)
return result return result
@ -506,7 +509,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
if is_next_token(["..."]): if is_next_token(["..."]):
if curr_struct.type != "simple": if curr_struct.type != "simple":
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Spread syntax '...' found in {curr_struct.type}. It must be used on tag attributes only" f"Spread syntax '...' found in {curr_struct.type}. It must be used on tag attributes only",
) )
spread_token = "..." spread_token = "..."
elif is_next_token(["**"]): elif is_next_token(["**"]):
@ -529,7 +532,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
if curr_struct.type == "simple" and key is not None: if curr_struct.type == "simple" and key is not None:
raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')") raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')")
taken_n(len(cast(str, spread_token))) # ... or * or ** taken_n(len(cast("str", spread_token))) # ... or * or **
# Allow whitespace between spread and the variable, but only for the Python-like syntax # Allow whitespace between spread and the variable, but only for the Python-like syntax
# (lists and dicts). E.g.: # (lists and dicts). E.g.:
# `{% component key=[ * spread ] %}` or `{% component key={ ** spread } %}` # `{% component key=[ * spread ] %}` or `{% component key={ ** spread } %}`
@ -539,8 +542,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
# `{% component key=val ...spread key2=val2 %}` # `{% component key=val ...spread key2=val2 %}`
if spread_token != "...": if spread_token != "...":
take_while(TAG_WHITESPACE) take_while(TAG_WHITESPACE)
else: elif is_next_token(TAG_WHITESPACE) or is_at_end():
if is_next_token(TAG_WHITESPACE) or is_at_end():
raise TemplateSyntaxError("Spread syntax '...' is missing a value") raise TemplateSyntaxError("Spread syntax '...' is missing a value")
return spread_token return spread_token
@ -586,8 +588,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
# Manage state with regards to lists and dictionaries # Manage state with regards to lists and dictionaries
if is_next_token(["[", "...[", "*[", "**["]): if is_next_token(["[", "...[", "*[", "**["]):
spread_token = extract_spread_token(curr_value, None) spread_token = extract_spread_token(curr_value, None)
if spread_token is not None: if spread_token is not None and curr_value.type == "simple" and key is not None:
if curr_value.type == "simple" and key is not None:
raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')") raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')")
# NOTE: The `...`, `**`, `*` are "taken" in `extract_spread_token()` # NOTE: The `...`, `**`, `*` are "taken" in `extract_spread_token()`
taken_n(1) # [ taken_n(1) # [
@ -596,7 +597,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
stack.append(struct) stack.append(struct)
continue continue
elif is_next_token(["]"]): if is_next_token(["]"]):
if curr_value.type != "list": if curr_value.type != "list":
raise TemplateSyntaxError("Unexpected closing bracket") raise TemplateSyntaxError("Unexpected closing bracket")
taken_n(1) # ] taken_n(1) # ]
@ -606,10 +607,9 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
stack.pop() stack.pop()
continue continue
elif is_next_token(["{", "...{", "*{", "**{"]): if is_next_token(["{", "...{", "*{", "**{"]):
spread_token = extract_spread_token(curr_value, None) spread_token = extract_spread_token(curr_value, None)
if spread_token is not None: if spread_token is not None and curr_value.type == "simple" and key is not None:
if curr_value.type == "simple" and key is not None:
raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')") raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')")
# NOTE: The `...`, `**`, `*` are "taken" in `extract_spread_token()` # NOTE: The `...`, `**`, `*` are "taken" in `extract_spread_token()`
taken_n(1) # { taken_n(1) # {
@ -630,7 +630,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
stack.append(struct) stack.append(struct)
continue continue
elif is_next_token(["}"]): if is_next_token(["}"]):
if curr_value.type != "dict": if curr_value.type != "dict":
raise TemplateSyntaxError("Unexpected closing bracket") raise TemplateSyntaxError("Unexpected closing bracket")
@ -643,28 +643,24 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
# Case: `{ "key": **{"key2": val2} }` # Case: `{ "key": **{"key2": val2} }`
if dict_pair: if dict_pair:
raise TemplateSyntaxError( raise TemplateSyntaxError(
"Spread syntax cannot be used in place of a dictionary value" "Spread syntax cannot be used in place of a dictionary value",
) )
# Case: `{ **{"key": val2} }` # Case: `{ **{"key": val2} }`
continue continue
else:
# Case: `{ {"key": val2}: value }` # Case: `{ {"key": val2}: value }`
if not dict_pair: if not dict_pair:
val_type = "Dictionary" if curr_value.type == "dict" else "List" val_type = "Dictionary" if curr_value.type == "dict" else "List"
raise TemplateSyntaxError(f"{val_type} cannot be used as a dictionary key") raise TemplateSyntaxError(f"{val_type} cannot be used as a dictionary key")
# Case: `{ "key": {"key2": val2} }` # Case: `{ "key": {"key2": val2} }`
else:
pass
dict_pair.append(entry) dict_pair.append(entry)
if len(dict_pair) == 2: if len(dict_pair) == 2:
dict_pair = [] dict_pair = []
else:
# Spread is fine when on its own, but cannot be used after a dict key # Spread is fine when on its own, but cannot be used after a dict key
if entry.is_spread: elif entry.is_spread:
# Case: `{ "key": **my_attrs }` # Case: `{ "key": **my_attrs }`
if dict_pair: if dict_pair:
raise TemplateSyntaxError( raise TemplateSyntaxError(
"Spread syntax cannot be used in place of a dictionary value" "Spread syntax cannot be used in place of a dictionary value",
) )
# Case: `{ **my_attrs }` # Case: `{ **my_attrs }`
continue continue
@ -687,7 +683,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
stack.pop() stack.pop()
continue continue
elif is_next_token([","]): if is_next_token([","]):
if curr_value.type not in ("list", "dict"): if curr_value.type not in ("list", "dict"):
raise TemplateSyntaxError("Unexpected comma") raise TemplateSyntaxError("Unexpected comma")
taken_n(1) # , taken_n(1) # ,
@ -698,7 +694,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
# NOTE: Altho `:` is used also in filter syntax, the "value" part # NOTE: Altho `:` is used also in filter syntax, the "value" part
# that the filter is part of is parsed as a whole block. So if we got # that the filter is part of is parsed as a whole block. So if we got
# here, we know we're NOT in filter. # here, we know we're NOT in filter.
elif is_next_token([":"]): if is_next_token([":"]):
if curr_value.type != "dict": if curr_value.type != "dict":
raise TemplateSyntaxError("Unexpected colon") raise TemplateSyntaxError("Unexpected colon")
if not curr_value.meta["expects_key"]: if not curr_value.meta["expects_key"]:
@ -707,12 +703,10 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
curr_value.meta["expects_key"] = False curr_value.meta["expects_key"] = False
continue continue
else:
# Allow only 1 top-level plain value, similar to JSON # Allow only 1 top-level plain value, similar to JSON
if curr_value.type == "simple": if curr_value.type == "simple":
stack.pop() stack.pop()
else: elif is_at_end():
if is_at_end():
raise TemplateSyntaxError("Unexpected end of text") raise TemplateSyntaxError("Unexpected end of text")
# Once we got here, we know that next token is NOT a list nor dict. # Once we got here, we know that next token is NOT a list nor dict.
@ -731,7 +725,6 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
if is_at_end(): if is_at_end():
if is_first_part: if is_first_part:
raise TemplateSyntaxError("Unexpected end of text") raise TemplateSyntaxError("Unexpected end of text")
else:
end_of_value = True end_of_value = True
continue continue
@ -744,7 +737,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
continue continue
# Catch cases like `|filter` or `:arg`, which should be `var|filter` or `filter:arg` # Catch cases like `|filter` or `:arg`, which should be `var|filter` or `filter:arg`
elif is_first_part and is_next_token(TAG_FILTER): if is_first_part and is_next_token(TAG_FILTER):
raise TemplateSyntaxError("Filter is missing a value") raise TemplateSyntaxError("Filter is missing a value")
# Get past the filter tokens like `|` or `:`, until the next value part. # Get past the filter tokens like `|` or `:`, until the next value part.
@ -800,7 +793,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
elif curr_value.type == "list": elif curr_value.type == "list":
terminal_tokens = (",", "]") terminal_tokens = (",", "]")
else: else:
terminal_tokens = tuple() terminal_tokens = ()
# Parse the value # Parse the value
# #
@ -860,7 +853,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
spread=spread_token, spread=spread_token,
translation=is_translation, translation=is_translation,
filter=filter_token, filter=filter_token,
) ),
) )
# Here we're done with the value (+ a sequence of filters) # Here we're done with the value (+ a sequence of filters)
@ -878,16 +871,15 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
# Validation for `{"key": **spread }` # Validation for `{"key": **spread }`
if not curr_value.meta["expects_key"]: if not curr_value.meta["expects_key"]:
raise TemplateSyntaxError( raise TemplateSyntaxError(
"Got spread syntax on the position of a value inside a dictionary key-value pair" "Got spread syntax on the position of a value inside a dictionary key-value pair",
) )
# Validation for `{**spread: value }` # Validation for `{**spread: value }`
take_while(TAG_WHITESPACE) take_while(TAG_WHITESPACE)
if is_next_token([":"]): if is_next_token([":"]):
raise TemplateSyntaxError("Spread syntax cannot be used in place of a dictionary key") raise TemplateSyntaxError("Spread syntax cannot be used in place of a dictionary key")
else:
# Validation for `{"key", value }` # Validation for `{"key", value }`
if curr_value.meta["expects_key"]: elif curr_value.meta["expects_key"]:
take_while(TAG_WHITESPACE) take_while(TAG_WHITESPACE)
if not is_next_token([":"]): if not is_next_token([":"]):
raise TemplateSyntaxError("Dictionary key is missing a value") raise TemplateSyntaxError("Dictionary key is missing a value")
@ -916,7 +908,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
key=key, key=key,
start_index=start_index, start_index=start_index,
value=total_value, value=total_value,
) ),
) )
return normalized, attrs return normalized, attrs

View file

@ -1,4 +1,4 @@
""" r"""
Parser for Django template. Parser for Django template.
The parser reads a template file (usually HTML, but not necessarily), which may contain The parser reads a template file (usually HTML, but not necessarily), which may contain
@ -83,7 +83,6 @@ def parse_template(text: str) -> List[Token]:
if token.token_type == TokenType.BLOCK and ("'" in token.contents or '"' in token.contents): if token.token_type == TokenType.BLOCK and ("'" in token.contents or '"' in token.contents):
broken_token = token broken_token = token
break break
else:
resolved_tokens.append(token) resolved_tokens.append(token)
# If we found a broken token, we switch to our slow parser # If we found a broken token, we switch to our slow parser
@ -110,8 +109,8 @@ def _detailed_tag_parser(text: str, lineno: int, start_index: int) -> Token:
result_content: List[str] = [] result_content: List[str] = []
# Pre-compute common substrings # Pre-compute common substrings
QUOTE_CHARS = ("'", '"') QUOTE_CHARS = ("'", '"') # noqa: N806
QUOTE_OR_PERCENT = (*QUOTE_CHARS, "%") QUOTE_OR_PERCENT = (*QUOTE_CHARS, "%") # noqa: N806
def take_char() -> str: def take_char() -> str:
nonlocal index nonlocal index
@ -192,7 +191,6 @@ def _detailed_tag_parser(text: str, lineno: int, start_index: int) -> Token:
take_char() # % take_char() # %
take_char() # } take_char() # }
break break
else:
# False alarm, just a string # False alarm, just a string
content = take_until_any(QUOTE_CHARS) content = take_until_any(QUOTE_CHARS)
result_content.append(content) result_content.append(content)

View file

@ -41,7 +41,8 @@ def validate_params(
args, kwargs = _validate_params_with_signature(validation_signature, params, extra_kwargs) args, kwargs = _validate_params_with_signature(validation_signature, params, extra_kwargs)
return args, kwargs return args, kwargs
except TypeError as e: except TypeError as e:
raise TypeError(f"Invalid parameters for tag '{tag}': {str(e)}") from None err_msg = str(e)
raise TypeError(f"Invalid parameters for tag '{tag}': {err_msg}") from None
@dataclass @dataclass
@ -88,7 +89,7 @@ def resolve_params(
resolved_params.append(TagParam(key=None, value=value)) resolved_params.append(TagParam(key=None, value=value))
else: else:
raise ValueError( raise ValueError(
f"Cannot spread non-iterable value: '{param.value.serialize()}' resolved to {resolved}" f"Cannot spread non-iterable value: '{param.value.serialize()}' resolved to {resolved}",
) )
else: else:
resolved_params.append(TagParam(key=param.key, value=resolved)) resolved_params.append(TagParam(key=param.key, value=resolved))
@ -110,7 +111,7 @@ class ParsedTag(NamedTuple):
def parse_template_tag( def parse_template_tag(
tag: str, tag: str,
end_tag: Optional[str], end_tag: Optional[str],
allowed_flags: Optional[List[str]], allowed_flags: Optional[Iterable[str]],
parser: Parser, parser: Parser,
token: Token, token: Token,
) -> ParsedTag: ) -> ParsedTag:
@ -138,7 +139,8 @@ def parse_template_tag(
else: else:
is_inline = not end_tag is_inline = not end_tag
raw_params, flags = _extract_flags(tag_name, attrs, allowed_flags or []) allowed_flags_set = set(allowed_flags) if allowed_flags else set()
raw_params, flags = _extract_flags(tag_name, attrs, allowed_flags_set)
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> Tuple[NodeList, Optional[str]]: def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> Tuple[NodeList, Optional[str]]:
if inline: if inline:
@ -188,7 +190,6 @@ def _extract_contents_until(parser: Parser, until_blocks: List[str]) -> str:
contents.append("{% " + token.contents + " %}") contents.append("{% " + token.contents + " %}")
if command in until_blocks: if command in until_blocks:
return "".join(contents) return "".join(contents)
else:
contents.append("{% " + token.contents + " %}") contents.append("{% " + token.contents + " %}")
elif token_type == 3: # TokenType.COMMENT elif token_type == 3: # TokenType.COMMENT
contents.append("{# " + token.contents + " #}") contents.append("{# " + token.contents + " #}")
@ -205,7 +206,9 @@ def _extract_contents_until(parser: Parser, until_blocks: List[str]) -> str:
def _extract_flags( def _extract_flags(
tag_name: str, attrs: List[TagAttr], allowed_flags: List[str] tag_name: str,
attrs: List[TagAttr],
allowed_flags: Set[str],
) -> Tuple[List[TagAttr], Dict[str, bool]]: ) -> Tuple[List[TagAttr], Dict[str, bool]]:
found_flags = set() found_flags = set()
remaining_attrs = [] remaining_attrs = []
@ -386,12 +389,11 @@ def _validate_params_with_signature(
if signature_param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD): if signature_param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
if signature_param.default == inspect.Parameter.empty: if signature_param.default == inspect.Parameter.empty:
raise TypeError(f"missing a required argument: '{param_name}'") raise TypeError(f"missing a required argument: '{param_name}'")
elif len(validated_args) <= next_positional_index: if len(validated_args) <= next_positional_index:
validated_kwargs[param_name] = signature_param.default validated_kwargs[param_name] = signature_param.default
elif signature_param.kind == inspect.Parameter.KEYWORD_ONLY: elif signature_param.kind == inspect.Parameter.KEYWORD_ONLY:
if signature_param.default == inspect.Parameter.empty: if signature_param.default == inspect.Parameter.empty:
raise TypeError(f"missing a required argument: '{param_name}'") raise TypeError(f"missing a required argument: '{param_name}'")
else:
validated_kwargs[param_name] = signature_param.default validated_kwargs[param_name] = signature_param.default
# Return args and kwargs # Return args and kwargs
@ -492,13 +494,12 @@ def _validate_params_with_code(
if i < positional_count: # Positional parameter if i < positional_count: # Positional parameter
if i < required_positional: if i < required_positional:
raise TypeError(f"missing a required argument: '{param_name}'") raise TypeError(f"missing a required argument: '{param_name}'")
elif len(validated_args) <= i: if len(validated_args) <= i:
default_index = i - required_positional default_index = i - required_positional
validated_kwargs[param_name] = defaults[default_index] validated_kwargs[param_name] = defaults[default_index]
elif i < positional_count + kwonly_count: # Keyword-only parameter elif i < positional_count + kwonly_count: # Keyword-only parameter
if param_name not in kwdefaults: if param_name not in kwdefaults:
raise TypeError(f"missing a required argument: '{param_name}'") raise TypeError(f"missing a required argument: '{param_name}'")
else:
validated_kwargs[param_name] = kwdefaults[param_name] validated_kwargs[param_name] = kwdefaults[param_name]
return tuple(validated_args), validated_kwargs return tuple(validated_args), validated_kwargs

View file

@ -2,7 +2,7 @@ import gc
import inspect import inspect
import sys import sys
from functools import wraps from functools import wraps
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple, Type, Union from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple, Type, Union
from unittest.mock import patch from unittest.mock import patch
from weakref import ReferenceType from weakref import ReferenceType
@ -14,12 +14,14 @@ from django.template.loaders.base import Loader
from django.test import override_settings from django.test import override_settings
from django_components.component import ALL_COMPONENTS, Component, component_node_subclasses_by_name from django_components.component import ALL_COMPONENTS, Component, component_node_subclasses_by_name
from django_components.component_media import ComponentMedia
from django_components.component_registry import ALL_REGISTRIES, ComponentRegistry from django_components.component_registry import ALL_REGISTRIES, ComponentRegistry
from django_components.extension import extensions from django_components.extension import extensions
from django_components.perfutil.provide import provide_cache from django_components.perfutil.provide import provide_cache
from django_components.template import _reset_component_template_file_cache, loading_components from django_components.template import _reset_component_template_file_cache, loading_components
if TYPE_CHECKING:
from django_components.component_media import ComponentMedia
# NOTE: `ReferenceType` is NOT a generic pre-3.9 # NOTE: `ReferenceType` is NOT a generic pre-3.9
if sys.version_info >= (3, 9): if sys.version_info >= (3, 9):
RegistryRef = ReferenceType[ComponentRegistry] RegistryRef = ReferenceType[ComponentRegistry]
@ -50,7 +52,7 @@ class GenIdPatcher:
# Random number so that the generated IDs are "hex-looking", e.g. a1bc3d # Random number so that the generated IDs are "hex-looking", e.g. a1bc3d
self._gen_id_count = 10599485 self._gen_id_count = 10599485
def mock_gen_id(*args: Any, **kwargs: Any) -> str: def mock_gen_id(*_args: Any, **_kwargs: Any) -> str:
self._gen_id_count += 1 self._gen_id_count += 1
return hex(self._gen_id_count)[2:] return hex(self._gen_id_count)[2:]
@ -67,7 +69,7 @@ class GenIdPatcher:
class CsrfTokenPatcher: class CsrfTokenPatcher:
def __init__(self) -> None: def __init__(self) -> None:
self._csrf_token = "predictabletoken" self._csrf_token = "predictabletoken" # noqa: S105
self._csrf_token_patch: Any = None self._csrf_token_patch: Any = None
def start(self) -> None: def start(self) -> None:
@ -201,7 +203,6 @@ def djc_test(
- `(param_names, param_values, ids)` - `(param_names, param_values, ids)`
Example: Example:
```py ```py
from django_components.testing import djc_test from django_components.testing import djc_test
@ -267,6 +268,7 @@ def djc_test(
3. The parametrized `components_settings` override the fields on the `components_settings` kwarg. 3. The parametrized `components_settings` override the fields on the `components_settings` kwarg.
Priority: `components_settings` (parametrized) > `components_settings` > `django_settings["COMPONENTS"]` > `django.conf.settings.COMPONENTS` Priority: `components_settings` (parametrized) > `components_settings` > `django_settings["COMPONENTS"]` > `django.conf.settings.COMPONENTS`
""" # noqa: E501 """ # noqa: E501
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@ -327,13 +329,13 @@ def djc_test(
# Make a copy of `ALL_COMPONENTS` and `ALL_REGISTRIES` as they were before the test. # Make a copy of `ALL_COMPONENTS` and `ALL_REGISTRIES` as they were before the test.
# Since the tests require Django to be configured, this should contain any # Since the tests require Django to be configured, this should contain any
# components that were registered with autodiscovery / at `AppConfig.ready()`. # components that were registered with autodiscovery / at `AppConfig.ready()`.
_ALL_COMPONENTS = ALL_COMPONENTS.copy() _all_components = ALL_COMPONENTS.copy()
_ALL_REGISTRIES_COPIES: RegistriesCopies = [] _all_registries_copies: RegistriesCopies = []
for reg_ref in ALL_REGISTRIES: for reg_ref in ALL_REGISTRIES:
reg = reg_ref() reg = reg_ref()
if not reg: if not reg:
continue continue
_ALL_REGISTRIES_COPIES.append((reg_ref, list(reg._registry.keys()))) _all_registries_copies.append((reg_ref, list(reg._registry.keys())))
# Prepare global state # Prepare global state
_setup_djc_global_state(gen_id_patcher, csrf_token_patcher) _setup_djc_global_state(gen_id_patcher, csrf_token_patcher)
@ -342,8 +344,8 @@ def djc_test(
_clear_djc_global_state( _clear_djc_global_state(
gen_id_patcher, gen_id_patcher,
csrf_token_patcher, csrf_token_patcher,
_ALL_COMPONENTS, # type: ignore[arg-type] _all_components, # type: ignore[arg-type]
_ALL_REGISTRIES_COPIES, _all_registries_copies,
gc_collect, gc_collect,
) )
@ -388,7 +390,7 @@ def djc_test(
# NOTE: Lazily import pytest, so user can still run tests with plain `unittest` # NOTE: Lazily import pytest, so user can still run tests with plain `unittest`
# if they choose not to use parametrization. # if they choose not to use parametrization.
import pytest import pytest # noqa: PLC0415
wrapper = pytest.mark.parametrize(param_names, values, ids=ids)(wrapper) wrapper = pytest.mark.parametrize(param_names, values, ids=ids)(wrapper)
@ -428,14 +430,14 @@ def _setup_djc_global_state(
# Declare that the code is running in test mode - this is used # Declare that the code is running in test mode - this is used
# by the import / autodiscover mechanism to clean up loaded modules # by the import / autodiscover mechanism to clean up loaded modules
# between tests. # between tests.
global IS_TESTING global IS_TESTING # noqa: PLW0603
IS_TESTING = True IS_TESTING = True
gen_id_patcher.start() gen_id_patcher.start()
csrf_token_patcher.start() csrf_token_patcher.start()
# Re-load the settings, so that the test-specific settings overrides are applied # Re-load the settings, so that the test-specific settings overrides are applied
from django_components.app_settings import app_settings from django_components.app_settings import app_settings # noqa: PLC0415
app_settings._load_settings() app_settings._load_settings()
extensions._initialized = False extensions._initialized = False
@ -465,7 +467,7 @@ def _clear_djc_global_state(
loader.reset() loader.reset()
# NOTE: There are 1-2 tests which check Templates, so we need to clear the cache # NOTE: There are 1-2 tests which check Templates, so we need to clear the cache
from django_components.cache import component_media_cache, template_cache from django_components.cache import component_media_cache, template_cache # noqa: PLC0415
if template_cache: if template_cache:
template_cache.clear() template_cache.clear()
@ -505,7 +507,7 @@ def _clear_djc_global_state(
del ALL_COMPONENTS[reverse_index] del ALL_COMPONENTS[reverse_index]
# Remove registries that were created during the test # Remove registries that were created during the test
initial_registries_set: Set[RegistryRef] = set([reg_ref for reg_ref, init_keys in initial_registries_copies]) initial_registries_set: Set[RegistryRef] = {reg_ref for reg_ref, init_keys in initial_registries_copies}
for index in range(len(ALL_REGISTRIES)): for index in range(len(ALL_REGISTRIES)):
registry_ref = ALL_REGISTRIES[len(ALL_REGISTRIES) - index - 1] registry_ref = ALL_REGISTRIES[len(ALL_REGISTRIES) - index - 1]
is_ref_deleted = registry_ref() is None is_ref_deleted = registry_ref() is None
@ -532,7 +534,7 @@ def _clear_djc_global_state(
# Delete autoimported modules from memory, so the module # Delete autoimported modules from memory, so the module
# is executed also the next time one of the tests calls `autodiscover`. # is executed also the next time one of the tests calls `autodiscover`.
from django_components.autodiscovery import LOADED_MODULES from django_components.autodiscovery import LOADED_MODULES # noqa: PLC0415
for mod in LOADED_MODULES: for mod in LOADED_MODULES:
sys.modules.pop(mod, None) sys.modules.pop(mod, None)
@ -556,5 +558,5 @@ def _clear_djc_global_state(
if gc_collect: if gc_collect:
gc.collect() gc.collect()
global IS_TESTING global IS_TESTING # noqa: PLW0603
IS_TESTING = False IS_TESTING = False

View file

@ -26,5 +26,3 @@ class Empty(NamedTuple):
Read more about [Typing and validation](../../concepts/fundamentals/typing_and_validation). Read more about [Typing and validation](../../concepts/fundamentals/typing_and_validation).
""" """
pass

View file

@ -11,7 +11,7 @@ T = TypeVar("T")
if sys.version_info >= (3, 9): if sys.version_info >= (3, 9):
@overload # type: ignore[misc] @overload # type: ignore[misc]
def cached_ref(obj: T) -> ReferenceType[T]: ... # noqa: E704 def cached_ref(obj: T) -> ReferenceType[T]: ...
def cached_ref(obj: Any) -> ReferenceType: def cached_ref(obj: Any) -> ReferenceType:

View file

@ -17,10 +17,10 @@ def compile_js_files_to_file(
file_paths: Sequence[Union[Path, str]], file_paths: Sequence[Union[Path, str]],
out_file: Union[Path, str], out_file: Union[Path, str],
esbuild_args: Optional[List[str]] = None, esbuild_args: Optional[List[str]] = None,
): ) -> None:
# Find Esbuild binary # Find Esbuild binary
bin_name = "esbuild.cmd" if os.name == "nt" else "esbuild" bin_name = "esbuild.cmd" if os.name == "nt" else "esbuild"
esbuild_path = Path(os.getcwd()) / "node_modules" / ".bin" / bin_name esbuild_path = Path.cwd() / "node_modules" / ".bin" / bin_name
# E.g. `esbuild js_file1.ts js_file2.ts js_file3.ts --bundle --minify --outfile=here.js` # E.g. `esbuild js_file1.ts js_file2.ts js_file3.ts --bundle --minify --outfile=here.js`
esbuild_cmd = [ esbuild_cmd = [
@ -39,12 +39,12 @@ def compile_js_files_to_file(
# - This script should be called from within django_components_js` dir! # - This script should be called from within django_components_js` dir!
# - Also you need to have esbuild installed. If not yet, run: # - Also you need to have esbuild installed. If not yet, run:
# `npm install -D esbuild` # `npm install -D esbuild`
def build(): def build() -> None:
entrypoint = "./src/index.ts" entrypoint = "./src/index.ts"
out_file = Path("../django_components/static/django_components/django_components.min.js") out_file = Path("../django_components/static/django_components/django_components.min.js")
# Prepare output dir # Prepare output dir
os.makedirs(out_file.parent, exist_ok=True) out_file.parent.mkdir(parents=True, exist_ok=True)
# Compile JS # Compile JS
compile_js_files_to_file(file_paths=[entrypoint], out_file=out_file) compile_js_files_to_file(file_paths=[entrypoint], out_file=out_file)

View file

@ -1,4 +1,3 @@
from django_components import Component from django_components import Component

View file

@ -20,7 +20,6 @@ class PathObj:
if self.static_path.endswith(".js"): if self.static_path.endswith(".js"):
return format_html('<script type="module" src="{}"></script>', static(self.static_path)) return format_html('<script type="module" src="{}"></script>', static(self.static_path))
else:
return format_html('<link href="{}" rel="stylesheet">', static(self.static_path)) return format_html('<link href="{}" rel="stylesheet">', static(self.static_path))

View file

@ -20,7 +20,7 @@ from testserver.views import (
urlpatterns = [ urlpatterns = [
path("", include("django_components.urls")), path("", include("django_components.urls")),
# Empty response with status 200 to notify other systems when the server has started # Empty response with status 200 to notify other systems when the server has started
path("poll/", lambda *args, **kwargs: HttpResponse("")), path("poll/", lambda *_args, **_kwargs: HttpResponse("")),
# Test views # Test views
path("single/", single_component_view, name="single"), path("single/", single_component_view, name="single"),
path("multi/", multiple_components_view, name="multi"), path("multi/", multiple_components_view, name="multi"),

View file

@ -1,11 +1,14 @@
from typing import TYPE_CHECKING
from django.http import HttpResponse from django.http import HttpResponse
from django.template import Context, Template from django.template import Context, Template
from testserver.components import FragComp, FragMedia from testserver.components import FragComp, FragMedia
if TYPE_CHECKING:
from django_components import types from django_components import types
def single_component_view(request): def single_component_view(_request):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
<!DOCTYPE html> <!DOCTYPE html>
@ -27,7 +30,7 @@ def single_component_view(request):
return HttpResponse(rendered) return HttpResponse(rendered)
def multiple_components_view(request): def multiple_components_view(_request):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
<!DOCTYPE html> <!DOCTYPE html>
@ -50,7 +53,7 @@ def multiple_components_view(request):
return HttpResponse(rendered) return HttpResponse(rendered)
def check_js_order_in_js_view(request): def check_js_order_in_js_view(_request):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
<!DOCTYPE html> <!DOCTYPE html>
@ -74,7 +77,7 @@ def check_js_order_in_js_view(request):
return HttpResponse(rendered) return HttpResponse(rendered)
def check_js_order_in_media_view(request): def check_js_order_in_media_view(_request):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
<!DOCTYPE html> <!DOCTYPE html>
@ -98,7 +101,7 @@ def check_js_order_in_media_view(request):
return HttpResponse(rendered) return HttpResponse(rendered)
def check_js_order_vars_not_available_before_view(request): def check_js_order_vars_not_available_before_view(_request):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
<!DOCTYPE html> <!DOCTYPE html>
@ -170,8 +173,8 @@ def fragment_base_js_view(request):
Context( Context(
{ {
"frag": frag, "frag": frag,
} },
) ),
) )
return HttpResponse(rendered) return HttpResponse(rendered)
@ -283,13 +286,12 @@ def fragment_view(request):
fragment_type = request.GET["frag"] fragment_type = request.GET["frag"]
if fragment_type == "comp": if fragment_type == "comp":
return FragComp.render_to_response(deps_strategy="fragment") return FragComp.render_to_response(deps_strategy="fragment")
elif fragment_type == "media": if fragment_type == "media":
return FragMedia.render_to_response(deps_strategy="fragment") return FragMedia.render_to_response(deps_strategy="fragment")
else:
raise ValueError("Invalid fragment type") raise ValueError("Invalid fragment type")
def alpine_in_head_view(request): def alpine_in_head_view(_request):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
<!DOCTYPE html> <!DOCTYPE html>
@ -309,7 +311,7 @@ def alpine_in_head_view(request):
return HttpResponse(rendered) return HttpResponse(rendered)
def alpine_in_body_view(request): def alpine_in_body_view(_request):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
<!DOCTYPE html> <!DOCTYPE html>
@ -330,7 +332,7 @@ def alpine_in_body_view(request):
# Same as before, but Alpine component defined in Component.js # Same as before, but Alpine component defined in Component.js
def alpine_in_body_view_2(request): def alpine_in_body_view_2(_request):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
<!DOCTYPE html> <!DOCTYPE html>
@ -350,7 +352,7 @@ def alpine_in_body_view_2(request):
return HttpResponse(rendered) return HttpResponse(rendered)
def alpine_in_body_vars_not_available_before_view(request): def alpine_in_body_vars_not_available_before_view(_request):
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %} {% load component_tags %}
<!DOCTYPE html> <!DOCTYPE html>

View file

@ -1,3 +1,5 @@
# ruff: noqa: T201
import functools import functools
import subprocess import subprocess
import sys import sys
@ -54,7 +56,7 @@ def run_django_dev_server():
start_time = time.time() start_time = time.time()
while time.time() - start_time < 30: # timeout after 30 seconds while time.time() - start_time < 30: # timeout after 30 seconds
try: try:
response = requests.get(f"http://127.0.0.1:{TEST_SERVER_PORT}/poll") response = requests.get(f"http://127.0.0.1:{TEST_SERVER_PORT}/poll") # noqa: S113
if response.status_code == 200: if response.status_code == 200:
print("Django dev server is up and running.") print("Django dev server is up and running.")
break break

View file

@ -23,7 +23,9 @@ class TestFormatAttributes:
assert format_attributes({"class": "foo", "style": "color: red;"}) == 'class="foo" style="color: red;"' assert format_attributes({"class": "foo", "style": "color: red;"}) == 'class="foo" style="color: red;"'
def test_escapes_special_characters(self): def test_escapes_special_characters(self):
assert format_attributes({"x-on:click": "bar", "@click": "'baz'"}) == 'x-on:click="bar" @click="&#x27;baz&#x27;"' # noqa: E501 assert (
format_attributes({"x-on:click": "bar", "@click": "'baz'"}) == 'x-on:click="bar" @click="&#x27;baz&#x27;"'
)
def test_does_not_escape_special_characters_if_safe_string(self): def test_does_not_escape_special_characters_if_safe_string(self):
assert format_attributes({"foo": mark_safe("'bar'")}) == "foo=\"'bar'\"" assert format_attributes({"foo": mark_safe("'bar'")}) == "foo=\"'bar'\""
@ -51,7 +53,7 @@ class TestMergeAttributes:
assert merge_attributes({"class": "foo", "id": "bar"}, {"class": "baz"}) == { assert merge_attributes({"class": "foo", "id": "bar"}, {"class": "baz"}) == {
"class": "foo baz", "class": "foo baz",
"id": "bar", "id": "bar",
} # noqa: E501 }
def test_merge_with_empty_dict(self): def test_merge_with_empty_dict(self):
assert merge_attributes({}, {"foo": "bar"}) == {"foo": "bar"} assert merge_attributes({}, {"foo": "bar"}) == {"foo": "bar"}
@ -70,7 +72,7 @@ class TestMergeAttributes:
"tuna3", "tuna3",
{"baz": True, "baz2": False, "tuna": False, "tuna2": True, "tuna3": None}, {"baz": True, "baz2": False, "tuna": False, "tuna2": True, "tuna3": None},
["extra", {"extra2": False, "baz2": True, "tuna": True, "tuna2": False}], ["extra", {"extra2": False, "baz2": True, "tuna": True, "tuna2": False}],
] ],
}, },
) == {"class": "foo bar tuna baz baz2 extra"} ) == {"class": "foo bar tuna baz baz2 extra"}
@ -82,7 +84,7 @@ class TestMergeAttributes:
"background-color: blue;", "background-color: blue;",
{"background-color": "green", "color": None, "width": False}, {"background-color": "green", "color": None, "width": False},
["position: absolute", {"height": "12px"}], ["position: absolute", {"height": "12px"}],
] ],
}, },
) == {"style": "color: red; height: 12px; background-color: green; position: absolute;"} ) == {"style": "color: red; height: 12px; background-color: green; position: absolute;"}
@ -142,7 +144,7 @@ class TestHtmlAttrs:
<div {% html_attrs attrs defaults class="added_class" class="another-class" data-id=123 %}> <div {% html_attrs attrs defaults class="added_class" class="another-class" data-id=123 %}>
content content
</div> </div>
""" # noqa: E501 """
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return { return {
@ -170,7 +172,7 @@ class TestHtmlAttrs:
<div {% html_attrs attrs defaults class %}> <div {% html_attrs attrs defaults class %}>
content content
</div> </div>
""" # noqa: E501 """
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return { return {
@ -183,7 +185,9 @@ class TestHtmlAttrs:
with pytest.raises( with pytest.raises(
TypeError, TypeError,
match=re.escape("Invalid parameters for tag 'html_attrs': takes 2 positional argument(s) but more were given"), # noqa: E501 match=re.escape(
"Invalid parameters for tag 'html_attrs': takes 2 positional argument(s) but more were given",
),
): ):
template.render(Context({"class_var": "padding-top-8"})) template.render(Context({"class_var": "padding-top-8"}))
@ -251,7 +255,7 @@ class TestHtmlAttrs:
<div {% html_attrs ...props class="another-class" %}> <div {% html_attrs ...props class="another-class" %}>
content content
</div> </div>
""" # noqa: E501 """
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return { return {
@ -298,7 +302,7 @@ class TestHtmlAttrs:
<div class="added_class another-class from_agg_key" data-djc-id-ca1bc3f data-id="123" type="submit"> <div class="added_class another-class from_agg_key" data-djc-id-ca1bc3f data-id="123" type="submit">
content content
</div> </div>
""", # noqa: E501 """,
) )
assert "override-me" not in rendered assert "override-me" not in rendered
@ -344,7 +348,7 @@ class TestHtmlAttrs:
%}> %}>
content content
</div> </div>
""" # noqa: E501 """
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return {"attrs": kwargs["attrs"]} return {"attrs": kwargs["attrs"]}
@ -389,7 +393,7 @@ class TestHtmlAttrs:
<div {% html_attrs attrs class="added_class" class="another-class" data-id=123 %}> <div {% html_attrs attrs class="added_class" class="another-class" data-id=123 %}>
content content
</div> </div>
""" # noqa: E501 """
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return {"attrs": kwargs["attrs"]} return {"attrs": kwargs["attrs"]}
@ -419,7 +423,7 @@ class TestHtmlAttrs:
<div {% html_attrs class="added_class" class="another-class" data-id=123 %}> <div {% html_attrs class="added_class" class="another-class" data-id=123 %}>
content content
</div> </div>
""" # noqa: E501 """
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return {"attrs": kwargs["attrs"]} return {"attrs": kwargs["attrs"]}

View file

@ -46,8 +46,8 @@ class TestAutodiscover:
class TestImportLibraries: class TestImportLibraries:
@djc_test( @djc_test(
components_settings={ components_settings={
"libraries": ["tests.components.single_file", "tests.components.multi_file.multi_file"] "libraries": ["tests.components.single_file", "tests.components.multi_file.multi_file"],
} },
) )
def test_import_libraries(self): def test_import_libraries(self):
all_components = registry.all().copy() all_components = registry.all().copy()
@ -74,8 +74,8 @@ class TestImportLibraries:
@djc_test( @djc_test(
components_settings={ components_settings={
"libraries": ["components.single_file", "components.multi_file.multi_file"] "libraries": ["components.single_file", "components.multi_file.multi_file"],
} },
) )
def test_import_libraries_map_modules(self): def test_import_libraries_map_modules(self):
all_components = registry.all().copy() all_components = registry.all().copy()

View file

@ -5,8 +5,8 @@
import difflib import difflib
import json import json
from dataclasses import MISSING, dataclass, field
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from dataclasses import dataclass, field, MISSING
from enum import Enum from enum import Enum
from inspect import signature from inspect import signature
from pathlib import Path from pathlib import Path
@ -30,16 +30,16 @@ from typing import (
import django import django
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.http import HttpRequest from django.http import HttpRequest
from django.middleware import csrf from django.middleware import csrf
from django.template import Context, Template from django.template import Context, Template
from django.template.defaultfilters import title from django.template.defaultfilters import title
from django.template.defaulttags import register as default_library
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.timezone import now from django.utils.timezone import now
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.template.defaulttags import register as default_library
from django_components import types, registry from django_components import registry, types
# DO NOT REMOVE - See https://github.com/django-components/django-components/pull/999 # DO NOT REMOVE - See https://github.com/django-components/django-components/pull/999
# ----------- IMPORTS END ------------ # # ----------- IMPORTS END ------------ #
@ -61,9 +61,9 @@ if not settings.configured:
"OPTIONS": { "OPTIONS": {
"builtins": [ "builtins": [
"django_components.templatetags.component_tags", "django_components.templatetags.component_tags",
] ],
},
}, },
}
], ],
COMPONENTS={ COMPONENTS={
"autodiscover": False, "autodiscover": False,
@ -74,9 +74,9 @@ if not settings.configured:
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:", "NAME": ":memory:",
}
}, },
SECRET_KEY="secret", },
SECRET_KEY="secret", # noqa: S106
ROOT_URLCONF="django_components.urls", ROOT_URLCONF="django_components.urls",
) )
django.setup() django.setup()
@ -91,19 +91,21 @@ else:
templates_cache: Dict[int, Template] = {} templates_cache: Dict[int, Template] = {}
def lazy_load_template(template: str) -> Template: def lazy_load_template(template: str) -> Template:
template_hash = hash(template) template_hash = hash(template)
if template_hash in templates_cache: if template_hash in templates_cache:
return templates_cache[template_hash] return templates_cache[template_hash]
else:
template_instance = Template(template) template_instance = Template(template)
templates_cache[template_hash] = template_instance templates_cache[template_hash] = template_instance
return template_instance return template_instance
##################################### #####################################
# RENDER ENTRYPOINT # RENDER ENTRYPOINT
##################################### #####################################
def gen_render_data(): def gen_render_data():
data = load_project_data_from_json(data_json) data = load_project_data_from_json(data_json)
@ -118,7 +120,7 @@ def gen_render_data():
"text": "Test bookmark", "text": "Test bookmark",
"url": "http://localhost:8000/bookmarks/9/create", "url": "http://localhost:8000/bookmarks/9/create",
"attachment": None, "attachment": None,
} },
] ]
request = HttpRequest() request = HttpRequest()
@ -140,7 +142,7 @@ def render(data):
# Render # Render
result = project_page( result = project_page(
Context(), Context(),
ProjectPageData(**data) ProjectPageData(**data),
) )
return result return result
@ -669,7 +671,7 @@ data_json = """
def load_project_data_from_json(contents: str) -> dict: def load_project_data_from_json(contents: str) -> dict:
""" """
Loads project data from JSON and resolves references between objects. Load project data from JSON and resolves references between objects.
Returns the data with all resolvable references replaced with actual object references. Returns the data with all resolvable references replaced with actual object references.
""" """
data = json.loads(contents) data = json.loads(contents)
@ -1003,7 +1005,7 @@ TAG_TYPE_META = MappingProxyType(
), ),
), ),
TagResourceType.PROJECT_OUTPUT: TagTypeMeta( TagResourceType.PROJECT_OUTPUT: TagTypeMeta(
allowed_values=tuple(), allowed_values=(),
), ),
TagResourceType.PROJECT_OUTPUT_ATTACHMENT: TagTypeMeta( TagResourceType.PROJECT_OUTPUT_ATTACHMENT: TagTypeMeta(
allowed_values=( allowed_values=(
@ -1024,7 +1026,7 @@ TAG_TYPE_META = MappingProxyType(
TagResourceType.PROJECT_TEMPLATE: TagTypeMeta( TagResourceType.PROJECT_TEMPLATE: TagTypeMeta(
allowed_values=("Tag 21",), allowed_values=("Tag 21",),
), ),
} },
) )
@ -1091,7 +1093,7 @@ PROJECT_PHASES_META = MappingProxyType(
ProjectOutputDef(title="Lorem ipsum 14"), ProjectOutputDef(title="Lorem ipsum 14"),
], ],
), ),
} },
) )
##################################### #####################################
@ -1156,7 +1158,7 @@ _secondary_btn_styling = "ring-1 ring-inset"
theme = Theme( theme = Theme(
default=ThemeStylingVariant( default=ThemeStylingVariant(
primary=ThemeStylingUnit( primary=ThemeStylingUnit(
color="bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition" color="bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition",
), ),
primary_disabled=ThemeStylingUnit(color="bg-blue-300 text-blue-50 focus-visible:outline-blue-600 transition"), primary_disabled=ThemeStylingUnit(color="bg-blue-300 text-blue-50 focus-visible:outline-blue-600 transition"),
secondary=ThemeStylingUnit( secondary=ThemeStylingUnit(
@ -1277,7 +1279,6 @@ def format_timestamp(timestamp: datetime):
""" """
if now() - timestamp > timedelta(days=7): if now() - timestamp > timedelta(days=7):
return timestamp.strftime("%b %-d, %Y") return timestamp.strftime("%b %-d, %Y")
else:
return naturaltime(timestamp) return naturaltime(timestamp)
@ -1400,15 +1401,14 @@ def serialize_to_js(obj):
items.append(f"{key}: {serialized_value}") items.append(f"{key}: {serialized_value}")
return f"{{ {', '.join(items)} }}" return f"{{ {', '.join(items)} }}"
elif isinstance(obj, (list, tuple)): if isinstance(obj, (list, tuple)):
# If the object is a list, recursively serialize each item # If the object is a list, recursively serialize each item
serialized_items = [serialize_to_js(item) for item in obj] serialized_items = [serialize_to_js(item) for item in obj]
return f"[{', '.join(serialized_items)}]" return f"[{', '.join(serialized_items)}]"
elif isinstance(obj, str): if isinstance(obj, str):
return obj return obj
else:
# For other types (int, float, etc.), just return the string representation # For other types (int, float, etc.), just return the string representation
return str(obj) return str(obj)
@ -1481,7 +1481,7 @@ def button(context: Context, data: ButtonData):
"attrs": all_attrs, "attrs": all_attrs,
"is_link": is_link, "is_link": is_link,
"slot_content": data.slot_content, "slot_content": data.slot_content,
} },
): ):
return lazy_load_template(button_template_str).render(context) return lazy_load_template(button_template_str).render(context)
@ -1615,7 +1615,7 @@ def menu(context: Context, data: MenuData):
{ {
"x-show": model, "x-show": model,
"x-cloak": "", "x-cloak": "",
} },
) )
menu_list_data = MenuListData( menu_list_data = MenuListData(
@ -1633,7 +1633,7 @@ def menu(context: Context, data: MenuData):
"attrs": data.attrs, "attrs": data.attrs,
"menu_list_data": menu_list_data, "menu_list_data": menu_list_data,
"slot_activator": data.slot_activator, "slot_activator": data.slot_activator,
} },
): ):
return lazy_load_template(menu_template_str).render(context) return lazy_load_template(menu_template_str).render(context)
@ -1738,7 +1738,7 @@ def menu_list(context: Context, data: MenuListData):
{ {
"item_groups": item_groups, "item_groups": item_groups,
"attrs": data.attrs, "attrs": data.attrs,
} },
): ):
return lazy_load_template(menu_list_template_str).render(context) return lazy_load_template(menu_list_template_str).render(context)
@ -1800,7 +1800,7 @@ class TableCell:
def __post_init__(self): def __post_init__(self):
if not isinstance(self.colspan, int) or self.colspan < 1: if not isinstance(self.colspan, int) or self.colspan < 1:
raise ValueError("TableCell.colspan must be a non-negative integer." f" Instead got {self.colspan}") raise ValueError(f"TableCell.colspan must be a non-negative integer. Instead got {self.colspan}")
NULL_CELL = TableCell("") NULL_CELL = TableCell("")
@ -1976,7 +1976,7 @@ class TableData(NamedTuple):
@registry.library.simple_tag(takes_context=True) @registry.library.simple_tag(takes_context=True)
def table(context: Context, data: TableData): def table(context: Context, data: TableData):
rows_to_render = [tuple([row, prepare_row_headers(row, data.headers)]) for row in data.rows] rows_to_render = [(row, prepare_row_headers(row, data.headers)) for row in data.rows]
with context.push( with context.push(
{ {
@ -1984,7 +1984,7 @@ def table(context: Context, data: TableData):
"rows_to_render": rows_to_render, "rows_to_render": rows_to_render,
"NULL_CELL": NULL_CELL, "NULL_CELL": NULL_CELL,
"attrs": data.attrs, "attrs": data.attrs,
} },
): ):
return lazy_load_template(table_template_str).render(context) return lazy_load_template(table_template_str).render(context)
@ -2080,7 +2080,7 @@ def icon(context: Context, data: IconData):
"attrs": data.attrs, "attrs": data.attrs,
"heroicon_data": heroicon_data, "heroicon_data": heroicon_data,
"slot_content": data.slot_content, "slot_content": data.slot_content,
} },
): ):
return lazy_load_template(icon_template_str).render(context) return lazy_load_template(icon_template_str).render(context)
@ -2097,9 +2097,9 @@ ICONS = {
"stroke-linecap": "round", "stroke-linecap": "round",
"stroke-linejoin": "round", "stroke-linejoin": "round",
"d": "M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5", # noqa: E501 "d": "M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5", # noqa: E501
} },
] ],
} },
} }
@ -2113,8 +2113,7 @@ class ComponentDefaults(metaclass=ComponentDefaultsMeta):
def __post_init__(self) -> None: def __post_init__(self) -> None:
fields = self.__class__.__dataclass_fields__ # type: ignore[attr-defined] fields = self.__class__.__dataclass_fields__ # type: ignore[attr-defined]
for field_name, dataclass_field in fields.items(): for field_name, dataclass_field in fields.items():
if dataclass_field.default is not MISSING: if dataclass_field.default is not MISSING and getattr(self, field_name) is None:
if getattr(self, field_name) is None:
setattr(self, field_name, dataclass_field.default) setattr(self, field_name, dataclass_field.default)
@ -2195,7 +2194,7 @@ def heroicon(context: Context, data: HeroIconData):
"icon_paths": icon_paths, "icon_paths": icon_paths,
"default_attrs": default_attrs, "default_attrs": default_attrs,
"attrs": kwargs.attrs, "attrs": kwargs.attrs,
} },
): ):
return lazy_load_template(heroicon_template_str).render(context) return lazy_load_template(heroicon_template_str).render(context)
@ -2295,10 +2294,11 @@ def expansion_panel(context: Context, data: ExpansionPanelData):
"expand_icon_data": expand_icon_data, "expand_icon_data": expand_icon_data,
"slot_header": data.slot_header, "slot_header": data.slot_header,
"slot_content": data.slot_content, "slot_content": data.slot_content,
} },
): ):
return lazy_load_template(expansion_panel_template_str).render(context) return lazy_load_template(expansion_panel_template_str).render(context)
##################################### #####################################
# PROJECT_PAGE # PROJECT_PAGE
##################################### #####################################
@ -2363,7 +2363,7 @@ def project_page(context: Context, data: ProjectPageData):
ListItem( ListItem(
value=title, value=title,
link=f"/projects/{data.project['id']}/phases/{phase['phase_template']['type']}", link=f"/projects/{data.project['id']}/phases/{phase['phase_template']['type']}",
) ),
) )
project_page_tabs = [ project_page_tabs = [
@ -2378,7 +2378,7 @@ def project_page(context: Context, data: ProjectPageData):
contacts=data.contacts, contacts=data.contacts,
status_updates=data.status_updates, status_updates=data.status_updates,
editable=data.user_is_project_owner, editable=data.user_is_project_owner,
) ),
), ),
), ),
TabItemData( TabItemData(
@ -2390,7 +2390,7 @@ def project_page(context: Context, data: ProjectPageData):
notes=data.notes_1, notes=data.notes_1,
comments_by_notes=data.comments_by_notes_1, # type: ignore[arg-type] comments_by_notes=data.comments_by_notes_1, # type: ignore[arg-type]
editable=data.user_is_project_member, editable=data.user_is_project_member,
) ),
), ),
), ),
TabItemData( TabItemData(
@ -2402,7 +2402,7 @@ def project_page(context: Context, data: ProjectPageData):
notes=data.notes_2, notes=data.notes_2,
comments_by_notes=data.comments_by_notes_2, # type: ignore[arg-type] comments_by_notes=data.comments_by_notes_2, # type: ignore[arg-type]
editable=data.user_is_project_member, editable=data.user_is_project_member,
) ),
), ),
), ),
TabItemData( TabItemData(
@ -2414,7 +2414,7 @@ def project_page(context: Context, data: ProjectPageData):
notes=data.notes_3, notes=data.notes_3,
comments_by_notes=data.comments_by_notes_3, # type: ignore[arg-type] comments_by_notes=data.comments_by_notes_3, # type: ignore[arg-type]
editable=data.user_is_project_member, editable=data.user_is_project_member,
) ),
), ),
), ),
TabItemData( TabItemData(
@ -2426,7 +2426,7 @@ def project_page(context: Context, data: ProjectPageData):
outputs=data.outputs, outputs=data.outputs,
editable=data.user_is_project_member, editable=data.user_is_project_member,
phase_titles=data.phase_titles, phase_titles=data.phase_titles,
) ),
), ),
), ),
] ]
@ -2435,7 +2435,7 @@ def project_page(context: Context, data: ProjectPageData):
with tabs_context.push( with tabs_context.push(
{ {
"project_page_tabs": project_page_tabs, "project_page_tabs": project_page_tabs,
} },
): ):
return lazy_load_template(project_page_tabs_template_str).render(tabs_context) return lazy_load_template(project_page_tabs_template_str).render(tabs_context)
@ -2444,12 +2444,12 @@ def project_page(context: Context, data: ProjectPageData):
ListData( ListData(
items=rendered_phases, items=rendered_phases,
item_attrs={"class": "py-5"}, item_attrs={"class": "py-5"},
) ),
) )
with context.push( with context.push(
{ {
"project": data.project, "project": data.project,
} },
): ):
header_content = lazy_load_template(project_page_header_template_str).render(context) header_content = lazy_load_template(project_page_header_template_str).render(context)
@ -2464,6 +2464,7 @@ def project_page(context: Context, data: ProjectPageData):
return project_layout_tabbed(context, layout_tabbed_data) return project_layout_tabbed(context, layout_tabbed_data)
##################################### #####################################
# PROJECT_LAYOUT_TABBED # PROJECT_LAYOUT_TABBED
##################################### #####################################
@ -2569,7 +2570,7 @@ def project_layout_tabbed(context: Context, data: ProjectLayoutTabbedData):
breadcrumbs_content = breadcrumbs_tag(context, BreadcrumbsData(items=prefixed_breadcrumbs)) breadcrumbs_content = breadcrumbs_tag(context, BreadcrumbsData(items=prefixed_breadcrumbs))
bookmarks_content = bookmarks_tag( bookmarks_content = bookmarks_tag(
context, context,
BookmarksData(bookmarks=data.layout_data.bookmarks, project_id=data.layout_data.project["id"]) BookmarksData(bookmarks=data.layout_data.bookmarks, project_id=data.layout_data.project["id"]),
) )
content_tabs_static_data = TabsStaticData( content_tabs_static_data = TabsStaticData(
@ -2600,7 +2601,7 @@ def project_layout_tabbed(context: Context, data: ProjectLayoutTabbedData):
"slot_left_panel": data.slot_left_panel, "slot_left_panel": data.slot_left_panel,
"slot_header": data.slot_header, "slot_header": data.slot_header,
"slot_tabs": data.slot_tabs, "slot_tabs": data.slot_tabs,
} },
): ):
layout_content = lazy_load_template(project_layout_tabbed_content_template_str).render(context) layout_content = lazy_load_template(project_layout_tabbed_content_template_str).render(context)
@ -2695,7 +2696,7 @@ def layout(context: Context, data: LayoutData):
"sidebar_data": sidebar_data, "sidebar_data": sidebar_data,
"slot_header": data.slot_header, "slot_header": data.slot_header,
"slot_content": data.slot_content, "slot_content": data.slot_content,
} },
): ):
layout_base_content = lazy_load_template(layout_base_content_template_str).render(provided_context) layout_base_content = lazy_load_template(layout_base_content_template_str).render(provided_context)
@ -2745,7 +2746,7 @@ def layout(context: Context, data: LayoutData):
with provided_context.push( with provided_context.push(
{ {
"base_data": base_data, "base_data": base_data,
} },
): ):
return lazy_load_template("{% base base_data %}").render(provided_context) return lazy_load_template("{% base base_data %}").render(provided_context)
@ -2757,7 +2758,7 @@ def layout(context: Context, data: LayoutData):
with context.push( with context.push(
{ {
"render_context_provider_data": render_context_provider_data, "render_context_provider_data": render_context_provider_data,
} },
): ):
return lazy_load_template(layout_template_str).render(context) return lazy_load_template(layout_template_str).render(context)
@ -3108,7 +3109,7 @@ def base(context: Context, data: BaseData) -> str:
"slot_css": data.slot_css, "slot_css": data.slot_css,
"slot_js": data.slot_js, "slot_js": data.slot_js,
"slot_content": data.slot_content, "slot_content": data.slot_content,
} },
): ):
return lazy_load_template(base_template_str).render(context) return lazy_load_template(base_template_str).render(context)
@ -3330,7 +3331,7 @@ def sidebar(context: Context, data: SidebarData):
"faq_icon_data": faq_icon_data, "faq_icon_data": faq_icon_data,
# Slots # Slots
"slot_content": data.slot_content, "slot_content": data.slot_content,
} },
): ):
return lazy_load_template(sidebar_template_str).render(context) return lazy_load_template(sidebar_template_str).render(context)
@ -3391,7 +3392,7 @@ def navbar(context: Context, data: NavbarData):
{ {
"sidebar_toggle_icon_data": sidebar_toggle_icon_data, "sidebar_toggle_icon_data": sidebar_toggle_icon_data,
"attrs": data.attrs, "attrs": data.attrs,
} },
): ):
return lazy_load_template(navbar_template_str).render(context) return lazy_load_template(navbar_template_str).render(context)
@ -3621,7 +3622,7 @@ def dialog(context: Context, data: DialogData):
"slot_title": data.slot_title, "slot_title": data.slot_title,
"slot_content": data.slot_content, "slot_content": data.slot_content,
"slot_append": data.slot_append, "slot_append": data.slot_append,
} },
): ):
return lazy_load_template(dialog_template_str).render(context) return lazy_load_template(dialog_template_str).render(context)
@ -3871,7 +3872,7 @@ def tags(context: Context, data: TagsData):
"remove_button_data": remove_button_data, "remove_button_data": remove_button_data,
"add_tag_button_data": add_tag_button_data, "add_tag_button_data": add_tag_button_data,
"slot_title": slot_title, "slot_title": slot_title,
} },
): ):
return lazy_load_template(tags_template_str).render(context) return lazy_load_template(tags_template_str).render(context)
@ -4062,7 +4063,7 @@ def form(context: Context, data: FormData):
"slot_actions_append": data.slot_actions_append, "slot_actions_append": data.slot_actions_append,
"slot_form": data.slot_form, "slot_form": data.slot_form,
"slot_below_form": data.slot_below_form, "slot_below_form": data.slot_below_form,
} },
): ):
return lazy_load_template(form_template_str).render(context) return lazy_load_template(form_template_str).render(context)
@ -4074,9 +4075,7 @@ def form(context: Context, data: FormData):
@dataclass(frozen=True) @dataclass(frozen=True)
class Breadcrumb: class Breadcrumb:
""" """Single breadcrumb item used with the `breadcrumb` components."""
Single breadcrumb item used with the `breadcrumb` components.
"""
value: Any value: Any
"""Value of the menu item to render.""" """Value of the menu item to render."""
@ -4160,7 +4159,7 @@ def breadcrumbs(context: Context, data: BreadcrumbsData):
{ {
"items": data.items, "items": data.items,
"attrs": data.attrs, "attrs": data.attrs,
} },
): ):
return lazy_load_template(breadcrumbs_template_str).render(context) return lazy_load_template(breadcrumbs_template_str).render(context)
@ -4383,7 +4382,7 @@ def bookmarks(context: Context, data: BookmarksData):
"bookmarks_icon_data": bookmarks_icon_data, "bookmarks_icon_data": bookmarks_icon_data,
"add_new_bookmark_icon_data": add_new_bookmark_icon_data, "add_new_bookmark_icon_data": add_new_bookmark_icon_data,
"context_menu_data": context_menu_data, "context_menu_data": context_menu_data,
} },
): ):
return lazy_load_template(bookmarks_template_str).render(context) return lazy_load_template(bookmarks_template_str).render(context)
@ -4485,7 +4484,7 @@ def bookmark(context: Context, data: BookmarkData):
"bookmark": data.bookmark._asdict(), "bookmark": data.bookmark._asdict(),
"js": data.js, "js": data.js,
"bookmark_icon_data": bookmark_icon_data, "bookmark_icon_data": bookmark_icon_data,
} },
): ):
return lazy_load_template(bookmark_template_str).render(context) return lazy_load_template(bookmark_template_str).render(context)
@ -4562,7 +4561,7 @@ def list_tag(context: Context, data: ListData):
"attrs": data.attrs, "attrs": data.attrs,
"item_attrs": data.item_attrs, "item_attrs": data.item_attrs,
"slot_empty": data.slot_empty, "slot_empty": data.slot_empty,
} },
): ):
return lazy_load_template(list_template_str).render(context) return lazy_load_template(list_template_str).render(context)
@ -4728,7 +4727,7 @@ def tabs_impl(context: Context, data: TabsImplData):
"content_attrs": data.content_attrs, "content_attrs": data.content_attrs,
"tabs_data": {"name": data.name}, "tabs_data": {"name": data.name},
"theme": theme, "theme": theme,
} },
): ):
return lazy_load_template(tabs_impl_template_str).render(context) return lazy_load_template(tabs_impl_template_str).render(context)
@ -4743,6 +4742,11 @@ class TabsData(NamedTuple):
slot_content: Optional[CallableSlot] = None slot_content: Optional[CallableSlot] = None
class ProvidedData(NamedTuple):
tabs: List[TabEntry]
enabled: bool
# This is an "API" component, meaning that it's designed to process # This is an "API" component, meaning that it's designed to process
# user input provided as nested components. But after the input is # user input provided as nested components. But after the input is
# processed, it delegates to an internal "implementation" component # processed, it delegates to an internal "implementation" component
@ -4752,7 +4756,6 @@ def tabs(context: Context, data: TabsData):
if not data.slot_content: if not data.slot_content:
return "" return ""
ProvidedData = NamedTuple("ProvidedData", [("tabs", List[TabEntry]), ("enabled", bool)])
collected_tabs: List[TabEntry] = [] collected_tabs: List[TabEntry] = []
provided_data = ProvidedData(tabs=collected_tabs, enabled=True) provided_data = ProvidedData(tabs=collected_tabs, enabled=True)
@ -4792,7 +4795,7 @@ def tab_item(context, data: TabItemData):
raise RuntimeError( raise RuntimeError(
"Component 'tab_item' was called with no parent Tabs component. " "Component 'tab_item' was called with no parent Tabs component. "
"Either wrap 'tab_item' in Tabs component, or check if the component " "Either wrap 'tab_item' in Tabs component, or check if the component "
"is not a descendant of another instance of 'tab_item'" "is not a descendant of another instance of 'tab_item'",
) )
parent_tabs = tab_ctx.tabs parent_tabs = tab_ctx.tabs
@ -4801,7 +4804,7 @@ def tab_item(context, data: TabItemData):
"header": data.header, "header": data.header,
"disabled": data.disabled, "disabled": data.disabled,
"content": mark_safe(data.slot_content or "").strip(), "content": mark_safe(data.slot_content or "").strip(),
} },
) )
return "" return ""
@ -4877,7 +4880,7 @@ def tabs_static(context: Context, data: TabsStaticData):
"hide_body": data.hide_body, "hide_body": data.hide_body,
"selected_content": selected_content, "selected_content": selected_content,
"theme": theme, "theme": theme,
} },
): ):
return lazy_load_template(tabs_static_template_str).render(context) return lazy_load_template(tabs_static_template_str).render(context)
@ -5055,7 +5058,7 @@ def project_info(context: Context, data: ProjectInfoData) -> str:
"edit_project_button_data": edit_project_button_data, "edit_project_button_data": edit_project_button_data,
"edit_team_button_data": edit_team_button_data, "edit_team_button_data": edit_team_button_data,
"edit_contacts_button_data": edit_contacts_button_data, "edit_contacts_button_data": edit_contacts_button_data,
} },
): ):
return lazy_load_template(project_info_template_str).render(context) return lazy_load_template(project_info_template_str).render(context)
@ -5126,11 +5129,7 @@ project_notes_template_str: types.django_html = """
def _make_comments_data(note: ProjectNote, comment: ProjectNoteComment): def _make_comments_data(note: ProjectNote, comment: ProjectNoteComment):
modified_time_str = format_timestamp(datetime.fromisoformat(comment["modified"])) modified_time_str = format_timestamp(datetime.fromisoformat(comment["modified"]))
formatted_modified_by = ( formatted_modified_by = modified_time_str + " " + comment["modified_by"]["name"]
modified_time_str
+ " "
+ comment['modified_by']['name']
)
edit_comment_icon_data = IconData( edit_comment_icon_data = IconData(
name="pencil-square", name="pencil-square",
@ -5174,7 +5173,7 @@ def _make_notes_data(
"edit_note_icon_data": edit_note_icon_data, "edit_note_icon_data": edit_note_icon_data,
"comments": comments_data, "comments": comments_data,
"create_comment_button_data": create_comment_button_data, "create_comment_button_data": create_comment_button_data,
} },
) )
return notes_data return notes_data
@ -5201,7 +5200,7 @@ def project_notes(context: Context, data: ProjectNotesData) -> str:
"create_note_button_data": create_note_button_data, "create_note_button_data": create_note_button_data,
"notes_data": notes_data, "notes_data": notes_data,
"editable": data.editable, "editable": data.editable,
} },
): ):
return lazy_load_template(project_notes_template_str).render(context) return lazy_load_template(project_notes_template_str).render(context)
@ -5271,9 +5270,11 @@ def project_outputs_summary(context: Context, data: ProjectOutputsSummaryData) -
{ {
"outputs_data": outputs_data, "outputs_data": outputs_data,
"outputs": data.outputs, "outputs": data.outputs,
} },
): ):
expansion_panel_content = lazy_load_template(outputs_summary_expansion_content_template_str).render(context) # noqa: E501 expansion_panel_content = lazy_load_template(outputs_summary_expansion_content_template_str).render(
context,
)
expansion_panel_data = ExpansionPanelData( expansion_panel_data = ExpansionPanelData(
open=has_outputs, open=has_outputs,
@ -5291,7 +5292,7 @@ def project_outputs_summary(context: Context, data: ProjectOutputsSummaryData) -
with context.push( with context.push(
{ {
"groups": groups, "groups": groups,
} },
): ):
return lazy_load_template(project_outputs_summary_template_str).render(context) return lazy_load_template(project_outputs_summary_template_str).render(context)
@ -5333,11 +5334,7 @@ project_status_updates_template_str: types.django_html = """
def _make_status_update_data(status_update: ProjectStatusUpdate): def _make_status_update_data(status_update: ProjectStatusUpdate):
modified_time_str = format_timestamp(datetime.fromisoformat(status_update["modified"])) modified_time_str = format_timestamp(datetime.fromisoformat(status_update["modified"]))
formatted_modified_by = ( formatted_modified_by = modified_time_str + " " + status_update["modified_by"]["name"]
modified_time_str
+ " "
+ status_update['modified_by']['name']
)
return { return {
"timestamp": formatted_modified_by, "timestamp": formatted_modified_by,
@ -5381,7 +5378,7 @@ def project_status_updates(context: Context, data: ProjectStatusUpdatesData) ->
"updates_data": updates_data, "updates_data": updates_data,
"editable": data.editable, "editable": data.editable,
"add_status_button_data": add_status_button_data, "add_status_button_data": add_status_button_data,
} },
): ):
return lazy_load_template(project_status_updates_template_str).render(context) return lazy_load_template(project_status_updates_template_str).render(context)
@ -5510,17 +5507,14 @@ def project_users(context: Context, data: ProjectUsersData) -> str:
"name": TableCell(user["name"]), "name": TableCell(user["name"]),
"role": TableCell(role["name"]), "role": TableCell(role["name"]),
"delete": delete_action, "delete": delete_action,
} },
) ),
) )
submit_url = f"/submit/{data.project_id}/role/create" submit_url = f"/submit/{data.project_id}/role/create"
project_url = f"/project/{data.project_id}" project_url = f"/project/{data.project_id}"
if data.available_roles: available_role_choices = [(role, role) for role in data.available_roles] if data.available_roles else []
available_role_choices = [(role, role) for role in data.available_roles]
else:
available_role_choices = []
if data.available_users: if data.available_users:
available_user_choices = [(str(user["id"]), user["name"]) for user in data.available_users] available_user_choices = [(str(user["id"]), user["name"]) for user in data.available_users]
@ -5554,7 +5548,7 @@ def project_users(context: Context, data: ProjectUsersData) -> str:
with context.push( with context.push(
{ {
"delete_icon_data": delete_icon_data, "delete_icon_data": delete_icon_data,
} },
): ):
user_dialog_title = lazy_load_template(user_dialog_title_template_str).render(context) user_dialog_title = lazy_load_template(user_dialog_title_template_str).render(context)
@ -5585,7 +5579,7 @@ def project_users(context: Context, data: ProjectUsersData) -> str:
"set_role_button_data": set_role_button_data, "set_role_button_data": set_role_button_data,
"cancel_button_data": cancel_button_data, "cancel_button_data": cancel_button_data,
"dialog_data": dialog_data, "dialog_data": dialog_data,
} },
): ):
return lazy_load_template(project_users_template_str).render(context) return lazy_load_template(project_users_template_str).render(context)
@ -5636,7 +5630,7 @@ def project_user_action(context: Context, data: ProjectUserActionData) -> str:
{ {
"role": role_data, "role": role_data,
"delete_icon_data": delete_icon_data, "delete_icon_data": delete_icon_data,
} },
): ):
return lazy_load_template(project_user_action_template_str).render(context) return lazy_load_template(project_user_action_template_str).render(context)
@ -5692,7 +5686,7 @@ def project_outputs(context: Context, data: ProjectOutputsData) -> str:
url=attachment[0]["url"], url=attachment[0]["url"],
text=attachment[0]["text"], text=attachment[0]["text"],
tags=attachment[1], tags=attachment[1],
) ),
) )
update_output_url = "/update" update_output_url = "/update"
@ -5713,10 +5707,10 @@ def project_outputs(context: Context, data: ProjectOutputsData) -> str:
} }
for d in attachments for d in attachments
], ],
) ),
) )
has_missing_deps = any([not output["completed"] for output, _ in dependencies]) has_missing_deps = any(not output["completed"] for output, _ in dependencies)
output_badge_data = ProjectOutputBadgeData( output_badge_data = ProjectOutputBadgeData(
completed=output["completed"], completed=output["completed"],
@ -5741,9 +5735,11 @@ def project_outputs(context: Context, data: ProjectOutputsData) -> str:
{ {
"dependencies_data": [ProjectOutputDependencyData(dependency=dep) for dep in deps], "dependencies_data": [ProjectOutputDependencyData(dependency=dep) for dep in deps],
"output_form_data": output_form_data, "output_form_data": output_form_data,
} },
): ):
output_expansion_panel_content = lazy_load_template(output_expansion_panel_content_template_str).render(context) # noqa: E501 output_expansion_panel_content = lazy_load_template(output_expansion_panel_content_template_str).render(
context,
)
expansion_panel_data = ExpansionPanelData( expansion_panel_data = ExpansionPanelData(
panel_id=output["id"], # type: ignore[arg-type] panel_id=output["id"], # type: ignore[arg-type]
@ -5752,7 +5748,7 @@ def project_outputs(context: Context, data: ProjectOutputsData) -> str:
header_attrs={"class": "flex align-center justify-between"}, header_attrs={"class": "flex align-center justify-between"},
slot_header=f""" slot_header=f"""
<div> <div>
{output['name']} {output["name"]}
</div> </div>
""", """,
slot_content=output_expansion_panel_content, slot_content=output_expansion_panel_content,
@ -5763,13 +5759,13 @@ def project_outputs(context: Context, data: ProjectOutputsData) -> str:
output_data, output_data,
output_badge_data, output_badge_data,
expansion_panel_data, expansion_panel_data,
) ),
) )
with context.push( with context.push(
{ {
"outputs_data": outputs_data, "outputs_data": outputs_data,
} },
): ):
return lazy_load_template(project_outputs_template_str).render(context) return lazy_load_template(project_outputs_template_str).render(context)
@ -5833,7 +5829,7 @@ def project_output_badge(context: Context, data: ProjectOutputBadgeData):
"theme": theme, "theme": theme,
"missing_icon_data": missing_icon_data, "missing_icon_data": missing_icon_data,
"completed_icon_data": completed_icon_data, "completed_icon_data": completed_icon_data,
} },
): ):
return lazy_load_template(project_output_badge_template_str).render(context) return lazy_load_template(project_output_badge_template_str).render(context)
@ -5952,7 +5948,7 @@ def project_output_dependency(context: Context, data: ProjectOutputDependencyDat
"warning_icon_data": warning_icon_data, "warning_icon_data": warning_icon_data,
"missing_button_data": missing_button_data, "missing_button_data": missing_button_data,
"parent_attachments_data": parent_attachments_data, "parent_attachments_data": parent_attachments_data,
} },
): ):
return lazy_load_template(project_output_dependency_template_str).render(context) return lazy_load_template(project_output_dependency_template_str).render(context)
@ -6131,7 +6127,7 @@ def project_output_attachments(context: Context, data: ProjectOutputAttachmentsD
"edit_button_data": edit_button_data, "edit_button_data": edit_button_data,
"remove_button_data": remove_button_data, "remove_button_data": remove_button_data,
"tags_data": tags_data, "tags_data": tags_data,
} },
): ):
return lazy_load_template(project_output_attachments_template_str).render(context) return lazy_load_template(project_output_attachments_template_str).render(context)
@ -6400,7 +6396,7 @@ def project_output_form(context: Context, data: ProjectOutputFormData):
"project_output_attachments_data": project_output_attachments_data, "project_output_attachments_data": project_output_attachments_data,
"save_button_data": save_button_data, "save_button_data": save_button_data,
"add_attachment_button_data": add_attachment_button_data, "add_attachment_button_data": add_attachment_button_data,
} },
): ):
form_content = lazy_load_template(form_content_template_str).render(context) form_content = lazy_load_template(form_content_template_str).render(context)
@ -6414,10 +6410,11 @@ def project_output_form(context: Context, data: ProjectOutputFormData):
{ {
"form_data": form_data, "form_data": form_data,
"alpine_attachments": [d._asdict() for d in data.data.attachments], "alpine_attachments": [d._asdict() for d in data.data.attachments],
} },
): ):
return lazy_load_template(project_output_form_template_str).render(context) return lazy_load_template(project_output_form_template_str).render(context)
##################################### #####################################
# #
# IMPLEMENTATION END # IMPLEMENTATION END
@ -6432,6 +6429,7 @@ def project_output_form(context: Context, data: ProjectOutputFormData):
from django_components.testing import djc_test # noqa: E402 from django_components.testing import djc_test # noqa: E402
@djc_test @djc_test
def test_render(snapshot): def test_render(snapshot):
data = gen_render_data() data = gen_render_data()

View file

@ -32,9 +32,9 @@ if not settings.configured:
"OPTIONS": { "OPTIONS": {
"builtins": [ "builtins": [
"django_components.templatetags.component_tags", "django_components.templatetags.component_tags",
] ],
},
}, },
}
], ],
COMPONENTS={ COMPONENTS={
"autodiscover": False, "autodiscover": False,
@ -45,9 +45,9 @@ if not settings.configured:
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:", "NAME": ":memory:",
}
}, },
SECRET_KEY="secret", },
SECRET_KEY="secret", # noqa: S106
ROOT_URLCONF="django_components.urls", ROOT_URLCONF="django_components.urls",
) )
django.setup() django.setup()
@ -67,7 +67,6 @@ def lazy_load_template(template: str) -> Template:
template_hash = hash(template) template_hash = hash(template)
if template_hash in templates_cache: if template_hash in templates_cache:
return templates_cache[template_hash] return templates_cache[template_hash]
else:
template_instance = Template(template) template_instance = Template(template)
templates_cache[template_hash] = template_instance templates_cache[template_hash] = template_instance
return template_instance return template_instance
@ -150,7 +149,7 @@ _secondary_btn_styling = "ring-1 ring-inset"
theme = Theme( theme = Theme(
default=ThemeStylingVariant( default=ThemeStylingVariant(
primary=ThemeStylingUnit( primary=ThemeStylingUnit(
color="bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition" color="bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition",
), ),
primary_disabled=ThemeStylingUnit(color="bg-blue-300 text-blue-50 focus-visible:outline-blue-600 transition"), primary_disabled=ThemeStylingUnit(color="bg-blue-300 text-blue-50 focus-visible:outline-blue-600 transition"),
secondary=ThemeStylingUnit( secondary=ThemeStylingUnit(
@ -314,7 +313,7 @@ def button(context: Context, data: ButtonData):
"attrs": all_attrs, "attrs": all_attrs,
"is_link": is_link, "is_link": is_link,
"slot_content": data.slot_content, "slot_content": data.slot_content,
} },
): ):
return lazy_load_template(button_template_str).render(context) return lazy_load_template(button_template_str).render(context)
@ -333,6 +332,7 @@ def button(context: Context, data: ButtonData):
from django_components.testing import djc_test # noqa: E402 from django_components.testing import djc_test # noqa: E402
@djc_test @djc_test
def test_render(snapshot): def test_render(snapshot):
data = gen_render_data() data = gen_render_data()

File diff suppressed because it is too large Load diff

View file

@ -32,9 +32,9 @@ if not settings.configured:
"OPTIONS": { "OPTIONS": {
"builtins": [ "builtins": [
"django_components.templatetags.component_tags", "django_components.templatetags.component_tags",
] ],
},
}, },
}
], ],
COMPONENTS={ COMPONENTS={
"autodiscover": False, "autodiscover": False,
@ -45,9 +45,9 @@ if not settings.configured:
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:", "NAME": ":memory:",
}
}, },
SECRET_KEY="secret", },
SECRET_KEY="secret", # noqa: S106
ROOT_URLCONF="django_components.urls", ROOT_URLCONF="django_components.urls",
) )
@ -68,7 +68,7 @@ def lazy_load_template(template: str) -> Template:
template_hash = hash(template) template_hash = hash(template)
if template_hash in templates_cache: if template_hash in templates_cache:
return templates_cache[template_hash] return templates_cache[template_hash]
else:
template_instance = Template(template) template_instance = Template(template)
templates_cache[template_hash] = template_instance templates_cache[template_hash] = template_instance
return template_instance return template_instance
@ -156,7 +156,7 @@ _secondary_btn_styling = "ring-1 ring-inset"
theme = Theme( theme = Theme(
default=ThemeStylingVariant( default=ThemeStylingVariant(
primary=ThemeStylingUnit( primary=ThemeStylingUnit(
color="bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition" color="bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition",
), ),
primary_disabled=ThemeStylingUnit(color="bg-blue-300 text-blue-50 focus-visible:outline-blue-600 transition"), primary_disabled=ThemeStylingUnit(color="bg-blue-300 text-blue-50 focus-visible:outline-blue-600 transition"),
secondary=ThemeStylingUnit( secondary=ThemeStylingUnit(
@ -268,7 +268,7 @@ class Button(Component):
disabled: Optional[bool] = False, disabled: Optional[bool] = False,
variant: Union["ThemeVariant", Literal["plain"]] = "primary", variant: Union["ThemeVariant", Literal["plain"]] = "primary",
color: Union["ThemeColor", str] = "default", color: Union["ThemeColor", str] = "default",
type: Optional[str] = "button", type: Optional[str] = "button", # noqa: A002
attrs: Optional[dict] = None, attrs: Optional[dict] = None,
): ):
common_css = ( common_css = (
@ -336,6 +336,7 @@ class Button(Component):
from django_components.testing import djc_test # noqa: E402 from django_components.testing import djc_test # noqa: E402
@djc_test @djc_test
def test_render(snapshot): def test_render(snapshot):
data = gen_render_data() data = gen_render_data()

View file

@ -125,8 +125,14 @@ class TestComponentMediaCache:
assert not test_cache.has_key(f"__components:{TestSimpleComponent.class_id}:css") assert not test_cache.has_key(f"__components:{TestSimpleComponent.class_id}:css")
# Check that we cache `Component.js` / `Component.css` # Check that we cache `Component.js` / `Component.css`
assert test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:js").strip() == "console.log('Hello from JS');" # noqa: E501 assert (
assert test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:css").strip() == ".novars-component { color: blue; }" # noqa: E501 test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:js").strip()
== "console.log('Hello from JS');"
)
assert (
test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:css").strip()
== ".novars-component { color: blue; }"
)
# Render the components to trigger caching of JS/CSS variables from `get_js_data` / `get_css_data` # Render the components to trigger caching of JS/CSS variables from `get_js_data` / `get_css_data`
TestMediaAndVarsComponent.render() TestMediaAndVarsComponent.render()
@ -138,4 +144,4 @@ class TestComponentMediaCache:
# TODO - Update once JS and CSS vars are enabled # TODO - Update once JS and CSS vars are enabled
assert test_cache.get(f"__components:{TestMediaAndVarsComponent.class_id}:js:{js_vars_hash}").strip() == "" 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 assert test_cache.get(f"__components:{TestMediaAndVarsComponent.class_id}:css:{css_vars_hash}").strip() == ""

View file

@ -2,7 +2,9 @@ from io import StringIO
from unittest.mock import patch from unittest.mock import patch
from django.core.management import call_command from django.core.management import call_command
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})

View file

@ -1,13 +1,15 @@
import os
import tempfile import tempfile
from io import StringIO from io import StringIO
from pathlib import Path
from shutil import rmtree from shutil import rmtree
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import CommandError from django.core.management.base import CommandError
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})
@ -22,12 +24,12 @@ class TestCreateComponentCommand:
call_command("components", "create", component_name, "--path", temp_dir) call_command("components", "create", component_name, "--path", temp_dir)
expected_files = [ expected_files = [
os.path.join(temp_dir, component_name, "script.js"), Path(temp_dir) / component_name / "script.js",
os.path.join(temp_dir, component_name, "style.css"), Path(temp_dir) / component_name / "style.css",
os.path.join(temp_dir, component_name, "template.html"), Path(temp_dir) / component_name / "template.html",
] ]
for file_path in expected_files: for file_path in expected_files:
assert os.path.exists(file_path) assert file_path.exists()
rmtree(temp_dir) rmtree(temp_dir)
@ -50,14 +52,14 @@ class TestCreateComponentCommand:
) )
expected_files = [ expected_files = [
os.path.join(temp_dir, component_name, "test.js"), Path(temp_dir) / component_name / "test.js",
os.path.join(temp_dir, component_name, "test.css"), Path(temp_dir) / component_name / "test.css",
os.path.join(temp_dir, component_name, "test.html"), Path(temp_dir) / component_name / "test.html",
os.path.join(temp_dir, component_name, f"{component_name}.py"), Path(temp_dir) / component_name / f"{component_name}.py",
] ]
for file_path in expected_files: for file_path in expected_files:
assert os.path.exists(file_path), f"File {file_path} was not created" assert file_path.exists(), f"File {file_path} was not created"
rmtree(temp_dir) rmtree(temp_dir)
@ -74,8 +76,8 @@ class TestCreateComponentCommand:
"--dry-run", "--dry-run",
) )
component_path = os.path.join(temp_dir, component_name) component_path = Path(temp_dir) / component_name
assert not os.path.exists(component_path) assert not component_path.exists()
rmtree(temp_dir) rmtree(temp_dir)
@ -83,10 +85,11 @@ class TestCreateComponentCommand:
temp_dir = tempfile.mkdtemp() temp_dir = tempfile.mkdtemp()
component_name = "existingcomponent" component_name = "existingcomponent"
component_path = os.path.join(temp_dir, component_name) component_path = Path(temp_dir) / component_name
os.makedirs(component_path) component_path.mkdir(parents=True)
with open(os.path.join(component_path, f"{component_name}.py"), "w") as f: filepath = component_path / f"{component_name}.py"
with filepath.open("w", encoding="utf-8") as f:
f.write("hello world") f.write("hello world")
call_command( call_command(
@ -98,7 +101,8 @@ class TestCreateComponentCommand:
"--force", "--force",
) )
with open(os.path.join(component_path, f"{component_name}.py"), "r") as f: filepath = component_path / f"{component_name}.py"
with filepath.open("r", encoding="utf-8") as f:
assert "hello world" not in f.read() assert "hello world" not in f.read()
rmtree(temp_dir) rmtree(temp_dir)
@ -107,8 +111,8 @@ class TestCreateComponentCommand:
temp_dir = tempfile.mkdtemp() temp_dir = tempfile.mkdtemp()
component_name = "existingcomponent_2" component_name = "existingcomponent_2"
component_path = os.path.join(temp_dir, component_name) component_path = Path(temp_dir) / component_name
os.makedirs(component_path) component_path.mkdir(parents=True)
with pytest.raises(CommandError): with pytest.raises(CommandError):
call_command("components", "create", component_name, "--path", temp_dir) call_command("components", "create", component_name, "--path", temp_dir)
@ -143,11 +147,11 @@ class TestCreateComponentCommand:
call_command("startcomponent", component_name, "--path", temp_dir) call_command("startcomponent", component_name, "--path", temp_dir)
expected_files = [ expected_files = [
os.path.join(temp_dir, component_name, "script.js"), Path(temp_dir) / component_name / "script.js",
os.path.join(temp_dir, component_name, "style.css"), Path(temp_dir) / component_name / "style.css",
os.path.join(temp_dir, component_name, "template.html"), Path(temp_dir) / component_name / "template.html",
] ]
for file_path in expected_files: for file_path in expected_files:
assert os.path.exists(file_path) assert file_path.exists()
rmtree(temp_dir) rmtree(temp_dir)

View file

@ -5,8 +5,10 @@ from textwrap import dedent
from unittest.mock import patch from unittest.mock import patch
from django.core.management import call_command from django.core.management import call_command
from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentExtension from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentExtension
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})
@ -51,7 +53,7 @@ class DummyCommand(ComponentCommand):
kwargs.pop("_command") kwargs.pop("_command")
kwargs.pop("_parser") kwargs.pop("_parser")
sorted_kwargs = dict(sorted(kwargs.items())) sorted_kwargs = dict(sorted(kwargs.items()))
print(f"DummyCommand.handle: args={args}, kwargs={sorted_kwargs}") print(f"DummyCommand.handle: args={args}, kwargs={sorted_kwargs}") # noqa: T201
class DummyExtension(ComponentExtension): class DummyExtension(ComponentExtension):
@ -85,7 +87,7 @@ class TestExtensionsCommand:
{{list,run}} {{list,run}}
list List all extensions. list List all extensions.
run Run a command added by an extension. run Run a command added by an extension.
""" """,
).lstrip() ).lstrip()
) )
@ -221,7 +223,7 @@ class TestExtensionsRunCommand:
subcommands: subcommands:
{{dummy}} {{dummy}}
dummy Run commands added by the 'dummy' extension. dummy Run commands added by the 'dummy' extension.
""" """,
).lstrip() ).lstrip()
) )
@ -248,7 +250,7 @@ class TestExtensionsRunCommand:
subcommands: subcommands:
{{dummy_cmd}} {{dummy_cmd}}
dummy_cmd Dummy command description. dummy_cmd Dummy command description.
""" """,
).lstrip() ).lstrip()
) )
@ -275,7 +277,7 @@ class TestExtensionsRunCommand:
subcommands: subcommands:
{{dummy_cmd}} {{dummy_cmd}}
dummy_cmd Dummy command description. dummy_cmd Dummy command description.
""" """,
).lstrip() ).lstrip()
) )
@ -294,7 +296,7 @@ class TestExtensionsRunCommand:
== dedent( == dedent(
""" """
DummyCommand.handle: args=(), kwargs={'bar': None, 'baz': None, 'foo': None, 'force_color': False, 'no_color': False, 'pythonpath': None, 'settings': None, 'skip_checks': True, 'traceback': False, 'verbosity': 1} DummyCommand.handle: args=(), kwargs={'bar': None, 'baz': None, 'foo': None, 'force_color': False, 'no_color': False, 'pythonpath': None, 'settings': None, 'skip_checks': True, 'traceback': False, 'verbosity': 1}
""" # noqa: E501 """, # noqa: E501
).lstrip() ).lstrip()
) )

View file

@ -1,3 +1,4 @@
# ruff: noqa: E501
import re import re
from io import StringIO from io import StringIO
from unittest.mock import patch from unittest.mock import patch
@ -6,6 +7,7 @@ from django.core.management import call_command
from django_components import Component from django_components import Component
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})
@ -41,7 +43,7 @@ class TestComponentListCommand:
# Check first line of output # Check first line of output
assert re.compile( assert re.compile(
# full_name path # full_name path
r"full_name\s+path\s+" r"full_name\s+path\s+",
).search(output.strip().split("\n")[0]) ).search(output.strip().split("\n")[0])
# Check that the output contains the built-in component # Check that the output contains the built-in component
@ -49,17 +51,17 @@ class TestComponentListCommand:
# django_components.components.dynamic.DynamicComponent src/django_components/components/dynamic.py # django_components.components.dynamic.DynamicComponent src/django_components/components/dynamic.py
# or # or
# django_components.components.dynamic.DynamicComponent .tox/py311/lib/python3.11/site-packages/django_components/components/dynamic.py # django_components.components.dynamic.DynamicComponent .tox/py311/lib/python3.11/site-packages/django_components/components/dynamic.py
r"django_components\.components\.dynamic\.DynamicComponent\s+[\w/\\.-]+django_components{SLASH}components{SLASH}dynamic\.py".format( r"django_components\.components\.dynamic\.DynamicComponent\s+[\w/\\.-]+django_components{SLASH}components{SLASH}dynamic\.py".format( # noqa: UP032
SLASH=SLASH SLASH=SLASH,
) ),
).search(output) ).search(output)
# Check that the output contains the test component # Check that the output contains the test component
assert re.compile( assert re.compile(
# tests.test_command_list.TestComponentListCommand.test_list_default.<locals>.TestComponent tests/test_command_list.py # tests.test_command_list.TestComponentListCommand.test_list_default.<locals>.TestComponent tests/test_command_list.py
r"tests\.test_command_list\.TestComponentListCommand\.test_list_default\.<locals>\.TestComponent\s+tests{SLASH}test_command_list\.py".format( r"tests\.test_command_list\.TestComponentListCommand\.test_list_default\.<locals>\.TestComponent\s+tests{SLASH}test_command_list\.py".format( # noqa: UP032
SLASH=SLASH SLASH=SLASH,
) ),
).search(output) ).search(output)
def test_list_all(self): def test_list_all(self):
@ -86,7 +88,7 @@ class TestComponentListCommand:
# Check first line of output # Check first line of output
assert re.compile( assert re.compile(
# name full_name path # name full_name path
r"name\s+full_name\s+path\s+" r"name\s+full_name\s+path\s+",
).search(output.strip().split("\n")[0]) ).search(output.strip().split("\n")[0])
# Check that the output contains the built-in component # Check that the output contains the built-in component
@ -94,17 +96,17 @@ class TestComponentListCommand:
# DynamicComponent django_components.components.dynamic.DynamicComponent src/django_components/components/dynamic.py # DynamicComponent django_components.components.dynamic.DynamicComponent src/django_components/components/dynamic.py
# or # or
# DynamicComponent django_components.components.dynamic.DynamicComponent .tox/py311/lib/python3.11/site-packages/django_components/components/dynamic.py # DynamicComponent django_components.components.dynamic.DynamicComponent .tox/py311/lib/python3.11/site-packages/django_components/components/dynamic.py
r"DynamicComponent\s+django_components\.components\.dynamic\.DynamicComponent\s+[\w/\\.-]+django_components{SLASH}components{SLASH}dynamic\.py".format( r"DynamicComponent\s+django_components\.components\.dynamic\.DynamicComponent\s+[\w/\\.-]+django_components{SLASH}components{SLASH}dynamic\.py".format( # noqa: UP032
SLASH=SLASH SLASH=SLASH,
) ),
).search(output) ).search(output)
# Check that the output contains the test component # Check that the output contains the test component
assert re.compile( assert re.compile(
# TestComponent tests.test_command_list.TestComponentListCommand.test_list_all.<locals>.TestComponent tests/test_command_list.py # TestComponent tests.test_command_list.TestComponentListCommand.test_list_all.<locals>.TestComponent tests/test_command_list.py
r"TestComponent\s+tests\.test_command_list\.TestComponentListCommand\.test_list_all\.<locals>\.TestComponent\s+tests{SLASH}test_command_list\.py".format( r"TestComponent\s+tests\.test_command_list\.TestComponentListCommand\.test_list_all\.<locals>\.TestComponent\s+tests{SLASH}test_command_list\.py".format( # noqa: UP032
SLASH=SLASH SLASH=SLASH,
) ),
).search(output) ).search(output)
def test_list_specific_columns(self): def test_list_specific_columns(self):
@ -131,19 +133,19 @@ class TestComponentListCommand:
# Check first line of output # Check first line of output
assert re.compile( assert re.compile(
# name full_name # name full_name
r"name\s+full_name" r"name\s+full_name",
).search(output.strip().split("\n")[0]) ).search(output.strip().split("\n")[0])
# Check that the output contains the built-in component # Check that the output contains the built-in component
assert re.compile( assert re.compile(
# DynamicComponent django_components.components.dynamic.DynamicComponent # DynamicComponent django_components.components.dynamic.DynamicComponent
r"DynamicComponent\s+django_components\.components\.dynamic\.DynamicComponent" r"DynamicComponent\s+django_components\.components\.dynamic\.DynamicComponent",
).search(output) ).search(output)
# Check that the output contains the test component # Check that the output contains the test component
assert re.compile( assert re.compile(
# TestComponent tests.test_command_list.TestComponentListCommand.test_list_specific_columns.<locals>.TestComponent # TestComponent tests.test_command_list.TestComponentListCommand.test_list_specific_columns.<locals>.TestComponent
r"TestComponent\s+tests\.test_command_list\.TestComponentListCommand\.test_list_specific_columns\.<locals>\.TestComponent" r"TestComponent\s+tests\.test_command_list\.TestComponentListCommand\.test_list_specific_columns\.<locals>\.TestComponent",
).search(output) ).search(output)
def test_list_simple(self): def test_list_simple(self):
@ -166,25 +168,28 @@ class TestComponentListCommand:
# tests.test_command_list.TestComponentListCommand.test_list_simple.<locals>.TestComponent tests/test_command_list.py # tests.test_command_list.TestComponentListCommand.test_list_simple.<locals>.TestComponent tests/test_command_list.py
# Check first line of output is omitted # Check first line of output is omitted
assert re.compile( assert (
re.compile(
# full_name path # full_name path
r"full_name\s+path\s+" r"full_name\s+path\s+",
).search(output.strip().split("\n")[0]) is None ).search(output.strip().split("\n")[0])
is None
)
# Check that the output contains the built-in component # Check that the output contains the built-in component
assert re.compile( assert re.compile(
# django_components.components.dynamic.DynamicComponent src/django_components/components/dynamic.py # django_components.components.dynamic.DynamicComponent src/django_components/components/dynamic.py
# or # or
# django_components.components.dynamic.DynamicComponent .tox/py311/lib/python3.11/site-packages/django_components/components/dynamic.py # django_components.components.dynamic.DynamicComponent .tox/py311/lib/python3.11/site-packages/django_components/components/dynamic.py
r"django_components\.components\.dynamic\.DynamicComponent\s+[\w/\\.-]+django_components{SLASH}components{SLASH}dynamic\.py".format( r"django_components\.components\.dynamic\.DynamicComponent\s+[\w/\\.-]+django_components{SLASH}components{SLASH}dynamic\.py".format( # noqa: UP032
SLASH=SLASH SLASH=SLASH,
) ),
).search(output) ).search(output)
# Check that the output contains the test component # Check that the output contains the test component
assert re.compile( assert re.compile(
# tests.test_command_list.TestComponentListCommand.test_list_simple.<locals>.TestComponent tests/test_command_list.py # tests.test_command_list.TestComponentListCommand.test_list_simple.<locals>.TestComponent tests/test_command_list.py
r"tests\.test_command_list\.TestComponentListCommand\.test_list_simple\.<locals>\.TestComponent\s+tests{SLASH}test_command_list\.py".format( r"tests\.test_command_list\.TestComponentListCommand\.test_list_simple\.<locals>\.TestComponent\s+tests{SLASH}test_command_list\.py".format( # noqa: UP032
SLASH=SLASH SLASH=SLASH,
) ),
).search(output) ).search(output)

View file

@ -29,9 +29,9 @@ from django_components import (
types, types,
) )
from django_components.template import _get_component_template from django_components.template import _get_component_template
from django_components.testing import djc_test
from django_components.urls import urlpatterns as dc_urlpatterns from django_components.urls import urlpatterns as dc_urlpatterns
from django_components.testing import djc_test
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})
@ -44,11 +44,11 @@ class CustomClient(Client):
if urlpatterns: if urlpatterns:
urls_module = types.ModuleType("urls") urls_module = types.ModuleType("urls")
urls_module.urlpatterns = urlpatterns + dc_urlpatterns # type: ignore urls_module.urlpatterns = urlpatterns + dc_urlpatterns # type: ignore[attr-defined]
settings.ROOT_URLCONF = urls_module settings.ROOT_URLCONF = urls_module
else: else:
settings.ROOT_URLCONF = __name__ settings.ROOT_URLCONF = __name__
settings.SECRET_KEY = "secret" # noqa settings.SECRET_KEY = "secret" # noqa: S105
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -294,6 +294,7 @@ class TestComponentLegacyApi:
""", """,
) )
@djc_test @djc_test
class TestComponent: class TestComponent:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
@ -365,19 +366,21 @@ class TestComponent:
"builtins": [ "builtins": [
"django_components.templatetags.component_tags", "django_components.templatetags.component_tags",
], ],
'loaders': [ "loaders": [
('django.template.loaders.cached.Loader', [ (
"django.template.loaders.cached.Loader",
[
# Default Django loader # Default Django loader
'django.template.loaders.filesystem.Loader', "django.template.loaders.filesystem.Loader",
# Including this is the same as APP_DIRS=True # Including this is the same as APP_DIRS=True
'django.template.loaders.app_directories.Loader', "django.template.loaders.app_directories.Loader",
# Components loader # Components loader
'django_components.template_loader.Loader', "django_components.template_loader.Loader",
]), ],
),
], ],
}, },
} },
], ],
}, },
) )
@ -398,8 +401,8 @@ class TestComponent:
"variable": kwargs.get("variable", None), "variable": kwargs.get("variable", None),
} }
SimpleComponent1.template # Triggers template loading _ = SimpleComponent1.template # Triggers template loading
SimpleComponent2.template # Triggers template loading _ = SimpleComponent2.template # Triggers template loading
# Both components have their own Template instance, but they point to the same template file. # Both components have their own Template instance, but they point to the same template file.
assert isinstance(SimpleComponent1._template, Template) assert isinstance(SimpleComponent1._template, Template)
@ -692,7 +695,7 @@ class TestComponentRenderAPI:
rendered = Outer.render() rendered = Outer.render()
assert rendered == 'hello' assert rendered == "hello"
assert isinstance(comp, TestComponent) assert isinstance(comp, TestComponent)
@ -706,9 +709,9 @@ class TestComponentRenderAPI:
assert comp.node.template_component == Outer assert comp.node.template_component == Outer
if os.name == "nt": if os.name == "nt":
assert comp.node.template_name.endswith("tests\\test_component.py::Outer") # type: ignore assert comp.node.template_name.endswith("tests\\test_component.py::Outer") # type: ignore[union-attr]
else: else:
assert comp.node.template_name.endswith("tests/test_component.py::Outer") # type: ignore assert comp.node.template_name.endswith("tests/test_component.py::Outer") # type: ignore[union-attr]
def test_metadata__python(self): def test_metadata__python(self):
comp: Any = None comp: Any = None
@ -734,7 +737,7 @@ class TestComponentRenderAPI:
registered_name="test", registered_name="test",
) )
assert rendered == 'hello' assert rendered == "hello"
assert isinstance(comp, TestComponent) assert isinstance(comp, TestComponent)
@ -1463,7 +1466,7 @@ class TestComponentRender:
TypeError, TypeError,
match=re.escape( match=re.escape(
"An error occured while rendering components Root > parent > provider > provider(slot:content) > broken:\n" # noqa: E501 "An error occured while rendering components Root > parent > provider > provider(slot:content) > broken:\n" # noqa: E501
"tuple indices must be integers or slices, not str" "tuple indices must be integers or slices, not str",
), ),
): ):
Root.render() Root.render()
@ -1810,40 +1813,41 @@ class TestComponentHook:
parametrize=( parametrize=(
["template", "action", "method"], ["template", "action", "method"],
[ [
# on_render - return None
["simple", "return_none", "on_render"], ["simple", "return_none", "on_render"],
["broken", "return_none", "on_render"], ["broken", "return_none", "on_render"],
[None, "return_none", "on_render"], [None, "return_none", "on_render"],
# on_render_after - return None
["simple", "return_none", "on_render_after"], ["simple", "return_none", "on_render_after"],
["broken", "return_none", "on_render_after"], ["broken", "return_none", "on_render_after"],
[None, "return_none", "on_render_after"], [None, "return_none", "on_render_after"],
# on_render - no return
["simple", "no_return", "on_render"], ["simple", "no_return", "on_render"],
["broken", "no_return", "on_render"], ["broken", "no_return", "on_render"],
[None, "no_return", "on_render"], [None, "no_return", "on_render"],
# on_render_after - no return
["simple", "no_return", "on_render_after"], ["simple", "no_return", "on_render_after"],
["broken", "no_return", "on_render_after"], ["broken", "no_return", "on_render_after"],
[None, "no_return", "on_render_after"], [None, "no_return", "on_render_after"],
# on_render - raise error
["simple", "raise_error", "on_render"], ["simple", "raise_error", "on_render"],
["broken", "raise_error", "on_render"], ["broken", "raise_error", "on_render"],
[None, "raise_error", "on_render"], [None, "raise_error", "on_render"],
# on_render_after - raise error
["simple", "raise_error", "on_render_after"], ["simple", "raise_error", "on_render_after"],
["broken", "raise_error", "on_render_after"], ["broken", "raise_error", "on_render_after"],
[None, "raise_error", "on_render_after"], [None, "raise_error", "on_render_after"],
# on_render - return html
["simple", "return_html", "on_render"], ["simple", "return_html", "on_render"],
["broken", "return_html", "on_render"], ["broken", "return_html", "on_render"],
[None, "return_html", "on_render"], [None, "return_html", "on_render"],
# on_render_after - return html
["simple", "return_html", "on_render_after"], ["simple", "return_html", "on_render_after"],
["broken", "return_html", "on_render_after"], ["broken", "return_html", "on_render_after"],
[None, "return_html", "on_render_after"], [None, "return_html", "on_render_after"],
], ],
None None,
) ),
) )
def test_result_interception( def test_result_interception(
self, self,
@ -1863,11 +1867,13 @@ class TestComponentHook:
# Set template # Set template
if template is None: if template is None:
class Inner(Inner): # type: ignore
class Inner(Inner): # type: ignore # noqa: PGH003
template = None template = None
elif template == "broken": elif template == "broken":
class Inner(Inner): # type: ignore
class Inner(Inner): # type: ignore # noqa: PGH003
template = "{% component 'broken' / %}" template = "{% component 'broken' / %}"
elif template == "simple": elif template == "simple":
@ -1876,16 +1882,18 @@ class TestComponentHook:
# Set `on_render` behavior # Set `on_render` behavior
if method == "on_render": if method == "on_render":
if action == "return_none": if action == "return_none":
class Inner(Inner): # type: ignore
class Inner(Inner): # type: ignore # noqa: PGH003
def on_render(self, context: Context, template: Optional[Template]): def on_render(self, context: Context, template: Optional[Template]):
if template is None: if template is None:
yield None yield None
else: else:
html, error = yield template.render(context) html, error = yield template.render(context)
return None return None # noqa: PLR1711
elif action == "no_return": elif action == "no_return":
class Inner(Inner): # type: ignore
class Inner(Inner): # type: ignore # noqa: PGH003
def on_render(self, context: Context, template: Optional[Template]): def on_render(self, context: Context, template: Optional[Template]):
if template is None: if template is None:
yield None yield None
@ -1893,7 +1901,8 @@ class TestComponentHook:
html, error = yield template.render(context) html, error = yield template.render(context)
elif action == "raise_error": elif action == "raise_error":
class Inner(Inner): # type: ignore
class Inner(Inner): # type: ignore # noqa: PGH003
def on_render(self, context: Context, template: Optional[Template]): def on_render(self, context: Context, template: Optional[Template]):
if template is None: if template is None:
yield None yield None
@ -1902,37 +1911,68 @@ class TestComponentHook:
raise ValueError("ERROR_FROM_ON_RENDER") raise ValueError("ERROR_FROM_ON_RENDER")
elif action == "return_html": elif action == "return_html":
class Inner(Inner): # type: ignore
class Inner(Inner): # type: ignore # noqa: PGH003
def on_render(self, context: Context, template: Optional[Template]): def on_render(self, context: Context, template: Optional[Template]):
if template is None: if template is None:
yield None yield None
else: else:
html, error = yield template.render(context) html, error = yield template.render(context)
return "HTML_FROM_ON_RENDER" return "HTML_FROM_ON_RENDER"
else: else:
raise pytest.fail(f"Unknown action: {action}") raise pytest.fail(f"Unknown action: {action}")
# Set `on_render_after` behavior # Set `on_render_after` behavior
elif method == "on_render_after": elif method == "on_render_after":
if action == "return_none": if action == "return_none":
class Inner(Inner): # type: ignore
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501 class Inner(Inner): # type: ignore # noqa: PGH003
def on_render_after(
self,
context: Context,
template: Template,
html: Optional[str],
error: Optional[Exception],
):
return None return None
elif action == "no_return": elif action == "no_return":
class Inner(Inner): # type: ignore
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501 class Inner(Inner): # type: ignore # noqa: PGH003
def on_render_after(
self,
context: Context,
template: Template,
html: Optional[str],
error: Optional[Exception],
):
pass pass
elif action == "raise_error": elif action == "raise_error":
class Inner(Inner): # type: ignore
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501 class Inner(Inner): # type: ignore # noqa: PGH003
def on_render_after(
self,
context: Context,
template: Template,
html: Optional[str],
error: Optional[Exception],
):
raise ValueError("ERROR_FROM_ON_RENDER") raise ValueError("ERROR_FROM_ON_RENDER")
elif action == "return_html": elif action == "return_html":
class Inner(Inner): # type: ignore
def on_render_after(self, context: Context, template: Template, html: Optional[str], error: Optional[Exception]): # noqa: E501 class Inner(Inner): # type: ignore # noqa: PGH003
def on_render_after(
self,
context: Context,
template: Template,
html: Optional[str],
error: Optional[Exception],
):
return "HTML_FROM_ON_RENDER" return "HTML_FROM_ON_RENDER"
else: else:
raise pytest.fail(f"Unknown action: {action}") raise pytest.fail(f"Unknown action: {action}")
else: else:

View file

@ -1,10 +1,10 @@
import re import re
import time import time
import pytest
from django.core.cache import caches from django.core.cache import caches
from django.template import Template from django.template import Template
from django.template.context import Context from django.template.context import Context
import pytest
from django_components import Component, register from django_components import Component, register
from django_components.testing import djc_test from django_components.testing import djc_test
@ -209,7 +209,6 @@ class TestComponentCache:
assert component.cache.get_entry(expected_key) == "<!-- _RENDERED TestComponent_28880f,ca1bc3f,, -->Hello" assert component.cache.get_entry(expected_key) == "<!-- _RENDERED TestComponent_28880f,ca1bc3f,, -->Hello"
def test_cached_component_inside_include(self): def test_cached_component_inside_include(self):
@register("test_component") @register("test_component")
class TestComponent(Component): class TestComponent(Component):
template = "Hello" template = "Hello"
@ -223,7 +222,7 @@ class TestComponentCache:
{% block content %} {% block content %}
THIS_IS_IN_ACTUAL_TEMPLATE_SO_SHOULD_NOT_BE_OVERRIDDEN THIS_IS_IN_ACTUAL_TEMPLATE_SO_SHOULD_NOT_BE_OVERRIDDEN
{% endblock %} {% endblock %}
""" """,
) )
result = template.render(Context({})) result = template.render(Context({}))
@ -251,7 +250,7 @@ class TestComponentCache:
{% component "test_component" input="cake" %} {% component "test_component" input="cake" %}
ONE ONE
{% endcomponent %} {% endcomponent %}
""" """,
).render(Context({})) ).render(Context({}))
Template( Template(
@ -259,7 +258,7 @@ class TestComponentCache:
{% component "test_component" input="cake" %} {% component "test_component" input="cake" %}
ONE ONE
{% endcomponent %} {% endcomponent %}
""" """,
).render(Context({})) ).render(Context({}))
# Check if the cache entry is set # Check if the cache entry is set
@ -277,7 +276,7 @@ class TestComponentCache:
{% component "test_component" input="cake" %} {% component "test_component" input="cake" %}
TWO TWO
{% endcomponent %} {% endcomponent %}
""" """,
).render(Context({})) ).render(Context({}))
assert len(cache._cache) == 2 assert len(cache._cache) == 2
@ -339,12 +338,12 @@ class TestComponentCache:
return {"input": kwargs["input"]} return {"input": kwargs["input"]}
with pytest.raises( with pytest.raises(
ValueError, TypeError,
match=re.escape( match=re.escape(
"Cannot hash slot 'content' of component 'TestComponent' - Slot functions are unhashable." "Cannot hash slot 'content' of component 'TestComponent' - Slot functions are unhashable.",
), ),
): ):
TestComponent.render( TestComponent.render(
kwargs={"input": "cake"}, kwargs={"input": "cake"},
slots={"content": lambda ctx: "ONE"}, slots={"content": lambda _ctx: "ONE"},
) )

View file

@ -3,8 +3,8 @@ from dataclasses import field
from django.template import Context from django.template import Context
from django_components import Component, Default from django_components import Component, Default
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})

View file

@ -2,8 +2,9 @@ from django.template import Context, Template
from pytest_django.asserts import assertHTMLEqual from pytest_django.asserts import assertHTMLEqual
from django_components import Component, register, types from django_components import Component, register, types
from django_components.extensions.debug_highlight import apply_component_highlight, COLORS from django_components.extensions.debug_highlight import COLORS, apply_component_highlight
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})
@ -87,7 +88,7 @@ class TestComponentHighlight:
"extensions_defaults": { "extensions_defaults": {
"debug_highlight": {"highlight_components": True}, "debug_highlight": {"highlight_components": True},
}, },
} },
) )
def test_component_highlight_extension(self): def test_component_highlight_extension(self):
template = _prepare_template() template = _prepare_template()
@ -232,7 +233,7 @@ class TestComponentHighlight:
"extensions_defaults": { "extensions_defaults": {
"debug_highlight": {"highlight_slots": True}, "debug_highlight": {"highlight_slots": True},
}, },
} },
) )
def test_slot_highlight_extension(self): def test_slot_highlight_extension(self):
template = _prepare_template() template = _prepare_template()
@ -399,12 +400,14 @@ class TestComponentHighlight:
highlight_components = True highlight_components = True
highlight_slots = True highlight_slots = True
template = Template(""" template = Template(
"""
{% load component_tags %} {% load component_tags %}
{% component "inner" %} {% component "inner" %}
{{ content }} {{ content }}
{% endcomponent %} {% endcomponent %}
""") """,
)
rendered = template.render(Context({"content": "Hello, world!"})) rendered = template.render(Context({"content": "Hello, world!"}))
expected = """ expected = """

View file

@ -1,9 +1,10 @@
# ruff: noqa: E501
import os import os
import re import re
import sys import sys
from pathlib import Path from pathlib import Path
from textwrap import dedent from textwrap import dedent
from typing import Optional from typing import List, Optional
import pytest import pytest
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
@ -15,8 +16,8 @@ from django.utils.safestring import mark_safe
from pytest_django.asserts import assertHTMLEqual, assertInHTML from pytest_django.asserts import assertHTMLEqual, assertInHTML
from django_components import Component, autodiscover, registry, render_dependencies, types from django_components import Component, autodiscover, registry, render_dependencies, types
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})
@ -34,7 +35,7 @@ class TestMainMedia:
{% component_js_dependencies %} {% component_js_dependencies %}
{% component_css_dependencies %} {% component_css_dependencies %}
<div class='html-css-only'>Content</div> <div class='html-css-only'>Content</div>
""" """,
) )
css = ".html-css-only { color: blue; }" css = ".html-css-only { color: blue; }"
js = "console.log('HTML and JS only');" js = "console.log('HTML and JS only');"
@ -64,7 +65,7 @@ class TestMainMedia:
{% component_js_dependencies %} {% component_js_dependencies %}
{% component_css_dependencies %} {% component_css_dependencies %}
<div class='html-css-only'>Content</div> <div class='html-css-only'>Content</div>
""" """,
) )
assert TestComponent.css == ".html-css-only { color: blue; }" assert TestComponent.css == ".html-css-only { color: blue; }"
assert TestComponent.js == "console.log('HTML and JS only');" assert TestComponent.js == "console.log('HTML and JS only');"
@ -75,9 +76,9 @@ class TestMainMedia:
@djc_test( @djc_test(
django_settings={ django_settings={
"STATICFILES_DIRS": [ "STATICFILES_DIRS": [
os.path.join(Path(__file__).resolve().parent, "static_root"), Path(__file__).resolve().parent / "static_root",
], ],
} },
) )
def test_html_js_css_filepath_rel_to_component(self): def test_html_js_css_filepath_rel_to_component(self):
from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent
@ -96,7 +97,7 @@ class TestMainMedia:
{% component_js_dependencies %} {% component_js_dependencies %}
{% component_css_dependencies %} {% component_css_dependencies %}
{% component "test" variable="test" / %} {% component "test" variable="test" / %}
""" """,
).render(Context()) ).render(Context())
assertInHTML( assertInHTML(
@ -135,9 +136,9 @@ class TestMainMedia:
@djc_test( @djc_test(
django_settings={ django_settings={
"STATICFILES_DIRS": [ "STATICFILES_DIRS": [
os.path.join(Path(__file__).resolve().parent, "static_root"), Path(__file__).resolve().parent / "static_root",
], ],
} },
) )
def test_html_js_css_filepath_from_static(self): def test_html_js_css_filepath_from_static(self):
class TestComponent(Component): class TestComponent(Component):
@ -165,7 +166,7 @@ class TestMainMedia:
{% component_js_dependencies %} {% component_js_dependencies %}
{% component_css_dependencies %} {% component_css_dependencies %}
{% component "test" variable="test" / %} {% component "test" variable="test" / %}
""" """,
).render(Context()) ).render(Context())
assert 'Variable: <strong data-djc-id-ca1bc41="">test</strong>' in rendered assert 'Variable: <strong data-djc-id-ca1bc41="">test</strong>' in rendered
@ -180,12 +181,14 @@ class TestMainMedia:
# Check that the HTML / JS / CSS can be accessed on the component class # Check that the HTML / JS / CSS can be accessed on the component class
assert TestComponent.template == "Variable: <strong>{{ variable }}</strong>\n" assert TestComponent.template == "Variable: <strong>{{ variable }}</strong>\n"
# fmt: off
assert TestComponent.css == ( assert TestComponent.css == (
"/* Used in `MainMediaTest` tests in `test_component_media.py` */\n" "/* Used in `MainMediaTest` tests in `test_component_media.py` */\n"
".html-css-only {\n" ".html-css-only {\n"
" color: blue;\n" " color: blue;\n"
"}" "}"
) )
# fmt: on
assert TestComponent.js == ( assert TestComponent.js == (
'/* Used in `MainMediaTest` tests in `test_component_media.py` */\nconsole.log("HTML and JS only");\n' '/* Used in `MainMediaTest` tests in `test_component_media.py` */\nconsole.log("HTML and JS only");\n'
) )
@ -193,9 +196,9 @@ class TestMainMedia:
@djc_test( @djc_test(
django_settings={ django_settings={
"STATICFILES_DIRS": [ "STATICFILES_DIRS": [
os.path.join(Path(__file__).resolve().parent, "static_root"), Path(__file__).resolve().parent / "static_root",
], ],
} },
) )
def test_html_js_css_filepath_lazy_loaded(self): def test_html_js_css_filepath_lazy_loaded(self):
from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent
@ -216,7 +219,7 @@ class TestMainMedia:
# # Access the property to load the CSS # # Access the property to load the CSS
# _ = TestComponent.css # _ = TestComponent.css
assert AppLvlCompComponent._component_media.css == (".html-css-only {\n" " color: blue;\n" "}\n") # type: ignore[attr-defined] assert AppLvlCompComponent._component_media.css == (".html-css-only {\n color: blue;\n}\n") # type: ignore[attr-defined]
assert AppLvlCompComponent._component_media.css_file == "app_lvl_comp/app_lvl_comp.css" # type: ignore[attr-defined] assert AppLvlCompComponent._component_media.css_file == "app_lvl_comp/app_lvl_comp.css" # type: ignore[attr-defined]
# Also check JS and HTML while we're at it # Also check JS and HTML while we're at it
@ -341,7 +344,7 @@ class TestComponentMedia:
def test_media_custom_render_js(self): def test_media_custom_render_js(self):
class MyMedia(Media): class MyMedia(Media):
def render_js(self): def render_js(self):
tags: list[str] = [] tags: List[str] = []
for path in self._js: # type: ignore[attr-defined] for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined] abs_path = self.absolute_path(path) # type: ignore[attr-defined]
tags.append(f'<script defer src="{abs_path}"></script>') tags.append(f'<script defer src="{abs_path}"></script>')
@ -367,7 +370,7 @@ class TestComponentMedia:
def test_media_custom_render_css(self): def test_media_custom_render_css(self):
class MyMedia(Media): class MyMedia(Media):
def render_css(self): def render_css(self):
tags: list[str] = [] tags: List[str] = []
media = sorted(self._css) # type: ignore[attr-defined] media = sorted(self._css) # type: ignore[attr-defined]
for medium in media: for medium in media:
for path in self._css[medium]: # type: ignore[attr-defined] for path in self._css[medium]: # type: ignore[attr-defined]
@ -399,7 +402,7 @@ class TestComponentMedia:
@djc_test( @djc_test(
django_settings={ django_settings={
"INSTALLED_APPS": ("django_components", "tests"), "INSTALLED_APPS": ("django_components", "tests"),
} },
) )
def test_glob_pattern_relative_to_component(self): def test_glob_pattern_relative_to_component(self):
from tests.components.glob.glob import GlobComponent from tests.components.glob.glob import GlobComponent
@ -414,7 +417,7 @@ class TestComponentMedia:
@djc_test( @djc_test(
django_settings={ django_settings={
"INSTALLED_APPS": ("django_components", "tests"), "INSTALLED_APPS": ("django_components", "tests"),
} },
) )
def test_glob_pattern_relative_to_root_dir(self): def test_glob_pattern_relative_to_root_dir(self):
from tests.components.glob.glob import GlobComponentRootDir from tests.components.glob.glob import GlobComponentRootDir
@ -429,7 +432,7 @@ class TestComponentMedia:
@djc_test( @djc_test(
django_settings={ django_settings={
"INSTALLED_APPS": ("django_components", "tests"), "INSTALLED_APPS": ("django_components", "tests"),
} },
) )
def test_non_globs_not_modified(self): def test_non_globs_not_modified(self):
from tests.components.glob.glob import NonGlobComponentRootDir from tests.components.glob.glob import NonGlobComponentRootDir
@ -442,7 +445,7 @@ class TestComponentMedia:
@djc_test( @djc_test(
django_settings={ django_settings={
"INSTALLED_APPS": ("django_components", "tests"), "INSTALLED_APPS": ("django_components", "tests"),
} },
) )
def test_non_globs_not_modified_nonexist(self): def test_non_globs_not_modified_nonexist(self):
from tests.components.glob.glob import NonGlobNonexistComponentRootDir from tests.components.glob.glob import NonGlobNonexistComponentRootDir
@ -464,14 +467,17 @@ class TestComponentMedia:
assertInHTML('<link href="/path/to/style.css" media="all" rel="stylesheet">', rendered) assertInHTML('<link href="/path/to/style.css" media="all" rel="stylesheet">', rendered)
assertInHTML( assertInHTML(
'<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.0.2/chart.min.js"></script>', rendered '<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.0.2/chart.min.js"></script>',
rendered,
) )
assertInHTML( assertInHTML(
'<script src="http://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.0.2/chart.min.js"></script>', rendered '<script src="http://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.0.2/chart.min.js"></script>',
rendered,
) )
# `://` is escaped because Django's `Media.absolute_path()` doesn't consider `://` a valid URL # `://` is escaped because Django's `Media.absolute_path()` doesn't consider `://` a valid URL
assertInHTML( assertInHTML(
'<script src="%3A//cdnjs.cloudflare.com/ajax/libs/Chart.js/3.0.2/chart.min.js"></script>', rendered '<script src="%3A//cdnjs.cloudflare.com/ajax/libs/Chart.js/3.0.2/chart.min.js"></script>',
rendered,
) )
assertInHTML('<script src="/path/to/script.js"></script>', rendered) assertInHTML('<script src="/path/to/script.js"></script>', rendered)
@ -604,7 +610,7 @@ class TestMediaPathAsObject:
""" """
class MyStr(str): class MyStr(str):
pass __slots__ = ()
class SimpleComponent(Component): class SimpleComponent(Component):
template = """ template = """
@ -718,7 +724,7 @@ class TestMediaPathAsObject:
@djc_test( @djc_test(
django_settings={ django_settings={
"STATIC_URL": "static/", "STATIC_URL": "static/",
} },
) )
def test_works_with_static(self): def test_works_with_static(self):
"""Test that all the different ways of defining media files works with Django's staticfiles""" """Test that all the different ways of defining media files works with Django's staticfiles"""
@ -770,20 +776,20 @@ class TestMediaStaticfiles:
# NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic # NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic
# See https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STATICFILES_DIRS # See https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STATICFILES_DIRS
"STATIC_URL": "static/", "STATIC_URL": "static/",
"STATIC_ROOT": os.path.join(Path(__file__).resolve().parent, "static_root"), "STATIC_ROOT": Path(__file__).resolve().parent / "static_root",
# `django.contrib.staticfiles` MUST be installed for staticfiles resolution to work. # `django.contrib.staticfiles` MUST be installed for staticfiles resolution to work.
"INSTALLED_APPS": [ "INSTALLED_APPS": [
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django_components", "django_components",
], ],
} },
) )
def test_default_static_files_storage(self): def test_default_static_files_storage(self):
"""Test integration with Django's staticfiles app""" """Test integration with Django's staticfiles app"""
class MyMedia(Media): class MyMedia(Media):
def render_js(self): def render_js(self):
tags: list[str] = [] tags: List[str] = []
for path in self._js: # type: ignore[attr-defined] for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined] abs_path = self.absolute_path(path) # type: ignore[attr-defined]
tags.append(f'<script defer src="{abs_path}"></script>') tags.append(f'<script defer src="{abs_path}"></script>')
@ -818,7 +824,7 @@ class TestMediaStaticfiles:
# NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic # NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic
# See https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STATICFILES_DIRS # See https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STATICFILES_DIRS
"STATIC_URL": "static/", "STATIC_URL": "static/",
"STATIC_ROOT": os.path.join(Path(__file__).resolve().parent, "static_root"), "STATIC_ROOT": Path(__file__).resolve().parent / "static_root",
# NOTE: STATICFILES_STORAGE is deprecated since 5.1, use STORAGES instead # NOTE: STATICFILES_STORAGE is deprecated since 5.1, use STORAGES instead
# See https://docs.djangoproject.com/en/5.2/ref/settings/#storages # See https://docs.djangoproject.com/en/5.2/ref/settings/#storages
"STORAGES": { "STORAGES": {
@ -836,14 +842,14 @@ class TestMediaStaticfiles:
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django_components", "django_components",
], ],
} },
) )
def test_manifest_static_files_storage(self): def test_manifest_static_files_storage(self):
"""Test integration with Django's staticfiles app and ManifestStaticFilesStorage""" """Test integration with Django's staticfiles app and ManifestStaticFilesStorage"""
class MyMedia(Media): class MyMedia(Media):
def render_js(self): def render_js(self):
tags: list[str] = [] tags: List[str] = []
for path in self._js: # type: ignore[attr-defined] for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined] abs_path = self.absolute_path(path) # type: ignore[attr-defined]
tags.append(f'<script defer src="{abs_path}"></script>') tags.append(f'<script defer src="{abs_path}"></script>')
@ -889,7 +895,7 @@ class TestMediaRelativePath:
{% endcomponent %} {% endcomponent %}
{% endslot %} {% endslot %}
</div> </div>
""" # noqa """
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return {"shadowing_variable": "NOT SHADOWED"} return {"shadowing_variable": "NOT SHADOWED"}
@ -921,7 +927,7 @@ class TestMediaRelativePath:
"STATICFILES_DIRS": [ "STATICFILES_DIRS": [
Path(__file__).resolve().parent / "components", Path(__file__).resolve().parent / "components",
], ],
} },
) )
def test_component_with_relative_media_paths(self): def test_component_with_relative_media_paths(self):
registry.register(name="parent_component", component=self._gen_parent_component()) registry.register(name="parent_component", component=self._gen_parent_component())
@ -973,7 +979,7 @@ class TestMediaRelativePath:
"STATICFILES_DIRS": [ "STATICFILES_DIRS": [
Path(__file__).resolve().parent / "components", Path(__file__).resolve().parent / "components",
], ],
} },
) )
def test_component_with_relative_media_paths_as_subcomponent(self): def test_component_with_relative_media_paths_as_subcomponent(self):
registry.register(name="parent_component", component=self._gen_parent_component()) registry.register(name="parent_component", component=self._gen_parent_component())
@ -1010,7 +1016,7 @@ class TestMediaRelativePath:
"STATICFILES_DIRS": [ "STATICFILES_DIRS": [
Path(__file__).resolve().parent / "components", Path(__file__).resolve().parent / "components",
], ],
} },
) )
def test_component_with_relative_media_does_not_trigger_safestring_path_at__new__(self): def test_component_with_relative_media_does_not_trigger_safestring_path_at__new__(self):
""" """
@ -1036,8 +1042,8 @@ class TestMediaRelativePath:
# Mark the PathObj instances of 'relative_file_pathobj_component' so they won't raise # Mark the PathObj instances of 'relative_file_pathobj_component' so they won't raise
# error if PathObj.__str__ is triggered. # error if PathObj.__str__ is triggered.
CompCls = registry.get("relative_file_pathobj_component") CompCls = registry.get("relative_file_pathobj_component")
CompCls.Media.js[0].throw_on_calling_str = False # type: ignore CompCls.Media.js[0].throw_on_calling_str = False # type: ignore # noqa: PGH003
CompCls.Media.css["all"][0].throw_on_calling_str = False # type: ignore CompCls.Media.css["all"][0].throw_on_calling_str = False # type: ignore # noqa: PGH003
rendered = CompCls.render(kwargs={"variable": "abc"}) rendered = CompCls.render(kwargs={"variable": "abc"})

View file

@ -1,14 +1,14 @@
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import NamedTuple, Optional from typing import NamedTuple, Optional
from typing_extensions import NotRequired, TypedDict
import pytest import pytest
from django.template import Context from django.template import Context
from typing_extensions import NotRequired, TypedDict
from django_components import Component, Empty, Slot, SlotInput from django_components import Component, Empty, Slot, SlotInput
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})
@ -76,7 +76,7 @@ class TestComponentTyping:
kwargs=Button.Kwargs(name="name", age=123), kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots( slots=Button.Slots(
header="HEADER", header="HEADER",
footer=Slot(lambda ctx: "FOOTER"), footer=Slot(lambda _ctx: "FOOTER"),
), ),
) )
@ -124,7 +124,7 @@ class TestComponentTyping:
kwargs={"name": "name", "age": 123}, kwargs={"name": "name", "age": 123},
slots={ slots={
"header": "HEADER", "header": "HEADER",
"footer": Slot(lambda ctx: "FOOTER"), "footer": Slot(lambda _ctx: "FOOTER"),
}, },
) )
@ -206,7 +206,7 @@ class TestComponentTyping:
kwargs={"name": "name", "age": 123}, kwargs={"name": "name", "age": 123},
slots={ slots={
"header": "HEADER", "header": "HEADER",
"footer": Slot(lambda ctx: "FOOTER"), "footer": Slot(lambda _ctx: "FOOTER"),
}, },
) )
@ -314,7 +314,7 @@ class TestComponentTyping:
kwargs={"name": "name", "age": 123}, kwargs={"name": "name", "age": 123},
slots={ slots={
"header": "HEADER", "header": "HEADER",
"footer": Slot(lambda ctx: "FOOTER"), "footer": Slot(lambda _ctx: "FOOTER"),
}, },
) )
@ -397,7 +397,7 @@ class TestComponentTyping:
kwargs=Button.Kwargs(name="name", age=123), kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots( slots=Button.Slots(
header="HEADER", header="HEADER",
footer=Slot(lambda ctx: "FOOTER"), footer=Slot(lambda _ctx: "FOOTER"),
), ),
) )
@ -412,7 +412,7 @@ class TestComponentTyping:
kwargs=Button.Kwargs(name="name", age=123), kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots( slots=Button.Slots(
header="HEADER", header="HEADER",
footer=Slot(lambda ctx: "FOOTER"), footer=Slot(lambda _ctx: "FOOTER"),
), ),
) )
@ -427,7 +427,7 @@ class TestComponentTyping:
kwargs=Button.Kwargs(age=123), # type: ignore[call-arg] kwargs=Button.Kwargs(age=123), # type: ignore[call-arg]
slots=Button.Slots( slots=Button.Slots(
header="HEADER", header="HEADER",
footer=Slot(lambda ctx: "FOOTER"), footer=Slot(lambda _ctx: "FOOTER"),
), ),
) )
@ -438,7 +438,7 @@ class TestComponentTyping:
args=Button.Args(arg1="arg1", arg2="arg2"), args=Button.Args(arg1="arg1", arg2="arg2"),
kwargs=Button.Kwargs(name="name", age=123), kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots( # type: ignore[typeddict-item] slots=Button.Slots( # type: ignore[typeddict-item]
footer=Slot(lambda ctx: "FOOTER"), # Missing header footer=Slot(lambda _ctx: "FOOTER"), # Missing header
), ),
) )

View file

@ -4,16 +4,18 @@ import pytest
from django.conf import settings from django.conf import settings
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.template import Context, Template from django.template import Context, Template
from django.test import Client, SimpleTestCase from django.test import Client
from django.urls import path from django.urls import path
from pytest_django.asserts import assertInHTML
from django_components import Component, ComponentView, get_component_url, register, types from django_components import Component, ComponentView, get_component_url, register, types
from django_components.testing import djc_test
from django_components.urls import urlpatterns as dc_urlpatterns from django_components.urls import urlpatterns as dc_urlpatterns
from django_components.util.misc import format_url from django_components.util.misc import format_url
from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
# DO NOT REMOVE! # DO NOT REMOVE!
# #
# This is intentionally defined before `setup_test_config()` in order to test that # This is intentionally defined before `setup_test_config()` in order to test that
@ -40,16 +42,16 @@ class CustomClient(Client):
if urlpatterns: if urlpatterns:
urls_module = types.ModuleType("urls") urls_module = types.ModuleType("urls")
urls_module.urlpatterns = urlpatterns + dc_urlpatterns # type: ignore urls_module.urlpatterns = urlpatterns + dc_urlpatterns # type: ignore[attr-defined]
settings.ROOT_URLCONF = urls_module settings.ROOT_URLCONF = urls_module
else: else:
settings.ROOT_URLCONF = __name__ settings.ROOT_URLCONF = __name__
settings.SECRET_KEY = "secret" # noqa settings.SECRET_KEY = "secret" # noqa: S105
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@djc_test @djc_test
class TestComponentAsView(SimpleTestCase): class TestComponentAsView:
def test_render_component_from_template(self): def test_render_component_from_template(self):
@register("testcomponent") @register("testcomponent")
class MockComponentRequest(Component): class MockComponentRequest(Component):
@ -64,19 +66,19 @@ class TestComponentAsView(SimpleTestCase):
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return {"variable": kwargs["variable"]} return {"variable": kwargs["variable"]}
def render_template_view(request): def render_template_view(_request):
template = Template( template = Template(
""" """
{% load component_tags %} {% load component_tags %}
{% component "testcomponent" variable="TEMPLATE" %}{% endcomponent %} {% component "testcomponent" variable="TEMPLATE" %}{% endcomponent %}
""" """,
) )
return HttpResponse(template.render(Context({}))) return HttpResponse(template.render(Context({})))
client = CustomClient(urlpatterns=[path("test_template/", render_template_view)]) client = CustomClient(urlpatterns=[path("test_template/", render_template_view)])
response = client.get("/test_template/") response = client.get("/test_template/")
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertInHTML( assertInHTML(
'<input type="text" name="variable" value="TEMPLATE">', '<input type="text" name="variable" value="TEMPLATE">',
response.content.decode(), response.content.decode(),
) )
@ -100,8 +102,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())]) client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.get("/test/") response = client.get("/test/")
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertInHTML( assertInHTML(
'<input type="text" name="variable" value="GET">', '<input type="text" name="variable" value="GET">',
response.content.decode(), response.content.decode(),
) )
@ -124,8 +126,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())]) client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.get("/test/") response = client.get("/test/")
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertInHTML( assertInHTML(
'<input type="text" name="variable" value="GET">', '<input type="text" name="variable" value="GET">',
response.content.decode(), response.content.decode(),
) )
@ -150,8 +152,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())]) client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.post("/test/", {"variable": "POST"}) response = client.post("/test/", {"variable": "POST"})
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertInHTML( assertInHTML(
'<input type="text" name="variable" value="POST">', '<input type="text" name="variable" value="POST">',
response.content.decode(), response.content.decode(),
) )
@ -175,8 +177,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())]) client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.post("/test/", {"variable": "POST"}) response = client.post("/test/", {"variable": "POST"})
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertInHTML( assertInHTML(
'<input type="text" name="variable" value="POST">', '<input type="text" name="variable" value="POST">',
response.content.decode(), response.content.decode(),
) )
@ -198,8 +200,8 @@ class TestComponentAsView(SimpleTestCase):
view = MockComponentRequest.as_view() view = MockComponentRequest.as_view()
client = CustomClient(urlpatterns=[path("test/", view)]) client = CustomClient(urlpatterns=[path("test/", view)])
response = client.get("/test/") response = client.get("/test/")
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertInHTML( assertInHTML(
'<input type="text" name="variable" value="MockComponentRequest">', '<input type="text" name="variable" value="MockComponentRequest">',
response.content.decode(), response.content.decode(),
) )
@ -225,15 +227,9 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test_slot/", MockComponentSlot.as_view())]) client = CustomClient(urlpatterns=[path("test_slot/", MockComponentSlot.as_view())])
response = client.get("/test_slot/") response = client.get("/test_slot/")
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertIn( assert b"Hey, I'm Bob" in response.content
b"Hey, I'm Bob", assert b"Nice to meet you, Bob" in response.content
response.content,
)
self.assertIn(
b"Nice to meet you, Bob",
response.content,
)
def test_replace_slot_in_view_with_insecure_content(self): def test_replace_slot_in_view_with_insecure_content(self):
class MockInsecureComponentSlot(Component): class MockInsecureComponentSlot(Component):
@ -253,11 +249,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test_slot_insecure/", MockInsecureComponentSlot.as_view())]) client = CustomClient(urlpatterns=[path("test_slot_insecure/", MockInsecureComponentSlot.as_view())])
response = client.get("/test_slot_insecure/") response = client.get("/test_slot_insecure/")
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertNotIn( assert b"<script>" not in response.content
b"<script>",
response.content,
)
def test_replace_context_in_view(self): def test_replace_context_in_view(self):
class TestComponent(Component): class TestComponent(Component):
@ -273,11 +266,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test_context_django/", TestComponent.as_view())]) client = CustomClient(urlpatterns=[path("test_context_django/", TestComponent.as_view())])
response = client.get("/test_context_django/") response = client.get("/test_context_django/")
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertIn( assert b"Hey, I'm Bob" in response.content
b"Hey, I'm Bob",
response.content,
)
def test_replace_context_in_view_with_insecure_content(self): def test_replace_context_in_view_with_insecure_content(self):
class MockInsecureComponentContext(Component): class MockInsecureComponentContext(Component):
@ -293,11 +283,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test_context_insecure/", MockInsecureComponentContext.as_view())]) client = CustomClient(urlpatterns=[path("test_context_insecure/", MockInsecureComponentContext.as_view())])
response = client.get("/test_context_insecure/") response = client.get("/test_context_insecure/")
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertNotIn( assert b"<script>" not in response.content
b"<script>",
response.content,
)
def test_component_url(self): def test_component_url(self):
class TestComponent(Component): class TestComponent(Component):
@ -315,13 +302,16 @@ class TestComponentAsView(SimpleTestCase):
# Check that the query and fragment are correctly escaped # Check that the query and fragment are correctly escaped
component_url3 = get_component_url(TestComponent, query={"f'oo": "b ar&ba'z"}, fragment='q u"x') component_url3 = get_component_url(TestComponent, query={"f'oo": "b ar&ba'z"}, fragment='q u"x')
assert component_url3 == f"/components/ext/view/components/{TestComponent.class_id}/?f%27oo=b+ar%26ba%27z#q%20u%22x" # noqa: E501 assert (
component_url3
== f"/components/ext/view/components/{TestComponent.class_id}/?f%27oo=b+ar%26ba%27z#q%20u%22x"
)
# Merges query params from original URL # Merges query params from original URL
component_url4 = format_url( component_url4 = format_url(
"/components/ext/view/components/123?foo=123&bar=456#abc", "/components/ext/view/components/123?foo=123&bar=456#abc",
query={"foo": "new", "baz": "new2"}, query={"foo": "new", "baz": "new2"},
fragment='xyz', fragment="xyz",
) )
assert component_url4 == "/components/ext/view/components/123?foo=new&bar=456&baz=new2#xyz" assert component_url4 == "/components/ext/view/components/123?foo=new&bar=456&baz=new2#xyz"

View file

@ -5,9 +5,9 @@ from django.template import Context, RequestContext, Template
from pytest_django.asserts import assertHTMLEqual, assertInHTML from pytest_django.asserts import assertHTMLEqual, assertInHTML
from django_components import Component, register, registry, types from django_components import Component, register, registry, types
from django_components.testing import djc_test
from django_components.util.misc import gen_id from django_components.util.misc import gen_id
from django_components.testing import djc_test
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
setup_test_config({"autodiscover": False}) setup_test_config({"autodiscover": False})
@ -15,7 +15,7 @@ setup_test_config({"autodiscover": False})
# Context processor that generates a unique ID. This is used to test that the context # Context processor that generates a unique ID. This is used to test that the context
# processor is generated only once, as each time this is called, it should generate a different ID. # processor is generated only once, as each time this is called, it should generate a different ID.
def dummy_context_processor(request): def dummy_context_processor(request): # noqa: ARG001
return {"dummy": gen_id()} return {"dummy": gen_id()}
@ -94,7 +94,7 @@ def gen_parent_component():
{% endcomponent %} {% endcomponent %}
{% endslot %} {% endslot %}
</div> </div>
""" # noqa """ # noqa: E501
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return {"shadowing_variable": "NOT SHADOWED"} return {"shadowing_variable": "NOT SHADOWED"}
@ -118,7 +118,7 @@ def gen_parent_component_with_args():
{% endcomponent %} {% endcomponent %}
{% endslot %} {% endslot %}
</div> </div>
""" # noqa """ # noqa: E501
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return {"inner_parent_value": kwargs["parent_value"]} return {"inner_parent_value": kwargs["parent_value"]}
@ -135,7 +135,8 @@ def gen_parent_component_with_args():
class TestContext: class TestContext:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_nested_component_context_shadows_parent_with_unfilled_slots_and_component_tag( def test_nested_component_context_shadows_parent_with_unfilled_slots_and_component_tag(
self, components_settings, self,
components_settings,
): ):
registry.register(name="variable_display", component=gen_variable_display_component()) registry.register(name="variable_display", component=gen_variable_display_component())
registry.register(name="parent_component", component=gen_parent_component()) registry.register(name="parent_component", component=gen_parent_component())
@ -153,7 +154,8 @@ class TestContext:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_nested_component_instances_have_unique_context_with_unfilled_slots_and_component_tag( def test_nested_component_instances_have_unique_context_with_unfilled_slots_and_component_tag(
self, components_settings, self,
components_settings,
): ):
registry.register(name="variable_display", component=gen_variable_display_component()) registry.register(name="variable_display", component=gen_variable_display_component())
registry.register(name="parent_component", component=gen_parent_component()) registry.register(name="parent_component", component=gen_parent_component())
@ -184,7 +186,7 @@ class TestContext:
{% endcomponent %} {% endcomponent %}
{% endfill %} {% endfill %}
{% endcomponent %} {% endcomponent %}
""" # NOQA """ # noqa: E501
template = Template(template_str) template = Template(template_str)
rendered = template.render(Context()) rendered = template.render(Context())
@ -205,7 +207,7 @@ class TestContext:
{% endcomponent %} {% endcomponent %}
{% endfill %} {% endfill %}
{% endcomponent %} {% endcomponent %}
""" # NOQA """ # noqa: E501
template = Template(template_str) template = Template(template_str)
rendered = template.render(Context()) rendered = template.render(Context())
@ -214,7 +216,8 @@ class TestContext:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_nested_component_context_shadows_outer_context_with_unfilled_slots_and_component_tag( def test_nested_component_context_shadows_outer_context_with_unfilled_slots_and_component_tag(
self, components_settings, self,
components_settings,
): ):
registry.register(name="variable_display", component=gen_variable_display_component()) registry.register(name="variable_display", component=gen_variable_display_component())
registry.register(name="parent_component", component=gen_parent_component()) registry.register(name="parent_component", component=gen_parent_component())
@ -232,7 +235,8 @@ class TestContext:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_nested_component_context_shadows_outer_context_with_filled_slots( def test_nested_component_context_shadows_outer_context_with_filled_slots(
self, components_settings, self,
components_settings,
): ):
registry.register(name="variable_display", component=gen_variable_display_component()) registry.register(name="variable_display", component=gen_variable_display_component())
registry.register(name="parent_component", component=gen_parent_component()) registry.register(name="parent_component", component=gen_parent_component())
@ -245,7 +249,7 @@ class TestContext:
{% endcomponent %} {% endcomponent %}
{% endfill %} {% endfill %}
{% endcomponent %} {% endcomponent %}
""" # NOQA """ # noqa: E501
template = Template(template_str) template = Template(template_str)
rendered = template.render(Context({"shadowing_variable": "NOT SHADOWED"})) rendered = template.render(Context({"shadowing_variable": "NOT SHADOWED"}))
@ -324,7 +328,7 @@ class TestParentArgs:
[{"context_behavior": "isolated"}, "passed_in", ""], [{"context_behavior": "isolated"}, "passed_in", ""],
], ],
["django", "isolated"], ["django", "isolated"],
) ),
) )
def test_parent_args_available_in_slots(self, components_settings, first_val, second_val): def test_parent_args_available_in_slots(self, components_settings, first_val, second_val):
registry.register(name="incrementer", component=gen_incrementer_component()) registry.register(name="incrementer", component=gen_incrementer_component())
@ -473,7 +477,7 @@ class TestComponentsCanAccessOuterContext:
[{"context_behavior": "isolated"}, ""], [{"context_behavior": "isolated"}, ""],
], ],
["django", "isolated"], ["django", "isolated"],
) ),
) )
def test_simple_component_can_use_outer_context(self, components_settings, expected_value): def test_simple_component_can_use_outer_context(self, components_settings, expected_value):
registry.register(name="simple_component", component=gen_simple_component()) registry.register(name="simple_component", component=gen_simple_component())
@ -890,7 +894,8 @@ class TestContextProcessors:
assert list(context_processors_data.keys()) == ["csrf_token"] # type: ignore[union-attr] assert list(context_processors_data.keys()) == ["csrf_token"] # type: ignore[union-attr]
assert inner_request == request assert inner_request == request
@djc_test(django_settings={ @djc_test(
django_settings={
"TEMPLATES": [ "TEMPLATES": [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
@ -903,9 +908,10 @@ class TestContextProcessors:
"tests.test_context.dummy_context_processor", "tests.test_context.dummy_context_processor",
], ],
}, },
} },
], ],
}) },
)
def test_data_generated_only_once(self): def test_data_generated_only_once(self):
context_processors_data: Optional[Dict] = None context_processors_data: Optional[Dict] = None
context_processors_data_child: Optional[Dict] = None context_processors_data_child: Optional[Dict] = None
@ -932,8 +938,8 @@ class TestContextProcessors:
request = HttpRequest() request = HttpRequest()
TestParentComponent.render(request=request) TestParentComponent.render(request=request)
parent_data = cast(dict, context_processors_data) parent_data = cast("dict", context_processors_data)
child_data = cast(dict, context_processors_data_child) child_data = cast("dict", context_processors_data_child)
# Check that the context processors data is reused across the components with # Check that the context processors data is reused across the components with
# the same request. # the same request.

Some files were not shown because too many files have changed in this diff Show more