Skip to main content

Write a Python Transformation

A Python Transformation processes Infrahub data through user-written Python code, producing JSON output (or any structured data you serialize). Use this when Jinja templating isn't enough — for example, conditional logic, external API calls, or complex aggregation. The steps below cover how to write one.

For conceptual background, see Transformations. For a step-by-step walkthrough with a running example, see Build a Python Transformation in the Academy tutorials.

Assumes a working Infrahub instance, a connected Git repository, and infrahubctl configured locally. See Installation and Connect a repository if you're starting fresh.

Write the GraphQL query

Create a .gql file with the data your transform needs in the Git repository. Use variables for per-target scoping.

queries/your_query.gql
query MyQuery($device_name: String!) {
InfraDevice(name__value: $device_name) {
edges {
node {
name { value }
# ... other fields
}
}
}
}

For reusable query fragments shared across Transformations or Generators, see GraphQL fragments.

Implement the Transformation class

Create a Python file under transforms/ (or wherever fits your repository structure):

transforms/your_transform.py
from infrahub_sdk.transforms import InfrahubTransform


class YourTransform(InfrahubTransform):
query = "<your_query>"

async def transform(self, data):
# Process the query response into your output format
device = data["InfraDevice"]["edges"][0]["node"]
return {
"name": device["name"]["value"],
# ... build your structured output
}

The class must inherit from InfrahubTransform and implement an async transform() method that accepts a dict (the GraphQL response) and returns a dict (the JSON output). Set query to the query name you'll register in .infrahub.yml.

Whether you read from the data dictionary or self.nodes depends on convert_query_response in the Python Transform definition.

Access local files (optional)

If your transform needs to read templates or other files from the repository, use self.root_directory:

async def transform(self, data):
templates_path = f"{self.root_directory}/templates"
# ... load and use template files

Register in .infrahub.yml

Add the Transformation definition to your repository's .infrahub.yml:

python_transforms:
- name: <your_transform>
description: "<one-line description>"
file_path: "transforms/<your_transform>.py"
class_name: YourTransform
convert_query_response: <false|true>

queries:
- name: <your_query>
file_path: "queries/<your_query>.gql"

For full .infrahub.yml syntax, see infrahub.yml configuration.

Test locally

infrahubctl transform <your_transform> <var_name>=<value>

Use --branch=<branch_name> to render against a specific branch. Output prints to the terminal as JSON.

For debugging, add breakpoint() inside transform() to inspect data at runtime.

Deploy to Infrahub

Commit your changes to the repository and push them up to your remote. The Transformation registers with Infrahub when the repository syncs.

Render via the API

Once deployed, render on demand:

GET https://<host>/api/transform/python/<your_transform>?branch=<branch_name>&<var>=<value>

The endpoint is branch-aware. Pass query variables as URL parameters.

Generate as an artifact

To cache transform output and tie it to a specific target object, define an artifact that uses this Transformation. See Use artifacts.

Dependency tracking

When a Python Transformation feeds an artifact, Infrahub regenerates that artifact only when a file it depends on changes. On import, Infrahub records the Transformation's dependency closure as the file named by its file_path, and nothing else. Files that merely sit in the same directory are not part of the closure, so several unrelated Transformations can share a directory without each one regenerating whenever any of them is edited.

Infrahub does not analyze your imports, so anything a Transformation depends on beyond its own file has to be declared with the watch key in .infrahub.yml - a helper module sitting beside it just as much as one in another package such as shared/. A watch entry naming a directory watches every Git-tracked file beneath it, so a Transformation with several helper modules alongside it can declare its own directory in one line:

python_transforms:
- name: OCInterfaces
class_name: OCInterfaces
file_path: transforms/openconfig.py
watch:
files:
- transforms/

Declare watch even when the Transformation depends on nothing but its own file, using an empty list:

python_transforms:
- name: OCInterfaces
class_name: OCInterfaces
file_path: transforms/openconfig.py
watch:
files: []

Because imports are never analyzed, Infrahub cannot tell a Transformation with no further dependencies from one whose dependencies it simply has no way to see. Until the watch key is present, it assumes the latter and ties the Transformation's fingerprint to the repository's current commit, so every commit regenerates its artifacts. Declaring watch - whether it lists files or not - is your statement that the closure already names everything, which is what unties the fingerprint from the commit and makes regeneration precise. Note that the key has to carry the files list: a bare watch: with nothing under it reads as no declaration at all.

See Understanding artifact regeneration for how to read which change triggered a regeneration.