Extending Dracon¶
You need a custom instruction tag or a custom include loader.
Custom instruction tags¶
Instruction tags are processed during composition. They can transform the YAML tree before construction. Built-in examples include !define, !if, !each, !assert, and !require.
The Instruction class¶
Subclass Instruction and implement two methods:
from dracon.instructions import Instruction, register_instruction
from dracon.composer import CompositionResult
from dracon.keypath import KeyPath
class ValidatePort(Instruction):
"""Check that a port value is in a valid range during composition."""
@staticmethod
def match(value):
if value == '!validate_port':
return ValidatePort()
return None
def process(self, comp_res: CompositionResult, path: KeyPath, loader):
from dracon.instructions import unpack_mapping_key
from dracon.diagnostics import CompositionError
key_node, value_node, parent_node = unpack_mapping_key(
comp_res, path, 'validate_port'
)
# evaluate the value to get the actual port number
port_value = value_node.value
if isinstance(port_value, str) and port_value.isdigit():
port_value = int(port_value)
if not isinstance(port_value, int) or not (1 <= port_value <= 65535):
raise CompositionError(
f"Invalid port: {port_value} (must be 1-65535)"
)
# remove the instruction key from the parent mapping
del parent_node[str(path[-1])]
return comp_res
Registering¶
The ! prefix is added automatically if you forget it, so register_instruction('validate_port', ValidatePort) also works.
Using it¶
During composition, ValidatePort.process() runs. If the port is out of range, you get a CompositionError.
match() details¶
The match() method receives the tag string (e.g., '!validate_port') and returns either an Instruction instance or None. This lets you support parameterized tags:
@staticmethod
def match(value):
import re
m = re.match(r'!validate_range\((\d+),(\d+)\)', value)
if m:
return ValidateRange(int(m.group(1)), int(m.group(2)))
return None
Deferred instructions¶
Set deferred = True on your class to make the instruction run after all regular instructions, during the assertion pass. This is how !assert works:
class MyCheck(Instruction):
deferred = True # runs after !define, !if, !each, etc.
@staticmethod
def match(value):
...
def process(self, comp_res, path, loader):
...
Custom include loaders¶
Dracon's !include tag supports scheme-prefixed paths like file:, pkg:, env:, var:. You can add your own schemes.
Loader function signature¶
A loader function takes a path string and returns a tuple of (content_string, context_dict):
def my_loader(path, node=None, draconloader=None):
"""
Args:
path: the part after the scheme prefix (e.g., "secret/data/myapp#password"
for "vault:secret/data/myapp#password")
node: the IncludeNode (has .context, .optional, etc.)
draconloader: the current DraconLoader instance
Returns:
tuple of (content_string, context_dict)
- content_string: YAML string to parse, or a CompositionResult
- context_dict: extra context variables to inject (e.g., FILE_PATH)
"""
content = fetch_from_somewhere(path)
return content, {'SOURCE': f'my_loader:{path}'}
Registering¶
Pass custom_loaders to the DraconLoader:
import dracon
loader = dracon.DraconLoader(
custom_loaders={'vault': vault_loader}
)
config = loader.load('config.yaml')
This adds your loader alongside the built-in ones (file, pkg, env, var, raw, rawpkg, cascade).
Example: HashiCorp Vault loader¶
import hvac
def vault_loader(path, node=None, draconloader=None):
"""Fetch secrets from HashiCorp Vault.
Usage in YAML: !include vault:secret/data/myapp#password
"""
# split path and key
if '#' in path:
vault_path, key = path.rsplit('#', 1)
else:
vault_path, key = path, None
client = hvac.Client()
response = client.secrets.kv.v2.read_secret_version(path=vault_path)
data = response['data']['data']
if key:
# return a single value as a YAML scalar
return str(data[key]), {}
else:
# return the whole secret as YAML
import yaml
return yaml.dump(data), {}
# register it
loader = dracon.DraconLoader(
custom_loaders={'vault': vault_loader}
)
Using it¶
Returning a CompositionResult¶
For more control, your loader can return a CompositionResult directly instead of a string. This skips the YAML parsing step:
from dracon.composer import CompositionResult
from dracon.nodes import DraconScalarNode
def my_loader(path, node=None, draconloader=None):
value = compute_value(path)
scalar = DraconScalarNode(tag='tag:yaml.org,2002:str', value=str(value))
return CompositionResult(root=scalar), {}
The built-in loaders all follow this same interface, so you can look at dracon/loaders/ for more examples.