diff --git a/dev/specs/infp-504-artifact-composition/contracts/filter-interfaces.md b/dev/specs/infp-504-artifact-composition/contracts/filter-interfaces.md index 7dc7874e..ed71b3b3 100644 --- a/dev/specs/infp-504-artifact-composition/contracts/filter-interfaces.md +++ b/dev/specs/infp-504-artifact-composition/contracts/filter-interfaces.md @@ -39,6 +39,40 @@ async def file_object_content(storage_id: str) -> str **Validation**: Blocked in `CORE` context. Allowed in `WORKER` and `LOCAL` contexts. +### file_object_content_by_id + +```python +async def file_object_content_by_id(node_id: str) -> str +``` + +| Input | Output | Error | +| ----- | ------ | ----- | +| Valid node UUID (text file) | Raw file content (text) | — | +| Valid node UUID (binary file) | — | `JinjaFilterError("file_object_content_by_id", "binary content not supported...")` | +| `None` | — | `JinjaFilterError("file_object_content_by_id", "node_id is null", hint="...")` | +| `""` (empty) | — | `JinjaFilterError("file_object_content_by_id", "node_id is empty", hint="...")` | +| Permission denied (401/403) | — | `JinjaFilterError("file_object_content_by_id", "permission denied for node_id: {id}")` | +| No client provided | — | `JinjaFilterError("file_object_content_by_id", "requires InfrahubClient", hint="...")` | + +**Validation**: Blocked in `CORE` context. Allowed in `WORKER` and `LOCAL` contexts. + +### file_object_content_by_hfid + +```python +async def file_object_content_by_hfid(hfid: str | list[str], kind: str = "") -> str +``` + +| Input | Output | Error | +| ----- | ------ | ----- | +| Valid HFID + kind (text file) | Raw file content (text) | — | +| Valid HFID + kind (binary file) | — | `JinjaFilterError("file_object_content_by_hfid", "binary content not supported...")` | +| `None` | — | `JinjaFilterError("file_object_content_by_hfid", "hfid is null", hint="...")` | +| Missing `kind` argument | — | `JinjaFilterError("file_object_content_by_hfid", "'kind' argument is required", hint="...")` | +| Permission denied (401/403) | — | `JinjaFilterError("file_object_content_by_hfid", "permission denied for hfid: {hfid}")` | +| No client provided | — | `JinjaFilterError("file_object_content_by_hfid", "requires InfrahubClient", hint="...")` | + +**Validation**: Blocked in `CORE` context. Allowed in `WORKER` and `LOCAL` contexts. + ### from_json ```python @@ -73,9 +107,15 @@ def from_yaml(value: str) -> dict | list Used by `artifact_content`. Returns plain text content. -### GET /api/files/by-storage-id/{storage_id} (new) +### File object endpoints + +All three endpoints return file content with the node's `file_type` as content-type. The SDK validates that the content-type is text-based. -Used by `file_object_content`. Returns file content with appropriate content-type header. +| Endpoint | Used by | Identifier | +| -------- | ------- | ---------- | +| `GET /api/files/by-storage-id/{storage_id}` | `file_object_content` | storage_id | +| `GET /api/files/{node_id}` | `file_object_content_by_id` | node UUID | +| `GET /api/files/by-hfid/{kind}?hfid=...` | `file_object_content_by_hfid` | kind + HFID components | **Accepted content-types** (text-based): diff --git a/dev/specs/infp-504-artifact-composition/data-model.md b/dev/specs/infp-504-artifact-composition/data-model.md index cc9bd419..1be99968 100644 --- a/dev/specs/infp-504-artifact-composition/data-model.md +++ b/dev/specs/infp-504-artifact-composition/data-model.md @@ -65,23 +65,32 @@ class JinjaFilterError(JinjaTemplateError): ```python class InfrahubFilters: - def __init__(self, client: InfrahubClient) -> None: + @classmethod + def get_filter_names(cls) -> tuple[str, ...]: + """Discover filter names from public methods.""" + ... + + def __init__(self, client: InfrahubClient | None = None) -> None: self.client = client - async def artifact_content(self, storage_id: str) -> str: - """Retrieve artifact content by storage_id.""" + def _require_client(self, filter_name: str) -> InfrahubClient: + """Raise JinjaFilterError if no client is available.""" ... - async def file_object_content(self, storage_id: str) -> str: - """Retrieve file object content by storage_id.""" - ... + async def artifact_content(self, storage_id: str) -> str: ... + async def file_object_content(self, storage_id: str) -> str: ... + async def file_object_content_by_id(self, node_id: str) -> str: ... + async def file_object_content_by_hfid(self, hfid: str | list[str], kind: str = "") -> str: ... ``` **Key design decisions**: +- Client is optional — `InfrahubFilters` is always instantiated, each method checks for a client at call time via `_require_client()` +- `get_filter_names()` discovers client-dependent filter names automatically from all public methods — adding a new filter only requires adding a method - Methods are `async` — Jinja2's `auto_await` handles them in async rendering mode - Holds an `InfrahubClient` (async only), not `InfrahubClientSync` - Each method validates inputs and catches `AuthenticationError` to wrap in `JinjaFilterError` +- File object retrieval is split into 3 filters matching the server's 3 endpoints (`by-storage-id`, `by-id`, `by-hfid`) ## Modified Entities @@ -114,9 +123,9 @@ def set_client(self, client: InfrahubClient) -> None: **Purpose**: Deferred client injection — allows creating a `Jinja2Template` first and adding the client later. Also supports replacing a previously set client. -- Calls `_register_client_filters(client)` to bind real filter methods +- Updates `self._infrahub_filters.client` on the existing `InfrahubFilters` instance (no re-registration needed since the bound methods are already registered) - If the Jinja2 environment was already created, patches it in place -- Without calling `set_client()` (and without passing `client` to `__init__`), client-dependent filters raise `JinjaFilterError` with a descriptive message at render time +- Without calling `set_client()` (and without passing `client` to `__init__`), client-dependent filters raise `JinjaFilterError` with a descriptive message at render time via `_require_client()` ### Jinja2Template.validate() (modified signature) @@ -143,7 +152,11 @@ async def get_file_by_storage_id(self, storage_id: str, tracker: str | None = No ... ``` -**API endpoint**: `GET /api/files/by-storage-id/{storage_id}` +**API endpoints**: + +- `GET /api/files/by-storage-id/{storage_id}` — used by `file_object_content` +- `GET /api/files/{node_id}` — used by `file_object_content_by_id` +- `GET /api/files/by-hfid/{kind}?hfid=...` — used by `file_object_content_by_hfid` **Content-type check**: Allow `text/*`, `application/json`, `application/yaml`, `application/x-yaml`. Reject all others. @@ -155,6 +168,8 @@ async def get_file_by_storage_id(self, storage_id: str, tracker: str | None = No # Infrahub client-dependent filters (worker and local contexts) FilterDefinition("artifact_content", allowed_contexts=ExecutionContext.WORKER | ExecutionContext.LOCAL, source="infrahub"), FilterDefinition("file_object_content", allowed_contexts=ExecutionContext.WORKER | ExecutionContext.LOCAL, source="infrahub"), +FilterDefinition("file_object_content_by_hfid", allowed_contexts=ExecutionContext.WORKER | ExecutionContext.LOCAL, source="infrahub"), +FilterDefinition("file_object_content_by_id", allowed_contexts=ExecutionContext.WORKER | ExecutionContext.LOCAL, source="infrahub"), # Parsing filters (trusted, all contexts) FilterDefinition("from_json", allowed_contexts=ExecutionContext.ALL, source="infrahub"), diff --git a/dev/specs/infp-504-artifact-composition/quickstart.md b/dev/specs/infp-504-artifact-composition/quickstart.md index 1e1a6dc7..27f1e272 100644 --- a/dev/specs/infp-504-artifact-composition/quickstart.md +++ b/dev/specs/infp-504-artifact-composition/quickstart.md @@ -22,9 +22,17 @@ hostname {{ device.hostname.value }} ### Inline file object content +File objects can be retrieved by storage ID, node UUID, or HFID: + ```jinja2 -{% set file_content = file_object.storage_id.value | file_object_content %} -{{ file_content }} +{# By storage_id (most common) #} +{{ file_object.storage_id.value | file_object_content }} + +{# By node UUID #} +{{ file_object.id | file_object_content_by_id }} + +{# By Human-Friendly ID #} +{{ hfid_components | file_object_content_by_hfid(kind="NetworkCircuitContract") }} ``` ### Parse structured content diff --git a/dev/specs/infp-504-artifact-composition/spec.md b/dev/specs/infp-504-artifact-composition/spec.md index 75d484a9..026cec7d 100644 --- a/dev/specs/infp-504-artifact-composition/spec.md +++ b/dev/specs/infp-504-artifact-composition/spec.md @@ -94,12 +94,16 @@ The existing single `restricted: bool` parameter on `validate()` is insufficient ### Functional requirements - **FR-001**: `Jinja2Template.__init__` MUST accept an optional `client` parameter of type `InfrahubClient | None` (default `None`). Additionally, `Jinja2Template` MUST expose a `set_client(client)` method for deferred client injection, allowing the template to be created first and the client added later. `InfrahubClientSync` is not supported. -- **FR-002**: A dedicated class (for example, `InfrahubFilters`) MUST be introduced to hold the client reference and expose the Infrahub-specific filter callable methods. `Jinja2Template` instantiates this class when a client is provided (via `__init__` or `set_client()`) and registers its filters into the Jinja2 environment. +- **FR-002**: A dedicated class (for example, `InfrahubFilters`) MUST be introduced to hold an optional client reference and expose the Infrahub-specific filter callable methods. `InfrahubFilters` is always instantiated by `Jinja2Template` (even without a client); each filter method checks for a client at call time and raises `JinjaFilterError` if none is available. `set_client()` updates the existing `InfrahubFilters` instance rather than creating a new one. `InfrahubFilters.get_filter_names()` discovers client-dependent filter names automatically from all public methods on the class. - **FR-003**: The system MUST provide an `artifact_content` Jinja2 filter that accepts a `storage_id` string and returns the raw string content of the referenced artifact, using the artifact-specific API path. -- **FR-004**: The system MUST provide a `file_object_content` Jinja2 filter that accepts a `storage_id` string and returns the raw string content of the referenced file object, using the file-object-specific API path or metadata handling — this implementation is distinct from `artifact_content`. -- **FR-005**: Both `artifact_content` and `file_object_content` MUST raise `JinjaFilterError` when the input `storage_id` is null or empty, or when the object store cannot retrieve the content for any reason (not found, network failure, auth failure). Additionally, `file_object_content` MUST raise `JinjaFilterError` when the retrieved content has a non-text content type (i.e., not `text/*`, `application/json`, or `application/yaml`). -- **FR-006**: Both `artifact_content` and `file_object_content` MUST raise `JinjaFilterError` when invoked and no `InfrahubClient` was supplied to `Jinja2Template` at construction time. The error message MUST name the filter and explain that an `InfrahubClient` is required. -- **FR-007**: Both `artifact_content` and `file_object_content` MUST be registered with `allowed_contexts=ExecutionContext.WORKER | ExecutionContext.LOCAL` in the `FilterDefinition` registry. The `validate()` method accepts an `ExecutionContext` flag; these filters are blocked in the `CORE` context (API server computed attributes) and permitted in the `WORKER` context (Prefect workers) and `LOCAL` context (CLI/unrestricted rendering). Within Infrahub, any Jinja2-based computed attributes that use these new filters should cause a schema violation when loading the schema. +- **FR-004**: The system MUST provide three file object content filters, each retrieving content via a different identifier: + - `file_object_content` — accepts a `storage_id` string, uses `GET /api/files/by-storage-id/{storage_id}` + - `file_object_content_by_id` — accepts a node UUID string, uses `GET /api/files/{node_id}` + - `file_object_content_by_hfid` — accepts an HFID string or list and a required `kind` argument, uses `GET /api/files/by-hfid/{kind}?hfid=...` + All three share the same binary content-type rejection and error handling behavior. +- **FR-005**: All client-dependent filters (`artifact_content`, `file_object_content`, `file_object_content_by_id`, `file_object_content_by_hfid`) MUST raise `JinjaFilterError` when the input identifier is null or empty, or when the object store cannot retrieve the content for any reason (not found, network failure, auth failure). Additionally, all file object filters MUST raise `JinjaFilterError` when the retrieved content has a non-text content type (i.e., not `text/*`, `application/json`, or `application/yaml`). `file_object_content_by_hfid` MUST also raise `JinjaFilterError` when the `kind` argument is missing. +- **FR-006**: All client-dependent filters MUST raise `JinjaFilterError` when invoked and no `InfrahubClient` was supplied to `Jinja2Template` at construction time. The error message MUST name the filter and explain that an `InfrahubClient` is required. +- **FR-007**: All client-dependent filters (`artifact_content`, `file_object_content`, `file_object_content_by_id`, `file_object_content_by_hfid`) MUST be registered with `allowed_contexts=ExecutionContext.WORKER | ExecutionContext.LOCAL` in the `FilterDefinition` registry. The `validate()` method accepts an `ExecutionContext` flag; these filters are blocked in the `CORE` context (API server computed attributes) and permitted in the `WORKER` context (Prefect workers) and `LOCAL` context (CLI/unrestricted rendering). Within Infrahub, any Jinja2-based computed attributes that use these new filters should cause a schema violation when loading the schema. - **FR-008**: The system MUST provide `from_json` and `from_yaml` Jinja2 filters (adding them only if not already present in the environment) that parse a string into a Python dict/list. Applying them to an empty string MUST return an empty dict without raising. Applying them to malformed content MUST raise `JinjaFilterError`. - **FR-009**: `from_json` and `from_yaml` MUST be registered as trusted filters (`trusted=True`) since they perform no external I/O. - **FR-010**: All new filters MUST work correctly with `InfrahubClient` (async). `InfrahubClientSync` is not a supported client type for `Jinja2Template`. Both the sandboxed environment (string-based templates) and the file-based environment MUST have `enable_async=True` to support async filter callables via Jinja2's `auto_await`. @@ -107,6 +111,9 @@ The existing single `restricted: bool` parameter on `validate()` is insufficient - **FR-012**: A new `JinjaFilterError` exception class MUST be added to `infrahub_sdk/template/exceptions.py` as a subclass of `JinjaTemplateError`. - **FR-013**: Documentation MUST include a Python transform example demonstrating artifact content retrieval via `client.object_store.get(identifier=storage_id)`. No new SDK convenience method will be added. - **FR-014**: If the current user isn't allowed due to a permission denied error to query for the artifact or object file the filter should catch such permission error and raise a Jinja2 error specifically related to the permission issue. +- **FR-015**: The auto-generated templating reference (`sdk_template_reference.j2` and `_generate_infrahub_sdk_template_documentation()`) MUST be updated to include `INFRAHUB_FILTERS` as a third section ("Infrahub filters"). The table MUST show which execution contexts each filter is allowed in (`CORE`, `WORKER`, `LOCAL`) rather than only a binary "Trusted" column. +- **FR-016**: The templating reference documentation MUST explain the `ExecutionContext` model — what `CORE`, `WORKER`, and `LOCAL` contexts mean, how they map to Infrahub's execution environments (computed attributes, Prefect workers, local CLI), and how `validate(context=...)` is used to enforce filter restrictions. +- **FR-017**: Usage examples for the new Jinja2 filters MUST be included in the SDK documentation, covering at minimum: `artifact_content` with a storage_id, `file_object_content` by storage_id, `file_object_content_by_id` by node UUID, `file_object_content_by_hfid` with `kind` argument, and `from_json`/`from_yaml` chaining. ### Key entities diff --git a/docs/_templates/sdk_template_reference.j2 b/docs/_templates/sdk_template_reference.j2 index b8a89c90..77828ca4 100644 --- a/docs/_templates/sdk_template_reference.j2 +++ b/docs/_templates/sdk_template_reference.j2 @@ -3,29 +3,117 @@ title: Python SDK Templating --- Filters can be used when defining [computed attributes](https://docs.infrahub.app/guides/computed-attributes) or [Jinja2 Transforms](https://docs.infrahub.app/guides/jinja2-transform) within Infrahub. +## Execution contexts + +Filters are restricted based on the execution context in which a template is rendered: + +- **CORE** — Computed attributes evaluated inside the Infrahub API server. Only fully trusted filters (no I/O, no side effects) are allowed. +- **WORKER** — Jinja2 transforms executed on Prefect background workers. Trusted filters and Infrahub client-dependent filters are allowed. +- **LOCAL** — Local CLI rendering and unrestricted usage. All filters are allowed. + +The `validate()` method on `Jinja2Template` accepts an optional `context` parameter to enforce these restrictions: + +{% raw %}```python +from infrahub_sdk.template import Jinja2Template +from infrahub_sdk.template.filters import ExecutionContext + +template = Jinja2Template(template="{{ sid | artifact_content }}") + +# Raises JinjaTemplateOperationViolationError — blocked in CORE +template.validate(context=ExecutionContext.CORE) + +# Passes — allowed in WORKER +template.validate(context=ExecutionContext.WORKER) +``` +{% endraw %} + +For backward compatibility, `validate(restricted=True)` maps to `CORE` and `validate(restricted=False)` maps to `LOCAL`. + ## Builtin Jinja2 filters -The following filters are those that are [shipped with Jinja2](https://jinja.palletsprojects.com/en/stable/templates/#list-of-builtin-filters) and enabled within Infrahub. The trusted column indicates if the filter is allowed for use with Infrahub's computed attributes when the server is configured in strict mode. +The following filters are [shipped with Jinja2](https://jinja.palletsprojects.com/en/stable/templates/#list-of-builtin-filters) and enabled within Infrahub. -| Name | Trusted | -| ---- | ------- | +| Name | CORE | WORKER | LOCAL | +| ---- | ---- | ------ | ----- | {% for filter in builtin %} -| {{ filter.name }} | {% if filter.trusted %}✅{% else %}❌{% endif %} | +| {{ filter.name }} | {% if filter.core %}✅{% else %}❌{% endif %} | {% if filter.worker %}✅{% else %}❌{% endif %} | {% if filter.local %}✅{% else %}❌{% endif %} | {% endfor %} ## Netutils filters The following Jinja2 filters from Netutils are included within Infrahub. + -| Name | Trusted | -| ---- | ------- | +| Name | CORE | WORKER | LOCAL | +| ---- | ---- | ------ | ----- | {% for filter in netutils %} -| {{ filter.name }} | {% if filter.trusted %}✅{% else %}❌{% endif %} | +| {{ filter.name }} | {% if filter.core %}✅{% else %}❌{% endif %} | {% if filter.worker %}✅{% else %}❌{% endif %} | {% if filter.local %}✅{% else %}❌{% endif %} | +{% endfor %} + + +## Infrahub filters + +These filters are provided by the Infrahub SDK for artifact and file object content composition. + + +| Name | CORE | WORKER | LOCAL | +| ---- | ---- | ------ | ----- | +{% for filter in infrahub %} +| `{{ filter.name }}` | {% if filter.core %}✅{% else %}❌{% endif %} | {% if filter.worker %}✅{% else %}❌{% endif %} | {% if filter.local %}✅{% else %}❌{% endif %} | {% endfor %} +### Usage examples + +**Inline artifact content by `storage_id`:** + +```jinja2 +{% raw %}{{ artifact.node.storage_id.value | artifact_content }} +{% endraw %}``` + +**Inline file object content:** + +```jinja2 +{% raw %}{# By storage_id #} +{{ file_object.storage_id.value | file_object_content }} + +{# By node UUID #} +{{ file_object.id | file_object_content_by_id }} + +{# By Human-Friendly ID #} +{{ hfid_components | file_object_content_by_hfid(kind="NetworkCircuitContract") }} +{% endraw %}``` + +**Parse structured content with chaining:** + +```jinja2 +{% raw %}{# JSON artifact → access parsed fields #} +{% set config = artifact.node.storage_id.value | artifact_content | from_json %} +interface {{ config.interface_name }} + ip address {{ config.ip_address }} + +{# YAML artifact → iterate parsed data #} +{% set config = artifact.node.storage_id.value | artifact_content | from_yaml %} +{% for route in config.static_routes %} +ip route {{ route.prefix }} {{ route.next_hop }} +{% endfor %} +{% endraw %}``` + +Client-dependent filters (`artifact_content`, `file_object_content`, `file_object_content_by_id`, `file_object_content_by_hfid`) require an `InfrahubClient` to be passed to `Jinja2Template`: + +```python +from infrahub_sdk.template import Jinja2Template + +# At construction time +template = Jinja2Template(template=my_template, client=client) + +# Or via deferred injection +template = Jinja2Template(template=my_template) +template.set_client(client) +``` + ## Known issues ### Unable to combine the map and sort filters (https://github.com/pallets/jinja/issues/2081) diff --git a/docs/docs/python-sdk/reference/templating.mdx b/docs/docs/python-sdk/reference/templating.mdx index b74f5f2f..83014309 100644 --- a/docs/docs/python-sdk/reference/templating.mdx +++ b/docs/docs/python-sdk/reference/templating.mdx @@ -3,155 +3,245 @@ title: Python SDK Templating --- Filters can be used when defining [computed attributes](https://docs.infrahub.app/guides/computed-attributes) or [Jinja2 Transforms](https://docs.infrahub.app/guides/jinja2-transform) within Infrahub. +## Execution contexts + +Filters are restricted based on the execution context in which a template is rendered: + +- **CORE** — Computed attributes evaluated inside the Infrahub API server. Only fully trusted filters (no I/O, no side effects) are allowed. +- **WORKER** — Jinja2 transforms executed on Prefect background workers. Trusted filters and Infrahub client-dependent filters are allowed. +- **LOCAL** — Local CLI rendering and unrestricted usage. All filters are allowed. + +The `validate()` method on `Jinja2Template` accepts an optional `context` parameter to enforce these restrictions: + +```python +from infrahub_sdk.template import Jinja2Template +from infrahub_sdk.template.filters import ExecutionContext + +template = Jinja2Template(template="{{ sid | artifact_content }}") + +# Raises JinjaTemplateOperationViolationError — blocked in CORE +template.validate(context=ExecutionContext.CORE) + +# Passes — allowed in WORKER +template.validate(context=ExecutionContext.WORKER) +``` + +For backward compatibility, `validate(restricted=True)` maps to `CORE` and `validate(restricted=False)` maps to `LOCAL`. + ## Builtin Jinja2 filters -The following filters are those that are [shipped with Jinja2](https://jinja.palletsprojects.com/en/stable/templates/#list-of-builtin-filters) and enabled within Infrahub. The trusted column indicates if the filter is allowed for use with Infrahub's computed attributes when the server is configured in strict mode. +The following filters are [shipped with Jinja2](https://jinja.palletsprojects.com/en/stable/templates/#list-of-builtin-filters) and enabled within Infrahub. -| Name | Trusted | -| ---- | ------- | -| abs | ✅ | -| attr | ❌ | -| batch | ❌ | -| capitalize | ✅ | -| center | ✅ | -| count | ✅ | -| d | ✅ | -| default | ✅ | -| dictsort | ❌ | -| e | ✅ | -| escape | ✅ | -| filesizeformat | ✅ | -| first | ✅ | -| float | ✅ | -| forceescape | ✅ | -| format | ✅ | -| groupby | ❌ | -| indent | ✅ | -| int | ✅ | -| items | ❌ | -| join | ✅ | -| last | ✅ | -| length | ✅ | -| list | ✅ | -| lower | ✅ | -| map | ❌ | -| max | ✅ | -| min | ✅ | -| pprint | ❌ | -| random | ❌ | -| reject | ❌ | -| rejectattr | ❌ | -| replace | ✅ | -| reverse | ✅ | -| round | ✅ | -| safe | ❌ | -| select | ❌ | -| selectattr | ❌ | -| slice | ✅ | -| sort | ❌ | -| string | ✅ | -| striptags | ✅ | -| sum | ✅ | -| title | ✅ | -| tojson | ❌ | -| trim | ✅ | -| truncate | ✅ | -| unique | ❌ | -| upper | ✅ | -| urlencode | ✅ | -| urlize | ❌ | -| wordcount | ✅ | -| wordwrap | ✅ | -| xmlattr | ❌ | +| Name | CORE | WORKER | LOCAL | +| ---- | ---- | ------ | ----- | +| abs | ✅ | ✅ | ✅ | +| attr | ❌ | ❌ | ✅ | +| batch | ❌ | ❌ | ✅ | +| capitalize | ✅ | ✅ | ✅ | +| center | ✅ | ✅ | ✅ | +| count | ✅ | ✅ | ✅ | +| d | ✅ | ✅ | ✅ | +| default | ✅ | ✅ | ✅ | +| dictsort | ❌ | ❌ | ✅ | +| e | ✅ | ✅ | ✅ | +| escape | ✅ | ✅ | ✅ | +| filesizeformat | ✅ | ✅ | ✅ | +| first | ✅ | ✅ | ✅ | +| float | ✅ | ✅ | ✅ | +| forceescape | ✅ | ✅ | ✅ | +| format | ✅ | ✅ | ✅ | +| groupby | ❌ | ❌ | ✅ | +| indent | ✅ | ✅ | ✅ | +| int | ✅ | ✅ | ✅ | +| items | ❌ | ❌ | ✅ | +| join | ✅ | ✅ | ✅ | +| last | ✅ | ✅ | ✅ | +| length | ✅ | ✅ | ✅ | +| list | ✅ | ✅ | ✅ | +| lower | ✅ | ✅ | ✅ | +| map | ❌ | ❌ | ✅ | +| max | ✅ | ✅ | ✅ | +| min | ✅ | ✅ | ✅ | +| pprint | ❌ | ❌ | ✅ | +| random | ❌ | ❌ | ✅ | +| reject | ❌ | ❌ | ✅ | +| rejectattr | ❌ | ❌ | ✅ | +| replace | ✅ | ✅ | ✅ | +| reverse | ✅ | ✅ | ✅ | +| round | ✅ | ✅ | ✅ | +| safe | ❌ | ❌ | ✅ | +| select | ❌ | ❌ | ✅ | +| selectattr | ❌ | ❌ | ✅ | +| slice | ✅ | ✅ | ✅ | +| sort | ❌ | ❌ | ✅ | +| string | ✅ | ✅ | ✅ | +| striptags | ✅ | ✅ | ✅ | +| sum | ✅ | ✅ | ✅ | +| title | ✅ | ✅ | ✅ | +| tojson | ❌ | ❌ | ✅ | +| trim | ✅ | ✅ | ✅ | +| truncate | ✅ | ✅ | ✅ | +| unique | ❌ | ❌ | ✅ | +| upper | ✅ | ✅ | ✅ | +| urlencode | ✅ | ✅ | ✅ | +| urlize | ❌ | ❌ | ✅ | +| wordcount | ✅ | ✅ | ✅ | +| wordwrap | ✅ | ✅ | ✅ | +| xmlattr | ❌ | ❌ | ✅ | ## Netutils filters The following Jinja2 filters from Netutils are included within Infrahub. + -| Name | Trusted | -| ---- | ------- | -| abbreviated_interface_name | ✅ | -| abbreviated_interface_name_list | ✅ | -| asn_to_int | ✅ | -| bits_to_name | ✅ | -| bytes_to_name | ✅ | -| canonical_interface_name | ✅ | -| canonical_interface_name_list | ✅ | -| cidr_to_netmask | ✅ | -| cidr_to_netmaskv6 | ✅ | -| clean_config | ✅ | -| compare_version_loose | ✅ | -| compare_version_strict | ✅ | -| config_compliance | ✅ | -| config_section_not_parsed | ✅ | -| delimiter_change | ✅ | -| diff_network_config | ✅ | -| feature_compliance | ✅ | -| find_unordered_cfg_lines | ✅ | -| fqdn_to_ip | ❌ | -| get_all_host | ❌ | -| get_broadcast_address | ✅ | -| get_first_usable | ✅ | -| get_ips_sorted | ✅ | -| get_nist_urls | ✅ | -| get_nist_vendor_platform_urls | ✅ | -| get_oui | ✅ | -| get_peer_ip | ✅ | -| get_range_ips | ✅ | -| get_upgrade_path | ✅ | -| get_usable_range | ✅ | -| hash_data | ✅ | -| int_to_asdot | ✅ | -| interface_range_compress | ✅ | -| interface_range_expansion | ✅ | -| ip_addition | ✅ | -| ip_subtract | ✅ | -| ip_to_bin | ✅ | -| ip_to_hex | ✅ | -| ipaddress_address | ✅ | -| ipaddress_interface | ✅ | -| ipaddress_network | ✅ | -| is_classful | ✅ | -| is_fqdn_resolvable | ❌ | -| is_ip | ✅ | -| is_ip_range | ✅ | -| is_ip_within | ✅ | -| is_netmask | ✅ | -| is_network | ✅ | -| is_reversible_wildcardmask | ✅ | -| is_valid_mac | ✅ | -| longest_prefix_match | ✅ | -| mac_normalize | ✅ | -| mac_to_format | ✅ | -| mac_to_int | ✅ | -| mac_type | ✅ | -| name_to_bits | ✅ | -| name_to_bytes | ✅ | -| name_to_name | ✅ | -| netmask_to_cidr | ✅ | -| netmask_to_wildcardmask | ✅ | -| normalise_delimiter_caret_c | ✅ | -| paloalto_panos_brace_to_set | ✅ | -| paloalto_panos_clean_newlines | ✅ | -| regex_findall | ❌ | -| regex_match | ❌ | -| regex_search | ❌ | -| regex_split | ❌ | -| regex_sub | ❌ | -| sanitize_config | ✅ | -| section_config | ✅ | -| sort_interface_list | ✅ | -| split_interface | ✅ | -| uptime_seconds_to_string | ✅ | -| uptime_string_to_seconds | ✅ | -| version_metadata | ✅ | -| vlanconfig_to_list | ✅ | -| vlanlist_to_config | ✅ | -| wildcardmask_to_netmask | ✅ | +| Name | CORE | WORKER | LOCAL | +| ---- | ---- | ------ | ----- | +| abbreviated_interface_name | ✅ | ✅ | ✅ | +| abbreviated_interface_name_list | ✅ | ✅ | ✅ | +| asn_to_int | ✅ | ✅ | ✅ | +| bits_to_name | ✅ | ✅ | ✅ | +| bytes_to_name | ✅ | ✅ | ✅ | +| canonical_interface_name | ✅ | ✅ | ✅ | +| canonical_interface_name_list | ✅ | ✅ | ✅ | +| cidr_to_netmask | ✅ | ✅ | ✅ | +| cidr_to_netmaskv6 | ✅ | ✅ | ✅ | +| clean_config | ✅ | ✅ | ✅ | +| compare_version_loose | ✅ | ✅ | ✅ | +| compare_version_strict | ✅ | ✅ | ✅ | +| config_compliance | ✅ | ✅ | ✅ | +| config_section_not_parsed | ✅ | ✅ | ✅ | +| delimiter_change | ✅ | ✅ | ✅ | +| diff_network_config | ✅ | ✅ | ✅ | +| feature_compliance | ✅ | ✅ | ✅ | +| find_unordered_cfg_lines | ✅ | ✅ | ✅ | +| fqdn_to_ip | ❌ | ❌ | ✅ | +| get_all_host | ❌ | ❌ | ✅ | +| get_broadcast_address | ✅ | ✅ | ✅ | +| get_first_usable | ✅ | ✅ | ✅ | +| get_ips_sorted | ✅ | ✅ | ✅ | +| get_nist_urls | ✅ | ✅ | ✅ | +| get_nist_vendor_platform_urls | ✅ | ✅ | ✅ | +| get_oui | ✅ | ✅ | ✅ | +| get_peer_ip | ✅ | ✅ | ✅ | +| get_range_ips | ✅ | ✅ | ✅ | +| get_upgrade_path | ✅ | ✅ | ✅ | +| get_usable_range | ✅ | ✅ | ✅ | +| hash_data | ✅ | ✅ | ✅ | +| int_to_asdot | ✅ | ✅ | ✅ | +| interface_range_compress | ✅ | ✅ | ✅ | +| interface_range_expansion | ✅ | ✅ | ✅ | +| ip_addition | ✅ | ✅ | ✅ | +| ip_subtract | ✅ | ✅ | ✅ | +| ip_to_bin | ✅ | ✅ | ✅ | +| ip_to_hex | ✅ | ✅ | ✅ | +| ipaddress_address | ✅ | ✅ | ✅ | +| ipaddress_interface | ✅ | ✅ | ✅ | +| ipaddress_network | ✅ | ✅ | ✅ | +| is_classful | ✅ | ✅ | ✅ | +| is_fqdn_resolvable | ❌ | ❌ | ✅ | +| is_ip | ✅ | ✅ | ✅ | +| is_ip_range | ✅ | ✅ | ✅ | +| is_ip_within | ✅ | ✅ | ✅ | +| is_netmask | ✅ | ✅ | ✅ | +| is_network | ✅ | ✅ | ✅ | +| is_reversible_wildcardmask | ✅ | ✅ | ✅ | +| is_valid_mac | ✅ | ✅ | ✅ | +| longest_prefix_match | ✅ | ✅ | ✅ | +| mac_normalize | ✅ | ✅ | ✅ | +| mac_to_format | ✅ | ✅ | ✅ | +| mac_to_int | ✅ | ✅ | ✅ | +| mac_type | ✅ | ✅ | ✅ | +| name_to_bits | ✅ | ✅ | ✅ | +| name_to_bytes | ✅ | ✅ | ✅ | +| name_to_name | ✅ | ✅ | ✅ | +| netmask_to_cidr | ✅ | ✅ | ✅ | +| netmask_to_wildcardmask | ✅ | ✅ | ✅ | +| normalise_delimiter_caret_c | ✅ | ✅ | ✅ | +| paloalto_panos_brace_to_set | ✅ | ✅ | ✅ | +| paloalto_panos_clean_newlines | ✅ | ✅ | ✅ | +| regex_findall | ❌ | ❌ | ✅ | +| regex_match | ❌ | ❌ | ✅ | +| regex_search | ❌ | ❌ | ✅ | +| regex_split | ❌ | ❌ | ✅ | +| regex_sub | ❌ | ❌ | ✅ | +| sanitize_config | ✅ | ✅ | ✅ | +| section_config | ✅ | ✅ | ✅ | +| sort_interface_list | ✅ | ✅ | ✅ | +| split_interface | ✅ | ✅ | ✅ | +| uptime_seconds_to_string | ✅ | ✅ | ✅ | +| uptime_string_to_seconds | ✅ | ✅ | ✅ | +| version_metadata | ✅ | ✅ | ✅ | +| vlanconfig_to_list | ✅ | ✅ | ✅ | +| vlanlist_to_config | ✅ | ✅ | ✅ | +| wildcardmask_to_netmask | ✅ | ✅ | ✅ | +## Infrahub filters + +These filters are provided by the Infrahub SDK for artifact and file object content composition. + + +| Name | CORE | WORKER | LOCAL | +| ---- | ---- | ------ | ----- | +| `artifact_content` | ❌ | ✅ | ✅ | +| `file_object_content` | ❌ | ✅ | ✅ | +| `file_object_content_by_hfid` | ❌ | ✅ | ✅ | +| `file_object_content_by_id` | ❌ | ✅ | ✅ | +| `from_json` | ✅ | ✅ | ✅ | +| `from_yaml` | ✅ | ✅ | ✅ | + + +### Usage examples + +**Inline artifact content by `storage_id`:** + +```jinja2 +{{ artifact.node.storage_id.value | artifact_content }} +``` + +**Inline file object content:** + +```jinja2 +{# By storage_id #} +{{ file_object.storage_id.value | file_object_content }} + +{# By node UUID #} +{{ file_object.id | file_object_content_by_id }} + +{# By Human-Friendly ID #} +{{ hfid_components | file_object_content_by_hfid(kind="NetworkCircuitContract") }} +``` + +**Parse structured content with chaining:** + +```jinja2 +{# JSON artifact → access parsed fields #} +{% set config = artifact.node.storage_id.value | artifact_content | from_json %} +interface {{ config.interface_name }} + ip address {{ config.ip_address }} + +{# YAML artifact → iterate parsed data #} +{% set config = artifact.node.storage_id.value | artifact_content | from_yaml %} +{% for route in config.static_routes %} +ip route {{ route.prefix }} {{ route.next_hop }} +{% endfor %} +``` + +Client-dependent filters (`artifact_content`, `file_object_content`, `file_object_content_by_id`, `file_object_content_by_hfid`) require an `InfrahubClient` to be passed to `Jinja2Template`: + +```python +from infrahub_sdk.template import Jinja2Template + +# At construction time +template = Jinja2Template(template=my_template, client=client) + +# Or via deferred injection +template = Jinja2Template(template=my_template) +template.set_client(client) +``` + ## Known issues ### Unable to combine the map and sort filters (https://github.com/pallets/jinja/issues/2081) diff --git a/infrahub_sdk/object_store.py b/infrahub_sdk/object_store.py index 3305fffe..4841564a 100644 --- a/infrahub_sdk/object_store.py +++ b/infrahub_sdk/object_store.py @@ -20,7 +20,15 @@ def _extract_content_type(response: httpx.Response) -> str: class ObjectStoreBase: - pass + @staticmethod + def _validate_text_content(response: httpx.Response, identifier: str) -> str: + """Validate that a file response has a text-based content-type and return the text.""" + content_type = _extract_content_type(response) + if not content_type.startswith("text/") and content_type not in ALLOWED_TEXT_CONTENT_TYPES: + raise ValueError( + f"Binary content not supported: content-type '{content_type}' for identifier '{identifier}'" + ) + return response.text class ObjectStore(ObjectStoreBase): @@ -70,12 +78,8 @@ async def upload(self, content: str, tracker: str | None = None) -> dict[str, st return resp.json() - async def get_file_by_storage_id(self, storage_id: str, tracker: str | None = None) -> str: - """Retrieve file object content by storage_id. - - Raises an error if the content-type indicates binary content. - """ - url = f"{self.client.address}/api/files/by-storage-id/{storage_id}" + async def _get_file(self, url: str, identifier: str, tracker: str | None = None) -> str: + """Fetch a file endpoint and validate that the response is text-based.""" headers = copy.copy(self.client.headers or {}) if self.client.insert_tracker and tracker: headers["X-Infrahub-Tracker"] = tracker @@ -94,13 +98,23 @@ async def get_file_by_storage_id(self, storage_id: str, tracker: str | None = No raise AuthenticationError(" | ".join(messages)) from exc raise - content_type = _extract_content_type(resp) - if not content_type.startswith("text/") and content_type not in ALLOWED_TEXT_CONTENT_TYPES: - raise ValueError( - f"Binary content not supported: content-type '{content_type}' for storage_id '{storage_id}'" - ) + return self._validate_text_content(response=resp, identifier=identifier) - return resp.text + async def get_file_by_storage_id(self, storage_id: str, tracker: str | None = None) -> str: + """Retrieve file object content by storage_id.""" + url = f"{self.client.address}/api/files/by-storage-id/{storage_id}" + return await self._get_file(url=url, identifier=storage_id, tracker=tracker) + + async def get_file_by_id(self, node_id: str, tracker: str | None = None) -> str: + """Retrieve file object content by node UUID.""" + url = f"{self.client.address}/api/files/{node_id}" + return await self._get_file(url=url, identifier=node_id, tracker=tracker) + + async def get_file_by_hfid(self, kind: str, hfid: list[str], tracker: str | None = None) -> str: + """Retrieve file object content by Human-Friendly ID.""" + params = "&".join(f"hfid={h}" for h in hfid) + url = f"{self.client.address}/api/files/by-hfid/{kind}?{params}" + return await self._get_file(url=url, identifier=f"{kind}:{'/'.join(hfid)}", tracker=tracker) class ObjectStoreSync(ObjectStoreBase): @@ -150,12 +164,8 @@ def upload(self, content: str, tracker: str | None = None) -> dict[str, str]: return resp.json() - def get_file_by_storage_id(self, storage_id: str, tracker: str | None = None) -> str: - """Retrieve file object content by storage_id. - - Raises an error if the content-type indicates binary content. - """ - url = f"{self.client.address}/api/files/by-storage-id/{storage_id}" + def _get_file(self, url: str, identifier: str, tracker: str | None = None) -> str: + """Fetch a file endpoint and validate that the response is text-based.""" headers = copy.copy(self.client.headers or {}) if self.client.insert_tracker and tracker: headers["X-Infrahub-Tracker"] = tracker @@ -174,10 +184,20 @@ def get_file_by_storage_id(self, storage_id: str, tracker: str | None = None) -> raise AuthenticationError(" | ".join(messages)) from exc raise - content_type = _extract_content_type(resp) - if not content_type.startswith("text/") and content_type not in ALLOWED_TEXT_CONTENT_TYPES: - raise ValueError( - f"Binary content not supported: content-type '{content_type}' for storage_id '{storage_id}'" - ) + return self._validate_text_content(resp, identifier) - return resp.text + def get_file_by_storage_id(self, storage_id: str, tracker: str | None = None) -> str: + """Retrieve file object content by storage_id.""" + url = f"{self.client.address}/api/files/by-storage-id/{storage_id}" + return self._get_file(url=url, identifier=storage_id, tracker=tracker) + + def get_file_by_id(self, node_id: str, tracker: str | None = None) -> str: + """Retrieve file object content by node UUID.""" + url = f"{self.client.address}/api/files/{node_id}" + return self._get_file(url=url, identifier=node_id, tracker=tracker) + + def get_file_by_hfid(self, kind: str, hfid: list[str], tracker: str | None = None) -> str: + """Retrieve file object content by Human-Friendly ID.""" + params = "&".join(f"hfid={h}" for h in hfid) + url = f"{self.client.address}/api/files/by-hfid/{kind}?{params}" + return self._get_file(url=url, identifier=f"{kind}:{'/'.join(hfid)}", tracker=tracker) diff --git a/infrahub_sdk/template/__init__.py b/infrahub_sdk/template/__init__.py index 29394a2f..d46be687 100644 --- a/infrahub_sdk/template/__init__.py +++ b/infrahub_sdk/template/__init__.py @@ -20,7 +20,7 @@ JinjaTemplateUndefinedError, ) from .filters import AVAILABLE_FILTERS, ExecutionContext -from .infrahub_filters import InfrahubFilters, from_json, from_yaml, no_client_filter +from .infrahub_filters import InfrahubFilters, from_json, from_yaml from .models import UndefinedJinja2Error if TYPE_CHECKING: @@ -50,7 +50,6 @@ def __init__( for user_filter in self._filters: self._available_filters.append(user_filter) - self._infrahub_filters: InfrahubFilters | None = None self._register_client_filters(client=client) self._register_filter("from_json", from_json) self._register_filter("from_yaml", from_yaml) @@ -59,26 +58,20 @@ def __init__( def set_client(self, client: InfrahubClient) -> None: """Set or replace the InfrahubClient used by client-dependent filters.""" - self._register_client_filters(client=client) + self._infrahub_filters.set_client(client=client) if self._environment: - self._environment.filters["artifact_content"] = self._filters["artifact_content"] - self._environment.filters["file_object_content"] = self._filters["file_object_content"] + for name in InfrahubFilters.get_filter_names(): + self._environment.filters[name] = self._filters[name] def _register_filter(self, name: str, func: Callable) -> None: - """Register a filter callable and make it available for validation.""" self._filters[name] = func if name not in self._available_filters: self._available_filters.append(name) def _register_client_filters(self, client: InfrahubClient | None) -> None: - """Register client-dependent filters, using fallbacks if no client is provided.""" - if client is not None: - self._infrahub_filters = InfrahubFilters(client=client) - self._register_filter("artifact_content", self._infrahub_filters.artifact_content) - self._register_filter("file_object_content", self._infrahub_filters.file_object_content) - else: - self._register_filter("artifact_content", no_client_filter("artifact_content")) - self._register_filter("file_object_content", no_client_filter("file_object_content")) + self._infrahub_filters = InfrahubFilters(client=client) + for name in InfrahubFilters.get_filter_names(): + self._register_filter(name, getattr(self._infrahub_filters, name)) def get_environment(self) -> jinja2.Environment: if self._environment: diff --git a/infrahub_sdk/template/filters.py b/infrahub_sdk/template/filters.py index 004011a8..eea02e8e 100644 --- a/infrahub_sdk/template/filters.py +++ b/infrahub_sdk/template/filters.py @@ -168,6 +168,16 @@ def trusted(self) -> bool: FilterDefinition( name="file_object_content", allowed_contexts=ExecutionContext.WORKER | ExecutionContext.LOCAL, source="infrahub" ), + FilterDefinition( + name="file_object_content_by_hfid", + allowed_contexts=ExecutionContext.WORKER | ExecutionContext.LOCAL, + source="infrahub", + ), + FilterDefinition( + name="file_object_content_by_id", + allowed_contexts=ExecutionContext.WORKER | ExecutionContext.LOCAL, + source="infrahub", + ), FilterDefinition(name="from_json", allowed_contexts=ExecutionContext.ALL, source="infrahub"), FilterDefinition(name="from_yaml", allowed_contexts=ExecutionContext.ALL, source="infrahub"), ] diff --git a/infrahub_sdk/template/infrahub_filters.py b/infrahub_sdk/template/infrahub_filters.py index a5261399..6279177d 100644 --- a/infrahub_sdk/template/infrahub_filters.py +++ b/infrahub_sdk/template/infrahub_filters.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import json from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any @@ -14,13 +15,35 @@ class InfrahubFilters: - """Holds an InfrahubClient and exposes async filter methods for Jinja2 templates.""" + """Holds an optional InfrahubClient and exposes async filter methods for Jinja2 templates.""" + + @classmethod + def get_filter_names(cls) -> tuple[str, ...]: + """Return all public async filter method names by convention.""" + return tuple( + name + for name in sorted(vars(cls)) + if not name.startswith("_") and inspect.iscoroutinefunction(vars(cls)[name]) + ) + + def __init__(self, client: InfrahubClient | None = None) -> None: + self._client = client + + def set_client(self, client: InfrahubClient) -> None: + self._client = client - def __init__(self, client: InfrahubClient) -> None: - self.client = client + def _require_client(self, filter_name: str) -> InfrahubClient: + if self._client is None: + raise JinjaFilterError( + filter_name=filter_name, + message="requires an InfrahubClient", + hint="pass a client via Jinja2Template(client=...)", + ) + return self._client async def artifact_content(self, storage_id: str) -> str: """Retrieve artifact content by storage_id.""" + client = self._require_client(filter_name="artifact_content") if storage_id is None: raise JinjaFilterError( filter_name="artifact_content", @@ -34,7 +57,7 @@ async def artifact_content(self, storage_id: str) -> str: hint="ensure the GraphQL query returns a non-empty storage_id value", ) try: - return await self.client.object_store.get(identifier=storage_id) + return await client.object_store.get(identifier=storage_id) except AuthenticationError as exc: raise JinjaFilterError( filter_name="artifact_content", message=f"permission denied for storage_id: {storage_id}" @@ -46,47 +69,78 @@ async def artifact_content(self, storage_id: str) -> str: hint=str(exc), ) from exc - async def file_object_content(self, storage_id: str) -> str: - """Retrieve file object content by storage_id.""" - if storage_id is None: + async def _fetch_file_object( + self, filter_name: str, identifier: str | list[str], label: str, fetch: Callable[[], Coroutine[Any, Any, str]] + ) -> str: + if identifier is None: raise JinjaFilterError( - filter_name="file_object_content", - message="storage_id is null", - hint="ensure the GraphQL query returns a valid storage_id value", + filter_name=filter_name, + message=f"{label} is null", + hint=f"ensure the GraphQL query returns a valid {label} value", ) - if not storage_id: + if not identifier: raise JinjaFilterError( - filter_name="file_object_content", - message="storage_id is empty", - hint="ensure the GraphQL query returns a non-empty storage_id value", + filter_name=filter_name, + message=f"{label} is empty", + hint=f"ensure the GraphQL query returns a non-empty {label} value", ) try: - return await self.client.object_store.get_file_by_storage_id(storage_id=storage_id) + return await fetch() except AuthenticationError as exc: raise JinjaFilterError( - filter_name="file_object_content", message=f"permission denied for storage_id: {storage_id}" + filter_name=filter_name, message=f"permission denied for {label}: {identifier}" ) from exc except ValueError as exc: - raise JinjaFilterError(filter_name="file_object_content", message=str(exc)) from exc + raise JinjaFilterError(filter_name=filter_name, message=str(exc)) from exc + except JinjaFilterError: + raise except Exception as exc: raise JinjaFilterError( - filter_name="file_object_content", - message=f"failed to retrieve content for storage_id: {storage_id}", - hint=str(exc), + filter_name=filter_name, message=f"failed to retrieve content for {label}: {identifier}", hint=str(exc) ) from exc + async def file_object_content(self, storage_id: str) -> str: + """Retrieve file object content by storage_id.""" + client = self._require_client(filter_name="file_object_content") + return await self._fetch_file_object( + filter_name="file_object_content", + identifier=storage_id, + label="storage_id", + fetch=lambda: client.object_store.get_file_by_storage_id(storage_id=storage_id), + ) -def no_client_filter(filter_name: str) -> Callable[[str], Coroutine[Any, Any, str]]: - """Create a filter function that raises JinjaFilterError because no client was provided.""" - - async def _filter(storage_id: str) -> str: # noqa: ARG001 - raise JinjaFilterError( - filter_name=filter_name, - message="requires an InfrahubClient", - hint="pass a client via Jinja2Template(client=...)", + async def file_object_content_by_id(self, node_id: str) -> str: + """Retrieve file object content by node UUID.""" + client = self._require_client(filter_name="file_object_content_by_id") + return await self._fetch_file_object( + filter_name="file_object_content_by_id", + identifier=node_id, + label="node_id", + fetch=lambda: client.object_store.get_file_by_id(node_id=node_id), ) - return _filter + async def file_object_content_by_hfid(self, hfid: str | list[str], kind: str = "") -> str: + """Retrieve file object content by Human-Friendly ID.""" + client = self._require_client(filter_name="file_object_content_by_hfid") + if not kind: + raise JinjaFilterError( + filter_name="file_object_content_by_hfid", + message="'kind' argument is required", + hint='use {{ hfid | file_object_content_by_hfid(kind="MyKind") }}', + ) + hfid_list = hfid if isinstance(hfid, list) else [hfid] + if not all(hfid_list): + raise JinjaFilterError( + filter_name="file_object_content_by_hfid", + message="hfid contains empty elements", + hint="ensure all HFID components are non-empty strings", + ) + return await self._fetch_file_object( + filter_name="file_object_content_by_hfid", + identifier=hfid, + label="hfid", + fetch=lambda: client.object_store.get_file_by_hfid(kind=kind, hfid=hfid_list), + ) def from_json(value: str) -> dict | list: diff --git a/tasks.py b/tasks.py index 32b13ae6..e18c8b14 100644 --- a/tasks.py +++ b/tasks.py @@ -114,7 +114,18 @@ def _generate_infrahub_sdk_template_documentation() -> None: from docs.docs_generation.content_gen_methods import Jinja2DocContentGenMethod from docs.docs_generation.pages import DocPage, MDXDocPage from infrahub_sdk.template import Jinja2Template - from infrahub_sdk.template.filters import BUILTIN_FILTERS, NETUTILS_FILTERS + from infrahub_sdk.template.filters import BUILTIN_FILTERS, INFRAHUB_FILTERS, NETUTILS_FILTERS, ExecutionContext + + def _filters_with_contexts(filters: list) -> list[dict]: + return [ + { + "name": f.name, + "core": bool(f.allowed_contexts & ExecutionContext.CORE), + "worker": bool(f.allowed_contexts & ExecutionContext.WORKER), + "local": bool(f.allowed_contexts & ExecutionContext.LOCAL), + } + for f in filters + ] print(" - Generate Infrahub SDK template documentation") # Generating one documentation page for template documentation @@ -124,7 +135,11 @@ def _generate_infrahub_sdk_template_documentation() -> None: template=Path("sdk_template_reference.j2"), template_directory=DOCUMENTATION_DIRECTORY / "_templates", ), - template_variables={"builtin": BUILTIN_FILTERS, "netutils": NETUTILS_FILTERS}, + template_variables={ + "builtin": _filters_with_contexts(BUILTIN_FILTERS), + "netutils": _filters_with_contexts(NETUTILS_FILTERS), + "infrahub": _filters_with_contexts(INFRAHUB_FILTERS), + }, ), ) output_path = DOCUMENTATION_DIRECTORY / "docs" / "python-sdk" / "reference" / "templating.mdx" diff --git a/tests/unit/sdk/test_infrahub_filters.py b/tests/unit/sdk/test_infrahub_filters.py index 1606cd9c..43d1bbce 100644 --- a/tests/unit/sdk/test_infrahub_filters.py +++ b/tests/unit/sdk/test_infrahub_filters.py @@ -8,7 +8,7 @@ from infrahub_sdk.template import Jinja2Template from infrahub_sdk.template.exceptions import JinjaFilterError, JinjaTemplateError, JinjaTemplateOperationViolationError from infrahub_sdk.template.filters import INFRAHUB_FILTERS, ExecutionContext, FilterDefinition -from infrahub_sdk.template.infrahub_filters import from_json, from_yaml, no_client_filter +from infrahub_sdk.template.infrahub_filters import InfrahubFilters, from_json, from_yaml if TYPE_CHECKING: from pytest_httpx import HTTPXMock @@ -19,7 +19,7 @@ pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) ARTIFACT_CONTENT_URL = "http://mock/api/storage/object" -FILE_OBJECT_CONTENT_URL = "http://mock/api/files/by-storage-id" +FILE_BY_STORAGE_ID_URL = "http://mock/api/files/by-storage-id" CLIENT_FILTER_PARAMS = [ pytest.param( @@ -32,7 +32,7 @@ pytest.param( "file_object_content", "{{ storage_id | file_object_content }}", - f"{FILE_OBJECT_CONTENT_URL}/test-id", + f"{FILE_BY_STORAGE_ID_URL}/test-id", {"content-type": "text/plain"}, id="file_object_content", ), @@ -131,16 +131,6 @@ def test_context_core_allows_from_json(self) -> None: jinja = Jinja2Template(template="{{ '{\"a\":1}' | from_json }}") jinja.validate(context=ExecutionContext.CORE) - def test_context_core_blocks_file_object_content(self) -> None: - jinja = Jinja2Template(template="{{ sid | file_object_content }}") - with pytest.raises(JinjaTemplateOperationViolationError) as exc: - jinja.validate(context=ExecutionContext.CORE) - assert exc.value.message == "The 'file_object_content' filter isn't allowed to be used" - - def test_context_worker_allows_file_object_content(self) -> None: - jinja = Jinja2Template(template="{{ sid | file_object_content }}") - jinja.validate(context=ExecutionContext.WORKER) - class TestClientDependentFilters: @pytest.mark.parametrize(("filter_name", "template", "url", "headers"), CLIENT_FILTER_PARAMS) @@ -195,14 +185,10 @@ async def test_invalid_storage_id( ("template", "url"), [ pytest.param( - "{{ storage_id | artifact_content }}", - f"{ARTIFACT_CONTENT_URL}/abc-123", - id="artifact_content", + "{{ storage_id | artifact_content }}", f"{ARTIFACT_CONTENT_URL}/abc-123", id="artifact_content" ), pytest.param( - "{{ storage_id | file_object_content }}", - f"{FILE_OBJECT_CONTENT_URL}/abc-123", - id="file_object_content", + "{{ storage_id | file_object_content }}", f"{FILE_BY_STORAGE_ID_URL}/abc-123", id="file_object_content" ), ], ) @@ -226,7 +212,7 @@ async def test_store_exception_is_wrapped( ), pytest.param( "{{ storage_id | file_object_content }}", - f"{FILE_OBJECT_CONTENT_URL}/fid-x", + f"{FILE_BY_STORAGE_ID_URL}/fid-x", "fid-x", "Filter 'file_object_content': permission denied for storage_id: fid-x", id="file_object_content", @@ -253,7 +239,7 @@ async def test_file_object_content_binary_content_rejected( ) -> None: httpx_mock.add_response( method="GET", - url=f"{FILE_OBJECT_CONTENT_URL}/fid-bin", + url=f"{FILE_BY_STORAGE_ID_URL}/fid-bin", content=b"\x00\x01\x02", headers={"content-type": "application/octet-stream"}, ) @@ -262,7 +248,16 @@ async def test_file_object_content_binary_content_rejected( await jinja.render(variables={"storage_id": "fid-bin"}) assert ( exc.value.message == "Filter 'file_object_content': Binary content not supported:" - " content-type 'application/octet-stream' for storage_id 'fid-bin'" + " content-type 'application/octet-stream' for identifier 'fid-bin'" + ) + + async def test_file_object_content_by_hfid_missing_kind(self, client: InfrahubClient) -> None: + jinja = Jinja2Template(template="{{ hfid | file_object_content_by_hfid }}", client=client) + with pytest.raises(JinjaTemplateError) as exc: + await jinja.render(variables={"hfid": ["contract-2024"]}) + assert exc.value.message == ( + "Filter 'file_object_content_by_hfid': 'kind' argument is required" + ' — use {{ hfid | file_object_content_by_hfid(kind="MyKind") }}' ) @@ -326,13 +321,15 @@ async def test_artifact_content_piped_to_from_json(self, client: InfrahubClient, class TestClientFilter: - @pytest.mark.parametrize("filter_name", ["artifact_content", "file_object_content"]) - async def test_no_client_filter_raises(self, filter_name: str) -> None: - fallback = no_client_filter(filter_name) + @pytest.mark.parametrize("filter_name", InfrahubFilters.get_filter_names()) + async def test_no_client_raises(self, filter_name: str) -> None: + filters = InfrahubFilters(client=None) + method = getattr(filters, filter_name) with pytest.raises(JinjaFilterError) as exc: - await fallback("some-id") - assert exc.value.message == ( - f"Filter '{filter_name}': requires an InfrahubClient — pass a client via Jinja2Template(client=...)" + await method("some-id") + assert ( + exc.value.message + == f"Filter '{filter_name}': requires an InfrahubClient — pass a client via Jinja2Template(client=...)" ) assert exc.value.filter_name == filter_name