Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# Default owner(s) of all files in this repository
* @itdependsnetworks @jeffkala @qduk
* @itdependsnetworks @jeffkala
18 changes: 18 additions & 0 deletions docs/admin/release_notes/version_1.18.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@

# v1.18 Release Notes

This document describes all new features and changes in the release. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Release Overview

- Added the `sanitize_config_jinja` function to render each replacement as a Jinja2 template. Requires the optional `jinja2` dependency (`pip install netutils[optionals]`).

## [v1.18.0 (2026-07-30)](https://github.com/networktocode/netutils/releases/tag/v1.18.0)

### Added

- [#845](https://github.com/networktocode/netutils/issues/845) - Added `sanitize_config_jinja` to render each replacement as a Jinja2 template. Requires the optional `jinja2` dependency (`pip install netutils[optionals]`).

### Dependencies

- [#845](https://github.com/networktocode/netutils/issues/845) - Added `jinja2` as an optional dependency.
2 changes: 1 addition & 1 deletion docs/dev/release_checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ For major versions, use `major`:
Bumping version from 1.0.0-beta.2 to 1.0.0
```

For patch versions, use `minor`:
For minor versions, use `minor`:

```no-highlight
> poetry version minor
Expand Down
83 changes: 83 additions & 0 deletions netutils/config/clean.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@
import re
import typing as t

from netutils.utils import jinja2_convenience_function

try:
from jinja2.sandbox import SandboxedEnvironment

HAS_JINJA2 = True
except ImportError:
HAS_JINJA2 = False


def clean_config(config: str, filters: t.List[t.Dict[str, str]]) -> str:
r"""Given a list of regex patterns, delete those lines that match.
Expand Down Expand Up @@ -81,3 +90,77 @@ def sanitize_config(config: str, filters: t.Optional[t.List[t.Dict[str, str]]] =
for item in filters:
config = re.sub(item["regex"], item["replace"], config, flags=re.MULTILINE)
return config


def sanitize_config_jinja(config: str, filters: t.Optional[t.List[t.Dict[str, str]]] = None) -> str:
r"""Like `sanitize_config`, but renders each `replace` value as a Jinja2 template.

This allows the replacement text to transform the matched data, e.g. hashing a secret
with the `hash_data` filter instead of dropping it with a static placeholder. The regex
capture groups are exposed to the template so the original values can be transformed in
place. References to capture groups follow the familiar `re.sub` backreference syntax
(`\1`, `\2`, ...) and may be used anywhere inside a `{{ ... }}` expression. Named
groups (`(?P<name>...)`) are additionally available by name, so a user-defined group
name can never shadow a positional backreference.

A `replace` value that contains no Jinja expression (`{{`) falls back to plain
`re.sub` string substitution, so a mixed list of filters works as expected.

This function requires the optional `jinja2` dependency
(`pip install netutils[optionals]`).

Args:
config: A string representation of a device configuration.
filters: A list of dictionaries of regex patterns and Jinja-aware replacement
templates used to sanitize configuration. Defaults to an empty list.

Returns:
str: Sanitized configuration.

Examples:
>>> from netutils.config.clean import sanitize_config_jinja
>>> config = "username admin privilege 15 secret 9 SuperSecret"
>>> SANITIZE_FILTERS = [
... {
... "regex": r"^username (\S+) privilege 15 secret 9 (\S+)$",
... "replace": r"username {{ \1 }} privilege 15 secret 9 {{ \2 | hash_data('md5') }}",
... }
... ]
>>> sanitize_config_jinja(config, SANITIZE_FILTERS)
'username admin privilege 15 secret 9 2257151269b83ef0e139c3eec8bbcbcb'
"""
if not filters:
return config

# Only the Jinja path needs jinja2; if every filter is plain, behave like sanitize_config.
if not any("{{" in item["replace"] for item in filters):
return sanitize_config(config, filters)

if not HAS_JINJA2:
raise ImportError(
"The optional 'jinja2' dependency is required to use sanitize_config_jinja. "
"Install it with `pip install netutils[optionals]` or `pip install jinja2`."
)

env = SandboxedEnvironment(autoescape=False) # noqa: S701 # config text must not be HTML-escaped
env.filters.update(jinja2_convenience_function())

def _make_replacer(template_str: str) -> t.Callable[[t.Match[str]], str]:
jinja_ready = re.sub(r"\\(\d+)", r"_re_groups[\1]", template_str)
template = env.from_string(jinja_ready)

def _replace(match: t.Match[str]) -> str:
# Named groups are exposed by name; positional groups are reached via `_re_groups`,
# indexed like a `re.Match` object (index 0 is the whole match, 1 is \1, and so on).
context: t.Dict[str, t.Any] = dict(match.groupdict())
context["_re_groups"] = (match.group(0), *match.groups())
return template.render(**context)

return _replace

for item in filters:
if "{{" in item["replace"]:
config = re.sub(item["regex"], _make_replacer(item["replace"]), config, flags=re.MULTILINE)
else:
config = re.sub(item["regex"], item["replace"], config, flags=re.MULTILINE)
return config
Loading
Loading