Package: laces
LacesDjango components that know how to render themselves.Laces components provide a simple way to combine data (in the form of Python objects) with the Django templates that are meant to render that data.
The components can then be simply rendered in any other template using the{% component %}template tag.
That parent template does not need to know anything about the component's template or data.
No need to receive, filter, restructure or pass any data to the component's template.
Just let the component render itself.Template and data are tied together (sorry, not sorry 😅) in the component, and they can be passed around together.
This becomes especially useful when components are nested — it allows us to avoid building the same nested structure twice (once in the data and again in the templates).Working with objects that know how to render themselves as HTML elements is a common pattern found in complex Django applications, such as theWagtailadmin interface.
The Wagtail admin is also where the APIs provided in this package have previously been discovered, developed and solidified.
The purpose of this package is to make these tools available to other Django projects outside the Wagtail ecosystem.LinksGetting startedInstallationCreating componentsPassing context to the component templateUsing components in other templatesAdding JavaScript and CSS assets to a componentPatterns for using componentsNesting componentsNested groups of componentsContainer componentsUsing dataclassesAbout Laces and componentsContributingChangelogDiscussionsSecurityGetting startedInstallationFirst, install with pip:$python-mpipinstalllacesThen, add to your installed apps:# settings.pyINSTALLED_APPS=["laces",...]That's it.Creating componentsThe simplest way to create a component is to define a subclass oflaces.components.Componentand specify atemplate_nameattribute on it.# my_app/components.pyfromlaces.componentsimportComponentclassWelcomePanel(Component):template_name="my_app/components/welcome.html"{# my_app/templates/my_app/components/welcome.html #}<h1>Hello World!</h1>With the above in place, you then instantiate the component (e.g., in a view) and pass it to another template for rendering.# my_app/views.pyfromdjango.shortcutsimportrenderfrommy_app.componentsimportWelcomePaneldefhome(request):welcome=WelcomePanel()# <-- Instantiates the componentreturnrender(request,"my_app/home.html",{"welcome":welcome},# <-- Passes the component to the view template)In the view template, weloadthelacestag library and use the{% component %}tag to render the component.{# my_app/templates/my_app/home.html #}{%loadlaces%}{%componentwelcome%}{# <-- Renders the component #}That's it!
The component's template will be rendered right there in the view template.Of course, this is a very simple example and not much more useful than using a simpleinclude.
We will go into some more useful use cases below.Without a templateBefore we dig deeper into the component use cases, just a quick note that components don't have to have a template.
For simple cases that don't require a template, therender_htmlmethod can be overridden instead.
If the return value contains HTML, it should be marked as safe usingdjango.utils.html.format_htmlordjango.utils.safestring.mark_safe.# my_app/components.pyfromdjango.utils.htmlimportformat_htmlfromlaces.componentsimportComponentclassWelcomePanel(Component):defrender_html(self,parent_context):returnformat_html("<h1>Hello World!</h1>")Passing context to the component templateNow back to components with templates.The example shown above with the static welcome message in the template is, of course, not very useful.
It seems more like an overcomplicated way to replace a simpleinclude.But, we rarely ever want to render templates with static content.
Usually, we want to pass some context variables to the template to be rendered.
This is where components start to become interesting.The default implementation ofrender_htmlcalls the component'sget_context_datamethod to get the context variables to pass to the template.
The default implementation ofget_context_datareturns an empty dictionary.
To customize the context variables passed to the template, we can overrideget_context_data.# my_app/components.pyfromlaces.componentsimportComponentclassWelcomePanel(Component):template_name="my_app/components/welcome.html"defget_context_data(self,parent_context):return{"name":"Alice"}{# my_app/templates/my_app/components/welcome.html #}<h1>Hello{{name}}</h1>With the above we are now rendering a welcome message with the name coming from the component'sget_context_datamethod.
Nice.
But, still not very useful, as the name is still hardcoded — in the component method instead of the template, but hardcoded nonetheless.Using class propertiesWhen considering how to make the context of our components more useful, it's helpful to remember that components are just normal Python classes and objects.
So, you are basically free to get the context data into the component in any way you like.For example, we can pass arguments to the constructor and use them in the component's methods, likeget_context_data.# my_app/components.pyfromlaces.componentsimportComponentclassWelcomePanel(Component):template_name="my_app/components/welcome.html"def__init__(self,name):self.name=namedefget_context_data(self,parent_context):return{"name":self.name}Nice, this is getting better.
Now we can pass the name to the component when we instantiate it and pass the component ready to be rendered to the view template.# my_app/views.pyfromdjango.shortcutsimportrenderfrommy_app.componentsimportWelcomePaneldefhome(request):welcome=WelcomePanel(name="Alice")returnrender(request,"my_app/home.html",{"welcome":welcome},)So, as mentioned before, we can use the full power of Python classes and objects to provide context data to our components.
A couple more examples of how components can be used can be foundbelow.Using the parent contextYou may have noticed in the above examples that therender_htmlandget_context_datamethods take aparent_contextargument.
This is the context of the template that is calling the component.
Theparent_contextis passed into therender_htmlmethod by the{% component %}template tag.
In the default implementation of therender_htmlmethod, theparent_contextis then passed to theget_context_datamethod.
The default implementation of theget_context_datamethod, however, ignores theparent_contextargument and returns an empty dictionary.
To make use of it, you will have to override theget_context_datamethod.Relying on data from the parent context somewhat forgoes some of the benefits of components, which is tying the data and template together.
Especially for nested uses of components, you now require that the data in the right format is passed through all layers of templates again.
It is usually cleaner to provide all the data needed by the component directly to the component itself.However, there may be cases where this is not possible or desirable.
For those cases, you have access to the parent context in the component'sget_context_datamethod.# my_app/components.pyfromlaces.componentsimportComponentclassWelcomePanel(Component):template_name="my_app/components/welcome.html"defget_context_data(self,parent_context):return{"name":parent_context["request"].user.first_name}(Of course, this could have also been achieved by passing the request or user object to the component in the view, but this is just an example.)Using components in other templatesAs mentioned in thefirst example, components are rendered in other templates using the{% component %}tag from thelacestag library.Here is that example from above again, in which the view passes an instance ofWelcomePanelto the context ofmy_app/home.html.# my_app/views.pyfromdjango.shortcutsimportrenderfrommy_app.componentsimportWelcomePaneldefhome(request):welcome=WelcomePanel()returnrender(request,"my_app/home.html",{"welcome":welcome,},)Then, in themy_app/templates/my_app/home.htmltemplate we render the welcome panel component as follows:{# my_app/templates/my_app/home.html #}{%loadlaces%}{%componentwelcome%}This is the basic usage of components and should cover most cases.However, the{% component %}tag also supports some additional features.
Specifically, the keywordswith,onlyandasare supported, similar to how they work with the{% include %}tag.Provide additional parent context variables withwithYou can pass additional parent context variables to the component using the keywordwith:{%componentwelcomewithname=request.user.first_name%}Note: These extra variables will be added to theparent_contextwhich is passed to the component'srender_htmlandget_context_datamethods.
The default implementation ofget_context_dataignores theparent_contextargument, so you will have to override it to make use of the extra variables.
For more information see the above section on theparent context.Limit the parent context variables withonlyTo limit the parent context variables passed to the component to only those variables provided by thewithkeyword (and no others from the calling template's context), useonly:{%componentwelcomewithname=request.user.first_nameonly%}Note: Both,withandonly, only affect theparent_contextwhich is passed to the component'srender_htmlandget_context_datamethods. They do not have any direct effect on actual context that is passed to the component's template. E.g. if the component'sget_context_datamethod returns a dictionary which always contains a keyfoo, then that key will be available in the component's template, regardless of whetheronlywas used or not.Store the rendered output in a variable withasTo store the component's rendered output in a variable rather than outputting it immediately, useasfollowed by the variable name:{%componentwelcomeaswelcome_html%}{{welcome_html}}Adding JavaScript and CSS assets to a componentLike Django form widgets, components can specify associated JavaScript and CSS assets.
The assets for a component can be specified in the same way thatDjango form assets are defined.
This can be achieved using either an innerMediaclass or a dynamicmediaproperty.An innerMediaclass definition looks like this:# my_app/components.pyfromlaces.componentsimportComponentclassWelcomePanel(Component):template_name="my_app/components/welcome.html"classMedia:css={"all":("my_app/css/welcome-panel.css",)}The more dynamic definition via amediaproperty looks like this:# my_app/components.pyfromdjango.formsimportMediafromlaces.componentsimportComponentclassWelcomePanel(Component):template_name="my_app/components/welcome.html"@propertydefmedia(self):returnMedia(css={"all":("my_app/css/welcome-panel.css",)})Note:
It is your template's responsibility to output any media declarations defined on the components.Outputting component media in templatesOnce you have defined the assets on the component in one of the two ways above, you can output them in your templates.
This, again, works in the same way as it does for Django form widgets.
The component instance will have amediaproperty which returns an instance of thedjango.forms.Mediaclass.
This is the case, even if you used the nestedMediaclass to define the assets.
Thestring representation of aMediaobjectsare the HTML declarations to include the assets.In the example home template from above, we can output the component's media declarations like so:{# my_app/templates/my_app/home.html #}{%loadlaces%}<head>{{welcome.media}}<head><body>{%componentwelcome%}</body>Combining media withMediaContainerWhen you have many components in a page, it can be cumbersome to output the media declarations for each component individually.
To make that process a bit easier, Laces provides aMediaContainerclass.
TheMediaContainerclass is a subclass of Python's built-inlistclass which combines themediaof all it's members.In a view we can create aMediaContainerinstance containing several media-defining components and pass it to the view template.# my_app/views.pyfromdjango.shortcutsimportrenderfromlaces.componentsimportMediaContainerfrommy_app.componentsimport(Dashboard,Footer,Header,Sidebar,WelcomePanel,)defhome(request):components=MediaContainer([Header(),Sidebar(),WelcomePanel(),Dashboard(),Footer(),])returnrender(request,"my_app/home.html",{"components":components,},)Then, in the view template, we can output the media declarations for all components in the container at once.{# my_app/templates/my_app/home.html #}{%loadlaces%}<head>{{components.media}}<head><body>{%forcomponentincomponents%}{%componentcomponent%}{%endfor%}</body>This will output a combined media declaration for all components in the container.
The combination of the media declarations follows the behaviour outlined in theDjango documentation.Note:
The use ofMediaContaineris not limited to contain components.
It can be used to combine themediaproperties of any kind of objects that have amediaproperty.Patterns for using componentsBelow, we want to show a few more examples of how components can be used that were not covered in the"Getting started" sectionabove.Nesting componentsThe combination of data and template that components provide becomes especially useful when components are nested.# my_app/components.pyfromlaces.componentsimportComponentclassWelcomePanel(Component):...classDashboard(Component):template_name="my_app/components/dashboard.html"def__init__(self,user):self.welcome=WelcomePanel(name=user.first_name)...defget_context_data(self,parent_context):return{"welcome":self.welcome}The template of the "parent" component does not need to know anything about the "child" component, except for which template variable is a component.
The child component already contains the data it needs and knows which template to use to render that data.{# my_app/templates/my_app/components/dashboard.html #}{%loadlaces%}<divclass="dashboard">{%componentwelcome%}...</div>The nesting also provides us with a nice data structure we can test.dashboard=Dashboard(user=request.user)assertdashboard.welcome.name==request.user.first_nameNested groups of componentsThe nesting of components is not limited to single instances.
We can also nest groups of components.# my_app/components.pyfromlaces.componentsimportComponentclassWelcomePanel(Component):...classUsagePanel(Component):...classTeamPanel(Component):...classDashboard(Component):template_name="my_app/components/dashboard.html"def__init__(self,user):self.panels=[WelcomePanel(name=user.first_name),UsagePanel(user=user),TeamPanel(groups=user.groups.all()),]...defget_context_data(self,parent_context):return{"panels":self.panels}{# my_app/templates/my_app/components/dashboard.html #}{%loadlaces%}<divclass="dashboard">{%forpanelinpanels%}{%componentpanel%}{%endfor%}...</div>Container componentsTheabove exampleis relatively static.
TheDashboardcomponent always contains the same panels.You could also imagine passing the child components in through the constructor.
This would make your component into a dynamic container component.# my_app/components.pyfromlaces.componentsimportComponentclassSection(Component):template_name="my_app/components/section.html"def__init__(self,children:list[Component]):self.children=children...defget_context_data(self,parent_context):return{"children":self.children}classHeading(Component):...classParagraph(Component):...classImage(Component):...{# my_app/templates/my_app/components/section.html #}{%loadlaces%}<section>{%forchildinchildren%}{%componentchild%}{%endfor%}</section>The aboveSectioncomponent can take any kind of component as children.
The only thing thatSectionrequires is that the children can be rendered with the{% component %}tag (which all components do).In the view, we can now instantiate theSectioncomponent with any children we want.# my_app/views.pyfromdjango.shortcutsimportrenderfrommy_app.componentsimport(Heading,Image,Paragraph,Section,)defhome(request):content=Section(children=[Heading(...),Paragraph(...),Image(...),])returnrender(request,"my_app/home.html",{"content":content},){# my_app/templates/my_app/home.html #}{%loadlaces%}<body>{%componentcontent%}...</body>Using dataclassesAbove, we showed how touse class propertiesto add data to the component's context.
This is a very useful and common pattern.
However, it is a bit verbose, especially when you have many properties and directly pass the properties to the template context.To make this a little more convenient, we can usedataclasses.# my_app/components.pyfromdataclassesimportdataclass,asdictfromlaces.componentsimportComponent@dataclassclassWelcomePanel(Component):template_name="my_app/components/welcome.html"name:strdefget_context_data(self,parent_context):returnasdict(self)With dataclasses we define the name and type of the properties we want to pass to the component in the class definition.
Then, we can use theasdictfunction to convert the dataclass instance to a dictionary that can be directly as the template context.Theasdictfunction only adds keys to the dictionary that were defined as the properties defined in the dataclass.
In the above example, the dictionary returned byasdictwould only contain thenamekey.
It would not contain thetemplate_namekey, because that is set on the class with a value but without a type annotation.
If you were to add the type annotation, then thetemplate_namekey would also be included in the dictionary returned byasdict.Custom constructor methodsWhen a component has many properties, it can be a pain to pass each property to the constructor individually.
This is especially true when the component is used in many places and the data preparation would need to be repeated in each use case.
Custom constructor methods can help with that.In case of ourWelcomePanelexample, we might want to show some more user information, including a profile image and link to the user's profile page.
We can add aclassmethodthat takes the user object and returns an instance of the component with all the data needed to render the component.
We can also use this method to encapsulate the logic for generating additional data, such as the profile URL.# my_app/components.pyfromdjangoimporturlsfromdataclassesimportdataclass,asdictfromlaces.componentsimportComponent@dataclassclassWelcomePanel(Component):template_name="my_app/components/welcome.html"first_name:strlast_name:strprofile_url:strprofile_image_url:str@classmethoddeffrom_user(cls,user):profile_url=urls.reverse("profile",kwargs={"pk":user.pk})returncls(first_name=user.first_name,last_name=user.last_name,profile_url=profile_url,profile_image_url=user.profile.image.url,)defget_context_data(self,parent_context):returnasdict(self)Now, we can instantiate the component in the view like so:# my_app/views.pyfromdjango.shortcutsimportrenderfrommy_app.componentsimportWelcomePaneldefhome(request):welcome=WelcomePanel.from_user(request.user)returnrender(request,"my_app/home.html",{"welcome":welcome},)The constructor method allows us to keep our view very simple and clean as all the data preparation is encapsulated in the component.As in the example above, custom constructor methods pair very well with the use of dataclasses, but they can of course also be used without them.About Laces and componentsWhy "Laces"?"Laces" is somewhat of a reference to the feature of tying data and templates together.
The components are also "self-rendering," which could be seen as "self-reliance," which relates to "bootstrapping."
And aren't "bootstraps" just a long kind of "(shoe)laces?"Finally, it is a nod to@mixxorz's fantasticSlippers package, which also takes a component focused approach to improve the experience when working with Django templates, but in a quite different way.Supported versionsPython >= 3.8Django >= 3.2ContributingInstallTo make changes to this project, first clone this repository:$gitclonehttps://github.com/tbrlpld/laces.git
$cdlacesWith your preferred virtualenv activated, install the development dependencies:Using pip$python-mpipinstall--upgradepip>=21.3
$python-mpipinstall-e'.[dev]'-UUsing flit$python-mpipinstallflit
$flitinstallpre-commitNote that this project usespre-commit.
It is included in the project testing requirements. To set up locally:# initialize pre-commit$pre-commitinstall# Optional, run all checks once for this, then the checks will run only on the changed files$gitls-files--others--cached--exclude-standard|xargspre-commitrun--filesHow to run testsNow you can run all tests like so:$toxOr, you can run them for a specific environment:$tox-epython3.11-django4.2Or, run only a specific test:$tox-epython3.11-django4.2laces.tests.test_file.TestClass.test_methodTo run the test app interactively, use:$tox-einteractiveYou can now visithttp://localhost:8020/.Testing with coveragetoxis configured to run tests with coverage.
The coverage report is combined for all environments.
This is done by using the--appendflag when running coverage intox.
This means it will also include previous results.You can see the coverage report by running:$coveragereportTo get a clean report, you can runcoverage erasebefore runningtox.Running tests withouttoxIf you want to run tests withouttox, you can use thetestmanage.pyscript.
This script is a wrapper around Django'smanage.pyand will run tests with the correct settings.To make this work, you need to have thetestingdependencies installed.$python-mpipinstall-e'.[testing]'-UThen you can run tests with:$./testmanage.pytestTo run tests with coverage, use:$coveragerun./testmanage.pytestPython version managementTox will attempt to find installed Python versions on your machine.If you usepyenvto manage multiple versions, you can telltoxto use those versions.
To ensure thattoxwill find Python versions installed withpyenvyou needvirtualenv-pyenv(note: this is notpyenv-virtualenv).virtualenv-pyenvis part of the development dependencies (just liketoxitself).
Additionally, you have to set the environment variableVIRTUALENV_DISCOVERY=pyenv.PublishingThis project uses theTrusted Publisher model for PyPI releases.
This means that publishing is done through GitHub Actions when anew release is created on GitHub.To create a release, you need a Git tag.
The tag can either be created on the command line and pushed or in the "create release" interface on GitHub.
The tag name should be the version number prefixed with av(e.g.v0.1.0).Before publishing a new release, make sure to updatethe changelog inCHANGELOG.md, andthe version number inlaces/__init__.py.To manually test publishing the package, you can useflit.
Be sure to configure thetestpypirepository in your~/.pypircfile according to the Flitdocumentation.
If your PyPI account is using 2FA, you'll need to create aPyPI API tokenand use that as your password and__token__as the username.When you're ready to test the publishing, run:$flitbuild
$flitpublish--repositorytestpypi