Feat(templates): add template selector tranformation

This commit is contained in:
Johannes Kirschbauer
2025-06-11 10:51:05 +02:00
parent 0e6f8766f7
commit d166f73c00
2 changed files with 194 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
import logging
from clan_lib.errors import ClanError
log = logging.getLogger(__name__)
def transform_url(template_type: str, identifier: str) -> tuple[str, str]:
"""
Transform a template flake ref by injecting the context (clan|machine|disko) into the url.
We do this for shorthand notation of URLs.
If the attribute selector path is longer than one, don't transform it.
Examples:
# injects "machine" as context
clan machines create --template .#new-machine
or
clan machines create --template #new-machine
-> .#clan.templates.machine.new-machine
# injects "clan" as context
clan create --template .#default
-> .#clan.templates.clan.default
# Dont transform explicit paths (e.g. when more than one attribute selector is present)
clan machines create --template .#clan.templates.machine.new-machine
-> .#clan.templates.machine.new-machine
clan machines create --template .#"new.machine"
-> .#clan.templates.machine."new.machine"
# Builtin templates
clan machines create --template new.machine
-> clanInternals.templates.machine."new.machine"
# Remote templates
clan machines create --template github:/org/repo#new.machine
-> clanInternals.templates.machine."new.machine"
As of URL specification (RFC 3986).
scheme:[//[user:password@]host[:port]][/path][?query][#fragment]
We can safely split the URL into a front part and the fragment
We can then analyze the fragment and inject the context into the path.
Of there is no fragment and no URL its a builtin template path.
new-machine -> #clanInternals.templates.machine."new-machine"
"""
if identifier.count("#") > 1:
msg = "Invalid template identifier: More than one '#' found. Please use a single '#'"
raise ClanError(msg)
[flake_ref, selector] = (
identifier.split("#", 1) if "#" in identifier else ["", identifier]
)
if "#" not in identifier:
# No fragment, so we assume its a builtin template
return ("", f'clanInternals.templates.{template_type}."{selector}"')
# TODO: implement support for quotes in the tail "a.b".c
# If the tail contains a dot, or is quoted we assume its a path and don't transform it.
if '"' in selector or "'" in selector:
log.warning(
"Quotes in template paths are not yet supported. Please use unquoted paths."
)
return (flake_ref, selector)
if "." in selector:
return (flake_ref, selector)
# Tail doesn't contain a dot at this point, so we can inject the context.
return (flake_ref, f'clan.templates.{template_type}."{selector}"')

View File

@@ -0,0 +1,119 @@
from pathlib import Path
import pytest
from clan_lib.errors import ClanError
from clan_lib.templates.template_url import transform_url
template_type = "machine"
class DummyFlake:
def __init__(self, path: str) -> None:
self.path: Path = Path(path)
local_path = DummyFlake(".")
def test_transform_url_self_explizit_dot() -> None:
user_input = ".#new-machine"
expected_selector = 'clan.templates.machine."new-machine"'
flake_ref, selector = transform_url(template_type, user_input)
assert flake_ref == "."
assert selector == expected_selector
def test_transform_url_self_no_dot() -> None:
user_input = "#new-machine"
expected_selector = 'clan.templates.machine."new-machine"'
flake_ref, selector = transform_url(template_type, user_input)
assert flake_ref == ""
assert selector == expected_selector
def test_transform_url_builtin_template() -> None:
user_input = "new-machine"
expected_selector = 'clanInternals.templates.machine."new-machine"'
flake_ref, selector = transform_url(template_type, user_input)
assert flake_ref == ""
assert selector == expected_selector
def test_transform_url_remote_template() -> None:
user_input = "github:/org/repo#new-machine"
expected_selector = 'clan.templates.machine."new-machine"'
flake_ref, selector = transform_url(template_type, user_input)
assert flake_ref == "github:/org/repo"
assert selector == expected_selector
def test_transform_url_explicit_path() -> None:
user_input = ".#clan.templates.machine.new-machine"
expected_selector = "clan.templates.machine.new-machine"
flake_ref, selector = transform_url(template_type, user_input)
assert flake_ref == "."
assert selector == expected_selector
# Currently quoted selectors are treated as explicit paths.
def test_transform_url_quoted_selector() -> None:
user_input = '.#"new.machine"'
expected_selector = '"new.machine"'
flake_ref, selector = transform_url(template_type, user_input)
assert flake_ref == "."
assert selector == expected_selector
def test_single_quote_selector() -> None:
user_input = ".#'new.machine'"
expected_selector = "'new.machine'"
flake_ref, selector = transform_url(template_type, user_input)
assert flake_ref == "."
assert selector == expected_selector
def test_custom_template_path() -> None:
user_input = "github:/org/repo#my.templates.custom.machine"
expected_selector = "my.templates.custom.machine"
flake_ref, selector = transform_url(template_type, user_input)
assert flake_ref == "github:/org/repo"
assert selector == expected_selector
def test_full_url_query_and_fragment() -> None:
user_input = "github:/org/repo?query=param#my.templates.custom.machine"
expected_flake_ref = "github:/org/repo?query=param"
expected_selector = "my.templates.custom.machine"
flake_ref, selector = transform_url(template_type, user_input)
assert flake_ref == expected_flake_ref
assert selector == expected_selector
def test_custom_template_type() -> None:
user_input = "#my.templates.custom.machine"
expected_selector = "my.templates.custom.machine"
flake_ref, selector = transform_url("custom", user_input)
assert flake_ref == ""
assert selector == expected_selector
def test_malformed_identifier() -> None:
user_input = "github:/org/repo#my.templates.custom.machine#extra"
with pytest.raises(ClanError) as exc_info:
_flake_ref, _selector = transform_url(template_type, user_input)
assert isinstance(exc_info.value, ClanError)
assert (
str(exc_info.value)
== "Invalid template identifier: More than one '#' found. Please use a single '#'"
)