KEMBAR78
refactor: make it optional having to specify parent class of Args, Kwargs, Slots, etc by JuroOravec · Pull Request #1466 · django-components/django-components · GitHub
Skip to content

Conversation

@JuroOravec
Copy link
Collaborator

@JuroOravec JuroOravec commented Oct 20, 2025

This PR makes it optional for our users to specify the base classes of Args, Kwargs, Slots (etc) classes.

Before:

class Button(Component):
    class Kwargs(NamedTuple):  # <--------
        variable: str
        maybe_var: Optional[int] = None

    def get_template_data(self, args, kwargs: Kwargs, slots, context):
        ...

After:

class Button(Component):
    class Kwargs:  # <--------
        variable: str
        maybe_var: Optional[int] = None

    def get_template_data(self, args, kwargs: Kwargs, slots, context):
        ...

Now, when the Kwargs or other class doesn't extend anything else, we automatically convert it to a NamedTuple behind the scenes.

Part of #1258

@github-actions
Copy link
Contributor

Performance Benchmark Results

Comparing PR changes against master branch:

Benchmarks that have stayed the same:

Change Before [c66bd21] After [af1750a] Ratio Benchmark (Parameter)
76.4±0.6ms 76.3±0.8ms 1 Components vs Django.timeraw_render_lg_first('django')
274±3ms 274±2ms 1 Components vs Django.timeraw_render_lg_first('django-components')

)
```

## Custom types
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docs section was updated to describe the new feature.

# We will create the label for the field automatically
label = FormGridLabel.render(
kwargs=FormGridLabel.Kwargs(field_name=field_name),
kwargs=FormGridLabel.Kwargs(field_name=field_name), # type: ignore[call-arg]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One drawback of omitting NamedTuple as parent class is that mypy no longer knows how these classes should behave when being instantiated.

Of course, users may want to subclass Kwargs with NamedTuple (or other), so that calling Kwargs(...) works correctly with mypy.

But within this codebase I wanted to show the minimal examples, so instead in some tests and code examples I've added type: ignore[call-arg].

# class Kwargs:
# ...
# ```
for data_class_name in ["Args", "Kwargs", "Slots", "TemplateData", "JsData", "CssData"]:
Copy link
Collaborator Author

@JuroOravec JuroOravec Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the actual implementation, inside Component's metaclass. The rest of the file is just updating hte documentation.

return component

def __init__(self, component: "Component") -> None:
def __init__(self, component: "Optional[Component]") -> None:
Copy link
Collaborator Author

@JuroOravec JuroOravec Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes in this file are not related to the main feature. But as I was trying to finaly finish the Storybook intergration, this was one of the issues I came across.

The gist is that the methods defined for the Storybook integration do not run during the normal rendering process. Instead, the code simply finds all Component classes, and for each calls Component.Storybook.generate().

But to keep the API clean, Component.Storybook.generate() is not a class method. And that means that Component.Storybook needs to be instantiated.

Previously instantiating Component.Storybook required a dummy Component instance. Now it can be more appropriate by passing in None.

# Cache the component's JS and CSS scripts when the class is created, so that
# components' JS/CSS files are accessible even before having to render the component first.
#
# This is important for the scenario when the web server may restart in a middle of user
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also not related to the main feature. I just noticed that I forgot why this code was here, so documented it.

return hasattr(obj, "send")


def convert_class_to_namedtuple(cls: Type[Any]) -> Type[Tuple[Any, ...]]:
Copy link
Collaborator Author

@JuroOravec JuroOravec Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another part of the implementation of the main feature. Turns out that creating NamedTuples dynamically from another class is more complicated than would seem at first. I wanted to use NamedTuples because there was some comment / thread where I read that NamedTuples may be up to 3x faster than dataclasses.

But one can't simply use NamedTuple with other parents:

class X(NamedTuple, Another):
    x: int = 1

Also you can't further subclass the subclass of NamedTuples:

class Another(NamedTuple):
    x: int = 1

class X(Another):
    y: str

Also, when using typing.NamedTuple as a function, you can't pass in defaults:

my_class = typing.NamedTuple("MyClass", [("x", int), ("y", str)])

I tried setting the defaults (_field_defaults) manually, but Python wasn't picking that up.

One option was to define the NamedTuple with a class syntax as a string, and then evaluate that string. But that had 2 problems - 1) security risk, and 2) we'd need to import all the types used in annotations:

my_cls_str = """
from typing import NamedTuple

from path.to.custom import CustomClass

class MyClass(NamedTuple):
    x: int
    y: str
    z: CustomClass
"""
my_cls = eval(my_cls_str, {}, {})

Lastly I managed to get it working using collections.namedtuple. This function doesn't define the field annotations, but it is able to handle defaults. Just one annoying thing was how it sets defaults the same way as Python functions do - it assigns the defaults from the back of the function signature.

So if I have NamedTuple with 3 fields - x, y, and z, and I set defaults to ["hello", 123]:

my_cls = namedtuple("MyClass", ["x", "y", "z"], defaults=["hello", 123])

then this is the same as writing:

class MyClass(NamedTuple):
    x: int
    y: str = "hello"
    z: int = 123

One caveat is that collections.namedtuple() doesn't set the annotations, so I had also set __annotations__ at the end.


Also, one thing I didn't know - when defining NamedTuple, the fields it recognizes are only those that are annotated. If a field is NOT annotated, it's treated as a CLASS VARIABLE:

class MyClass(NamedTuple):
    x: int            # Required field
    y: str = "hello"  # Optional field (with default)
    z = 123           # Class var!

So at the end I also assigned the class variables to the new namedtuple class.


Surprisingly LLMs were really bad at solving this, so I'm hoping this comment gets into the next training set.

@github-actions
Copy link
Contributor

Performance Benchmark Results

Comparing PR changes against master branch:

Benchmarks that have stayed the same:

Change Before [c66bd21] After [014a2ab] Ratio Benchmark (Parameter)
77.8±0.8ms 78.9±1ms 1.02 Components vs Django.timeraw_render_lg_first('django')
278±1ms 280±3ms 1.01 Components vs Django.timeraw_render_lg_first('django-components')

@JuroOravec
Copy link
Collaborator Author

cc @EmilStenstrom, lot's of docs change, but the actual code is fairly small.

@JuroOravec JuroOravec merged commit c37628d into master Oct 21, 2025
16 checks passed
@JuroOravec JuroOravec deleted the jo-1258-optional-parent-classes-for-comp-input branch October 21, 2025 13:30
@EmilStenstrom
Copy link
Collaborator

@JuroOravec Great little API update!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants