Debugging Kubernetes

23 Jul 2024

Helpful tips for debugging applications running in k8s

build/k8s.png

Handling multiple errors in Rust iterator adapters

17 Dec 2023

Approaches for handling multiple errors within iterator adapters

build/rust.png

Better FastAPI Background Jobs

29 Aug 2022

A more featureful background task runner for Async apps like FastAPI or Discord bots

build/fastapi_logo.png

Useful Linux Examples

21 Dec 2021

A plethora of helpful tips for working in Linux

build/bash_logo.png
Continue to all blog posts

Latest Updated Post

Effective Repository Templates with Copier

Copier is immensely useful as a vehicle for building and distributing developer tooling that remains maintainable at scale

The critical feature it provides beyond tools like cookiecutter is the ability to update the code you have generated from it to a newer version of the template, while respecting any changes you’ve made to it

In this post I’ll detail a number of sensible defaults, techniques, design patterns and practices that enhance the utility of copier templates

Set _min_copier_version

Unless you’re using CI to test your template with multiple versions of copier I suggest setting this to the latest version available. With tools like uv or pipx for isolated installs of python CLI tools, in the vast majority of cases there is no need to support older versions and so you can nudge them to update. This ensures your users are using the most up to date software with all the fixes to the copier cli tool and that as the template author, you can use all features described in the docs

Set _subdirectory

As a sensible default, I suggest setting

# copier.yml
_subdirectory: template

which means your template will be the contents of the template/ directory rather than the root of the template repository

This is quite useful for separating the template contents from files for the maintenance of the template itself such as; tests, docs, CI/CD config, and developer tooling

A more complex pattern, that can be useful if you’re supporting multiple variants of template that require a fundamentally different structure, is to template this value with the answer to a question

# copier.yml
_subdirectory: '{{template}}'

template:
    choices:
        - Tech Stack A: template_dir_for_tech_stack_a
        - Tech Stack B: template_dir_for_tech_stack_b

However, if the templates are really so different that they require their own variant like this, I would recommend that you consider if they should be entirely separate templates (i.e. in separate repos) so they can be maintained and versioned separately

If only part of your template is fundamentally different you can achieve similar results with conditional directories: e.g. template/{% if your_variable == 'feature_A' %}feature_A_dir{% endif %}/

Hardcoded template values

Sometimes it’s useful to include variables that are used within the template as a helper for the template maintainers, but don’t need to be asked to users. You can achieve this with answers that are never asked

template_name:
    type: str
    default: "your-template-name"
    when: false

Note: These values do not appear in the users answers file

Highlight merge conflicts

The way copier provides the ability to update code from an older version of the template is by using git to apply the differences between the old template and the new template to your code. Copier raises merge conflicts for you to resolve if you’ve modified the code differently in your instance of the files.

By including a .pre-commit-config.yaml file with check-merge-conflict for use with pre-commit in the template, you can help ensure users more easily find conflicts if they occur when they update the template

# .pre-commit-config.yaml
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
    -   id: check-merge-conflict
        args: [--assume-in-merge]

Ideally pre-commit validation should be checked in CI with a job that runs pre-commit run --all-files in case a developer has forgotten to install it

Handling templates inside your template

If files you’re including in your template are also themselves templates and use the same template delimiters as jinja defaults ({{ }}) used by copier, then things can get a bit awkward.

For instance if you include task’s Taskfile.yaml or just’s justfile or you use jinja in the output project, and you want to template values inside those files, you can end up with the delimiters conflicting.

Then you either have to use lots of {% raw %}{% endraw %} blocks to escape the templating manually.

Alternatively, there are a number of settings to modify the (jinja) templating within _envops which are helpful to change the delimiters used by copier in the template

_envops:
    block_end_string: "%]"
    block_start_string: "[%"
    comment_end_string: "#]"
    comment_start_string: "[#"
    variable_end_string: "]]"
    variable_start_string: "[["

You might also consider customizing the suffix of files that get templated (from the default of .jinja) if it clashes with your desired output too

_templates_suffix: .tmpl

Whitespace control

Familiarity with the whitespace-control functionality offered by jinja templating is very helpful for maintaining functional and tidy output files

This includes the use of - or + to control whitespace around the jinja blocks

{%-  -%}

and/or setting global defaults you can customize within _envops

_envops:
    trim_blocks: true
    keep_trailing_newline: true
    lstrip_blocks: true

Loop helpers

Jinja templating provides you with some helpers when you are using for loops via the loop variable which has various helper attributes and methods attached

For instance to construct a valid json array, you need comma’s for all the lines except the last:

[
{% for item in items %}
    "{{ item }}"{% if not loop.last %},{% endif %}
{% endfor %}
]

See the jinja docs for details of other attributes and method

Validate your inputs

Empty strings are valid inputs to stings but might not be what you want, so make sure to validate your inputs where appropriate

question:
    type: str
    validator: >-
        {% if question == "" %}
        Answer must not be an empty string
        {% endif %}

You can utilize the guard clause pattern to create more complex validation

question:
    type: str
    validator: |-
        {% if question == "" %}
        Answer must not be an empty string
        {% endif %}
        {% if len(question) > 20 %}
        Answer must be less than (or equal to) 20 characters
        {% endif %}

If describing the validation via jinja templating is getting unmanageable you can use jinja extensions to use python to do more complex validation. You can add jinja “filters” (a python function that receives the value piped in) that will parse the input and produce the validation sting.

question:
    type: str
    validator: |-
        {{ question | your_validation_filter }}

This is made easier with copier through the copier_templates_extensions extension enabling easier authoring of extensions as part of the template.

_jinja_extensions:
    - copier_templates_extensions.TemplateExtensionLoader

The downside of this approach is that it requires the user to use the --trust flag (as it can run arbitrary code) which potentially will cause them some concern or require them to investigate what it’s doing before being happy using it. However if your template already requires the use of the trust flag for other reasons (e.g. migrations) then this is less of a issue.

Creating files/directories with for loops

Copier recently (in version 9.5) introduced support for looping over iterables to construct files and directories

With this change, it increases the value in asking questions that have iterable answers, to construct data structures to loop over

There are a number of different ways to support this with copier

For lists of values known statically ahead of time you can use choices with multiselect:

environments:
    multiselect: true
    choices:
        - dev
        - test
        - staging
        - production
📁 dotenv
└── 📄 {% yield env from environments %}{{ env }}{% endyield %}.env.jinja
# {% yield env from environments %}{{ env }}{% endyield %}.env.jinja
ENVIRONMENT={{ env }}

Using yaml (or json) inputs which expect nested maps allows you to construct more useful files, but can require more complex validation

# helper list
required_config:
    type: yaml
    default: |-
        db_host: string
        db_port: number
    when: false

environments_config:
    type: yaml
    multiline: true
    default: |-
        dev:
            db_host: localhost
            db_port: '5432'
    validator: |-
        {% if environments_config is not mapping %}
        Environment config must be a yaml map with keys as the environment name
        {% endif %}
        {% for env, config in environments_config.items() %}
        {% if config is not mapping %}
        Environment '{{ env }}' value must be a yaml map with keys: {{ required_config | join(',') }}
        {% endif %}
        {% for var in required_config %}
        {% if var not in config %}
        {{ env }} must contain {{ var }} setting
        {% endif %}
        {% if config[var] is not string %}
        {{ var }} must be a string setting
        {% endif %}
        {% endfor %}
        {% endfor %}

Then your file can be more advanced like this

# {% yield env from environments_config %}{{ env }}{% endyield %}.env.jinja
ENVIRONMENT={{ env }}
DB_HOST={{ environments_config[env].db_host }}
DB_PORT={{ environments_config[env].db_port }}

Modular templates

A collection of approaches supporting composition of multiple copier templates

Use Separate Directory structure

Put your files in separate directories using locations like

.gitlab-ci/{{ template_name }}/.gitlab-ci.yml
scripts/{{ template_name }}/your_templates_script.sh
{{ user_specified_name }}/your_files.py

Where needed these file can be imported/included into top level files with tools that support this

Set a unique _answers_file name

_answers_file: .copier.unique-name-for-template.yaml

Answering questions from existing yaml files

If you construct sets of related modular copier templates, then you might be able to reuse answers from another copier answers file, or construct a special interface file in a parent template for use in a child template to reduce the number of questions users need to answer.

The most basic way to do this is with copier --data-file answers-in-file.yaml

For more advanced cases _external_data can be used with jinja templating e.g. to convert names

# path/to/file/relative/to/copier/target/directory.yaml
different_name: value
_external_data:
    existing_file: "path/to/file/relative/to/copier/target/directory.yaml"

question:
    default: "{{ _external_data.existing_file.different_name }}"