diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..1d4773a --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,42 @@ +name: Run pull request tasks + +on: + pull_request: + +jobs: + run_tests: + name: Run tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install build tools + run: pip install .[build] + + - name: Check build version + run: python3 -m setuptools_scm + + - name: Build wheel + run: python -m build + + - name: Check wheel + run: | + pip install twine + twine check dist/* + + - name: Install built wheel + run: pip install dist/*.whl + + - name: Install maintainer requirements for testing + run: pip install .[maintainer] + + - name: Run tests + run: pytest diff --git a/volt/components.py.j2 b/volt/components.py.j2 index 1c7ac96..6fec337 100644 --- a/volt/components.py.j2 +++ b/volt/components.py.j2 @@ -3,36 +3,58 @@ This file is automatically generated. Do not edit this file directly. """ -{% if not import_types %}from typing import Any +{% if not import_types %} +from typing import Any {% endif %} from dataclasses import dataclass from volt.components import Component + {% if components and import_types %} -from custom_types import ({% for component in components %}{% if component.fields %} - {{ component.name }}Types,{% endif %}{% endfor %} +from custom_types import ( + {% for component in components %} + {% if component.fields %} + {{ component.name }}Types, + {% endif %} + {% endfor %} ) {% endif %} + {% for component in components %} -{% if component.parent_components|length == 0 %}class {{ component.name }}(Component): -{% elif component.parent_components|length == 1 %}class {{ component.name }}({{ component.parent_components[0] }}): -{% else %}class {{ component.name }}( - {% for parent in component.parent_components %}{{ parent }}, + +{% if component.parent_components|length == 0 %} +class {{ component.name }}(Component): +{% elif component.parent_components|length == 1 %} +class {{ component.name }}({{ component.parent_components[0] }}): +{% else %} +class {{ component.name }}( + {% for parent in component.parent_components %} + {{ parent }}, {% endfor %} -):{% endif %} +): +{% endif %} template_name: str = "{{ component.template_name }}" block_name: str = "{{ component.block_name }}" - {% if component.parent_components|length == 0 %}@dataclass - class Context(Component.Context):{% elif component.parent_components|length == 1 %}@dataclass - class Context({{ component.parent_components[0] }}.Context):{% else %}@dataclass + @dataclass + {% if component.parent_components|length == 0 %} + class Context(Component.Context): + {% elif component.parent_components|length == 1 %} + class Context({{ component.parent_components[0] }}.Context): + {% else %} class Context( - {% for parent in component.parent_components %}{{ parent }}.Context, + {% for parent in component.parent_components %} + {{ parent }}.Context, {% endfor %} - ):{% endif %} - {% for field in component.fields %}{{ field }}: {% if import_types %}{{ component.name }}Types.{{ field }} - {% else %}Any{% endif %}{% else %}... + ): + {% endif %} + {% for field in component.fields %} + {{ field }}: {% if import_types %}{{ component.name }}Types.{{ field }}{% else %}Any{% endif %} + + {% else %} + ... {% endfor %} + def __init__(self, context: Context) -> None: super().__init__(context) diff --git a/volt/config.py b/volt/config.py index 0f5b15c..baae08d 100644 --- a/volt/config.py +++ b/volt/config.py @@ -75,3 +75,9 @@ def get_config_value(name: str, default: T) -> T: allowed_hosts = get_config_value("allowed_hosts", default=[]) log.debug("allowed_hosts: %s", allowed_hosts) + +templates_location = get_config_value("templates_location", default="templates") +log.debug("templates_location: %s", templates_location) + +require_component_types = get_config_value("require_component_types", default=False) +log.debug("require_component_types: %s", require_component_types) diff --git a/volt/generator.py b/volt/generator.py index 6e6ec65..eb91afe 100644 --- a/volt/generator.py +++ b/volt/generator.py @@ -5,10 +5,9 @@ from jinja2 import Environment, FileSystemLoader, meta from jinja2.nodes import Block, For, Name, Tuple -log = logging.getLogger('volt.generator.py') -log.setLevel(logging.DEBUG) +from volt import config -environment = Environment(loader=FileSystemLoader("templates/")) +log = logging.getLogger("volt.generator.py") @dataclass @@ -29,7 +28,9 @@ class Context: all_components: list[GeneratedComponent] = [] -def get_block_children(block: Block, template_name: str, referenced_templates: Iterator[str | None], top_level: bool) -> Iterable[Block]: +def get_block_children( + block: Block, template_name: str, referenced_templates: Iterator[str | None], top_level: bool +) -> Iterable[Block]: blocks: list[Block] = [] child_blocks = block.iter_child_nodes() @@ -40,7 +41,7 @@ def get_block_children(block: Block, template_name: str, referenced_templates: I blocks.append(child_block) blocks.extend(get_block_children(child_block, template_name, referenced_templates, top_level=False)) - # We want to make sure that we are excluding fields that are captured by parents. Since we are inheriting from + # We want to make sure that we are excluding fields that are captured by parents. Since we are inheriting from # these components, we don't want to require them on on the child components as well parent_fields: list[str] = [] for parent_block in blocks: @@ -52,13 +53,10 @@ def get_block_children(block: Block, template_name: str, referenced_templates: I # in which case we name it the file name formatted_template_name = template_name_as_title(template_name) name = formatted_template_name + name_as_title(block.name) - # if block.name == "content": - # name = template_name[: template_name.find(".")].title() - parent_components = [formatted_template_name + name_as_title(block.name) for block in blocks] # If we have an 'extends' at the top level, we ensure this is added as a parent component to any component - # at that same 'top level', so as to ensure any fields in extended templates are also required as part of the + # at that same 'top level', so as to ensure any fields in extended templates are also required as part of the # dataclass context if top_level: for referenced_template in referenced_templates: @@ -82,6 +80,7 @@ def name_as_title(name: str) -> str: name = f"{name.replace('_', ' ').title().replace(' ', '')}" return name + def template_name_as_title(template_name: str) -> str: return template_name[: template_name.find(".")].title().replace("_", "") @@ -126,11 +125,12 @@ def get_block_fields(block: Block) -> list[str]: return fields + # TODO: Check against templates without blocks -def generate(): +def _generate(environment: Environment, import_types: bool) -> str: context = Context( components=[], - import_types=True, + import_types=import_types, ) if environment.loader is None: @@ -155,28 +155,50 @@ def generate(): parent_components.append(template_name_as_title(template_name) + name_as_title(block.name)) name = template_name_as_title(template_name) - all_components.append(GeneratedComponent( - name=name, - template_name=template_name, - block_name="content", - parent_components=parent_components, - fields=[], - )) + all_components.append( + GeneratedComponent( + name=name, + template_name=template_name, + block_name="content", + parent_components=parent_components, + fields=[], + ) + ) + + # Add all components without parents + for c in all_components: + if len(list(c.parent_components)) != 0: + continue + context.components.append(c) + while len(all_components) != len(context.components): + for c in all_components: + if c in context.components: + continue + if not all(pc in [cc.name for cc in context.components] for pc in c.parent_components): + continue - for component in all_components: - log.warning(f"component created: {component}") - context.components.append(component) + context.components.append(c) parent_dir = Path(__file__).parent - gen_environment = Environment(loader=FileSystemLoader(parent_dir)) - template_file = "components.py.j2" + gen_environment = Environment(loader=FileSystemLoader(parent_dir), trim_blocks=True, lstrip_blocks=True) + template_file = "components.py.j2" template = gen_environment.get_template(template_file) output = template.render(asdict(context)) + return output + +def generate(): + templates_location = Path(config.templates_location) + if not templates_location.is_dir(): + raise Exception(f"{config.templates_location} must be a directory") + + environment = Environment(loader=FileSystemLoader(templates_location)) + output = _generate(environment, config.require_component_types) with open("components_gen.py", "w") as f: len_written = f.write(output) assert len_written == len(output) + if __name__ == "__main__": generate() diff --git a/volt/generator_test.py b/volt/generator_test.py new file mode 100644 index 0000000..1753f6f --- /dev/null +++ b/volt/generator_test.py @@ -0,0 +1,85 @@ +from jinja2 import DictLoader, Environment +from volt.generator import _generate + + +def test_generate(): + templates = { + "about.html": """ +{% extends "base.html" %} + +{% block content %} +{{ bar }} +{% endblock %} +""", + "base.html": """ +{{ foo }} +{% block content %} +{% endblock %} +""", + } + environment = Environment(loader=DictLoader(templates)) + output = _generate(environment, import_types=True) + print(output) + expected_output = """# pyright: basic +\"\"\" +This file is automatically generated. +Do not edit this file directly. +\"\"\" +from dataclasses import dataclass + +from volt.components import Component + +from custom_types import ( + AboutContentTypes, +) + + +class BaseContent(Component): + template_name: str = "base.html" + block_name: str = "content" + + @dataclass + class Context(Component.Context): + ... + + def __init__(self, context: Context) -> None: + super().__init__(context) + + +class Base(BaseContent): + template_name: str = "base.html" + block_name: str = "content" + + @dataclass + class Context(BaseContent.Context): + ... + + def __init__(self, context: Context) -> None: + super().__init__(context) + + +class AboutContent(Base): + template_name: str = "about.html" + block_name: str = "content" + + @dataclass + class Context(Base.Context): + bar: AboutContentTypes.bar + + def __init__(self, context: Context) -> None: + super().__init__(context) + + +class About(AboutContent): + template_name: str = "about.html" + block_name: str = "content" + + @dataclass + class Context(AboutContent.Context): + ... + + def __init__(self, context: Context) -> None: + super().__init__(context) + +""" + assert output == expected_output