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
- 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
- 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.
@ -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_templatetags*.py` -- runs template tag tests (~10 seconds, 349 tests)
- Run linting and code quality checks:
- `black --check src/django_components` -- check code formatting (~1 second)
- `black src/django_components` -- format code
- `isort --check-only --diff src/django_components` -- check import sorting (~1 second)
- `flake8 .` -- run linting (~2 seconds)
- `ruff check .` -- run linting, and import sorting (~2 seconds)
- `ruff format .` -- format code
- `mypy .` -- run type checking (~10 seconds, may show some errors in tests)
- Use tox for comprehensive testing (requires network access):
- `tox -e black` -- run black in isolated environment
- `tox -e flake8` -- run flake8 in isolated environment
- `tox -e ruff` -- run ruff in isolated environment
- `tox` -- run full test matrix (multiple Python/Django versions). NEVER CANCEL: Takes 10-30 minutes.
### Sample Project Testing
@ -52,7 +49,7 @@ The package provides custom Django management commands:
## 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`
- 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')"`
@ -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
- Includes Playwright browser testing (requires `playwright install chromium --with-deps`)
- Documentation building uses mkdocs
- Pre-commit hooks run black, isort, and flake8
- Pre-commit hooks run ruff
### Time Expectations
- 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 .`
2. Make changes to source code in `src/django_components/`
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`
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`

View file

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

View file

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

View file

@ -1,8 +1,10 @@
from typing import Any
from asv_runner.benchmarks.timeraw import TimerawBenchmark, _SeparateProcessTimer
# 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.
@ -16,7 +18,7 @@ def _get_timer(self, *param):
"""
if param:
def func():
def func() -> Any:
# ---------- OUR CHANGES: ADDED RETURN STATEMENT ----------
return self.func(*param)
# ---------- OUR CHANGES END ----------

View file

@ -1,9 +1,9 @@
import os
import sys
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 typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
# NOTE: benchmark_name constraints:
@ -20,35 +20,35 @@ def benchmark(
number: Optional[int] = None,
min_run_count: Optional[int] = None,
include_in_quick_benchmark: bool = False,
**kwargs,
):
def decorator(func):
**kwargs: Any,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
# 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).
# This is done by setting DJC_BENCHMARK_QUICK=1 in the environment.
if os.getenv("DJC_BENCHMARK_QUICK") and not include_in_quick_benchmark:
# 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.
func.benchmark_name = "noop"
func.benchmark_name = "noop" # type: ignore[attr-defined]
return func
# "group_name" is our custom field, which we actually convert to asv's "benchmark_name"
if group_name is not None:
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"
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:
func.pretty_name = pretty_name
func.pretty_name = pretty_name # type: ignore[attr-defined]
if timeout is not None:
func.timeout = timeout
func.timeout = timeout # type: ignore[attr-defined]
if number is not None:
func.number = number
func.number = number # type: ignore[attr-defined]
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
for k, v in kwargs.items():
@ -60,11 +60,11 @@ def benchmark(
class VirtualModuleLoader(Loader):
def __init__(self, code_string):
def __init__(self, code_string: str) -> None:
self.code_string = code_string
def exec_module(self, module):
exec(self.code_string, module.__dict__)
def exec_module(self, module: ModuleType) -> None:
exec(self.code_string, module.__dict__) # noqa: S102
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).
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 .
```
## Running tests
Now you can run the tests to make sure everything works as expected:
```sh
@ -47,15 +49,39 @@ tox -e py38
NOTE: See the available environments in `tox.ini`.
And to run only linters, use:
## Linting and formatting
To check linting rules, run:
```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:
@ -64,13 +90,15 @@ pip install -r requirements-dev.txt
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
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?
@ -96,9 +124,10 @@ Use the [sampleproject](https://github.com/django-components/django-components/t
!!! 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
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:
1. Make sure you are inside `src/django_components_js`:
1. Navigate to `src/django_components_js`:
```sh
cd src/django_components_js

View file

@ -91,7 +91,7 @@ MyTable.render(
## 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`).
Then navigate to these URLs:

View file

@ -1,5 +1,5 @@
from pathlib import Path
from typing import List, Optional, Type
from typing import Any, List, Optional, Type
import griffe
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):
"""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:
return
@ -37,7 +37,7 @@ class RuntimeBasesExtension(griffe.Extension):
class SourceCodeExtension(griffe.Extension):
"""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:
return
@ -46,7 +46,7 @@ class SourceCodeExtension(griffe.Extension):
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
repo_url = load_config()["repo_url"].strip("/ ")
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
def generate_release_notes():
def generate_release_notes() -> None:
"""
Reads CHANGELOG.md, splits it into per-version pages,
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
(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()
# 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)"
nav_title = version_title_full
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")
nav_title += f" ({formatted_date})"

View file

@ -3,22 +3,22 @@
from functools import lru_cache
from importlib import import_module
from pathlib import Path
from typing import Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Union
import griffe
import yaml # type: ignore[import-untyped]
@lru_cache()
@lru_cache
def load_config() -> Dict:
mkdocs_config_str = Path("mkdocs.yml").read_text()
# 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
mkdocs_config = yaml.load(mkdocs_config_str, yaml.BaseLoader)
mkdocs_config = yaml.load(mkdocs_config_str, yaml.BaseLoader) # noqa: S506
return mkdocs_config
@lru_cache()
@lru_cache
def find_plugin(name: str) -> Optional[Dict]:
config = load_config()
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:
if isinstance(plugin, str):
plugin = {plugin: {}}
plugin_name, plugin_conf = list(plugin.items())[0]
plugin = {plugin: {}} # noqa: PLW2901
plugin_name, plugin_conf = next(iter(plugin.items()))
if plugin_name == name:
return plugin_conf
@ -43,7 +43,7 @@ def get_mkdocstrings_plugin_handler_options() -> Optional[Dict]:
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)
runtime_obj = getattr(module, obj.name)
return runtime_obj

View file

@ -42,19 +42,21 @@ from argparse import ArgumentParser
from importlib import import_module
from pathlib import Path
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
from django.conf import settings
from django.core.management.base import BaseCommand
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.node import BaseNode
from django_components.util.command import setup_parser_from_command
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`.
# However, `gen-files` plugin runs this file as a script, NOT as a module.
# That means that:
@ -71,7 +73,7 @@ from extensions import _format_source_code_html # noqa: E402
root = Path(__file__).parent.parent.parent
def gen_reference_api():
def gen_reference_api() -> None:
"""
Generate documentation for the Python API of `django_components`.
@ -109,14 +111,14 @@ def gen_reference_api():
# options:
# 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")
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`.
@ -142,17 +144,15 @@ def gen_reference_testing_api():
# options:
# 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")
mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_exceptions():
"""
Generate documentation for the Exception classes included in the Python API of `django_components`.
"""
def gen_reference_exceptions() -> None:
"""Generate documentation for the Exception classes included in the Python API of `django_components`."""
module = import_module("django_components")
preface = "<!-- Autogenerated by reference.py -->\n\n"
@ -178,14 +178,14 @@ def gen_reference_exceptions():
# options:
# 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")
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
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:
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):
continue
@ -236,7 +236,7 @@ def gen_reference_components():
f" show_root_heading: true\n"
f" show_signature: false\n"
f" separate_signature: false\n"
f" members: {members}\n"
f" members: {members}\n",
)
f.write("\n")
@ -244,10 +244,8 @@ def gen_reference_components():
mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_settings():
"""
Generate documentation for the settings of django-components, as defined by the `ComponentsSettings` class.
"""
def gen_reference_settings() -> None:
"""Generate documentation for the settings of django-components, as defined by the `ComponentsSettings` class."""
module = import_module("django_components.app_settings")
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_toc: false\n"
f" show_if_no_docstring: true\n"
f" show_labels: false\n"
f" show_labels: false\n",
)
f.write("\n")
@ -301,7 +299,7 @@ def gen_reference_settings():
# 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))
subclass_methods = set(dir(sub_class))
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.
dynamic_re = re.compile(r"Dynamic\(lambda\: (?P<code>.+)\)")
cleaned_snippet_lines = []
cleaned_snippet_lines: List[str] = []
for line in defaults_snippet_lines:
line = comment_re.split(line)[0].rstrip()
line = dynamic_re.sub(
curr_line = comment_re.split(line)[0].rstrip()
curr_line = dynamic_re.sub(
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)
return (
"### Settings defaults\n\n"
"Here's overview of all available settings and their defaults:\n\n"
+ f"```py\n{clean_defaults_snippet}\n```"
+ "\n\n"
f"```py\n{clean_defaults_snippet}\n```"
"\n\n"
)
def gen_reference_tagformatters():
def gen_reference_tagformatters() -> None:
"""
Generate documentation for all pre-defined TagFormatters included
in the Python API of `django_components`.
@ -387,7 +385,7 @@ def gen_reference_tagformatters():
formatted_instances = "\n".join(formatted_instances_lines)
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)
# Generate reference entry for each TagFormatter class.
@ -408,7 +406,7 @@ def gen_reference_tagformatters():
f" show_symbol_type_toc: false\n"
f" show_if_no_docstring: true\n"
f" show_labels: false\n"
f" members: false\n"
f" members: false\n",
)
f.write("\n")
@ -416,10 +414,8 @@ def gen_reference_tagformatters():
mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_urls():
"""
Generate documentation for all URLs (`urlpattern` entries) defined by django-components.
"""
def gen_reference_urls() -> None:
"""Generate documentation for all URLs (`urlpattern` entries) defined by django-components."""
module = import_module("django_components.urls")
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]))
def gen_reference_commands():
def gen_reference_commands() -> None:
"""
Generate documentation for all Django admin commands defined by django-components.
@ -474,7 +470,7 @@ def gen_reference_commands():
# becomes this:
# `usage: python manage.py components ext run [-h]`
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
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.
try:
obj_lineno = inspect.findsource(cmd_def_cls)[1]
except Exception:
except Exception: # noqa: BLE001
obj_lineno = None
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"{cmd_summary}\n\n"
f"{formatted_args}\n\n"
f"{cmd_desc}\n\n"
f"{cmd_desc}\n\n",
)
# Add 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`
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"{cmd_summary}\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)
def gen_reference_template_tags():
def gen_reference_template_tags() -> None:
"""
Generate documentation for all Django template tags defined by django-components,
like `{% slot %}`, `{% component %}`, etc.
@ -573,7 +569,7 @@ def gen_reference_template_tags():
f.write(
f"All following template tags are defined in\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):
@ -597,19 +593,21 @@ def gen_reference_template_tags():
# {% component [arg, ...] **kwargs [only] %}
# {% endcomponent %}
# ```
# fmt: off
f.write(
f"## {name}\n\n"
f"```django\n"
f"{tag_signature}\n"
f"```\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)
def gen_reference_template_variables():
def gen_reference_template_variables() -> None:
"""
Generate documentation for all variables that are available inside the component templates
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)
def gen_reference_extension_hooks():
"""
Generate documentation for the hooks that are available to the extensions.
"""
def gen_reference_extension_hooks() -> None:
"""Generate documentation for the hooks that are available to the extensions."""
module = import_module("django_components.extension")
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_toc: false\n"
f" show_if_no_docstring: true\n"
f" show_labels: false\n"
f" show_labels: false\n",
)
f.write("\n")
f.write(available_data)
@ -714,7 +710,7 @@ def gen_reference_extension_hooks():
f"::: {module.__name__}.{name}\n"
f" options:\n"
f" heading_level: 3\n"
f" show_if_no_docstring: true\n"
f" show_if_no_docstring: true\n",
)
f.write("\n")
@ -722,10 +718,8 @@ def gen_reference_extension_hooks():
mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_extension_commands():
"""
Generate documentation for the objects related to defining extension commands.
"""
def gen_reference_extension_commands() -> None:
"""Generate documentation for the objects related to defining extension commands."""
module = import_module("django_components")
preface = "<!-- Autogenerated by reference.py -->\n\n"
@ -753,7 +747,7 @@ def gen_reference_extension_commands():
f"::: {module.__name__}.{name}\n"
f" options:\n"
f" heading_level: 3\n"
f" show_if_no_docstring: true\n"
f" show_if_no_docstring: true\n",
)
f.write("\n")
@ -761,10 +755,8 @@ def gen_reference_extension_commands():
mkdocs_gen_files.set_edit_path(out_path, template_path)
def gen_reference_extension_urls():
"""
Generate documentation for the objects related to defining extension URLs.
"""
def gen_reference_extension_urls() -> None:
"""Generate documentation for the objects related to defining extension URLs."""
module = import_module("django_components")
preface = "<!-- Autogenerated by reference.py -->\n\n"
@ -792,7 +784,7 @@ def gen_reference_extension_urls():
f"::: {module.__name__}.{name}\n"
f" options:\n"
f" heading_level: 3\n"
f" show_if_no_docstring: true\n"
f" show_if_no_docstring: true\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.
"""
lines, start_line_index = inspect.getsourcelines(cls)
attrs_lines = []
attrs_lines: List[str] = []
ignore = True
for line in lines:
if ignore:
@ -863,10 +855,9 @@ def _extract_property_docstrings(cls: Type) -> Dict[str, str]:
ignore = False
continue
# Ignore comments
elif line.strip().startswith("#"):
if line.strip().startswith("#"):
continue
else:
attrs_lines.append(line)
attrs_lines.append(line)
attrs_docstrings = {}
curr_attr = None
@ -886,7 +877,7 @@ def _extract_property_docstrings(cls: Type) -> Dict[str, str]:
attrs_docstrings[curr_attr] = ""
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
# Found start of docstring
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
# 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
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)
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"""
urls: List[str] = []
@ -1077,7 +1068,7 @@ def _parse_command_args(cmd_inputs: str) -> Dict[str, List[Dict]]:
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)
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)
def gen_reference():
def gen_reference() -> None:
"""The entrypoint to generate all the reference documentation."""
# Set up Django settings so we can import `extensions`
if not settings.configured:
settings.configure(

View file

@ -10,8 +10,8 @@ description = "A way to create simple reusable template components in Django."
keywords = ["django", "components", "css", "js", "html"]
readme = "README.md"
authors = [
{name = "Emil Stenström", email = "emil@emilstenstrom.se"},
{name = "Juro Oravec", email = "juraj.oravec.josefson@gmail.com"},
{ name = "Emil Stenström", email = "emil@emilstenstrom.se" },
{ name = "Juro Oravec", email = "juraj.oravec.josefson@gmail.com" },
]
classifiers = [
"Framework :: Django",
@ -33,7 +33,7 @@ dependencies = [
'djc-core-html-parser>=1.0.2',
'typing-extensions>=4.12.2',
]
license = {text = "MIT"}
license = { text = "MIT" }
# See https://docs.pypi.org/project_metadata/#icons
[project.urls]
@ -68,39 +68,117 @@ exclude = '''
)/
'''
[tool.isort]
profile = "black"
line_length = 119
multi_line_output = 3
include_trailing_comma = "True"
known_first_party = "django_components"
[tool.flake8]
ignore = ['E302', 'W503']
max-line-length = 119
[tool.ruff]
line-length = 119
src = ["src", "tests"]
exclude = [
'migrations',
'__pycache__',
'manage.py',
'settings.py',
'env',
'.env',
'.venv',
'.tox',
'build',
"migrations",
"manage.py",
"settings.py",
"env",
".env",
# From mypy
"test_structures",
]
per-file-ignores = [
'tests/test_command_list.py:E501',
'tests/test_component_media.py:E501',
'tests/test_dependency_rendering.py:E501',
# See https://docs.astral.sh/ruff/linter/#rule-selection
[tool.ruff.lint]
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]
check_untyped_defs = true
ignore_missing_imports = true
exclude = [
'test_structures',
'build',
"test_structures",
"build",
]
[[tool.mypy.overrides]]
@ -110,14 +188,14 @@ disallow_untyped_defs = true
[tool.pytest.ini_options]
testpaths = [
"tests"
"tests",
]
asyncio_mode = "auto"
[tool.hatch.env]
requires = [
"hatch-mkdocs",
"hatch-pip-compile"
"hatch-pip-compile",
]
[tool.hatch.envs.default]
@ -126,11 +204,8 @@ dependencies = [
"djc-core-html-parser",
"tox",
"pytest",
"flake8",
"flake8-pyproject",
"isort",
"ruff",
"pre-commit",
"black",
"mypy",
]
type = "pip-compile"
@ -141,9 +216,7 @@ type = "pip-compile"
lock-filename = "requirements-docs.txt"
detached = false
# Dependencies are fetched automatically from the mkdocs.yml file with hatch-mkdocs
# We only add black for formatting code in the docs
dependencies = [
"black",
"pygments",
"pygments-djc",
"mkdocs-awesome-nav",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
# ruff: noqa: T201, S310
import re
import textwrap
from collections import defaultdict
@ -8,21 +9,21 @@ Version = Tuple[int, ...]
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]
def keys_from_content(content: str):
def keys_from_content(content: str) -> List[str]:
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:
response_content = response.read()
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,
'<section id="supported-versions">',
@ -37,13 +38,13 @@ def get_python_supported_version(url: str) -> list[Version]:
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:
response_content = response.read()
content = response_content.decode("utf-8")
def parse_supported_versions(content):
def parse_supported_versions(content: str) -> VersionMapping:
content = cut_by_content(
content,
'<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
def get_latest_version(url: str):
def get_latest_version(url: str) -> Version:
with request.urlopen(url) as response:
response_content = response.read()
@ -101,11 +102,11 @@ def get_latest_version(url: str):
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("."))
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)
for django_version, python_versions in django_to_python.items():
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
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)
def build_tox_envlist(python_to_django: VersionMapping):
def build_tox_envlist(python_to_django: VersionMapping) -> str:
lines_data = [
(
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()
]
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=" ")
def build_gh_actions_envlist(python_to_django: VersionMapping):
def build_gh_actions_envlist(python_to_django: VersionMapping) -> str:
lines_data = [
(
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()
]
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=" ")
def build_deps_envlist(python_to_django: VersionMapping):
def build_deps_envlist(python_to_django: VersionMapping) -> str:
all_django_versions = set()
for django_versions in python_to_django.values():
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=" ")
def build_pypi_classifiers(python_to_django: VersionMapping):
def build_pypi_classifiers(python_to_django: VersionMapping) -> str:
classifiers = []
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)
def build_readme(python_to_django: VersionMapping):
def build_readme(python_to_django: VersionMapping) -> str:
print(
textwrap.dedent(
"""\
| Python version | Django version |
|----------------|--------------------------|
""".rstrip()
)
""".rstrip(),
),
)
lines_data = [
(
@ -200,24 +201,25 @@ def build_readme(python_to_django: VersionMapping):
for python_version, django_versions in python_to_django.items()
]
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
def build_pyenv(python_to_django: VersionMapping):
def build_pyenv(python_to_django: VersionMapping) -> str:
lines = []
all_python_versions = python_to_django.keys()
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")
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']
lines = [
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
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()))
def main():
def main() -> None:
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/")
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.
"""
# ruff: noqa: T201,BLE001,PTH118
import argparse
import os
import re
import requests
import sys
import time
from collections import defaultdict, deque
from dataclasses import dataclass
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 bs4 import BeautifulSoup
import pathspec
import requests
from bs4 import BeautifulSoup
from django_components.util.misc import format_as_ascii_table
@ -77,7 +80,7 @@ IGNORED_PATHS = [
IGNORE_DOMAINS = [
"127.0.0.1",
"localhost",
"0.0.0.0",
"0.0.0.0", # noqa: S104
"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:
try:
with open(filepath, "rb") as f:
with filepath.open("rb") as f:
chunk = f.read(1024)
if b"\0" in chunk:
return True
@ -127,7 +156,7 @@ def load_gitignore(root: Path) -> pathspec.PathSpec:
gitignore = root / ".gitignore"
patterns = []
if gitignore.exists():
with open(gitignore) as f:
with gitignore.open() as f:
patterns = f.read().splitlines()
# Add additional ignored paths
patterns += IGNORED_PATHS
@ -153,29 +182,33 @@ def find_files(root: Path, spec: pathspec.PathSpec) -> List[Path]:
# Extract URLs from a file
def extract_urls_from_file(filepath: Path) -> List[Tuple[str, int, str, str]]:
urls = []
def extract_links_from_file(filepath: Path) -> List[Link]:
urls: List[Link] = []
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 match in URL_REGEX.finditer(line):
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:
print(f"[WARN] Could not read {filepath}: {e}", file=sys.stderr)
return urls
def get_base_url(url: str) -> str:
"""Return the URL without the fragment."""
return url.split("#", 1)[0]
def pick_next_url(domains, domain_to_urls, last_request_time):
"""
Pick the next (domain, url) to fetch, respecting REQUEST_DELAY per domain.
Returns (domain, url) or None if all are on cooldown or empty.
"""
# We validate the links by fetching them, reaching the (potentially 3rd party) servers.
# This can be slow, because servers am have rate limiting policies.
# 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: List[str],
domain_to_urls: Dict[str, Deque[str]],
last_request_time: Dict[str, float],
) -> Optional[Tuple[str, str]]:
now = time.time()
for domain in domains:
if not domain_to_urls[domain]:
@ -187,16 +220,23 @@ def pick_next_url(domains, domain_to_urls, last_request_time):
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).
If a URL is invalid, print a warning and skip fetching.
Skip URLs whose netloc matches IGNORE_DOMAINS.
Use round-robin scheduling per domain, with cooldown.
"""
url_cache: Dict[str, Union[requests.Response, Exception, str]] = {}
unique_base_urls = sorted(set(get_base_url(url) for _, _, _, url in all_urls))
all_url_results: FetchedResults = {}
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
# 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
domain_to_urls: DefaultDict[str, Deque[str]] = defaultdict(deque)
for url in unique_base_urls:
for url in base_urls:
parsed = urlparse(url)
if parsed.hostname and any(parsed.hostname == d for d in IGNORE_DOMAINS):
url_cache[url] = "SKIPPED"
all_url_results[url] = "SKIPPED"
continue
domain_to_urls[parsed.netloc].append(url)
@ -236,37 +276,83 @@ def validate_urls(all_urls):
domain, url = pick
# 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)")
done_count += 1
continue
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.")
done_count += 1
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:
resp = requests.get(
url, timeout=REQUEST_TIMEOUT, headers={"User-Agent": "django-components-link-checker/0.1"}
)
url_cache[url] = resp
# 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(
url,
allow_redirects=True,
timeout=REQUEST_TIMEOUT,
headers={"User-Agent": "django-components-link-checker/0.1"},
)
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}")
except Exception as err:
url_cache[url] = err
all_url_results[url] = err
print(f"ERROR: {err}")
last_request_time[domain] = time.time()
done_count += 1
return url_cache
return all_url_results
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 rewrite_links(links: List[Link], files: List[Path], dry_run: bool) -> None:
# Group by file for efficient rewriting
file_to_lines: Dict[str, List[str]] = {}
for filepath in files:
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]]]:
@ -279,16 +365,82 @@ def rewrite_url(url: str) -> Union[Tuple[None, None], Tuple[str, Union[str, re.P
if key.search(url):
return key.sub(repl, url), key
else:
raise ValueError(f"Invalid key type: {type(key)}")
raise TypeError(f"Invalid key type: {type(key)}")
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
headers = ["Type", "Details", "File", "URL"]
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)
@ -300,106 +452,59 @@ def output_summary(errors: List[Tuple[str, int, str, str, str]], output: str):
print(table + "\n")
# 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():
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Validate links and fragments in the codebase.")
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(
"--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)
files = find_files(root, spec)
print(f"Scanning {len(files)} files...")
all_urls: List[Tuple[str, int, str, str]] = []
for f in files:
if is_binary_file(f):
# Find links in those files
all_links: List[Link] = []
for filepath in files:
if is_binary_file(filepath):
continue
all_urls.extend(extract_urls_from_file(f))
all_links.extend(extract_links_from_file(filepath))
# HTTP request and caching step
url_cache = validate_urls(all_urls)
# --- URL rewriting logic ---
# Rewrite links in those files if requested
if args.rewrite:
# Group by file for efficient rewriting
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}")
rewrite_links(all_links, files, dry_run=args.dry_run)
return # After rewriting, skip error reporting
# --- Categorize the results / errors ---
errors = []
for file, lineno, line, url in all_urls:
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"))
# Otherwise proceed to validation of the URLs and fragments
# by first fetching the HTTP requests.
all_url_results = fetch_urls(all_links)
# After everything's fetched, check for errors.
errors = check_links_for_errors(all_links, all_url_results)
if not errors:
print("\nAll links and fragments are valid!")
return
# Format the errors into a table
output_summary(errors, args.output)
output_summary(errors, args.output or None)
if __name__ == "__main__":

View file

@ -76,7 +76,7 @@ from django_components.tag_formatter import (
component_shorthand_formatter,
)
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.routing import URLRoute, URLRouteHandler
from django_components.util.types import Empty
@ -85,12 +85,8 @@ from django_components.util.types import Empty
__all__ = [
"all_components",
"all_registries",
"AlreadyRegistered",
"autodiscover",
"BaseNode",
"cached_template",
"CommandArg",
"CommandArgGroup",
"CommandHandler",
@ -113,8 +109,6 @@ __all__ = [
"ComponentVars",
"ComponentView",
"ComponentsSettings",
"component_formatter",
"component_shorthand_formatter",
"ContextBehavior",
"Default",
"DependenciesStrategy",
@ -122,13 +116,6 @@ __all__ = [
"Empty",
"ExtensionComponentConfig",
"FillNode",
"format_attributes",
"get_component_by_class_id",
"get_component_dirs",
"get_component_files",
"get_component_url",
"import_libraries",
"merge_attributes",
"NotRegistered",
"OnComponentClassCreatedContext",
"OnComponentClassDeletedContext",
@ -140,10 +127,7 @@ __all__ = [
"OnRegistryDeletedContext",
"OnRenderGenerator",
"ProvideNode",
"register",
"registry",
"RegistrySettings",
"render_dependencies",
"ShorthandComponentFormatter",
"Slot",
"SlotContent",
@ -157,8 +141,24 @@ __all__ = [
"TagFormatterABC",
"TagProtectedError",
"TagResult",
"template_tag",
"types",
"URLRoute",
"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
from dataclasses import dataclass
from enum import Enum
@ -440,7 +441,7 @@ class ComponentsSettings(NamedTuple):
reload_on_template_change: Optional[bool] = None
"""Deprecated. Use
[`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
"""
@ -515,7 +516,7 @@ class ComponentsSettings(NamedTuple):
forbidden_static_files: Optional[List[Union[str, re.Pattern]]] = None
"""Deprecated. Use
[`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
"""
@ -693,7 +694,7 @@ class Dynamic(Generic[T]):
# for `COMPONENTS.dirs`, we do it lazily.
# NOTE 2: We show the defaults in the documentation, together with the comments
# (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.
#
# fmt: off
@ -757,7 +758,7 @@ class InternalSettings:
# 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.
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()
self._settings = ComponentsSettings(
@ -766,11 +767,13 @@ class InternalSettings:
dirs=default(components_settings.dirs, dirs_default),
app_dirs=default(components_settings.app_dirs, defaults.app_dirs),
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),
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),
# 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:
if self._settings is None:
self._load_settings()
return cast(ComponentsSettings, self._settings)
return cast("ComponentsSettings", self._settings)
def _prepare_extensions(self, new_settings: ComponentsSettings) -> List["ComponentExtension"]:
extensions: Sequence[Union[Type["ComponentExtension"], str]] = default(
new_settings.extensions, cast(List[str], defaults.extensions)
extensions: Sequence[Union[Type[ComponentExtension], str]] = default(
new_settings.extensions,
cast("List[str]", defaults.extensions),
)
# Prepend built-in extensions
@ -804,7 +808,7 @@ class InternalSettings:
from django_components.extensions.view import ViewExtension
extensions = cast(
List[Type["ComponentExtension"]],
"List[Type[ComponentExtension]]",
[
CacheExtension,
DefaultsExtension,
@ -815,12 +819,12 @@ class InternalSettings:
) + list(extensions)
# Extensions may be passed in either as classes or import strings.
extension_instances: List["ComponentExtension"] = []
extension_instances: List[ComponentExtension] = []
for extension in extensions:
if isinstance(extension, str):
import_path, class_name = extension.rsplit(".", 1)
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):
extension_instance = extension()
@ -837,7 +841,7 @@ class InternalSettings:
if val is None:
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]]:
val = new_settings.static_files_forbidden
@ -845,18 +849,18 @@ class InternalSettings:
if val is None:
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"]:
raw_value = cast(
Literal["django", "isolated"],
"Literal['django', 'isolated']",
default(new_settings.context_behavior, defaults.context_behavior),
)
try:
ContextBehavior(raw_value)
except ValueError:
except ValueError as err:
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

View file

@ -1,3 +1,4 @@
# ruff: noqa: PLC0415
import re
from pathlib import Path
from typing import Any
@ -76,7 +77,7 @@ def _watch_component_files_for_autoreload() -> None:
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
for dir_path in file_path.parents:
if dir_path in component_dirs:

View file

@ -70,11 +70,11 @@ class HtmlAttrsNode(BaseNode):
tag = "html_attrs"
end_tag = None # inline-only
allowed_flags = []
allowed_flags = ()
def render(
self,
context: Context,
context: Context, # noqa: ARG002
attrs: Optional[Dict] = None,
defaults: Optional[Dict] = None,
**kwargs: Any,
@ -269,7 +269,7 @@ def normalize_class(value: ClassValue) -> str:
res: Dict[str, bool] = {}
if isinstance(value, str):
return value.strip()
elif isinstance(value, (list, tuple)):
if isinstance(value, (list, tuple)):
# List items may be strings, dicts, or other lists/tuples
for item in value:
# 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"`
res = value
else:
raise ValueError(f"Invalid class value: {value}")
raise TypeError(f"Invalid class value: {value}")
res_str = ""
for key, val in res.items():
@ -313,7 +313,7 @@ def _normalize_class(value: ClassValue) -> Dict[str, bool]:
elif isinstance(value, dict):
res = value
else:
raise ValueError(f"Invalid class value: {value}")
raise TypeError(f"Invalid class value: {value}")
return res
@ -360,7 +360,7 @@ def normalize_style(value: StyleValue) -> str:
res: StyleDict = {}
if isinstance(value, str):
return value.strip()
elif isinstance(value, (list, tuple)):
if isinstance(value, (list, tuple)):
# List items may be strings, dicts, or other lists/tuples
for item in value:
normalized = _normalize_style(item)
@ -369,7 +369,7 @@ def normalize_style(value: StyleValue) -> str:
# Remove entries with `None` value
res = _normalize_style(value)
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.
# 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:
res[key] = val
else:
raise ValueError(f"Invalid style value: {value}")
raise TypeError(f"Invalid style value: {value}")
return res

View file

@ -40,6 +40,7 @@ def autodiscover(
modules = get_component_files(".py")
```
"""
modules = get_component_files(".py")
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."))
```
"""
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)
@ -93,7 +95,7 @@ def _import_modules(
imported_modules: List[str] = []
for module_name in modules:
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
# 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()`
def get_template_cache() -> LRUCache:
global template_cache
global template_cache # noqa: PLW0603
if template_cache is None:
template_cache = LRUCache(maxsize=app_settings.TEMPLATE_CACHE_SIZE)
@ -32,7 +32,7 @@ def get_component_media_cache() -> BaseCache:
return caches[app_settings.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:
component_media_cache = LocMemCache(
"django-components-media",

View file

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

View file

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

View file

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

View file

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

View file

@ -22,7 +22,7 @@ def _gen_subcommands() -> List[Type[ComponentCommand]]:
if not extension.commands:
continue
ExtCommand = type(
ExtCommand = type( # noqa: N806
"ExtRunSubcommand_" + extension.name,
(ComponentCommand,),
{
@ -113,4 +113,4 @@ class ExtRunCommand(ComponentCommand):
name = "run"
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
from typing import Any, Dict, List, Optional, Type
# ruff: noqa: T201
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.util.command import CommandArg, ComponentCommand
@ -48,8 +49,8 @@ class ListCommand(ComponentCommand):
# SUBCLASS API
####################
columns: List[str]
default_columns: List[str]
columns: ClassVar[Union[List[str], Tuple[str, ...], Set[str]]]
default_columns: ClassVar[Union[List[str], Tuple[str, ...], Set[str]]]
def get_data(self) -> List[Dict[str, Any]]:
return []
@ -60,7 +61,7 @@ class ListCommand(ComponentCommand):
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
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"
help = "List all components created in this project."
columns = ["name", "full_name", "path"]
default_columns = ["full_name", "path"]
columns = ("name", "full_name", "path")
default_columns = ("full_name", "path")
def get_data(self) -> List[Dict[str, Any]]:
components = all_components()
@ -158,13 +159,13 @@ class ComponentListCommand(ListCommand):
# Make paths relative to CWD
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(
{
"name": component.__name__,
"full_name": full_name,
"path": module_file_path,
}
},
)
return data

View file

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

View file

@ -1,7 +1,8 @@
# ruff: noqa: T201
import os
import re
from pathlib import Path
from typing import Any
from typing import Any, List
from django.conf import settings
from django.template.engine import Engine
@ -15,14 +16,14 @@ class UpgradeCommand(ComponentCommand):
name = "upgrade"
help = "Upgrade django components syntax from '{%% component_block ... %%}' to '{%% component ... %%}'."
arguments = [
arguments = (
CommandArg(
name_or_flags="--path",
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()
loader = DjcLoader(current_engine)
dirs = loader.get_dirs(include_apps=False)
@ -33,7 +34,7 @@ class UpgradeCommand(ComponentCommand):
if options["path"]:
dirs = [options["path"]]
all_files = []
all_files: List[Path] = []
for dir_path in dirs:
print(f"Searching for components in {dir_path}...")
@ -41,11 +42,11 @@ class UpgradeCommand(ComponentCommand):
for file in files:
if not file.endswith((".html", ".py")):
continue
file_path = os.path.join(root, file)
file_path = Path(root) / file
all_files.append(file_path)
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_with_closed_components, step0_count = re.subn(
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?
class UpgradeComponentCommand(UpgradeCommand):
"""
**Deprecated**. Use [`components upgrade`](../commands#components-upgrade) instead.
"""
"""**Deprecated**. Use [`components upgrade`](../commands#components-upgrade) instead."""
name = "upgradecomponent"
help = "Deprecated. Use `components upgrade` instead."

View file

@ -32,7 +32,7 @@ DJANGO_COMMAND_ARGS = [
default=1,
type=int,
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(
"--settings",
@ -44,7 +44,7 @@ DJANGO_COMMAND_ARGS = [
),
CommandArg(
"--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(
"--traceback",
@ -93,7 +93,7 @@ def load_as_django_command(command: Type[ComponentCommand]) -> Type[DjangoComman
def __init__(self) -> None:
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)
for arg in DJANGO_COMMAND_ARGS:
_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.
def handle(self, *args: Any, **options: Any) -> None:
# 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:
resolved_command.handle(*args, **options)
return
# 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:
cmd_parser.print_help()
return

View file

@ -1,8 +1,10 @@
# ruff: noqa: ARG002, N804, N805
import sys
from dataclasses import dataclass
from inspect import signature
from types import MethodType
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
@ -25,7 +27,6 @@ from django.template.base import NodeList, Parser, Template, Token
from django.template.context import Context, RequestContext
from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
from django.test.signals import template_rendered
from django.views import View
from django_components.app_settings import ContextBehavior
from django_components.component_media import ComponentMediaInput, ComponentMediaMeta
@ -40,11 +41,9 @@ from django_components.dependencies import (
cache_component_js,
cache_component_js_vars,
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,
)
from django_components.dependencies import render_dependencies as _render_dependencies
from django_components.extension import (
OnComponentClassCreatedContext,
OnComponentClassDeletedContext,
@ -85,14 +84,17 @@ from django_components.util.weakref import cached_ref
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
# isort: off
from django_components.component_registry import AlreadyRegistered as AlreadyRegistered # NOQA
from django_components.component_registry import ComponentRegistry as ComponentRegistry # NOQA
from django_components.component_registry import NotRegistered as NotRegistered # NOQA
from django_components.component_registry import register as register # NOQA
from django_components.component_registry import registry as registry # NOQA
from django_components.component_registry import AlreadyRegistered as AlreadyRegistered # noqa: PLC0414
from django_components.component_registry import ComponentRegistry as ComponentRegistry # noqa: PLC0414,F811
from django_components.component_registry import NotRegistered as NotRegistered # noqa: PLC0414
from django_components.component_registry import register as register # noqa: PLC0414
from django_components.component_registry import registry as registry # noqa: PLC0414
# isort: on
if TYPE_CHECKING:
from django.views import View
COMP_ONLY_FLAG = "only"
@ -160,7 +162,7 @@ ALL_COMPONENTS: AllComponents = []
def all_components() -> List[Type["Component"]]:
"""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:
comp = comp_ref()
if comp is not None:
@ -473,13 +475,13 @@ class ComponentMeta(ComponentMediaMeta):
attrs["template_file"] = attrs.pop("template_name")
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
# with that template file path.
# 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.
if "template_file" in attrs and attrs["template_file"]:
if attrs.get("template_file"):
cache_component_template_file(cls)
# TODO_V1 - Remove. This is only for backwards compatibility with v0.139 and earlier,
@ -493,7 +495,7 @@ class ComponentMeta(ComponentMediaMeta):
context: Context,
template: Template,
result: str,
error: Optional[Exception],
_error: Optional[Exception],
) -> Optional[SlotResult]:
return orig_on_render_after(self, context, template, result) # type: ignore[call-arg]
@ -507,7 +509,7 @@ class ComponentMeta(ComponentMediaMeta):
if not extensions:
return
comp_cls = cast(Type["Component"], cls)
comp_cls = cast("Type[Component]", cls)
extensions.on_component_class_deleted(OnComponentClassDeletedContext(comp_cls))
@ -826,6 +828,7 @@ class Component(metaclass=ComponentMeta):
Returns:
Optional[str]: The filepath to the template.
"""
return None
@ -915,11 +918,12 @@ class Component(metaclass=ComponentMeta):
Returns:
Optional[Union[str, Template]]: The inlined Django template string or\
a [`Template`](https://docs.djangoproject.com/en/5.1/topics/templates/#template) instance.
"""
return None
# 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.
Will be removed in v2.
@ -1788,7 +1792,7 @@ class Component(metaclass=ComponentMeta):
media_class = MyMediaClass
```
""" # noqa: E501
"""
Media: ClassVar[Optional[Type[ComponentMediaInput]]] = None
"""
@ -1832,7 +1836,7 @@ class Component(metaclass=ComponentMeta):
"print": ["path/to/style2.css"],
}
```
""" # noqa: E501
"""
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
every time a component is rendered.
"""
pass
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:
return None
else:
return template.render(context)
return template.render(context)
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]:
"""
Hook that runs when the component was fully rendered,
@ -2099,7 +2106,6 @@ class Component(metaclass=ComponentMeta):
print(f"Error: {error}")
```
"""
pass
# #####################################
# BUILT-IN EXTENSIONS
@ -2225,7 +2231,7 @@ class Component(metaclass=ComponentMeta):
self,
registered_name: Optional[str] = None,
outer_context: Optional[Context] = None,
registry: Optional[ComponentRegistry] = None, # noqa F811
registry: Optional[ComponentRegistry] = None, # noqa: F811
context: Optional[Context] = None,
args: Optional[Any] = None,
kwargs: Optional[Any] = None,
@ -2233,8 +2239,8 @@ class Component(metaclass=ComponentMeta):
deps_strategy: Optional[DependenciesStrategy] = None,
request: Optional[HttpRequest] = 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
# where one could do:
# `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 = MethodType(primed_render, 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[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.name = _get_component_name(self.__class__, registered_name)
@ -2293,9 +2299,9 @@ class Component(metaclass=ComponentMeta):
self.input = ComponentInput(
context=self.context,
# NOTE: Convert args / kwargs / slots to plain lists / dicts
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)),
slots=cast(Dict, slots if isinstance(self.slots, dict) else to_dict(self.slots)),
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)),
slots=cast("Dict", slots if isinstance(self.slots, dict) else to_dict(self.slots)),
deps_strategy=deps_strategy,
# TODO_v1 - Remove, superseded by `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)
""" # noqa: E501
"""
request: Optional[HttpRequest]
"""
@ -2882,8 +2888,7 @@ class Component(metaclass=ComponentMeta):
if request is None:
return {}
else:
return gen_context_processors_data(self.context, request)
return gen_context_processors_data(self.context, request)
# #####################################
# MISC
@ -2950,7 +2955,7 @@ class Component(metaclass=ComponentMeta):
def outer_view(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# `view` is a built-in extension defined in `extensions.view`. It subclasses
# 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.
inner_view = view_cls.as_view(**initkwargs, component=cls(), component_cls=cls)
@ -2971,13 +2976,13 @@ class Component(metaclass=ComponentMeta):
slots: Optional[Any] = None,
deps_strategy: DependenciesStrategy = "document",
# 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"`
render_dependencies: bool = True,
request: Optional[HttpRequest] = None,
outer_context: Optional[Context] = None,
# TODO_v2 - Remove `registered_name` and `registry`
registry: Optional[ComponentRegistry] = None,
registry: Optional[ComponentRegistry] = None, # noqa: F811
registered_name: Optional[str] = None,
node: Optional["ComponentNode"] = None,
**response_kwargs: Any,
@ -3061,13 +3066,13 @@ class Component(metaclass=ComponentMeta):
slots: Optional[Any] = None,
deps_strategy: DependenciesStrategy = "document",
# 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"`
render_dependencies: bool = True,
request: Optional[HttpRequest] = None,
outer_context: Optional[Context] = None,
# TODO_v2 - Remove `registered_name` and `registry`
registry: Optional[ComponentRegistry] = None,
registry: Optional[ComponentRegistry] = None, # noqa: F811
registered_name: Optional[str] = None,
node: Optional["ComponentNode"] = None,
) -> str:
@ -3253,13 +3258,12 @@ class Component(metaclass=ComponentMeta):
)
```
""" # noqa: E501
# TODO_v1 - Remove, superseded by `deps_strategy`
if type is not None:
if deps_strategy != "document":
raise ValueError(
"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
@ -3293,7 +3297,7 @@ class Component(metaclass=ComponentMeta):
request: Optional[HttpRequest] = None,
outer_context: Optional[Context] = None,
# TODO_v2 - Remove `registered_name` and `registry`
registry: Optional[ComponentRegistry] = None,
registry: Optional[ComponentRegistry] = None, # noqa: F811
registered_name: Optional[str] = None,
node: Optional["ComponentNode"] = None,
) -> str:
@ -3326,7 +3330,7 @@ class Component(metaclass=ComponentMeta):
request: Optional[HttpRequest] = None,
outer_context: Optional[Context] = None,
# TODO_v2 - Remove `registered_name` and `registry`
registry: Optional[ComponentRegistry] = None,
registry: Optional[ComponentRegistry] = None, # noqa: F811
registered_name: Optional[str] = None,
node: Optional["ComponentNode"] = None,
) -> str:
@ -3394,7 +3398,7 @@ class Component(metaclass=ComponentMeta):
kwargs=kwargs_dict,
slots=slots_dict,
context=context,
)
),
)
# 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
# See https://github.com/django-components/django-components/pull/859
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.
@ -3485,7 +3489,7 @@ class Component(metaclass=ComponentMeta):
template_data=template_data,
js_data=js_data,
css_data=css_data,
)
),
)
# 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 %}`
is_filled=component.is_filled,
),
}
},
):
# 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:
html = maybe_output
error = None
except Exception as new_error:
except Exception as new_error: # noqa: BLE001
error = new_error
html = None
@ -3619,7 +3623,7 @@ class Component(metaclass=ComponentMeta):
component_id=render_id,
result=html,
error=error,
)
),
)
if result is not None:
@ -3769,7 +3773,7 @@ class Component(metaclass=ComponentMeta):
if legacy_template_data and new_template_data:
raise RuntimeError(
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
@ -3896,13 +3900,13 @@ class ComponentNode(BaseNode):
tag = "component"
end_tag = "endcomponent"
allowed_flags = [COMP_ONLY_FLAG]
allowed_flags = (COMP_ONLY_FLAG,)
def __init__(
self,
# ComponentNode inputs
name: str,
registry: ComponentRegistry, # noqa F811
registry: ComponentRegistry, # noqa: F811
# BaseNode inputs
params: List[TagAttr],
flags: Optional[Dict[str, bool]] = None,
@ -3930,7 +3934,7 @@ class ComponentNode(BaseNode):
cls,
parser: Parser,
token: Token,
registry: ComponentRegistry, # noqa F811
registry: ComponentRegistry, # noqa: F811
name: str,
start_tag: str,
end_tag: str,
@ -3952,11 +3956,11 @@ class ComponentNode(BaseNode):
if cached_registry is not registry:
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(
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.

View file

@ -1,3 +1,4 @@
# ruff: noqa: PTH100, PTH118, PTH120, PTH207
import glob
import os
import sys
@ -254,7 +255,7 @@ class ComponentMediaInput(Protocol):
print(MyComponent.media._js) # ["script.js", "other1.js", "other2.js"]
```
""" # noqa: E501
"""
@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):
raise ImproperlyConfigured(
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
self._original = copy(self)
@ -338,7 +339,7 @@ class ComponentMediaMeta(type):
_normalize_media(attrs["Media"])
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)
@ -358,9 +359,9 @@ class ComponentMediaMeta(type):
if name in COMP_MEDIA_LAZY_ATTRS:
comp_media: Optional[ComponentMedia] = getattr(cls, "_component_media", None)
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"
" 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
@ -393,8 +394,7 @@ def _setup_lazy_media_resolve(comp_cls: Type["Component"], attrs: Dict[str, Any]
def get_comp_media_attr(attr: str) -> Any:
if attr == "media":
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.
# And because these fields are class attributes, we can't use `@property` decorator.
@ -432,27 +432,26 @@ 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
# 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.
def resolve_pair(inline_attr: str, file_attr: str) -> Any:
inline_attr_empty = getattr(comp_media, inline_attr, UNSET) is UNSET
file_attr_empty = getattr(comp_media, file_attr, UNSET) is UNSET
def is_pair_empty(inline_attr: str, file_attr: str) -> bool:
inline_attr_empty = getattr(comp_media, inline_attr, UNSET) is UNSET # noqa: B023
file_attr_empty = getattr(comp_media, file_attr, UNSET) is UNSET # noqa: B023
is_pair_empty = inline_attr_empty and file_attr_empty
if is_pair_empty:
return UNSET
else:
return value
return inline_attr_empty and file_attr_empty
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"):
value = resolve_pair("css", "css_file")
is_empty_pair = is_pair_empty("css", "css_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:
continue
else:
return value
return value
return None
@ -509,7 +508,7 @@ def _get_comp_cls_media(comp_cls: Type["Component"]) -> Any:
# pass
# ```
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)
# 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:
bases = curr_cls.__bases__
elif media_extend is False:
bases = tuple()
bases = ()
else:
bases = media_extend
@ -716,17 +715,17 @@ def _normalize_media(media: Type[ComponentMediaInput]) -> None:
for media_type, path_or_list in media.css.items():
# {"all": "style.css"}
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"]}
else:
media.css[media_type] = path_or_list # type: ignore
media.css[media_type] = path_or_list # type: ignore[misc]
else:
raise ValueError(f"Media.css must be str, list, or dict, got {type(media.css)}")
if hasattr(media, "js") and media.js:
# Allow: class Media: js = "script.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"]
else:
# 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:
# Case callable
if callable(filepath):
return True
# Case SafeString
if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"):
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
# Case bytes
if isinstance(filepath, bytes):
return True
if isinstance(filepath, str):
return True
return False
# Case str
return isinstance(filepath, str)
def _normalize_media_filepath(filepaths: Sequence[ComponentMediaInputPath]) -> List[Union[str, SafeData]]:
normalized: List[Union[str, SafeData]] = []
for filepath in filepaths:
if callable(filepath):
filepath = filepath()
filepath = filepath() # noqa: PLW2901
if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"):
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__"):
# 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):
filepath = filepath.decode("utf-8")
filepath = filepath.decode("utf-8") # noqa: PLW2901
if isinstance(filepath, str):
normalized.append(filepath)
@ -800,14 +801,16 @@ def _normalize_media_filepath(filepaths: Sequence[ComponentMediaInputPath]) -> L
raise ValueError(
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
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:
"""
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):
will_resolve_files = True
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
if not will_resolve_files:
@ -837,7 +841,7 @@ def _resolve_component_relative_files(
if not module_file_path:
logger.debug(
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
@ -851,7 +855,7 @@ def _resolve_component_relative_files(
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,"
" 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
@ -876,12 +880,11 @@ def _resolve_component_relative_files(
# NOTE: It's important to use `repr`, so we don't trigger __str__ on SafeStrings
if has_matched:
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:
logger.debug(
f"Interpreting file '{repr(filepath)}' of component '{module_name}'"
" relatively to components directory"
f"Interpreting file '{filepath!r}' of component '{module_name}' relatively to components directory",
)
return resolved_filepaths
@ -904,18 +907,18 @@ def _resolve_component_relative_files(
# Check if template name is a local file or not
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):
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):
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):
_map_media_filepaths(
comp_media.Media,
# 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`.
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,
@ -925,7 +928,7 @@ def _resolve_component_relative_files(
comp_media.Media,
# 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`.
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,12 +960,11 @@ def resolve_media_file(
if allow_glob and is_glob(filepath_abs_or_glob):
# Since globs are matched against the files, then we know that these files exist.
matched_abs_filepaths = glob.glob(filepath_abs_or_glob)
# But if we were given non-glob file path, then we need to check if it exists.
elif Path(filepath_abs_or_glob).exists():
matched_abs_filepaths = [filepath_abs_or_glob]
else:
# But if we were given non-glob file path, then we need to check if it exists.
if Path(filepath_abs_or_glob).exists():
matched_abs_filepaths = [filepath_abs_or_glob]
else:
matched_abs_filepaths = []
matched_abs_filepaths = []
# If there are no matches, return the original filepath
if not matched_abs_filepaths:
@ -1082,7 +1084,7 @@ def _get_asset(
if asset_content is not UNSET and asset_file is not UNSET:
raise ValueError(
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.
@ -1108,7 +1110,7 @@ def _get_asset(
if asset_file is None:
return None, None
asset_file = cast(str, asset_file)
asset_file = cast("str", asset_file)
if inlined_attr == "template":
# NOTE: `load_component_template()` applies `on_template_loaded()` and `on_template_compiled()` hooks.
@ -1139,14 +1141,14 @@ def _get_asset(
OnJsLoadedContext(
component_cls=comp_cls,
content=content,
)
),
)
elif inlined_attr == "css":
content = extensions.on_css_loaded(
OnCssLoadedContext(
component_cls=comp_cls,
content=content,
)
),
)
return content, None

View file

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

View file

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

View file

@ -70,8 +70,7 @@ def _gen_cache_key(
) -> str:
if 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}"
def _is_script_in_cache(
@ -94,7 +93,6 @@ def _cache_script(
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.
"""
# E.g. `__components:MyButton:js:df7c6d10`
if script_type in ("js", "css"):
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.
"""
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):
return None
return
_cache_script(
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.
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.
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:
raise RuntimeError(
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>"
@ -177,10 +175,10 @@ def cache_component_css(comp_cls: Type["Component"], force: bool) -> None:
times, this CSS is loaded only once.
"""
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):
return None
return
_cache_script(
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.
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.
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:
raise RuntimeError(
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>"
@ -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()`
# - css - Cache key for the CSS data from `get_css_data()`
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`
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`
MAYBE_COMP_CSS_ID = r'(?: data-djc-css-\w{6}="")?'
@ -358,7 +356,7 @@ PLACEHOLDER_REGEX = re.compile(
r"{css_placeholder}|{js_placeholder}".format(
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>',
).encode()
).encode(),
)
@ -400,10 +398,11 @@ def render_dependencies(content: TContent, strategy: DependenciesStrategy = "doc
return HttpResponse(processed_html)
```
"""
if strategy not in DEPS_STRATEGIES:
raise ValueError(f"Invalid strategy '{strategy}'")
elif strategy == "ignore":
if strategy == "ignore":
return content
is_safestring = isinstance(content, SafeString)
@ -411,7 +410,7 @@ def render_dependencies(content: TContent, strategy: DependenciesStrategy = "doc
if isinstance(content, str):
content_ = content.encode()
else:
content_ = cast(bytes, content)
content_ = cast("bytes", content)
content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, strategy)
@ -438,7 +437,7 @@ def render_dependencies(content: TContent, strategy: DependenciesStrategy = "doc
else:
raise RuntimeError(
"Unexpected error: Regex for component dependencies processing"
f" matched unknown string '{match[0].decode()}'"
f" matched unknown string '{match[0].decode()}'",
)
return replacement
@ -469,7 +468,7 @@ def render_dependencies(content: TContent, strategy: DependenciesStrategy = "doc
# Return the same type as we were given
output = content_.decode() if isinstance(content, str) else content_
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
@ -531,7 +530,7 @@ def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) ->
`<!-- _RENDERED table_10bac31,123,a92ef298,bd002c3 -->`
"""
# 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:
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)
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)
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
# loaded, so we have to mark those scripts as loaded in the dependency manager.
*(media_css_urls if strategy == "document" else []),
]
],
)
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
# loaded, so we have to mark those scripts as loaded in the dependency manager.
*(media_js_urls if strategy == "document" else []),
]
],
)
# 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(
[
# JS by us
*[tag for tag in core_script_tags],
*core_script_tags,
# Make calls to the JS dependency manager
# Loads JS from `Media.js` and `Component.js` if fragment
*([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.
*(media_js_tags if strategy in ("document", "simple", "prepend", "append") else []),
# JS variables
*[tag for tag in js_variables_tags],
*js_variables_tags,
# JS from `Component.js` (if not fragment)
*[tag for tag in component_js_tags],
]
*component_js_tags,
],
)
final_css_tags = "".join(
@ -707,14 +706,14 @@ def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) ->
# CSS by us
# <NONE>
# CSS from `Component.css` (if not fragment)
*[tag for tag in component_css_tags],
*component_css_tags,
# CSS variables
*[tag for tag in css_variables_tags],
*css_variables_tags,
# CSS from `Media.css` (plus from `Component.css` if fragment)
# 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.
*[tag for tag in media_css_tags],
]
*media_css_tags,
],
)
return (content, final_script_tags.encode("utf-8"), final_css_tags.encode("utf-8"))
@ -748,10 +747,10 @@ def _postprocess_media_tags(
raise RuntimeError(
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"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
if url in tags_by_url:
@ -770,7 +769,7 @@ def _prepare_tags_and_urls(
data: List[Tuple[str, ScriptType, Optional[str]]],
strategy: DependenciesStrategy,
) -> 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
inlined_js_tags: List[str] = []
@ -859,7 +858,7 @@ def get_script_tag(
content = get_script_content(script_type, comp_cls, input_hash)
if content is None:
raise RuntimeError(
f"Could not find {script_type.upper()} for component '{comp_cls.__name__}' (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":
@ -979,8 +978,8 @@ def _insert_js_css_to_default_locations(
if did_modify_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,
input_hash: Optional[str] = None,
) -> 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":
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."""
if type == "css":
if dep_type == "css":
placeholder = CSS_DEPENDENCY_PLACEHOLDER
elif type == "js":
elif dep_type == "js":
placeholder = JS_DEPENDENCY_PLACEHOLDER
else:
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)
@ -1066,9 +1065,9 @@ class ComponentCssDependenciesNode(BaseNode):
tag = "component_css_dependencies"
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")
@ -1088,7 +1087,7 @@ class ComponentJsDependenciesNode(BaseNode):
tag = "component_js_dependencies"
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")

View file

@ -81,18 +81,18 @@ class DynamicFilterExpression:
# to avoid it being stringified
if isinstance(node, VariableNode):
return node.filter_expression.resolve(context)
else:
# 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.
return node.render(context)
else:
# 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.
# However, we want to support tags that return non-string results, so we can pass
# them as inputs to components. So we wrap the nodes in `StringifiedNode`
nodelist = NodeList(StringifiedNode(node) for node in self.nodelist)
return nodelist.render(context)
# 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.
return node.render(context)
# 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.
# However, we want to support tags that return non-string results, so we can pass
# them as inputs to components. So we wrap the nodes in `StringifiedNode`
nodelist = NodeList(StringifiedNode(node) for node in self.nodelist)
return nodelist.render(context)
class StringifiedNode(Node):
@ -127,23 +127,20 @@ DYNAMIC_EXPR_RE = re.compile(
comment_tag=r"(?:\{#.*?#\})",
start_quote=r"(?P<quote>['\"])", # NOTE: Capture group so we check for the same quote at the end
end_quote=r"(?P=quote)",
)
),
)
def is_dynamic_expression(value: Any) -> bool:
# NOTE: Currently dynamic expression need at least 6 characters
# 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:
return False
# Is not wrapped in quotes, or does not contain any tags
if not DYNAMIC_EXPR_RE.match(value):
return False
return True
return bool(DYNAMIC_EXPR_RE.match(value))
# 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
"fallthrough attributes", and sufficiently easy for component authors to process
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)
@ -233,7 +231,7 @@ def process_aggregate_kwargs(params: List["TagParam"]) -> List["TagParam"]:
if key in seen_keys:
raise TemplateSyntaxError(
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))
@ -256,7 +254,7 @@ def _check_kwargs_for_agg_conflict(params: List["TagParam"]) -> None:
): # fmt: skip
raise TemplateSyntaxError(
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:

View file

@ -555,7 +555,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
ctx.component_cls.my_attr = "my_value"
```
"""
pass
def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
"""
@ -577,7 +576,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
self.cache.pop(ctx.component_cls, None)
```
"""
pass
def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
"""
@ -599,7 +597,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
ctx.registry.my_attr = "my_value"
```
"""
pass
def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
"""
@ -621,7 +618,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
self.cache.pop(ctx.registry, None)
```
"""
pass
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}'")
```
"""
pass
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}'")
```
"""
pass
###########################
# Component render hooks
@ -712,7 +706,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
[`Component.slots`](./api.md#django_components.Component.slots)
are plain `list` / `dict` objects.
"""
pass
def on_component_data(self, ctx: OnComponentDataContext) -> None:
"""
@ -739,7 +732,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
ctx.template_data["my_template_var"] = "my_value"
```
"""
pass
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
"""
@ -797,7 +789,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
print(f"Result: {ctx.result}")
```
"""
pass
##########################
# Template / JS / CSS hooks
@ -826,7 +817,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
return ctx.content.replace("Hello", "Hi")
```
"""
pass
def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None:
"""
@ -849,7 +839,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
print(f"Template origin: {ctx.template.origin.name}")
```
"""
pass
def on_css_loaded(self, ctx: OnCssLoadedContext) -> Optional[str]:
"""
@ -874,7 +863,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
return ctx.content.replace("Hello", "Hi")
```
"""
pass
def on_js_loaded(self, ctx: OnJsLoadedContext) -> Optional[str]:
"""
@ -899,7 +887,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
return ctx.content.replace("Hello", "Hi")
```
"""
pass
##########################
# Tags lifecycle hooks
@ -944,7 +931,6 @@ class ComponentExtension(metaclass=ExtensionMeta):
print(f"Slot owner: {slot_owner}")
```
"""
pass
# 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:
if not self._initialized:
self._events.append((fn_name, ctx))
return
return None
return func(self, ctx)
@ -1051,13 +1037,13 @@ class ExtensionManager:
extension_defaults = all_extensions_defaults.get(extension.name, None)
if 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)
if 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
# `component_cls`.
@ -1118,7 +1104,7 @@ class ExtensionManager:
urls: List[URLResolver] = []
seen_names: Set[str] = set()
from django_components import Component
from django_components import Component # noqa: PLC0415
for extension in self.extensions:
# Ensure that the extension name won't conflict with existing Component class API
@ -1297,7 +1283,7 @@ class ExtensionManager:
for extension in self.extensions:
try:
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
ctx = ctx._replace(result=None, error=error)
else:

View file

@ -120,7 +120,7 @@ class ComponentCache(ExtensionComponentConfig):
if self.include_slots:
cache_key += ":" + self.hash_slots(slots)
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
def hash(self, args: List, kwargs: Dict) -> str:
@ -141,10 +141,10 @@ class ComponentCache(ExtensionComponentConfig):
hash_parts = []
for key, slot in sorted_items:
if callable(slot.contents):
raise ValueError(
raise TypeError(
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"
" with `Cache.include_slots=False`."
" with `Cache.include_slots=False`.",
)
hash_parts.append(f"{key}-{slot.contents}")
return ",".join(hash_parts)
@ -175,8 +175,8 @@ class CacheExtension(ComponentExtension):
ComponentConfig = ComponentCache
def __init__(self, *args: Any, **kwargs: Any):
self.render_id_to_cache_key: dict[str, str] = {}
def __init__(self, *_args: Any, **_kwargs: Any) -> None:
self.render_id_to_cache_key: Dict[str, str] = {}
def on_component_input(self, ctx: OnComponentInputContext) -> Optional[Any]:
cache_instance = ctx.component.cache
@ -196,7 +196,7 @@ class CacheExtension(ComponentExtension):
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> None:
cache_instance = ctx.component.cache
if not cache_instance.enabled:
return None
return
if ctx.error is not None:
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.
This is part of the component / slot highlighting feature. User can toggle on
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,
# 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"""
<style>
.{type}-highlight-{highlight_id}::before {{
.{highlight_type}-highlight-{highlight_id}::before {{
content: "{name}: ";
font-weight: bold;
color: {color.text_color};
}}
</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}
</div>
"""

View file

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

View file

@ -27,7 +27,7 @@ else:
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:
@ -143,7 +143,7 @@ class ComponentView(ExtensionComponentConfig, View):
```
"""
component_cls = cast(Type["Component"], None)
component_cls = cast("Type[Component]", None)
"""
The parent component class.
@ -220,28 +220,28 @@ class ComponentView(ExtensionComponentConfig, View):
# `return self.component_cls.render_to_response(request, *args, **kwargs)` or similar
# or raise NotImplementedError.
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:
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:
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:
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:
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:
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:
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:
return getattr(self.component_cls(), "trace")(request, *args, **kwargs)
return self.component_cls().trace(request, *args, **kwargs) # type: ignore[attr-defined]
class ViewExtension(ComponentExtension):

View file

@ -1,11 +1,12 @@
import os
import re
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
from django import VERSION as DJANGO_VERSION
from django.contrib.staticfiles.finders import BaseFinder
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.utils._os import safe_join
@ -34,7 +35,7 @@ class ComponentsFileSystemFinder(BaseFinder):
- 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()]
# 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] = {}
for root in component_dirs:
if isinstance(root, (list, tuple)):
prefix, root = root
prefix, root = root # noqa: PLW2901
else:
prefix = ""
if (prefix, root) not in self.locations:
@ -60,41 +61,39 @@ class ComponentsFileSystemFinder(BaseFinder):
super().__init__(*args, **kwargs)
# NOTE: Based on `FileSystemFinder.check`
def check(self, **kwargs: Any) -> List[CheckMessage]:
errors: List[CheckMessage] = []
def check(self, **_kwargs: Any) -> List[checks.CheckMessage]:
errors: List[checks.CheckMessage] = []
if not isinstance(app_settings.DIRS, (list, tuple)):
errors.append(
Error(
checks.Error(
"The COMPONENTS.dirs setting is not a tuple or list.",
hint="Perhaps you forgot a trailing comma?",
id="components.E001",
)
),
)
return errors
for root in app_settings.DIRS:
if isinstance(root, (list, tuple)):
prefix, root = root
prefix, root = root # noqa: PLW2901
if prefix.endswith("/"):
errors.append(
Error(
"The prefix %r in the COMPONENTS.dirs setting must not end with a slash." % prefix,
checks.Error(
f"The prefix {prefix!r} in the COMPONENTS.dirs setting must not end with a slash.",
id="staticfiles.E003",
)
),
)
elif not os.path.isdir(root):
elif not Path(root).is_dir():
errors.append(
Warning(
checks.Warning(
f"The directory '{root}' in the COMPONENTS.dirs setting does not exist.",
id="components.W004",
)
),
)
return errors
# NOTE: Same as `FileSystemFinder.find`
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:
# - 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
@ -104,7 +103,7 @@ class ComponentsFileSystemFinder(BaseFinder):
# 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
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):
find_all = kwargs.get("find_all", False)
else:
@ -128,28 +127,26 @@ class ComponentsFileSystemFinder(BaseFinder):
absolute path (or ``None`` if no match).
"""
if prefix:
prefix = "%s%s" % (prefix, os.sep)
prefix = f"{prefix}{os.sep}"
if not path.startswith(prefix):
return None
path = path.removeprefix(prefix)
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 None
# `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 2: Yield can be annotated as Iterable, see https://stackoverflow.com/questions/38419654
def list(self, ignore_patterns: List[str]) -> Iterable[Tuple[str, FileSystemStorage]]:
"""
List all files in all locations.
"""
for prefix, root in self.locations:
"""List all files in all locations."""
for _prefix, root in self.locations:
# Skip nonexistent directories.
if os.path.isdir(root):
if Path(root).is_dir():
storage = self.storages[root]
for path in get_files(storage, ignore_patterns):
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
a forbidden name, such that it would overwrite one of django_component's own template tags.
""" # noqa: E501
pass
"""
PROTECTED_TAGS = [
@ -57,9 +55,8 @@ def register_tag(
) -> None:
# Register inline tag
if is_tag_protected(library, tag):
raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag)
else:
library.tag(tag, tag_fn)
raise TagProtectedError(f'Cannot register tag "{tag}", this tag name is protected')
library.tag(tag, tag_fn)
def mark_protected_tags(lib: Library, tags: Optional[List[str]] = None) -> None:

View file

@ -1,7 +1,7 @@
import functools
import inspect
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.base import Node, NodeList, Parser, Token
@ -50,10 +50,10 @@ class NodeMeta(type):
bases: Tuple[Type, ...],
attrs: Dict[str, Any],
) -> 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
if attrs.get("__module__", None) == "django_components.node":
if attrs.get("__module__") == "django_components.node":
return cls
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
# actual render method.
cls.render = wrapper_render # type: ignore
cls.render._djc_wrapped = True # type: ignore
cls.render = wrapper_render # type: ignore[assignment]
cls.render._djc_wrapped = True # type: ignore[attr-defined]
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
[`tag`](../api#django_components.BaseNode.tag),
[`end_tag`](../api#django_components.BaseNode.end_tag),
and [`allowed_flags`](../api#django_components.BaseNode.allowed_flags)
attributes:
and [`allowed_flags`](../api#django_components.BaseNode.allowed_flags) attributes:
```python
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.
@ -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.
@ -491,7 +490,7 @@ class BaseNode(Node, metaclass=NodeMeta):
contents: Optional[str] = None,
template_name: Optional[str] = None,
template_component: Optional[Type["Component"]] = None,
):
) -> None:
self.params = params
self.flags = flags or {flag: False for flag in self.allowed_flags or []}
self.nodelist = nodelist or NodeList()
@ -501,10 +500,7 @@ class BaseNode(Node, metaclass=NodeMeta):
self.template_component = template_component
def __repr__(self) -> str:
return (
f"<{self.__class__.__name__}: {self.node_id}. Contents: {repr(self.nodelist)}."
f" Flags: {self.active_flags}>"
)
return f"<{self.__class__.__name__}: {self.node_id}. Contents: {self.contents}. Flags: {self.active_flags}>"
@property
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).
"""
# 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 = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token)
@ -650,7 +646,7 @@ def template_tag(
{
"tag": tag,
"end_tag": end_tag,
"allowed_flags": allowed_flags or [],
"allowed_flags": allowed_flags or (),
"render": fn,
},
)

View file

@ -77,10 +77,10 @@ component_renderer_cache: Dict[str, Tuple[ComponentRenderer, str]] = {}
child_component_attrs: Dict[str, List[str]] = {}
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(
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,
parent_id: Optional[str],
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],
) -> str:
@ -345,11 +346,11 @@ def component_post_render(
continue
# Skip parts of errored components
elif curr_item.parent_id in ignored_ids:
if curr_item.parent_id in ignored_ids:
continue
# 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.append(curr_item.text)
@ -388,7 +389,7 @@ def component_post_render(
# - Rendering of component's template
#
# 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)
continue
@ -416,7 +417,7 @@ def component_post_render(
last_index = 0
parts_to_process: List[Union[TextPart, ComponentPart]] = []
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()
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
# the new error.
except Exception as new_error:
except Exception as new_error: # noqa: BLE001
error = new_error
html = None
# 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 typing import Dict, Generator, NamedTuple, Set

View file

@ -1,5 +1,4 @@
from collections import namedtuple
from typing import Any, Dict, Optional
from typing import Any, Dict, NamedTuple, Optional
from django.template import Context, TemplateSyntaxError
from django.utils.safestring import SafeString
@ -85,7 +84,7 @@ class ProvideNode(BaseNode):
tag = "provide"
end_tag = "endprovide"
allowed_flags = []
allowed_flags = ()
def render(self, context: Context, name: str, **kwargs: Any) -> SafeString:
# NOTE: The "provided" kwargs are meant to be shared privately, meaning that components
@ -130,7 +129,7 @@ def get_injected_context_var(
raise KeyError(
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" 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.
if not key:
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():
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"
# is immutable. This ensures that the data returned from `inject` will always
# 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)
# 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
@ -238,7 +239,7 @@ class Slot(Generic[TSlotData]):
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.
@ -319,7 +320,7 @@ class Slot(Generic[TSlotData]):
# Raise if Slot received another Slot instance as `contents`,
# because this leads to ambiguity about how to handle the metadata.
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:
self.contents, new_nodelist, self.content_func = self._resolve_contents(self.contents)
@ -327,7 +328,7 @@ class Slot(Generic[TSlotData]):
self.nodelist = new_nodelist
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
def __call__(
@ -463,9 +464,9 @@ class SlotFallback:
def slot_function(self, ctx: SlotContext):
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._context = context
@ -486,12 +487,10 @@ name_escape_re = re.compile(r"[^\w]")
# TODO_v1 - Remove, superseded by `Component.slots` and `component_vars.slots`
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:
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)
def __missing__(self, key: Any) -> bool:
@ -641,7 +640,7 @@ class SlotNode(BaseNode):
tag = "slot"
end_tag = "endslot"
allowed_flags = [SLOT_DEFAULT_FLAG, SLOT_REQUIRED_FLAG]
allowed_flags = (SLOT_DEFAULT_FLAG, SLOT_REQUIRED_FLAG)
# NOTE:
# In the current implementation, the slots are resolved only at the render time.
@ -675,7 +674,7 @@ class SlotNode(BaseNode):
raise TemplateSyntaxError(
"Encountered a SlotNode outside of a Component context. "
"Make sure that all {% slot %} tags are nested within {% component %} tags.\n"
f"SlotNode: {self.__repr__()}"
f"SlotNode: {self.__repr__()}",
)
# Component info
@ -715,7 +714,7 @@ class SlotNode(BaseNode):
"Only one component slot may be marked as 'default', "
f"found '{default_slot_name}' and '{slot_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:
@ -730,7 +729,7 @@ class SlotNode(BaseNode):
):
raise TemplateSyntaxError(
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,
@ -798,7 +797,8 @@ class SlotNode(BaseNode):
# 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).
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)
@ -808,7 +808,8 @@ class SlotNode(BaseNode):
# 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):
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:
parent_index = parent_index + curr_index + 1
@ -914,7 +915,7 @@ class SlotNode(BaseNode):
# {% endprovide %}
for key, value in context.flatten().items():
if key.startswith(_INJECT_CONTEXT_KEY_PREFIX):
extra_context[key] = value
extra_context[key] = value # noqa: PERF403
fallback = SlotFallback(self, context)
@ -982,10 +983,9 @@ class SlotNode(BaseNode):
registry_settings = component.registry.settings
if registry_settings.context_behavior == ContextBehavior.DJANGO:
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()
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}'")
class FillNode(BaseNode):
@ -1138,7 +1138,7 @@ class FillNode(BaseNode):
tag = "fill"
end_tag = "endfill"
allowed_flags = []
allowed_flags = ()
def render(
self,
@ -1155,15 +1155,15 @@ class FillNode(BaseNode):
if fallback is not None and default is not None:
raise TemplateSyntaxError(
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
if not _is_extracting_fill(context):
raise TemplateSyntaxError(
"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
@ -1175,31 +1175,31 @@ class FillNode(BaseNode):
raise TemplateSyntaxError(f"Fill tag '{FILL_DATA_KWARG}' kwarg must resolve to a string, got {data}")
if not is_identifier(data):
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 not isinstance(fallback, str):
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):
raise RuntimeError(
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
if data and fallback and data == fallback:
raise RuntimeError(
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:
raise TemplateSyntaxError(
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(
@ -1229,7 +1229,7 @@ class FillNode(BaseNode):
if captured_fills is None:
raise RuntimeError(
"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
@ -1282,9 +1282,9 @@ class FillNode(BaseNode):
# ]
for layer in context.dicts:
if "forloop" in layer:
layer = layer.copy()
layer["forloop"] = layer["forloop"].copy()
data.extra_context.update(layer)
layer_copy = layer.copy()
layer_copy["forloop"] = layer_copy["forloop"].copy()
data.extra_context.update(layer_copy)
captured_fills.append(data)
@ -1472,11 +1472,11 @@ def _extract_fill_content(
if not captured_fills:
return False
elif content:
if content:
raise TemplateSyntaxError(
f"Illegal content passed to component '{component_name}'. "
"Explicit 'fill' tags cannot occur alongside other text. "
"The component body rendered content: {content}"
"The component body rendered content: {content}",
)
# Check for any duplicates
@ -1485,7 +1485,7 @@ def _extract_fill_content(
if fill.name in seen_names:
raise TemplateSyntaxError(
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)
@ -1550,7 +1550,7 @@ def normalize_slot_fills(
if content is None:
continue
# 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__()`
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.
@ -1573,17 +1573,15 @@ def _nodelist_to_slot(
fill_node: Optional[Union[FillNode, "ComponentNode"]] = None,
extra: Optional[Dict[str, Any]] = None,
) -> Slot:
if data_var:
if not data_var.isidentifier():
raise TemplateSyntaxError(
f"Slot data alias in fill '{slot_name}' must be a valid identifier. Got '{data_var}'"
)
if data_var and not data_var.isidentifier():
raise TemplateSyntaxError(
f"Slot data alias in fill '{slot_name}' must be a valid identifier. Got '{data_var}'",
)
if fallback_var:
if not fallback_var.isidentifier():
raise TemplateSyntaxError(
f"Slot fallback alias in fill '{slot_name}' must be a valid identifier. Got '{fallback_var}'"
)
if fallback_var and not fallback_var.isidentifier():
raise TemplateSyntaxError(
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
# and binds the context.
@ -1655,7 +1653,7 @@ def _nodelist_to_slot(
return rendered
return Slot(
content_func=cast(SlotFunc, render_func),
content_func=cast("SlotFunc", render_func),
component_name=component_name,
slot_name=slot_name,
nodelist=nodelist,

View file

@ -13,7 +13,7 @@ if TYPE_CHECKING:
# Require the start / end tags to contain NO spaces and only these characters
TAG_CHARS = r"\w\-\:\@\.\#/"
TAG_RE = re.compile(r"^[{chars}]+$".format(chars=TAG_CHARS))
TAG_RE = re.compile(rf"^[{TAG_CHARS}]+$")
class TagResult(NamedTuple):
@ -106,6 +106,7 @@ class TagFormatterABC(abc.ABC):
Returns:
str: The formatted start tag.
"""
...
@ -119,6 +120,7 @@ class TagFormatterABC(abc.ABC):
Returns:
str: The formatted end tag.
"""
...
@ -131,7 +133,7 @@ class TagFormatterABC(abc.ABC):
which is a tuple of `(component_name, remaining_tokens)`.
Args:
tokens [List(str]): List of tokens passed to the component tag.
tokens (List[str]): List of tokens passed to the component tag.
Returns:
TagResult: Parsed component name and remaining tokens.
@ -160,16 +162,15 @@ class TagFormatterABC(abc.ABC):
```python
TagResult('my_comp', ['key=val', 'key2=val2'])
```
"""
...
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
def start_tag(self, name: str) -> str:
@ -192,13 +193,13 @@ class InternalTagFormatter:
if not tag:
raise ValueError(
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):
raise ValueError(
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
def start_tag(self, name: str) -> str:
def start_tag(self, _name: str) -> str:
return self.tag
def end_tag(self, name: str) -> str:
def end_tag(self, _name: str) -> str:
return f"end{self.tag}"
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")
# If the first arg is a kwarg, then clearly the component name is not set.
if "=" in args[0]:
comp_name = None
else:
comp_name = args.pop(0)
comp_name = None if "=" in args[0] else args.pop(0)
if not comp_name:
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=...
)
```
""" # noqa: E501
"""
template_cache = get_template_cache()
template_cls = template_cls or Template
@ -103,7 +104,7 @@ def prepare_component_template(
"Django-components received a Template instance which was not patched."
"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"
"manually patch the class."
"manually patch the class.",
)
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
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)
else:
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]
if len(sources_with_values) > 1:
raise ImproperlyConfigured(
f"Component template was set multiple times in Component {component.name}."
f"Sources: {sources_with_values}"
f"Component template was set multiple times in Component {component.name}. Sources: {sources_with_values}",
)
# Load the template based on the source
@ -281,7 +281,7 @@ def _get_component_template(component: "Component") -> Optional[Template]:
if template is not None:
return template
# 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)
# 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
# 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.
# 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
# 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:
component_template_file_cache_initialized = True
# NOTE: Avoids circular import
from django_components.component import all_components
from django_components.component import all_components # noqa: PLC0415
components = all_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
def _reset_component_template_file_cache() -> None:
global component_template_file_cache
global component_template_file_cache # noqa: PLW0603
component_template_file_cache = {}
global component_template_file_cache_initialized
global component_template_file_cache_initialized # noqa: PLW0603
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 typing import List

View file

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

View file

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

View file

@ -35,7 +35,15 @@ def mark_extension_command_api(obj: TClass) -> TClass:
#############################
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.
@ -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
[`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
@ -54,7 +62,7 @@ class CommandArg:
Fields on this class correspond to the arguments for
[`ArgumentParser.add_argument()`](https://docs.python.org/3/library/argparse.html#the-add-argument-method)
""" # noqa: E501
"""
name_or_flags: Union[str, Sequence[str]]
"""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
[`ArgumentParser.add_argument_group()`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument_group)
""" # noqa: E501
"""
title: Optional[str] = None
"""
@ -137,7 +145,7 @@ class CommandSubcommand:
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)
""" # noqa: E501
"""
title: Optional[str] = None
"""
@ -208,7 +216,7 @@ class CommandParserInput:
formatter_class: Optional[Type["_FormatterClass"]] = None
"""A class for customizing the help output"""
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
"""The set of characters that prefix files from which additional arguments should be read (default: `None`)"""
argument_default: Optional[Any] = None
@ -234,7 +242,7 @@ class CommandParserInput:
@mark_extension_command_api
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
@ -367,7 +375,7 @@ def setup_parser_from_command(command: Type[ComponentCommand]) -> ArgumentParser
# Recursively setup the parser and its subcommands
def _setup_parser_from_command(
parser: ArgumentParser,
command: Union[Type[ComponentCommand], Type[ComponentCommand]],
command: Type[ComponentCommand],
) -> ArgumentParser:
# Attach the command to the data returned by `parser.parse_args()`, so we know
# which command was matched.
@ -383,9 +391,9 @@ def _setup_parser_from_command(
# NOTE: Seems that dataclass's `asdict()` calls `asdict()` also on the
# nested dataclass fields. Thus we need to apply `_remove_none_values()`
# 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:
_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:
new_data = {}
for key, val in data.items():
if val is not None:
new_data[key] = val
return new_data
return {key: val for key, val in data.items() if val is not None}
def style_success(message: str) -> str:

View file

@ -20,8 +20,6 @@ else:
class CopiedDict(dict):
"""Dict subclass to identify dictionaries that have been copied with `snapshot_context`"""
pass
def snapshot_context(context: Context) -> Context:
"""
@ -151,7 +149,7 @@ def gen_context_processors_data(context: BaseContext, request: HttpRequest) -> D
try:
processors_data.update(data)
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

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.
# See https://github.com/django/django/blame/main/django/template/base.py#L139
def __init__(
def __init__( # noqa: N807
self: Template,
template_string: Any,
origin: Optional[Origin] = None,
@ -38,7 +38,7 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None:
**kwargs: Any,
) -> None:
# NOTE: Avoids circular import
from django_components.template import (
from django_components.template import ( # noqa: PLC0415
get_component_by_template_file,
get_component_from_origin,
set_component_to_origin,
@ -70,7 +70,7 @@ def monkeypatch_template_init(template_cls: Type[Template]) -> None:
content=template_string,
origin=origin,
name=name,
)
),
)
# 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(
component_cls=component_cls,
template=self,
)
),
)
template_cls.__init__ = __init__
@ -129,7 +129,7 @@ def monkeypatch_template_compile_nodelist(template_cls: Type[Template]) -> None:
return nodelist
except Exception as e:
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
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)
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.
if COMPONENT_IS_NESTED_KEY not in context:
isolated_context = True
@ -254,7 +254,7 @@ def monkeypatch_template_proxy_cls() -> None:
# Patch TemplateProxy if template_partials is installed
# See https://github.com/django-components/django-components/issues/1323#issuecomment-3164224042
try:
from template_partials.templatetags.partials import TemplateProxy
from template_partials.templatetags.partials import TemplateProxy # noqa: PLC0415
except ImportError:
# template_partials is in INSTALLED_APPS but not actually installed
# 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:
# NOTE: TemplateProxy.render() is same logic as Template.render(), just duplicated.
# 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)
template_proxy_cls.render = _template_proxy_render

View file

@ -1,4 +1,3 @@
import glob
import os
from pathlib import Path, PurePosixPath, PureWindowsPath
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)
must be absolute paths.
"""
# Allow to configure from settings which dirs should be checked for components
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_legacy_paths = (
# Use value of `STATICFILES_DIRS` ONLY if `COMPONENT.dirs` not set
not is_component_dirs_set
and hasattr(settings, "STATICFILES_DIRS")
and settings.STATICFILES_DIRS
not is_component_dirs_set and getattr(settings, "STATICFILES_DIRS", None)
)
if is_legacy_paths:
# 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(
"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`
@ -89,23 +87,22 @@ def get_component_dirs(include_apps: bool = True) -> List[Path]:
# Consider tuples for STATICFILES_DIRS (See #489)
# See https://docs.djangoproject.com/en/5.2/ref/settings/#prefixes-optional
if isinstance(component_dir, (tuple, list)):
component_dir = component_dir[1]
component_dir = component_dir[1] # noqa: PLW2901
try:
Path(component_dir)
except TypeError:
logger.warning(
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
if not Path(component_dir).is_absolute():
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(
"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)
@ -143,6 +140,7 @@ def get_component_files(suffix: Optional[str] = None) -> List[ComponentFileEntry
modules = get_component_files(".py")
```
"""
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)
if hasattr(settings, "BASE_DIR") and settings.BASE_DIR:
project_root = str(settings.BASE_DIR)
project_root = settings.BASE_DIR
else:
# 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.
modules: List[ComponentFileEntry] = []
@ -212,6 +210,7 @@ def _filepath_to_python_module(
- And file_path is `/path/to/project/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`
"""
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] = []
for directory in dirs:
for path_str in glob.iglob(str(Path(directory) / search_glob), recursive=True):
path = Path(path_str)
for path in Path(directory).rglob(search_glob):
# Skip any subdirectory or file (under the top-level directory) that starts with an underscore
rel_dir_parts = list(path.relative_to(directory).parts)
name_part = rel_dir_parts.pop()

View file

@ -11,7 +11,7 @@ actual_trace_level_num = -1
def setup_logging() -> None:
# 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
global actual_trace_level_num
global actual_trace_level_num # noqa: PLW0603
log_levels = _get_log_levels()
if "TRACE" in log_levels:
@ -25,8 +25,7 @@ def _get_log_levels() -> Dict[str, int]:
# Use official API if possible
if sys.version_info >= (3, 11):
return logging.getLevelNamesMapping()
else:
return logging._nameToLevel.copy()
return logging._nameToLevel.copy()
def trace(message: str, *args: Any, **kwargs: Any) -> None:
@ -54,6 +53,7 @@ def trace(message: str, *args: Any, **kwargs: Any) -> None:
},
}
```
"""
if actual_trace_level_num == -1:
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 "`
"""
if component_id:
component_id_str = f"ID {component_id}"
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 = ""
component_id_str = f"ID {component_id}" if component_id else ""
slot_name_str = f"SLOT: '{slot_name}'" if slot_name else ""
component_path_str = "PATH: " + " > ".join(component_path) if component_path else ""
slot_fills_str = "FILLS: " + ", ".join(slot_fills.keys()) if slot_fills else ""
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 itertools import chain
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 django_components.constants import UID_LENGTH
@ -43,9 +43,7 @@ def snake_to_pascal(name: str) -> str:
def is_identifier(value: Any) -> bool:
if not isinstance(value, str):
return False
if not value.isidentifier():
return False
return True
return value.isidentifier()
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
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__
if module == "builtins":
return cls_or_fn.__qualname__ # avoid outputs like 'builtins.str'
@ -79,7 +75,7 @@ def get_module_info(
else:
try:
module = import_module(module_name)
except Exception:
except Exception: # noqa: BLE001
module = None
else:
module = None
@ -96,9 +92,9 @@ def default(val: Optional[T], default: Union[U, Callable[[], U], Type[T]], facto
if val is not None:
return val
if factory:
default_func = cast(Callable[[], U], default)
default_func = cast("Callable[[], U]", default)
return default_func()
return cast(U, default)
return cast("U", default)
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`
def hash_comp_cls(comp_cls: Type["Component"]) -> str:
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
@ -148,9 +144,9 @@ def to_dict(data: Any) -> dict:
"""
if isinstance(data, dict):
return data
elif hasattr(data, "_asdict"): # Case: NamedTuple
if hasattr(data, "_asdict"): # Case: NamedTuple
return data._asdict()
elif is_dataclass(data): # Case: dataclass
if is_dataclass(data): # Case: dataclass
return asdict(data) # type: ignore[arg-type]
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))
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.
@ -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
ProjectDashboardAction project.components.dashboard_action.ProjectDashboardAction ./project/components/dashboard_action
```
""" # noqa: E501
# Calculate the width of each column
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)
# Combine all parts into the final table
if include_headers:
table = "\n".join([header_row, separator] + data_rows)
else:
table = "\n".join(data_rows)
table = "\n".join([header_row, separator, *data_rows]) if include_headers else "\n".join(data_rows)
return table

View file

@ -13,17 +13,16 @@ def generate(alphabet: str, size: int) -> str:
mask = 1
if alphabet_len > 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:
random_bytes = bytearray(urandom(step))
for i in range(step):
random_byte = random_bytes[i] & mask
if random_byte < alphabet_len:
if alphabet[random_byte]:
id += alphabet[random_byte]
if random_byte < alphabet_len and alphabet[random_byte]:
id_str += alphabet[random_byte]
if len(id) == size:
return id
if len(id_str) == size:
return id_str

View file

@ -15,7 +15,7 @@ def mark_extension_url_api(obj: TClass) -> TClass:
class URLRouteHandler(Protocol):
"""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

View file

@ -1,3 +1,4 @@
# ruff: noqa: S105
"""
Parser for Django template tags.
@ -180,7 +181,7 @@ class TagValuePart:
# Create a hash based on the attributes that define object equality
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):
return False
return (
@ -231,16 +232,15 @@ class TagValueStruct:
def render_value(value: Union[TagValue, TagValueStruct]) -> str:
if isinstance(value, TagValue):
return value.serialize()
else:
return value.serialize()
return value.serialize()
if self.type == "simple":
value = self.entries[0]
return render_value(value)
elif self.type == "list":
if self.type == "list":
prefix = self.spread or ""
return prefix + "[" + ", ".join([render_value(entry) for entry in self.entries]) + "]"
elif self.type == "dict":
if self.type == "dict":
prefix = self.spread or ""
dict_pairs = []
dict_pair: List[str] = []
@ -255,18 +255,19 @@ class TagValueStruct:
dict_pairs.append(rendered)
else:
dict_pair.append(rendered)
elif entry.is_spread:
if dict_pair:
raise TemplateSyntaxError("Malformed dict: spread operator cannot be used as a dict key")
dict_pairs.append(rendered)
else:
if entry.is_spread:
if dict_pair:
raise TemplateSyntaxError("Malformed dict: spread operator cannot be used as a dict key")
dict_pairs.append(rendered)
else:
dict_pair.append(rendered)
dict_pair.append(rendered)
if len(dict_pair) == 2:
dict_pairs.append(": ".join(dict_pair))
dict_pair = []
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,
# 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")
return value.resolve(context)
elif self.type == "list":
if self.type == "list":
resolved_list: List[Any] = []
for entry in self.entries:
resolved = entry.resolve(context)
@ -325,7 +326,7 @@ class TagValueStruct:
resolved_list.append(resolved)
return resolved_list
elif self.type == "dict":
if self.type == "dict":
resolved_dict: Dict = {}
dict_pair: List = []
@ -336,14 +337,14 @@ class TagValueStruct:
if isinstance(entry, TagValueStruct) and entry.spread:
if dict_pair:
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} }
resolved_dict.update(resolved)
elif isinstance(entry, TagValue) and entry.is_spread:
if dict_pair:
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 }
resolved_dict.update(resolved)
@ -358,6 +359,8 @@ class TagValueStruct:
dict_pair = []
return resolved_dict
raise ValueError(f"Invalid type: {self.type}")
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
def taken_n(n: int) -> str:
result = text[index : index + n] # noqa: E203
result = text[index : index + n]
add_token(result)
return result
@ -506,7 +509,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
if is_next_token(["..."]):
if curr_struct.type != "simple":
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 = "..."
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:
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
# (lists and dicts). E.g.:
# `{% component key=[ * spread ] %}` or `{% component key={ ** spread } %}`
@ -539,9 +542,8 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
# `{% component key=val ...spread key2=val2 %}`
if spread_token != "...":
take_while(TAG_WHITESPACE)
else:
if is_next_token(TAG_WHITESPACE) or is_at_end():
raise TemplateSyntaxError("Spread syntax '...' is missing a value")
elif is_next_token(TAG_WHITESPACE) or is_at_end():
raise TemplateSyntaxError("Spread syntax '...' is missing a value")
return spread_token
# Parse attributes
@ -586,9 +588,8 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
# Manage state with regards to lists and dictionaries
if is_next_token(["[", "...[", "*[", "**["]):
spread_token = extract_spread_token(curr_value, None)
if spread_token is not None:
if curr_value.type == "simple" and key is not None:
raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')")
if spread_token is not None and curr_value.type == "simple" and key is not None:
raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')")
# NOTE: The `...`, `**`, `*` are "taken" in `extract_spread_token()`
taken_n(1) # [
struct = TagValueStruct(type="list", entries=[], spread=spread_token, meta={}, parser=parser)
@ -596,7 +597,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
stack.append(struct)
continue
elif is_next_token(["]"]):
if is_next_token(["]"]):
if curr_value.type != "list":
raise TemplateSyntaxError("Unexpected closing bracket")
taken_n(1) # ]
@ -606,11 +607,10 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
stack.pop()
continue
elif is_next_token(["{", "...{", "*{", "**{"]):
if is_next_token(["{", "...{", "*{", "**{"]):
spread_token = extract_spread_token(curr_value, None)
if spread_token is not None:
if curr_value.type == "simple" and key is not None:
raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')")
if spread_token is not None and curr_value.type == "simple" and key is not None:
raise TemplateSyntaxError("Spread syntax '...' cannot follow a key ('key=...attrs')")
# NOTE: The `...`, `**`, `*` are "taken" in `extract_spread_token()`
taken_n(1) # {
@ -630,7 +630,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
stack.append(struct)
continue
elif is_next_token(["}"]):
if is_next_token(["}"]):
if curr_value.type != "dict":
raise TemplateSyntaxError("Unexpected closing bracket")
@ -643,37 +643,33 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
# Case: `{ "key": **{"key2": val2} }`
if dict_pair:
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} }`
continue
else:
# Case: `{ {"key": val2}: value }`
if not dict_pair:
val_type = "Dictionary" if curr_value.type == "dict" else "List"
raise TemplateSyntaxError(f"{val_type} cannot be used as a dictionary key")
# Case: `{ "key": {"key2": val2} }`
else:
pass
# Case: `{ {"key": val2}: value }`
if not dict_pair:
val_type = "Dictionary" if curr_value.type == "dict" else "List"
raise TemplateSyntaxError(f"{val_type} cannot be used as a dictionary key")
# Case: `{ "key": {"key2": val2} }`
dict_pair.append(entry)
if len(dict_pair) == 2:
dict_pair = []
# Spread is fine when on its own, but cannot be used after a dict key
elif entry.is_spread:
# Case: `{ "key": **my_attrs }`
if dict_pair:
raise TemplateSyntaxError(
"Spread syntax cannot be used in place of a dictionary value",
)
# Case: `{ **my_attrs }`
continue
# Non-spread value can be both key and value.
else:
# Spread is fine when on its own, but cannot be used after a dict key
if entry.is_spread:
# Case: `{ "key": **my_attrs }`
if dict_pair:
raise TemplateSyntaxError(
"Spread syntax cannot be used in place of a dictionary value"
)
# Case: `{ **my_attrs }`
continue
# Non-spread value can be both key and value.
else:
# Cases: `{ my_attrs: "value" }` or `{ "key": my_attrs }`
dict_pair.append(entry)
if len(dict_pair) == 2:
dict_pair = []
# Cases: `{ my_attrs: "value" }` or `{ "key": my_attrs }`
dict_pair.append(entry)
if len(dict_pair) == 2:
dict_pair = []
# If, at the end, there an unmatched key-value pair, raise an error
if dict_pair:
raise TemplateSyntaxError("Dictionary key is missing a value")
@ -687,7 +683,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
stack.pop()
continue
elif is_next_token([","]):
if is_next_token([","]):
if curr_value.type not in ("list", "dict"):
raise TemplateSyntaxError("Unexpected comma")
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
# that the filter is part of is parsed as a whole block. So if we got
# here, we know we're NOT in filter.
elif is_next_token([":"]):
if is_next_token([":"]):
if curr_value.type != "dict":
raise TemplateSyntaxError("Unexpected colon")
if not curr_value.meta["expects_key"]:
@ -707,13 +703,11 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
curr_value.meta["expects_key"] = False
continue
else:
# Allow only 1 top-level plain value, similar to JSON
if curr_value.type == "simple":
stack.pop()
else:
if is_at_end():
raise TemplateSyntaxError("Unexpected end of text")
# Allow only 1 top-level plain value, similar to JSON
if curr_value.type == "simple":
stack.pop()
elif is_at_end():
raise TemplateSyntaxError("Unexpected end of text")
# Once we got here, we know that next token is NOT a list nor dict.
# So we can now parse the value.
@ -731,9 +725,8 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
if is_at_end():
if is_first_part:
raise TemplateSyntaxError("Unexpected end of text")
else:
end_of_value = True
continue
end_of_value = True
continue
# In this case we've reached the end of a filter sequence
# e.g. image: `height="20"|lower key1=value1`
@ -744,7 +737,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
continue
# 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")
# 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":
terminal_tokens = (",", "]")
else:
terminal_tokens = tuple()
terminal_tokens = ()
# Parse the value
#
@ -860,7 +853,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
spread=spread_token,
translation=is_translation,
filter=filter_token,
)
),
)
# Here we're done with the value (+ a sequence of filters)
@ -878,19 +871,18 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
# Validation for `{"key": **spread }`
if not curr_value.meta["expects_key"]:
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 }`
take_while(TAG_WHITESPACE)
if is_next_token([":"]):
raise TemplateSyntaxError("Spread syntax cannot be used in place of a dictionary key")
else:
# Validation for `{"key", value }`
if curr_value.meta["expects_key"]:
take_while(TAG_WHITESPACE)
if not is_next_token([":"]):
raise TemplateSyntaxError("Dictionary key is missing a value")
# Validation for `{"key", value }`
elif curr_value.meta["expects_key"]:
take_while(TAG_WHITESPACE)
if not is_next_token([":"]):
raise TemplateSyntaxError("Dictionary key is missing a value")
# And at this point, we have the full representation of the tag value,
# including any lists or dictionaries (even nested). E.g.
@ -916,7 +908,7 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]:
key=key,
start_index=start_index,
value=total_value,
)
),
)
return normalized, attrs

View file

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

View file

@ -41,7 +41,8 @@ def validate_params(
args, kwargs = _validate_params_with_signature(validation_signature, params, extra_kwargs)
return args, kwargs
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
@ -88,7 +89,7 @@ def resolve_params(
resolved_params.append(TagParam(key=None, value=value))
else:
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:
resolved_params.append(TagParam(key=param.key, value=resolved))
@ -110,7 +111,7 @@ class ParsedTag(NamedTuple):
def parse_template_tag(
tag: str,
end_tag: Optional[str],
allowed_flags: Optional[List[str]],
allowed_flags: Optional[Iterable[str]],
parser: Parser,
token: Token,
) -> ParsedTag:
@ -138,7 +139,8 @@ def parse_template_tag(
else:
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]]:
if inline:
@ -188,8 +190,7 @@ def _extract_contents_until(parser: Parser, until_blocks: List[str]) -> str:
contents.append("{% " + token.contents + " %}")
if command in until_blocks:
return "".join(contents)
else:
contents.append("{% " + token.contents + " %}")
contents.append("{% " + token.contents + " %}")
elif token_type == 3: # TokenType.COMMENT
contents.append("{# " + token.contents + " #}")
else:
@ -205,7 +206,9 @@ def _extract_contents_until(parser: Parser, until_blocks: List[str]) -> str:
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]]:
found_flags = set()
remaining_attrs = []
@ -386,13 +389,12 @@ def _validate_params_with_signature(
if signature_param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
if signature_param.default == inspect.Parameter.empty:
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
elif signature_param.kind == inspect.Parameter.KEYWORD_ONLY:
if signature_param.default == inspect.Parameter.empty:
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 validated_args, validated_kwargs
@ -492,13 +494,12 @@ def _validate_params_with_code(
if i < positional_count: # Positional parameter
if i < required_positional:
raise TypeError(f"missing a required argument: '{param_name}'")
elif len(validated_args) <= i:
if len(validated_args) <= i:
default_index = i - required_positional
validated_kwargs[param_name] = defaults[default_index]
elif i < positional_count + kwonly_count: # Keyword-only parameter
if param_name not in kwdefaults:
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

View file

@ -2,7 +2,7 @@ import gc
import inspect
import sys
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 weakref import ReferenceType
@ -14,12 +14,14 @@ from django.template.loaders.base import Loader
from django.test import override_settings
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.extension import extensions
from django_components.perfutil.provide import provide_cache
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
if sys.version_info >= (3, 9):
RegistryRef = ReferenceType[ComponentRegistry]
@ -50,7 +52,7 @@ class GenIdPatcher:
# Random number so that the generated IDs are "hex-looking", e.g. a1bc3d
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
return hex(self._gen_id_count)[2:]
@ -67,7 +69,7 @@ class GenIdPatcher:
class CsrfTokenPatcher:
def __init__(self) -> None:
self._csrf_token = "predictabletoken"
self._csrf_token = "predictabletoken" # noqa: S105
self._csrf_token_patch: Any = None
def start(self) -> None:
@ -200,8 +202,7 @@ def djc_test(
- `(param_names, param_values)` or
- `(param_names, param_values, ids)`
Example:
Example:
```py
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.
Priority: `components_settings` (parametrized) > `components_settings` > `django_settings["COMPONENTS"]` > `django.conf.settings.COMPONENTS`
""" # noqa: E501
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.
# Since the tests require Django to be configured, this should contain any
# components that were registered with autodiscovery / at `AppConfig.ready()`.
_ALL_COMPONENTS = ALL_COMPONENTS.copy()
_ALL_REGISTRIES_COPIES: RegistriesCopies = []
_all_components = ALL_COMPONENTS.copy()
_all_registries_copies: RegistriesCopies = []
for reg_ref in ALL_REGISTRIES:
reg = reg_ref()
if not reg:
continue
_ALL_REGISTRIES_COPIES.append((reg_ref, list(reg._registry.keys())))
_all_registries_copies.append((reg_ref, list(reg._registry.keys())))
# Prepare global state
_setup_djc_global_state(gen_id_patcher, csrf_token_patcher)
@ -342,8 +344,8 @@ def djc_test(
_clear_djc_global_state(
gen_id_patcher,
csrf_token_patcher,
_ALL_COMPONENTS, # type: ignore[arg-type]
_ALL_REGISTRIES_COPIES,
_all_components, # type: ignore[arg-type]
_all_registries_copies,
gc_collect,
)
@ -388,7 +390,7 @@ def djc_test(
# NOTE: Lazily import pytest, so user can still run tests with plain `unittest`
# if they choose not to use parametrization.
import pytest
import pytest # noqa: PLC0415
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
# by the import / autodiscover mechanism to clean up loaded modules
# between tests.
global IS_TESTING
global IS_TESTING # noqa: PLW0603
IS_TESTING = True
gen_id_patcher.start()
csrf_token_patcher.start()
# 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()
extensions._initialized = False
@ -465,7 +467,7 @@ def _clear_djc_global_state(
loader.reset()
# 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:
template_cache.clear()
@ -505,7 +507,7 @@ def _clear_djc_global_state(
del ALL_COMPONENTS[reverse_index]
# 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)):
registry_ref = ALL_REGISTRIES[len(ALL_REGISTRIES) - index - 1]
is_ref_deleted = registry_ref() is None
@ -532,7 +534,7 @@ def _clear_djc_global_state(
# Delete autoimported modules from memory, so the module
# 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:
sys.modules.pop(mod, None)
@ -556,5 +558,5 @@ def _clear_djc_global_state(
if gc_collect:
gc.collect()
global IS_TESTING
global IS_TESTING # noqa: PLW0603
IS_TESTING = False

View file

@ -26,5 +26,3 @@ class Empty(NamedTuple):
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):
@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:

View file

@ -17,10 +17,10 @@ def compile_js_files_to_file(
file_paths: Sequence[Union[Path, str]],
out_file: Union[Path, str],
esbuild_args: Optional[List[str]] = None,
):
) -> None:
# Find Esbuild binary
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`
esbuild_cmd = [
@ -39,12 +39,12 @@ def compile_js_files_to_file(
# - This script should be called from within django_components_js` dir!
# - Also you need to have esbuild installed. If not yet, run:
# `npm install -D esbuild`
def build():
def build() -> None:
entrypoint = "./src/index.ts"
out_file = Path("../django_components/static/django_components/django_components.min.js")
# Prepare output dir
os.makedirs(out_file.parent, exist_ok=True)
out_file.parent.mkdir(parents=True, exist_ok=True)
# Compile JS
compile_js_files_to_file(file_paths=[entrypoint], out_file=out_file)

View file

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

View file

@ -20,8 +20,7 @@ class PathObj:
if self.static_path.endswith(".js"):
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))
@register("relative_file_pathobj_component")

View file

@ -20,7 +20,7 @@ from testserver.views import (
urlpatterns = [
path("", include("django_components.urls")),
# 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
path("single/", single_component_view, name="single"),
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.template import Context, Template
from testserver.components import FragComp, FragMedia
from django_components import types
if TYPE_CHECKING:
from django_components import types
def single_component_view(request):
def single_component_view(_request):
template_str: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
@ -27,7 +30,7 @@ def single_component_view(request):
return HttpResponse(rendered)
def multiple_components_view(request):
def multiple_components_view(_request):
template_str: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
@ -50,7 +53,7 @@ def multiple_components_view(request):
return HttpResponse(rendered)
def check_js_order_in_js_view(request):
def check_js_order_in_js_view(_request):
template_str: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
@ -74,7 +77,7 @@ def check_js_order_in_js_view(request):
return HttpResponse(rendered)
def check_js_order_in_media_view(request):
def check_js_order_in_media_view(_request):
template_str: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
@ -98,7 +101,7 @@ def check_js_order_in_media_view(request):
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 = """
{% load component_tags %}
<!DOCTYPE html>
@ -170,8 +173,8 @@ def fragment_base_js_view(request):
Context(
{
"frag": frag,
}
)
},
),
)
return HttpResponse(rendered)
@ -283,13 +286,12 @@ def fragment_view(request):
fragment_type = request.GET["frag"]
if fragment_type == "comp":
return FragComp.render_to_response(deps_strategy="fragment")
elif fragment_type == "media":
if fragment_type == "media":
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 = """
{% load component_tags %}
<!DOCTYPE html>
@ -309,7 +311,7 @@ def alpine_in_head_view(request):
return HttpResponse(rendered)
def alpine_in_body_view(request):
def alpine_in_body_view(_request):
template_str: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
@ -330,7 +332,7 @@ def alpine_in_body_view(request):
# 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 = """
{% load component_tags %}
<!DOCTYPE html>
@ -350,7 +352,7 @@ def alpine_in_body_view_2(request):
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 = """
{% load component_tags %}
<!DOCTYPE html>

View file

@ -1,3 +1,5 @@
# ruff: noqa: T201
import functools
import subprocess
import sys
@ -54,7 +56,7 @@ def run_django_dev_server():
start_time = time.time()
while time.time() - start_time < 30: # timeout after 30 seconds
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:
print("Django dev server is up and running.")
break

View file

@ -23,7 +23,9 @@ class TestFormatAttributes:
assert format_attributes({"class": "foo", "style": "color: red;"}) == 'class="foo" style="color: red;"'
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):
assert format_attributes({"foo": mark_safe("'bar'")}) == "foo=\"'bar'\""
@ -51,7 +53,7 @@ class TestMergeAttributes:
assert merge_attributes({"class": "foo", "id": "bar"}, {"class": "baz"}) == {
"class": "foo baz",
"id": "bar",
} # noqa: E501
}
def test_merge_with_empty_dict(self):
assert merge_attributes({}, {"foo": "bar"}) == {"foo": "bar"}
@ -70,7 +72,7 @@ class TestMergeAttributes:
"tuna3",
{"baz": True, "baz2": False, "tuna": False, "tuna2": True, "tuna3": None},
["extra", {"extra2": False, "baz2": True, "tuna": True, "tuna2": False}],
]
],
},
) == {"class": "foo bar tuna baz baz2 extra"}
@ -82,7 +84,7 @@ class TestMergeAttributes:
"background-color: blue;",
{"background-color": "green", "color": None, "width": False},
["position: absolute", {"height": "12px"}],
]
],
},
) == {"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 %}>
content
</div>
""" # noqa: E501
"""
def get_template_data(self, args, kwargs, slots, context):
return {
@ -170,7 +172,7 @@ class TestHtmlAttrs:
<div {% html_attrs attrs defaults class %}>
content
</div>
""" # noqa: E501
"""
def get_template_data(self, args, kwargs, slots, context):
return {
@ -183,7 +185,9 @@ class TestHtmlAttrs:
with pytest.raises(
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"}))
@ -251,7 +255,7 @@ class TestHtmlAttrs:
<div {% html_attrs ...props class="another-class" %}>
content
</div>
""" # noqa: E501
"""
def get_template_data(self, args, kwargs, slots, context):
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">
content
</div>
""", # noqa: E501
""",
)
assert "override-me" not in rendered
@ -344,7 +348,7 @@ class TestHtmlAttrs:
%}>
content
</div>
""" # noqa: E501
"""
def get_template_data(self, args, kwargs, slots, context):
return {"attrs": kwargs["attrs"]}
@ -389,7 +393,7 @@ class TestHtmlAttrs:
<div {% html_attrs attrs class="added_class" class="another-class" data-id=123 %}>
content
</div>
""" # noqa: E501
"""
def get_template_data(self, args, kwargs, slots, context):
return {"attrs": kwargs["attrs"]}
@ -419,7 +423,7 @@ class TestHtmlAttrs:
<div {% html_attrs class="added_class" class="another-class" data-id=123 %}>
content
</div>
""" # noqa: E501
"""
def get_template_data(self, args, kwargs, slots, context):
return {"attrs": kwargs["attrs"]}

View file

@ -46,8 +46,8 @@ class TestAutodiscover:
class TestImportLibraries:
@djc_test(
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):
all_components = registry.all().copy()
@ -74,8 +74,8 @@ class TestImportLibraries:
@djc_test(
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):
all_components = registry.all().copy()

View file

@ -5,8 +5,8 @@
import difflib
import json
from dataclasses import MISSING, dataclass, field
from datetime import date, datetime, timedelta
from dataclasses import dataclass, field, MISSING
from enum import Enum
from inspect import signature
from pathlib import Path
@ -30,16 +30,16 @@ from typing import (
import django
from django import forms
from django.conf import settings
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.http import HttpRequest
from django.middleware import csrf
from django.template import Context, Template
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.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
# ----------- IMPORTS END ------------ #
@ -61,9 +61,9 @@ if not settings.configured:
"OPTIONS": {
"builtins": [
"django_components.templatetags.component_tags",
]
],
},
}
},
],
COMPONENTS={
"autodiscover": False,
@ -74,9 +74,9 @@ if not settings.configured:
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
},
},
SECRET_KEY="secret",
SECRET_KEY="secret", # noqa: S106
ROOT_URLCONF="django_components.urls",
)
django.setup()
@ -91,19 +91,21 @@ else:
templates_cache: Dict[int, Template] = {}
def lazy_load_template(template: str) -> Template:
template_hash = hash(template)
if template_hash in templates_cache:
return templates_cache[template_hash]
else:
template_instance = Template(template)
templates_cache[template_hash] = template_instance
return template_instance
template_instance = Template(template)
templates_cache[template_hash] = template_instance
return template_instance
#####################################
# RENDER ENTRYPOINT
#####################################
def gen_render_data():
data = load_project_data_from_json(data_json)
@ -118,7 +120,7 @@ def gen_render_data():
"text": "Test bookmark",
"url": "http://localhost:8000/bookmarks/9/create",
"attachment": None,
}
},
]
request = HttpRequest()
@ -140,7 +142,7 @@ def render(data):
# Render
result = project_page(
Context(),
ProjectPageData(**data)
ProjectPageData(**data),
)
return result
@ -669,7 +671,7 @@ data_json = """
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.
"""
data = json.loads(contents)
@ -1003,7 +1005,7 @@ TAG_TYPE_META = MappingProxyType(
),
),
TagResourceType.PROJECT_OUTPUT: TagTypeMeta(
allowed_values=tuple(),
allowed_values=(),
),
TagResourceType.PROJECT_OUTPUT_ATTACHMENT: TagTypeMeta(
allowed_values=(
@ -1024,7 +1026,7 @@ TAG_TYPE_META = MappingProxyType(
TagResourceType.PROJECT_TEMPLATE: TagTypeMeta(
allowed_values=("Tag 21",),
),
}
},
)
@ -1091,7 +1093,7 @@ PROJECT_PHASES_META = MappingProxyType(
ProjectOutputDef(title="Lorem ipsum 14"),
],
),
}
},
)
#####################################
@ -1156,7 +1158,7 @@ _secondary_btn_styling = "ring-1 ring-inset"
theme = Theme(
default=ThemeStylingVariant(
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"),
secondary=ThemeStylingUnit(
@ -1277,8 +1279,7 @@ def format_timestamp(timestamp: datetime):
"""
if now() - timestamp > timedelta(days=7):
return timestamp.strftime("%b %-d, %Y")
else:
return naturaltime(timestamp)
return naturaltime(timestamp)
def group_by(
@ -1400,17 +1401,16 @@ def serialize_to_js(obj):
items.append(f"{key}: {serialized_value}")
return f"{{ {', '.join(items)} }}"
elif isinstance(obj, (list, tuple)):
if isinstance(obj, (list, tuple)):
# If the object is a list, recursively serialize each item
serialized_items = [serialize_to_js(item) for item in obj]
return f"[{', '.join(serialized_items)}]"
elif isinstance(obj, str):
if isinstance(obj, str):
return obj
else:
# For other types (int, float, etc.), just return the string representation
return str(obj)
# For other types (int, float, etc.), just return the string representation
return str(obj)
#####################################
@ -1481,7 +1481,7 @@ def button(context: Context, data: ButtonData):
"attrs": all_attrs,
"is_link": is_link,
"slot_content": data.slot_content,
}
},
):
return lazy_load_template(button_template_str).render(context)
@ -1615,7 +1615,7 @@ def menu(context: Context, data: MenuData):
{
"x-show": model,
"x-cloak": "",
}
},
)
menu_list_data = MenuListData(
@ -1633,7 +1633,7 @@ def menu(context: Context, data: MenuData):
"attrs": data.attrs,
"menu_list_data": menu_list_data,
"slot_activator": data.slot_activator,
}
},
):
return lazy_load_template(menu_template_str).render(context)
@ -1738,7 +1738,7 @@ def menu_list(context: Context, data: MenuListData):
{
"item_groups": item_groups,
"attrs": data.attrs,
}
},
):
return lazy_load_template(menu_list_template_str).render(context)
@ -1800,7 +1800,7 @@ class TableCell:
def __post_init__(self):
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("")
@ -1976,7 +1976,7 @@ class TableData(NamedTuple):
@registry.library.simple_tag(takes_context=True)
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(
{
@ -1984,7 +1984,7 @@ def table(context: Context, data: TableData):
"rows_to_render": rows_to_render,
"NULL_CELL": NULL_CELL,
"attrs": data.attrs,
}
},
):
return lazy_load_template(table_template_str).render(context)
@ -2080,7 +2080,7 @@ def icon(context: Context, data: IconData):
"attrs": data.attrs,
"heroicon_data": heroicon_data,
"slot_content": data.slot_content,
}
},
):
return lazy_load_template(icon_template_str).render(context)
@ -2097,9 +2097,9 @@ ICONS = {
"stroke-linecap": "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
}
]
}
},
],
},
}
@ -2113,9 +2113,8 @@ class ComponentDefaults(metaclass=ComponentDefaultsMeta):
def __post_init__(self) -> None:
fields = self.__class__.__dataclass_fields__ # type: ignore[attr-defined]
for field_name, dataclass_field in fields.items():
if dataclass_field.default is not MISSING:
if getattr(self, field_name) is None:
setattr(self, field_name, dataclass_field.default)
if dataclass_field.default is not MISSING and getattr(self, field_name) is None:
setattr(self, field_name, dataclass_field.default)
class IconDefaults(ComponentDefaults):
@ -2195,7 +2194,7 @@ def heroicon(context: Context, data: HeroIconData):
"icon_paths": icon_paths,
"default_attrs": default_attrs,
"attrs": kwargs.attrs,
}
},
):
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,
"slot_header": data.slot_header,
"slot_content": data.slot_content,
}
},
):
return lazy_load_template(expansion_panel_template_str).render(context)
#####################################
# PROJECT_PAGE
#####################################
@ -2363,7 +2363,7 @@ def project_page(context: Context, data: ProjectPageData):
ListItem(
value=title,
link=f"/projects/{data.project['id']}/phases/{phase['phase_template']['type']}",
)
),
)
project_page_tabs = [
@ -2378,7 +2378,7 @@ def project_page(context: Context, data: ProjectPageData):
contacts=data.contacts,
status_updates=data.status_updates,
editable=data.user_is_project_owner,
)
),
),
),
TabItemData(
@ -2390,7 +2390,7 @@ def project_page(context: Context, data: ProjectPageData):
notes=data.notes_1,
comments_by_notes=data.comments_by_notes_1, # type: ignore[arg-type]
editable=data.user_is_project_member,
)
),
),
),
TabItemData(
@ -2402,7 +2402,7 @@ def project_page(context: Context, data: ProjectPageData):
notes=data.notes_2,
comments_by_notes=data.comments_by_notes_2, # type: ignore[arg-type]
editable=data.user_is_project_member,
)
),
),
),
TabItemData(
@ -2414,7 +2414,7 @@ def project_page(context: Context, data: ProjectPageData):
notes=data.notes_3,
comments_by_notes=data.comments_by_notes_3, # type: ignore[arg-type]
editable=data.user_is_project_member,
)
),
),
),
TabItemData(
@ -2426,7 +2426,7 @@ def project_page(context: Context, data: ProjectPageData):
outputs=data.outputs,
editable=data.user_is_project_member,
phase_titles=data.phase_titles,
)
),
),
),
]
@ -2435,7 +2435,7 @@ def project_page(context: Context, data: ProjectPageData):
with tabs_context.push(
{
"project_page_tabs": project_page_tabs,
}
},
):
return lazy_load_template(project_page_tabs_template_str).render(tabs_context)
@ -2444,12 +2444,12 @@ def project_page(context: Context, data: ProjectPageData):
ListData(
items=rendered_phases,
item_attrs={"class": "py-5"},
)
),
)
with context.push(
{
"project": data.project,
}
},
):
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)
#####################################
# PROJECT_LAYOUT_TABBED
#####################################
@ -2569,7 +2570,7 @@ def project_layout_tabbed(context: Context, data: ProjectLayoutTabbedData):
breadcrumbs_content = breadcrumbs_tag(context, BreadcrumbsData(items=prefixed_breadcrumbs))
bookmarks_content = bookmarks_tag(
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(
@ -2600,7 +2601,7 @@ def project_layout_tabbed(context: Context, data: ProjectLayoutTabbedData):
"slot_left_panel": data.slot_left_panel,
"slot_header": data.slot_header,
"slot_tabs": data.slot_tabs,
}
},
):
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,
"slot_header": data.slot_header,
"slot_content": data.slot_content,
}
},
):
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(
{
"base_data": base_data,
}
},
):
return lazy_load_template("{% base base_data %}").render(provided_context)
@ -2757,7 +2758,7 @@ def layout(context: Context, data: LayoutData):
with context.push(
{
"render_context_provider_data": render_context_provider_data,
}
},
):
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_js": data.slot_js,
"slot_content": data.slot_content,
}
},
):
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,
# Slots
"slot_content": data.slot_content,
}
},
):
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,
"attrs": data.attrs,
}
},
):
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_content": data.slot_content,
"slot_append": data.slot_append,
}
},
):
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,
"add_tag_button_data": add_tag_button_data,
"slot_title": slot_title,
}
},
):
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_form": data.slot_form,
"slot_below_form": data.slot_below_form,
}
},
):
return lazy_load_template(form_template_str).render(context)
@ -4074,9 +4075,7 @@ def form(context: Context, data: FormData):
@dataclass(frozen=True)
class Breadcrumb:
"""
Single breadcrumb item used with the `breadcrumb` components.
"""
"""Single breadcrumb item used with the `breadcrumb` components."""
value: Any
"""Value of the menu item to render."""
@ -4160,7 +4159,7 @@ def breadcrumbs(context: Context, data: BreadcrumbsData):
{
"items": data.items,
"attrs": data.attrs,
}
},
):
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,
"add_new_bookmark_icon_data": add_new_bookmark_icon_data,
"context_menu_data": context_menu_data,
}
},
):
return lazy_load_template(bookmarks_template_str).render(context)
@ -4485,7 +4484,7 @@ def bookmark(context: Context, data: BookmarkData):
"bookmark": data.bookmark._asdict(),
"js": data.js,
"bookmark_icon_data": bookmark_icon_data,
}
},
):
return lazy_load_template(bookmark_template_str).render(context)
@ -4562,7 +4561,7 @@ def list_tag(context: Context, data: ListData):
"attrs": data.attrs,
"item_attrs": data.item_attrs,
"slot_empty": data.slot_empty,
}
},
):
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,
"tabs_data": {"name": data.name},
"theme": theme,
}
},
):
return lazy_load_template(tabs_impl_template_str).render(context)
@ -4743,6 +4742,11 @@ class TabsData(NamedTuple):
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
# user input provided as nested components. But after the input is
# processed, it delegates to an internal "implementation" component
@ -4752,7 +4756,6 @@ def tabs(context: Context, data: TabsData):
if not data.slot_content:
return ""
ProvidedData = NamedTuple("ProvidedData", [("tabs", List[TabEntry]), ("enabled", bool)])
collected_tabs: List[TabEntry] = []
provided_data = ProvidedData(tabs=collected_tabs, enabled=True)
@ -4792,7 +4795,7 @@ def tab_item(context, data: TabItemData):
raise RuntimeError(
"Component 'tab_item' was called with no parent Tabs 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
@ -4801,7 +4804,7 @@ def tab_item(context, data: TabItemData):
"header": data.header,
"disabled": data.disabled,
"content": mark_safe(data.slot_content or "").strip(),
}
},
)
return ""
@ -4877,7 +4880,7 @@ def tabs_static(context: Context, data: TabsStaticData):
"hide_body": data.hide_body,
"selected_content": selected_content,
"theme": theme,
}
},
):
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_team_button_data": edit_team_button_data,
"edit_contacts_button_data": edit_contacts_button_data,
}
},
):
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):
modified_time_str = format_timestamp(datetime.fromisoformat(comment["modified"]))
formatted_modified_by = (
modified_time_str
+ " "
+ comment['modified_by']['name']
)
formatted_modified_by = modified_time_str + " " + comment["modified_by"]["name"]
edit_comment_icon_data = IconData(
name="pencil-square",
@ -5174,7 +5173,7 @@ def _make_notes_data(
"edit_note_icon_data": edit_note_icon_data,
"comments": comments_data,
"create_comment_button_data": create_comment_button_data,
}
},
)
return notes_data
@ -5201,7 +5200,7 @@ def project_notes(context: Context, data: ProjectNotesData) -> str:
"create_note_button_data": create_note_button_data,
"notes_data": notes_data,
"editable": data.editable,
}
},
):
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,
}
},
):
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(
open=has_outputs,
@ -5291,7 +5292,7 @@ def project_outputs_summary(context: Context, data: ProjectOutputsSummaryData) -
with context.push(
{
"groups": groups,
}
},
):
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):
modified_time_str = format_timestamp(datetime.fromisoformat(status_update["modified"]))
formatted_modified_by = (
modified_time_str
+ " "
+ status_update['modified_by']['name']
)
formatted_modified_by = modified_time_str + " " + status_update["modified_by"]["name"]
return {
"timestamp": formatted_modified_by,
@ -5381,7 +5378,7 @@ def project_status_updates(context: Context, data: ProjectStatusUpdatesData) ->
"updates_data": updates_data,
"editable": data.editable,
"add_status_button_data": add_status_button_data,
}
},
):
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"]),
"role": TableCell(role["name"]),
"delete": delete_action,
}
)
},
),
)
submit_url = f"/submit/{data.project_id}/role/create"
project_url = f"/project/{data.project_id}"
if data.available_roles:
available_role_choices = [(role, role) for role in data.available_roles]
else:
available_role_choices = []
available_role_choices = [(role, role) for role in data.available_roles] if data.available_roles else []
if 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(
{
"delete_icon_data": delete_icon_data,
}
},
):
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,
"cancel_button_data": cancel_button_data,
"dialog_data": dialog_data,
}
},
):
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,
"delete_icon_data": delete_icon_data,
}
},
):
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"],
text=attachment[0]["text"],
tags=attachment[1],
)
),
)
update_output_url = "/update"
@ -5713,10 +5707,10 @@ def project_outputs(context: Context, data: ProjectOutputsData) -> str:
}
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(
completed=output["completed"],
@ -5741,9 +5735,11 @@ def project_outputs(context: Context, data: ProjectOutputsData) -> str:
{
"dependencies_data": [ProjectOutputDependencyData(dependency=dep) for dep in deps],
"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(
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"},
slot_header=f"""
<div>
{output['name']}
{output["name"]}
</div>
""",
slot_content=output_expansion_panel_content,
@ -5763,13 +5759,13 @@ def project_outputs(context: Context, data: ProjectOutputsData) -> str:
output_data,
output_badge_data,
expansion_panel_data,
)
),
)
with context.push(
{
"outputs_data": outputs_data,
}
},
):
return lazy_load_template(project_outputs_template_str).render(context)
@ -5833,7 +5829,7 @@ def project_output_badge(context: Context, data: ProjectOutputBadgeData):
"theme": theme,
"missing_icon_data": missing_icon_data,
"completed_icon_data": completed_icon_data,
}
},
):
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,
"missing_button_data": missing_button_data,
"parent_attachments_data": parent_attachments_data,
}
},
):
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,
"remove_button_data": remove_button_data,
"tags_data": tags_data,
}
},
):
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,
"save_button_data": save_button_data,
"add_attachment_button_data": add_attachment_button_data,
}
},
):
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,
"alpine_attachments": [d._asdict() for d in data.data.attachments],
}
},
):
return lazy_load_template(project_output_form_template_str).render(context)
#####################################
#
# IMPLEMENTATION END
@ -6432,6 +6429,7 @@ def project_output_form(context: Context, data: ProjectOutputFormData):
from django_components.testing import djc_test # noqa: E402
@djc_test
def test_render(snapshot):
data = gen_render_data()

View file

@ -32,9 +32,9 @@ if not settings.configured:
"OPTIONS": {
"builtins": [
"django_components.templatetags.component_tags",
]
],
},
}
},
],
COMPONENTS={
"autodiscover": False,
@ -45,9 +45,9 @@ if not settings.configured:
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
},
},
SECRET_KEY="secret",
SECRET_KEY="secret", # noqa: S106
ROOT_URLCONF="django_components.urls",
)
django.setup()
@ -67,10 +67,9 @@ def lazy_load_template(template: str) -> Template:
template_hash = hash(template)
if template_hash in templates_cache:
return templates_cache[template_hash]
else:
template_instance = Template(template)
templates_cache[template_hash] = template_instance
return template_instance
template_instance = Template(template)
templates_cache[template_hash] = template_instance
return template_instance
#####################################
@ -150,7 +149,7 @@ _secondary_btn_styling = "ring-1 ring-inset"
theme = Theme(
default=ThemeStylingVariant(
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"),
secondary=ThemeStylingUnit(
@ -314,7 +313,7 @@ def button(context: Context, data: ButtonData):
"attrs": all_attrs,
"is_link": is_link,
"slot_content": data.slot_content,
}
},
):
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
@djc_test
def test_render(snapshot):
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": {
"builtins": [
"django_components.templatetags.component_tags",
]
],
},
}
},
],
COMPONENTS={
"autodiscover": False,
@ -45,9 +45,9 @@ if not settings.configured:
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
},
},
SECRET_KEY="secret",
SECRET_KEY="secret", # noqa: S106
ROOT_URLCONF="django_components.urls",
)
@ -68,10 +68,10 @@ def lazy_load_template(template: str) -> Template:
template_hash = hash(template)
if template_hash in templates_cache:
return templates_cache[template_hash]
else:
template_instance = Template(template)
templates_cache[template_hash] = template_instance
return template_instance
template_instance = Template(template)
templates_cache[template_hash] = template_instance
return template_instance
#####################################
@ -156,7 +156,7 @@ _secondary_btn_styling = "ring-1 ring-inset"
theme = Theme(
default=ThemeStylingVariant(
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"),
secondary=ThemeStylingUnit(
@ -268,7 +268,7 @@ class Button(Component):
disabled: Optional[bool] = False,
variant: Union["ThemeVariant", Literal["plain"]] = "primary",
color: Union["ThemeColor", str] = "default",
type: Optional[str] = "button",
type: Optional[str] = "button", # noqa: A002
attrs: Optional[dict] = None,
):
common_css = (
@ -336,6 +336,7 @@ class Button(Component):
from django_components.testing import djc_test # noqa: E402
@djc_test
def test_render(snapshot):
data = gen_render_data()

View file

@ -125,8 +125,14 @@ class TestComponentMediaCache:
assert not test_cache.has_key(f"__components:{TestSimpleComponent.class_id}:css")
# Check that we cache `Component.js` / `Component.css`
assert test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:js").strip() == "console.log('Hello from JS');" # noqa: E501
assert test_cache.get(f"__components:{TestMediaNoVarsComponent.class_id}:css").strip() == ".novars-component { color: blue; }" # noqa: E501
assert (
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`
TestMediaAndVarsComponent.render()
@ -138,4 +144,4 @@ class TestComponentMediaCache:
# 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}: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 django.core.management import call_command
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})

View file

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

View file

@ -5,8 +5,10 @@ from textwrap import dedent
from unittest.mock import patch
from django.core.management import call_command
from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentExtension
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})
@ -51,7 +53,7 @@ class DummyCommand(ComponentCommand):
kwargs.pop("_command")
kwargs.pop("_parser")
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):
@ -85,7 +87,7 @@ class TestExtensionsCommand:
{{list,run}}
list List all extensions.
run Run a command added by an extension.
"""
""",
).lstrip()
)
@ -221,7 +223,7 @@ class TestExtensionsRunCommand:
subcommands:
{{dummy}}
dummy Run commands added by the 'dummy' extension.
"""
""",
).lstrip()
)
@ -248,7 +250,7 @@ class TestExtensionsRunCommand:
subcommands:
{{dummy_cmd}}
dummy_cmd Dummy command description.
"""
""",
).lstrip()
)
@ -275,7 +277,7 @@ class TestExtensionsRunCommand:
subcommands:
{{dummy_cmd}}
dummy_cmd Dummy command description.
"""
""",
).lstrip()
)
@ -294,7 +296,7 @@ class TestExtensionsRunCommand:
== 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}
""" # noqa: E501
""", # noqa: E501
).lstrip()
)

View file

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

View file

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

View file

@ -1,10 +1,10 @@
import re
import time
import pytest
from django.core.cache import caches
from django.template import Template
from django.template.context import Context
import pytest
from django_components import Component, register
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"
def test_cached_component_inside_include(self):
@register("test_component")
class TestComponent(Component):
template = "Hello"
@ -223,7 +222,7 @@ class TestComponentCache:
{% block content %}
THIS_IS_IN_ACTUAL_TEMPLATE_SO_SHOULD_NOT_BE_OVERRIDDEN
{% endblock %}
"""
""",
)
result = template.render(Context({}))
@ -251,7 +250,7 @@ class TestComponentCache:
{% component "test_component" input="cake" %}
ONE
{% endcomponent %}
"""
""",
).render(Context({}))
Template(
@ -259,7 +258,7 @@ class TestComponentCache:
{% component "test_component" input="cake" %}
ONE
{% endcomponent %}
"""
""",
).render(Context({}))
# Check if the cache entry is set
@ -277,7 +276,7 @@ class TestComponentCache:
{% component "test_component" input="cake" %}
TWO
{% endcomponent %}
"""
""",
).render(Context({}))
assert len(cache._cache) == 2
@ -339,12 +338,12 @@ class TestComponentCache:
return {"input": kwargs["input"]}
with pytest.raises(
ValueError,
TypeError,
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(
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_components import Component, Default
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})

View file

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

View file

@ -1,9 +1,10 @@
# ruff: noqa: E501
import os
import re
import sys
from pathlib import Path
from textwrap import dedent
from typing import Optional
from typing import List, Optional
import pytest
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 django_components import Component, autodiscover, registry, render_dependencies, types
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})
@ -34,7 +35,7 @@ class TestMainMedia:
{% component_js_dependencies %}
{% component_css_dependencies %}
<div class='html-css-only'>Content</div>
"""
""",
)
css = ".html-css-only { color: blue; }"
js = "console.log('HTML and JS only');"
@ -64,7 +65,7 @@ class TestMainMedia:
{% component_js_dependencies %}
{% component_css_dependencies %}
<div class='html-css-only'>Content</div>
"""
""",
)
assert TestComponent.css == ".html-css-only { color: blue; }"
assert TestComponent.js == "console.log('HTML and JS only');"
@ -75,9 +76,9 @@ class TestMainMedia:
@djc_test(
django_settings={
"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):
from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent
@ -96,7 +97,7 @@ class TestMainMedia:
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component "test" variable="test" / %}
"""
""",
).render(Context())
assertInHTML(
@ -135,9 +136,9 @@ class TestMainMedia:
@djc_test(
django_settings={
"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):
class TestComponent(Component):
@ -165,7 +166,7 @@ class TestMainMedia:
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component "test" variable="test" / %}
"""
""",
).render(Context())
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
assert TestComponent.template == "Variable: <strong>{{ variable }}</strong>\n"
# fmt: off
assert TestComponent.css == (
"/* Used in `MainMediaTest` tests in `test_component_media.py` */\n"
".html-css-only {\n"
" color: blue;\n"
"}"
)
# fmt: on
assert TestComponent.js == (
'/* Used in `MainMediaTest` tests in `test_component_media.py` */\nconsole.log("HTML and JS only");\n'
)
@ -193,9 +196,9 @@ class TestMainMedia:
@djc_test(
django_settings={
"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):
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
# _ = 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]
# Also check JS and HTML while we're at it
@ -341,7 +344,7 @@ class TestComponentMedia:
def test_media_custom_render_js(self):
class MyMedia(Media):
def render_js(self):
tags: list[str] = []
tags: List[str] = []
for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
tags.append(f'<script defer src="{abs_path}"></script>')
@ -367,7 +370,7 @@ class TestComponentMedia:
def test_media_custom_render_css(self):
class MyMedia(Media):
def render_css(self):
tags: list[str] = []
tags: List[str] = []
media = sorted(self._css) # type: ignore[attr-defined]
for medium in media:
for path in self._css[medium]: # type: ignore[attr-defined]
@ -399,7 +402,7 @@ class TestComponentMedia:
@djc_test(
django_settings={
"INSTALLED_APPS": ("django_components", "tests"),
}
},
)
def test_glob_pattern_relative_to_component(self):
from tests.components.glob.glob import GlobComponent
@ -414,7 +417,7 @@ class TestComponentMedia:
@djc_test(
django_settings={
"INSTALLED_APPS": ("django_components", "tests"),
}
},
)
def test_glob_pattern_relative_to_root_dir(self):
from tests.components.glob.glob import GlobComponentRootDir
@ -429,7 +432,7 @@ class TestComponentMedia:
@djc_test(
django_settings={
"INSTALLED_APPS": ("django_components", "tests"),
}
},
)
def test_non_globs_not_modified(self):
from tests.components.glob.glob import NonGlobComponentRootDir
@ -442,7 +445,7 @@ class TestComponentMedia:
@djc_test(
django_settings={
"INSTALLED_APPS": ("django_components", "tests"),
}
},
)
def test_non_globs_not_modified_nonexist(self):
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(
'<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(
'<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
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)
@ -604,7 +610,7 @@ class TestMediaPathAsObject:
"""
class MyStr(str):
pass
__slots__ = ()
class SimpleComponent(Component):
template = """
@ -718,7 +724,7 @@ class TestMediaPathAsObject:
@djc_test(
django_settings={
"STATIC_URL": "static/",
}
},
)
def test_works_with_static(self):
"""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
# See https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STATICFILES_DIRS
"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.
"INSTALLED_APPS": [
"django.contrib.staticfiles",
"django_components",
],
}
},
)
def test_default_static_files_storage(self):
"""Test integration with Django's staticfiles app"""
class MyMedia(Media):
def render_js(self):
tags: list[str] = []
tags: List[str] = []
for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
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
# See https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STATICFILES_DIRS
"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
# See https://docs.djangoproject.com/en/5.2/ref/settings/#storages
"STORAGES": {
@ -836,14 +842,14 @@ class TestMediaStaticfiles:
"django.contrib.staticfiles",
"django_components",
],
}
},
)
def test_manifest_static_files_storage(self):
"""Test integration with Django's staticfiles app and ManifestStaticFilesStorage"""
class MyMedia(Media):
def render_js(self):
tags: list[str] = []
tags: List[str] = []
for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
tags.append(f'<script defer src="{abs_path}"></script>')
@ -889,7 +895,7 @@ class TestMediaRelativePath:
{% endcomponent %}
{% endslot %}
</div>
""" # noqa
"""
def get_template_data(self, args, kwargs, slots, context):
return {"shadowing_variable": "NOT SHADOWED"}
@ -921,7 +927,7 @@ class TestMediaRelativePath:
"STATICFILES_DIRS": [
Path(__file__).resolve().parent / "components",
],
}
},
)
def test_component_with_relative_media_paths(self):
registry.register(name="parent_component", component=self._gen_parent_component())
@ -973,7 +979,7 @@ class TestMediaRelativePath:
"STATICFILES_DIRS": [
Path(__file__).resolve().parent / "components",
],
}
},
)
def test_component_with_relative_media_paths_as_subcomponent(self):
registry.register(name="parent_component", component=self._gen_parent_component())
@ -1010,7 +1016,7 @@ class TestMediaRelativePath:
"STATICFILES_DIRS": [
Path(__file__).resolve().parent / "components",
],
}
},
)
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
# error if PathObj.__str__ is triggered.
CompCls = registry.get("relative_file_pathobj_component")
CompCls.Media.js[0].throw_on_calling_str = False # type: ignore
CompCls.Media.css["all"][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 # noqa: PGH003
rendered = CompCls.render(kwargs={"variable": "abc"})

View file

@ -1,14 +1,14 @@
import re
from dataclasses import dataclass
from typing import NamedTuple, Optional
from typing_extensions import NotRequired, TypedDict
import pytest
from django.template import Context
from typing_extensions import NotRequired, TypedDict
from django_components import Component, Empty, Slot, SlotInput
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})
@ -76,7 +76,7 @@ class TestComponentTyping:
kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots(
header="HEADER",
footer=Slot(lambda ctx: "FOOTER"),
footer=Slot(lambda _ctx: "FOOTER"),
),
)
@ -124,7 +124,7 @@ class TestComponentTyping:
kwargs={"name": "name", "age": 123},
slots={
"header": "HEADER",
"footer": Slot(lambda ctx: "FOOTER"),
"footer": Slot(lambda _ctx: "FOOTER"),
},
)
@ -206,7 +206,7 @@ class TestComponentTyping:
kwargs={"name": "name", "age": 123},
slots={
"header": "HEADER",
"footer": Slot(lambda ctx: "FOOTER"),
"footer": Slot(lambda _ctx: "FOOTER"),
},
)
@ -314,7 +314,7 @@ class TestComponentTyping:
kwargs={"name": "name", "age": 123},
slots={
"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),
slots=Button.Slots(
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),
slots=Button.Slots(
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]
slots=Button.Slots(
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"),
kwargs=Button.Kwargs(name="name", age=123),
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.http import HttpRequest, HttpResponse
from django.template import Context, Template
from django.test import Client, SimpleTestCase
from django.test import Client
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.testing import djc_test
from django_components.urls import urlpatterns as dc_urlpatterns
from django_components.util.misc import format_url
from django_components.testing import djc_test
from .testutils import setup_test_config
# DO NOT REMOVE!
#
# This is intentionally defined before `setup_test_config()` in order to test that
@ -40,16 +42,16 @@ class CustomClient(Client):
if urlpatterns:
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
else:
settings.ROOT_URLCONF = __name__
settings.SECRET_KEY = "secret" # noqa
settings.SECRET_KEY = "secret" # noqa: S105
super().__init__(*args, **kwargs)
@djc_test
class TestComponentAsView(SimpleTestCase):
class TestComponentAsView:
def test_render_component_from_template(self):
@register("testcomponent")
class MockComponentRequest(Component):
@ -64,19 +66,19 @@ class TestComponentAsView(SimpleTestCase):
def get_template_data(self, args, kwargs, slots, context):
return {"variable": kwargs["variable"]}
def render_template_view(request):
def render_template_view(_request):
template = Template(
"""
{% load component_tags %}
{% component "testcomponent" variable="TEMPLATE" %}{% endcomponent %}
"""
""",
)
return HttpResponse(template.render(Context({})))
client = CustomClient(urlpatterns=[path("test_template/", render_template_view)])
response = client.get("/test_template/")
self.assertEqual(response.status_code, 200)
self.assertInHTML(
assert response.status_code == 200
assertInHTML(
'<input type="text" name="variable" value="TEMPLATE">',
response.content.decode(),
)
@ -100,8 +102,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.get("/test/")
self.assertEqual(response.status_code, 200)
self.assertInHTML(
assert response.status_code == 200
assertInHTML(
'<input type="text" name="variable" value="GET">',
response.content.decode(),
)
@ -124,8 +126,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.get("/test/")
self.assertEqual(response.status_code, 200)
self.assertInHTML(
assert response.status_code == 200
assertInHTML(
'<input type="text" name="variable" value="GET">',
response.content.decode(),
)
@ -150,8 +152,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.post("/test/", {"variable": "POST"})
self.assertEqual(response.status_code, 200)
self.assertInHTML(
assert response.status_code == 200
assertInHTML(
'<input type="text" name="variable" value="POST">',
response.content.decode(),
)
@ -175,8 +177,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.post("/test/", {"variable": "POST"})
self.assertEqual(response.status_code, 200)
self.assertInHTML(
assert response.status_code == 200
assertInHTML(
'<input type="text" name="variable" value="POST">',
response.content.decode(),
)
@ -198,8 +200,8 @@ class TestComponentAsView(SimpleTestCase):
view = MockComponentRequest.as_view()
client = CustomClient(urlpatterns=[path("test/", view)])
response = client.get("/test/")
self.assertEqual(response.status_code, 200)
self.assertInHTML(
assert response.status_code == 200
assertInHTML(
'<input type="text" name="variable" value="MockComponentRequest">',
response.content.decode(),
)
@ -225,15 +227,9 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test_slot/", MockComponentSlot.as_view())])
response = client.get("/test_slot/")
self.assertEqual(response.status_code, 200)
self.assertIn(
b"Hey, I'm Bob",
response.content,
)
self.assertIn(
b"Nice to meet you, Bob",
response.content,
)
assert response.status_code == 200
assert b"Hey, I'm Bob" in response.content
assert b"Nice to meet you, Bob" in response.content
def test_replace_slot_in_view_with_insecure_content(self):
class MockInsecureComponentSlot(Component):
@ -253,11 +249,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test_slot_insecure/", MockInsecureComponentSlot.as_view())])
response = client.get("/test_slot_insecure/")
self.assertEqual(response.status_code, 200)
self.assertNotIn(
b"<script>",
response.content,
)
assert response.status_code == 200
assert b"<script>" not in response.content
def test_replace_context_in_view(self):
class TestComponent(Component):
@ -273,11 +266,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test_context_django/", TestComponent.as_view())])
response = client.get("/test_context_django/")
self.assertEqual(response.status_code, 200)
self.assertIn(
b"Hey, I'm Bob",
response.content,
)
assert response.status_code == 200
assert b"Hey, I'm Bob" in response.content
def test_replace_context_in_view_with_insecure_content(self):
class MockInsecureComponentContext(Component):
@ -293,11 +283,8 @@ class TestComponentAsView(SimpleTestCase):
client = CustomClient(urlpatterns=[path("test_context_insecure/", MockInsecureComponentContext.as_view())])
response = client.get("/test_context_insecure/")
self.assertEqual(response.status_code, 200)
self.assertNotIn(
b"<script>",
response.content,
)
assert response.status_code == 200
assert b"<script>" not in response.content
def test_component_url(self):
class TestComponent(Component):
@ -315,13 +302,16 @@ class TestComponentAsView(SimpleTestCase):
# 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')
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
component_url4 = format_url(
"/components/ext/view/components/123?foo=123&bar=456#abc",
query={"foo": "new", "baz": "new2"},
fragment='xyz',
fragment="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 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.testing import djc_test
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
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
# 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()}
@ -94,7 +94,7 @@ def gen_parent_component():
{% endcomponent %}
{% endslot %}
</div>
""" # noqa
""" # noqa: E501
def get_template_data(self, args, kwargs, slots, context):
return {"shadowing_variable": "NOT SHADOWED"}
@ -118,7 +118,7 @@ def gen_parent_component_with_args():
{% endcomponent %}
{% endslot %}
</div>
""" # noqa
""" # noqa: E501
def get_template_data(self, args, kwargs, slots, context):
return {"inner_parent_value": kwargs["parent_value"]}
@ -135,7 +135,8 @@ def gen_parent_component_with_args():
class TestContext:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
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="parent_component", component=gen_parent_component())
@ -153,7 +154,8 @@ class TestContext:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
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="parent_component", component=gen_parent_component())
@ -184,7 +186,7 @@ class TestContext:
{% endcomponent %}
{% endfill %}
{% endcomponent %}
""" # NOQA
""" # noqa: E501
template = Template(template_str)
rendered = template.render(Context())
@ -205,7 +207,7 @@ class TestContext:
{% endcomponent %}
{% endfill %}
{% endcomponent %}
""" # NOQA
""" # noqa: E501
template = Template(template_str)
rendered = template.render(Context())
@ -214,7 +216,8 @@ class TestContext:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
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="parent_component", component=gen_parent_component())
@ -232,7 +235,8 @@ class TestContext:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
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="parent_component", component=gen_parent_component())
@ -245,7 +249,7 @@ class TestContext:
{% endcomponent %}
{% endfill %}
{% endcomponent %}
""" # NOQA
""" # noqa: E501
template = Template(template_str)
rendered = template.render(Context({"shadowing_variable": "NOT SHADOWED"}))
@ -324,7 +328,7 @@ class TestParentArgs:
[{"context_behavior": "isolated"}, "passed_in", ""],
],
["django", "isolated"],
)
),
)
def test_parent_args_available_in_slots(self, components_settings, first_val, second_val):
registry.register(name="incrementer", component=gen_incrementer_component())
@ -473,7 +477,7 @@ class TestComponentsCanAccessOuterContext:
[{"context_behavior": "isolated"}, ""],
],
["django", "isolated"],
)
),
)
def test_simple_component_can_use_outer_context(self, components_settings, expected_value):
registry.register(name="simple_component", component=gen_simple_component())
@ -890,22 +894,24 @@ class TestContextProcessors:
assert list(context_processors_data.keys()) == ["csrf_token"] # type: ignore[union-attr]
assert inner_request == request
@djc_test(django_settings={
"TEMPLATES": [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": ["tests/templates/", "tests/components/"],
"OPTIONS": {
"builtins": [
"django_components.templatetags.component_tags",
],
"context_processors": [
"tests.test_context.dummy_context_processor",
],
@djc_test(
django_settings={
"TEMPLATES": [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": ["tests/templates/", "tests/components/"],
"OPTIONS": {
"builtins": [
"django_components.templatetags.component_tags",
],
"context_processors": [
"tests.test_context.dummy_context_processor",
],
},
},
}
],
})
],
},
)
def test_data_generated_only_once(self):
context_processors_data: Optional[Dict] = None
context_processors_data_child: Optional[Dict] = None
@ -932,8 +938,8 @@ class TestContextProcessors:
request = HttpRequest()
TestParentComponent.render(request=request)
parent_data = cast(dict, context_processors_data)
child_data = cast(dict, context_processors_data_child)
parent_data = cast("dict", context_processors_data)
child_data = cast("dict", context_processors_data_child)
# Check that the context processors data is reused across the components with
# the same request.

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