Browse Source

Add context to Templating, cleanly handle records w/o template method, doc

pull/1259/head
Ross McFarland 6 months ago
parent
commit
31be2c5de5
No known key found for this signature in database GPG Key ID: 943B179E15D3B22A
2 changed files with 171 additions and 7 deletions
  1. +63
    -1
      octodns/processor/templating.py
  2. +108
    -6
      tests/test_octodns_processor_templating.py

+ 63
- 1
octodns/processor/templating.py View File

@ -6,9 +6,58 @@ from octodns.processor.base import BaseProcessor
class Templating(BaseProcessor): class Templating(BaseProcessor):
'''
Record templating using python format. For simple records like TXT and CAA
that is the value itself. For multi-field records like MX or SRV it's the
text portions, exchange and target respectively.
def __init__(self, id, *args, **kwargs):
Example Processor Config:
templating:
class: octodns.processor.templating.Templating
# Any k/v present in context will be passed into the .format method and
# thus be available as additional variables in the template. This is all
# optional.
context:
key: value
another: 42
Example Records:
foo:
type: TXT
value: The zone this record lives in is {zone_name}. There are {zone_num_records} record(s).
bar:
type: MX
values:
- preference: 1
exchange: mx1.{zone_name}.mail.mx.
- preference: 1
exchange: mx2.{zone_name}.mail.mx.
Note that validations for some types reject values with {}. When
encountered the best option is to use record level `lenient: true`
https://github.com/octodns/octodns/blob/main/docs/records.md#lenience
Note that if you need to add dynamic context you can create a custom
processor that inherits from Templating and passes them into the call to
super, e.g.
class MyTemplating(Templating):
def __init__(self, *args, context={}, **kwargs):
context['year'] = lambda desired, sources: datetime.now().strftime('%Y')
super().__init__(*args, context, **kwargs)
See https://docs.python.org/3/library/string.html#custom-string-formatting
for details on formatting options. Anything possible in an `f-string` or
`.format` should work here.
'''
def __init__(self, id, *args, context={}, **kwargs):
super().__init__(id, *args, **kwargs) super().__init__(id, *args, **kwargs)
self.context = context
def process_source_zone(self, desired, sources): def process_source_zone(self, desired, sources):
sources = sources or [] sources = sources or []
@ -18,6 +67,13 @@ class Templating(BaseProcessor):
'zone_encoded_name': desired.name, 'zone_encoded_name': desired.name,
'zone_num_records': len(desired.records), 'zone_num_records': len(desired.records),
'zone_source_ids': ', '.join(s.id for s in sources), 'zone_source_ids': ', '.join(s.id for s in sources),
# add any extra context provided to us, if the value is a callable
# object call it passing our params so that arbitrary dynamic
# context can be added for use in formatting
**{
k: (v(desired, sources) if callable(v) else v)
for k, v in self.context.items()
},
} }
def params(record): def params(record):
@ -36,12 +92,18 @@ class Templating(BaseProcessor):
for record in desired.records: for record in desired.records:
if hasattr(record, 'values'): if hasattr(record, 'values'):
if record.values and not hasattr(record.values[0], 'template'):
# the (custom) value type does not support templating
continue
new_values = [v.template(params(record)) for v in record.values] new_values = [v.template(params(record)) for v in record.values]
if record.values != new_values: if record.values != new_values:
new = record.copy() new = record.copy()
new.values = new_values new.values = new_values
desired.add_record(new, replace=True) desired.add_record(new, replace=True)
else: else:
if not hasattr(record.value, 'template'):
# the (custom) value type does not support templating
continue
new_value = record.value.template(params(record)) new_value = record.value.template(params(record))
if record.value != new_value: if record.value != new_value:
new = record.copy() new = record.copy()


+ 108
- 6
tests/test_octodns_processor_templating.py View File

@ -6,10 +6,59 @@ from unittest import TestCase
from unittest.mock import call, patch from unittest.mock import call, patch
from octodns.processor.templating import Templating from octodns.processor.templating import Templating
from octodns.record import Record
from octodns.record import Record, ValueMixin, ValuesMixin
from octodns.zone import Zone from octodns.zone import Zone
class DummySource:
def __init__(self, id):
self.id = str(id)
class CustomValue(str):
@classmethod
def validate(cls, *args, **kwargs):
return []
@classmethod
def process(cls, v):
if isinstance(v, (list, tuple)):
return (CustomValue(i) for i in v)
return CustomValue(v)
@classmethod
def parse_rdata_text(cls, *args, **kwargs):
pass
def __init__(self, *args, **kwargs):
self._asked_for = set()
def rdata_text(self):
pass
def __getattr__(self, item):
self._asked_for.add(item)
raise AttributeError('nope')
class Single(ValueMixin, Record):
_type = 'S'
_value_type = CustomValue
Record.register_type(Single, 'S')
class Multiple(ValuesMixin, Record):
_type = 'M'
_value_type = CustomValue
Record.register_type(Multiple, 'M')
def _find(zone, name): def _find(zone, name):
return next(r for r in zone.records if r.name == name) return next(r for r in zone.records if r.name == name)
@ -75,14 +124,29 @@ class TemplatingTest(TestCase):
noop = _find(got, 'noop') noop = _find(got, 'noop')
self.assertEqual('Nothing to template here.', noop.values[0]) self.assertEqual('Nothing to template here.', noop.values[0])
@patch('octodns.record.TxtValue.template')
def test_params(self, mock_template):
def test_no_template(self):
templ = Templating('test') templ = Templating('test')
class DummySource:
zone = Zone('unit.tests.', [])
s = Record.new(zone, 's', {'type': 'S', 'ttl': 42, 'value': 'string'})
zone.add_record(s)
def __init__(self, id):
self.id = id
m = Record.new(
zone, 'm', {'type': 'M', 'ttl': 43, 'values': ('string', 'another')}
)
zone.add_record(m)
# this should check for the template method on our values that don't
# have one
templ.process_source_zone(zone, None)
# and these should make sure that the value types were asked if they
# have a template method
self.assertEqual({'template'}, s.value._asked_for)
self.assertEqual({'template'}, m.values[0]._asked_for)
@patch('octodns.record.TxtValue.template')
def test_params(self, mock_template):
templ = Templating('test')
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
record_source = DummySource('record') record_source = DummySource('record')
@ -123,3 +187,41 @@ class TemplatingTest(TestCase):
), ),
mock_template.call_args, mock_template.call_args,
) )
def test_context(self):
templ = Templating(
'test',
context={
# static
'the_answer': 42,
# dynamic
'the_date': lambda _, __: 'today',
# uses a param
'num_sources': lambda z, ss: len(ss),
},
)
zone = Zone('unit.tests.', [])
txt = Record.new(
zone,
'txt',
{
'type': 'TXT',
'ttl': 42,
'values': (
'the_answer: {the_answer}',
'the_date: {the_date}',
'num_sources: {num_sources}',
),
},
)
zone.add_record(txt)
got = templ.process_source_zone(
zone, tuple(DummySource(i) for i in range(3))
)
txt = _find(got, 'txt')
self.assertEqual(3, len(txt.values))
self.assertEqual('num_sources: 3', txt.values[0])
self.assertEqual('the_answer: 42', txt.values[1])
self.assertEqual('the_date: today', txt.values[2])

Loading…
Cancel
Save