Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -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
52 changes: 37 additions & 15 deletions volt/components.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 6 additions & 0 deletions volt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
68 changes: 45 additions & 23 deletions volt/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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("_", "")

Expand Down Expand Up @@ -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:
Expand All @@ -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()
85 changes: 85 additions & 0 deletions volt/generator_test.py
Original file line number Diff line number Diff line change
@@ -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