Browse Source

Merge branch 'main' into escaped-semi

pull/1253/head
Ross McFarland 5 months ago
committed by GitHub
parent
commit
6ef0e854e5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
43 changed files with 956 additions and 69 deletions
  1. +4
    -0
      .changelog/80f748502d3e4258bcbf4a05d5f5bdb0.md
  2. +0
    -4
      .changelog/acc2596fb367494db070e6c06abf705a.md
  3. +4
    -0
      .changelog/c4f025d1c23c40dd98380e6d3496364d.md
  4. +1
    -1
      .git_hooks_pre-commit
  5. +51
    -0
      .github/workflows/geo-data.yml
  6. +6
    -3
      CHANGELOG.md
  7. +24
    -22
      README.md
  8. +59
    -0
      docs/processors.md
  9. +1
    -1
      octodns/__init__.py
  10. +113
    -0
      octodns/processor/templating.py
  11. +7
    -0
      octodns/record/caa.py
  12. +5
    -0
      octodns/record/chunked.py
  13. +7
    -0
      octodns/record/ds.py
  14. +1
    -1
      octodns/record/geo_data.py
  15. +3
    -0
      octodns/record/ip.py
  16. +3
    -0
      octodns/record/loc.py
  17. +7
    -0
      octodns/record/mx.py
  18. +13
    -0
      octodns/record/naptr.py
  19. +7
    -0
      octodns/record/srv.py
  20. +7
    -0
      octodns/record/sshfp.py
  21. +8
    -0
      octodns/record/svcb.py
  22. +10
    -0
      octodns/record/target.py
  23. +9
    -0
      octodns/record/tlsa.py
  24. +8
    -0
      octodns/record/urlfwd.py
  25. +30
    -28
      requirements-dev.txt
  26. +1
    -1
      requirements.txt
  27. +38
    -7
      script/changelog
  28. +4
    -0
      script/release
  29. +12
    -0
      script/update-requirements
  30. +227
    -0
      tests/test_octodns_processor_templating.py
  31. +17
    -0
      tests/test_octodns_record_caa.py
  32. +12
    -0
      tests/test_octodns_record_chunked.py
  33. +27
    -0
      tests/test_octodns_record_ds.py
  34. +8
    -0
      tests/test_octodns_record_ip.py
  35. +23
    -0
      tests/test_octodns_record_loc.py
  36. +13
    -0
      tests/test_octodns_record_mx.py
  37. +33
    -0
      tests/test_octodns_record_naptr.py
  38. +27
    -0
      tests/test_octodns_record_srv.py
  39. +21
    -0
      tests/test_octodns_record_sshfp.py
  40. +15
    -0
      tests/test_octodns_record_svcb.py
  41. +31
    -1
      tests/test_octodns_record_target.py
  42. +29
    -0
      tests/test_octodns_record_tlsa.py
  43. +30
    -0
      tests/test_octodns_record_urlfwd.py

+ 4
- 0
.changelog/80f748502d3e4258bcbf4a05d5f5bdb0.md View File

@ -0,0 +1,4 @@
---
type: none
---
Create dist directory in scripte/release if it doesn't exist

+ 0
- 4
.changelog/acc2596fb367494db070e6c06abf705a.md View File

@ -1,4 +0,0 @@
---
type: none
---
Adding changelog management infra and doc

+ 4
- 0
.changelog/c4f025d1c23c40dd98380e6d3496364d.md View File

@ -0,0 +1,4 @@
---
type: none
---
Documentation for processors

+ 1
- 1
.git_hooks_pre-commit View File

@ -7,7 +7,7 @@ GIT=$(dirname "$HOOKS")
ROOT=$(dirname "$GIT")
. "$ROOT/env/bin/activate"
"$ROOT/script/changelog" check
"$ROOT/script/lint"
"$ROOT/script/format" --check --quiet || (echo "Formatting check failed, run ./script/format" && exit 1)
"$ROOT/script/coverage"
"$ROOT/script/changelog" check

+ 51
- 0
.github/workflows/geo-data.yml View File

@ -0,0 +1,51 @@
name: Update geo_data.py
on:
workflow_dispatch: # option to run manually if/when needed
schedule:
- cron: "42 3 * * 6" # sat @ 3:42am
jobs:
config:
runs-on: ubuntu-latest
outputs:
json: ${{ steps.load.outputs.json }}
steps:
- uses: actions/checkout@v4
- id: load
# based on https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
run: |
{
echo 'json<<EOF'
cat ./.ci-config.json
echo EOF
} >> $GITHUB_OUTPUT
update-geo-data:
needs: config
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup python
uses: actions/setup-python@v4
with:
python-version: ${{ fromJson(needs.config.outputs.json).python_version_current }}
architecture: x64
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install virtualenv
- name: Generate geo_data.py
run: |
./script/bootstrap
source env/bin/activate
./script/generate-geo-data > octodns/record/geo_data.py
git diff
[ `git status --porcelain=1 | wc -l` -ne 0 ] && ./script/changelog create -t minor Periodic updates to geo_data.py || true
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v7
with:
commit-message: Periodic updates to geo_data.py
branch: update-geo-data
title: Update geo_data.py to reflect recent changes
body: Auto-generated with https://github.com/peter-evans/create-pull-request

+ 6
- 3
CHANGELOG.md View File

@ -1,7 +1,10 @@
## v1.11.? - 2025-??-?? - ???
## 1.12.0 - 2025-06-25 - Automated changelogs
* Correct type-o in name of AcmeManagingProcessor, backwards compatible alias
in place
Minor:
* Templating processor added [#1259](https://github.com/octodns/octodns/pull/1259)
* Update geo-data, Türkiye [#1263](https://github.com/octodns/octodns/pull/1263)
* New provider: Bunny DNS [#1262](https://github.com/octodns/octodns/pull/1262)
* Correct type-o in name of AcmeManagingProcessor, backwards compatible alias in place [#1251](https://github.com/octodns/octodns/pull/1251)
## v1.11.0 - 2025-02-03 - Cleanup & deprecations with meta planning


+ 24
- 22
README.md View File

@ -153,7 +153,6 @@ zones:
- config
targets:
- ns1
```
#### General Configuration Concepts
@ -269,28 +268,37 @@ It is important to review any `WARNING` log lines printed out during an `octodns
The table below lists the providers octoDNS supports. They are maintained in their own repositories and released as independent modules.
| Provider | Module | Notes |
|--|--|--|
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| /etc/hosts | [octodns_etchosts](https://github.com/octodns/octodns-etchosts/) | |
| [Akamai Edge DNS](https://www.akamai.com/products/edge-dns) | [octodns_edgedns](https://github.com/octodns/octodns-edgedns/) | |
| [Amazon Route 53](https://aws.amazon.com/route53/) | [octodns_route53](https://github.com/octodns/octodns-route53) | |
| [AutoDNS](https://www.internetx.com/autodns/) | [octodns_autodns](https://github.com/octodns/octodns-autodns) | |
| [Azure DNS](https://azure.microsoft.com/en-us/services/dns/) | [octodns_azure](https://github.com/octodns/octodns-azure/) | |
| [BIND, AXFR, RFC-2136](https://www.isc.org/bind/) | [octodns_bind](https://github.com/octodns/octodns-bind/) | |
| [Bunny DNS](https://bunny.net/dns/) | [octodns_bunny](https://github.com/Relkian/octodns-bunny) | |
| [Cloudflare DNS](https://www.cloudflare.com/dns/) | [octodns_cloudflare](https://github.com/octodns/octodns-cloudflare/) | |
| [ClouDNS](https://www.cloudns.net/) | [octodns_cloudns](https://github.com/ClouDNS/octodns_cloudns) | |
| [Constellix](https://constellix.com/) | [octodns_constellix](https://github.com/octodns/octodns-constellix/) | |
| [deSEC](https://desec.io/) | [octodns_desec](https://github.com/rootshell-labs/octodns-desec) | |
| [DigitalOcean](https://docs.digitalocean.com/products/networking/dns/) | [octodns_digitalocean](https://github.com/octodns/octodns-digitalocean/) | |
| [DNS Made Easy](https://dnsmadeeasy.com/) | [octodns_dnsmadeeasy](https://github.com/octodns/octodns-dnsmadeeasy/) | |
| [DNSimple](https://dnsimple.com/) | [octodns_dnsimple](https://github.com/octodns/octodns-dnsimple/) | |
| [Dyn](https://www.oracle.com/cloud/networking/dns/) ([deprecated](https://www.oracle.com/corporate/acquisitions/dyn/technologies/migrate-your-services/)) | [octodns_dyn](https://github.com/octodns/octodns-dyn/) | |
| [easyDNS](https://easydns.com/) | [octodns_easydns](https://github.com/octodns/octodns-easydns/) | |
| [EdgeCenter DNS](https://edgecenter.ru/dns/) | [octodns_edgecenter](https://github.com/octodns/octodns-edgecenter/) | |
| /etc/hosts | [octodns_etchosts](https://github.com/octodns/octodns-etchosts/) | |
| [Gandi](https://www.gandi.net/en-US/domain/dns) | [octodns_gandi](https://github.com/octodns/octodns-gandi/) | |
| [Fastly](https://www.fastly.com/de/) | [Financial-Times/octodns-fastly](https://github.com/Financial-Times/octodns-fastly) | |
| [G-Core Labs DNS](https://gcorelabs.com/dns/) | [octodns_gcore](https://github.com/octodns/octodns-gcore/) | |
| [Gandi](https://www.gandi.net/en-US/domain/dns) | [octodns_gandi](https://github.com/octodns/octodns-gandi/) | |
| [Google Cloud DNS](https://cloud.google.com/dns) | [octodns_googlecloud](https://github.com/octodns/octodns-googlecloud/) | |
| [Hetzner DNS](https://www.hetzner.com/dns-console) | [octodns_hetzner](https://github.com/octodns/octodns-hetzner/) | |
| [Infoblox](https://www.infoblox.com/) | [asyncon/octoblox](https://github.com/asyncon/octoblox) | |
| [Infomaniak](https://www.infomaniak.com/) | [octodns_infomaniak](https://github.com/M0NsTeRRR/octodns-infomaniak) | |
| [Lexicon](https://dns-lexicon.github.io/dns-lexicon/#) | [dns-lexicon/dns-lexicon](https://github.com/dns-lexicon/dns-lexicon) | |
| [Mythic Beasts DNS](https://www.mythic-beasts.com/support/hosting/dns) | [octodns_mythicbeasts](https://github.com/octodns/octodns-mythicbeasts/) | |
| [NetBox-DNS Plugin](https://github.com/peteeckel/netbox-plugin-dns) | [olofvndrhr/octodns-netbox-dns](https://github.com/olofvndrhr/octodns-netbox-dns) | |
| [NS1](https://ns1.com/products/managed-dns) | [octodns_ns1](https://github.com/octodns/octodns-ns1/) | |
| [OVHcloud DNS](https://www.ovhcloud.com/en/domains/dns-subdomain/) | [octodns_ovh](https://github.com/octodns/octodns-ovh/) | |
| [Pi-hole](https://pi-hole.net/) | [jvoss/octodns-pihole](https://github.com/jvoss/octodns-pihole) | |
| [PowerDNS](https://www.powerdns.com/) | [octodns_powerdns](https://github.com/octodns/octodns-powerdns/) | |
| [Rackspace](https://www.rackspace.com/library/what-is-dns) | [octodns_rackspace](https://github.com/octodns/octodns-rackspace/) | |
| [Scaleway](https://www.scaleway.com/en/dns/) | [octodns_scaleway](https://github.com/scaleway/octodns-scaleway) | |
@ -299,7 +307,7 @@ The table below lists the providers octoDNS supports. They are maintained in the
| [TransIP](https://www.transip.eu/knowledgebase/entry/155-dns-and-nameservers/) | [octodns_transip](https://github.com/octodns/octodns-transip/) | |
| [UltraDNS](https://vercara.com/authoritative-dns) | [octodns_ultra](https://github.com/octodns/octodns-ultra/) | |
| [YamlProvider](/octodns/provider/yaml.py) | built-in | Supports all record types and core functionality |
| [deSEC](https://desec.io/) | [octodns_desec](https://github.com/rootshell-labs/octodns-desec) | |
| Zonefile | [kompetenzbolzen/octodns-custom-provider](https://github.com/kompetenzbolzen/octodns-custom-provider) | |
### Updating to use extracted providers
@ -313,11 +321,15 @@ The module required and provider class path for extracted providers can be found
Similar to providers, but can only serve to populate records into a zone, cannot be synced to.
| Source | Record Support | Dynamic | Notes |
|--|--|--|--|
| ---------------------------------------------------------------------------- | ---------------------------------------------------- | ------- | ---------------------------------------- |
| [AxfrSource (BIND)](https://github.com/octodns/octodns-bind/) | A, AAAA, CAA, CNAME, LOC, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [DDNS Source](https://github.com/octodns/octodns-ddns) | A, AAAA | No | read-only |
| [EnvVarSource](/octodns/source/envvar.py) | TXT | No | read-only environment variable injection |
| [AxfrSource](https://github.com/octodns/octodns-bind/) | A, AAAA, CAA, CNAME, LOC, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [ZoneFileSource](https://github.com/octodns/octodns-bind/) | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [Lexicon Source](https://github.com/doddo/octodns-lexicon) | A, 'AAA, ALIAS, CAA, CNAME, MX, NS, SRV, TXT | No | read-only |
| [Netbox Source](https://github.com/sukiyaki/octodns-netbox) | A, AAAA, PTR | No | read-only |
| [PHPIPAM source](https://github.com/kompetenzbolzen/octodns-custom-provider) | A, AAAA | No | read-only |
| [TinyDnsFileSource](/octodns/source/tinydns.py) | A, CNAME, MX, NS, PTR | No | read-only |
| [ZoneFileSource](https://github.com/octodns/octodns-bind/) | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
### Notes
@ -329,7 +341,7 @@ Similar to providers, but can only serve to populate records into a zone, cannot
## Processors
| Processor | Description |
|--|--|
| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| [AcmeManagingProcessor](/octodns/processor/acme.py) | Useful when processes external to octoDNS are managing acme challenge DNS records, e.g. LetsEncrypt |
| [AutoArpa](/octodns/processor/arpa.py) | See [Automatic PTR generation](#automatic-ptr-generation) below |
| [EnsureTrailingDots](/octodns/processor/trailing_dots.py) | Processor that ensures ALIAS, CNAME, DNAME, MX, NS, PTR, and SRVs have trailing dots |
@ -407,7 +419,6 @@ providers:
password: env/DYN_PASSWORD
zones:
githubtest.net.:
sources:
- route53
@ -428,11 +439,10 @@ providers:
token: env/GPANEL_SITE_TOKEN
powerdns-site:
class: octodns.provider.powerdns.PowerDnsProvider
host: 'internal-dns.site.github.foo'
host: "internal-dns.site.github.foo"
api_key: env/POWERDNS_SITE_API_KEY
zones:
hosts.site.github.foo.:
sources:
- gpanel-site
@ -453,20 +463,12 @@ If you have a problem or suggestion, please [open an issue](https://github.com/o
- **GitHub Action:** [octoDNS-Sync](https://github.com/marketplace/actions/octodns-sync)
- **NixOS Integration:** [NixOS-DNS](https://github.com/Janik-Haag/nixos-dns/)
- **Sample Implementations.** See how others are using it
- [`hackclub/dns`](https://github.com/hackclub/dns)
- [`kubernetes/k8s.io:/dns`](https://github.com/kubernetes/k8s.io/tree/main/dns)
- [`g0v-network/domains`](https://github.com/g0v-network/domains)
- [`jekyll/dns`](https://github.com/jekyll/dns)
- **Custom Sources & Providers.**
- [`octodns/octodns-ddns`](https://github.com/octodns/octodns-ddns): A simple Dynamic DNS source.
- [`doddo/octodns-lexicon`](https://github.com/doddo/octodns-lexicon): Use [Lexicon](https://github.com/AnalogJ/lexicon) providers as octoDNS providers.
- [`asyncon/octoblox`](https://github.com/asyncon/octoblox): [Infoblox](https://www.infoblox.com/) provider.
- [`sukiyaki/octodns-netbox`](https://github.com/sukiyaki/octodns-netbox): [NetBox](https://github.com/netbox-community/netbox) source.
- [`jcollie/octodns-netbox-dns`](https://github.com/jcollie/octodns-netbox-dns): [NetBox-DNS Plugin](https://github.com/auroraresearchlab/netbox-dns) provider.
- [`kompetenzbolzen/octodns-custom-provider`](https://github.com/kompetenzbolzen/octodns-custom-provider): zonefile provider & phpIPAM source.
- [`Financial-Times/octodns-fastly`](https://github.com/Financial-Times/octodns-fastly): An octoDNS source for Fastly.
- [`jvoss/octodns-pihole`](https://github.com/jvoss/octodns-pihole): [Pi-hole](https://pi-hole.net/) provider.
- [`M0NsTeRRR/octodns-infomaniak`](https://github.com/M0NsTeRRR/octodns-infomaniak): [Infomaniak](https://www.infomaniak.com/) provider.
- **Resources.**
- Article: [Visualising DNS records with Neo4j](https://medium.com/@costask/querying-and-visualising-octodns-records-with-neo4j-f4f72ab2d474) + code
- Video: [FOSDEM 2019 - DNS as code with octodns](https://archive.fosdem.org/2019/schedule/event/dns_octodns/)


+ 59
- 0
docs/processors.md View File

@ -0,0 +1,59 @@
# octoDNS processors
## Available processors
These are listed in the main [`README`](../README.md#processors)
## Configuring processors
Configuring processors is done in the main config file.
### Defining a processor configuration
This is done under the top-level `processors` key in the octoDNS config file (for example `config.yaml`), as a sibling to the `manager` key.
The `processors` key contains YAML objects, where the key is the name of the processor, and the `class` value within that object refers to the processor name.
For example, to define a provider called `custom_meta` using the [`MetaProcessor`](../octodns/processor/meta.py) in order to extend the default `include_meta` behaviour:
```yaml
manager:
include_meta: false # disable default, basic `meta` records
processors:
custom_meta:
class: octodns.processor.meta.MetaProcessor
record_name: meta
include_time: true
include_uuid: true
include_provider: true
include_version: false
```
**NOTE:** the specific parameters for each processor are only documented within [the code](../octodns/processor/)
### Utilising the processor configuration
#### On **individual** domains
Each domain can utilise the processor independently by adding the name of the defined processor to a `processors` key beneath a `zone`:
```yaml
zones:
example.com.:
source:
- yaml_config
target:
- hetzner
processors:
- custom_meta
```
#### On **all** domains
To utilise the processor on **all** domains automatically, including new domains added to the `zones` config in future then you can add this to the `processors` key under the `manager` section of the configuration:
```yaml
manager:
processors:
- custom_meta
```

+ 1
- 1
octodns/__init__.py View File

@ -1,4 +1,4 @@
'OctoDNS: DNS as code - Tools for managing DNS across multiple providers'
# TODO: remove __VERSION__ w/2.x
__version__ = __VERSION__ = '1.11.0'
__version__ = __VERSION__ = '1.12.0'

+ 113
- 0
octodns/processor/templating.py View File

@ -0,0 +1,113 @@
#
#
#
from octodns.processor.base import 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.
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)
self.context = context
def process_source_zone(self, desired, sources):
sources = sources or []
zone_params = {
'zone_name': desired.decoded_name,
'zone_decoded_name': desired.decoded_name,
'zone_encoded_name': desired.name,
'zone_num_records': len(desired.records),
'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):
return {
'record_name': record.decoded_name,
'record_decoded_name': record.decoded_name,
'record_encoded_name': record.name,
'record_fqdn': record.decoded_fqdn,
'record_decoded_fqdn': record.decoded_fqdn,
'record_encoded_fqdn': record.fqdn,
'record_type': record._type,
'record_ttl': record.ttl,
'record_source_id': record.source.id if record.source else None,
**zone_params,
}
for record in desired.records:
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]
if record.values != new_values:
new = record.copy()
new.values = new_values
desired.add_record(new, replace=True)
else:
if not hasattr(record.value, 'template'):
# the (custom) value type does not support templating
continue
new_value = record.value.template(params(record))
if record.value != new_value:
new = record.copy()
new.value = new_value
desired.add_record(new, replace=True)
return desired

+ 7
- 0
octodns/record/caa.py View File

@ -87,6 +87,13 @@ class CaaValue(EqualityTupleMixin, dict):
def rdata_text(self):
return f'{self.flags} {self.tag} {self.value}'
def template(self, params):
if '{' not in self.value:
return self
new = self.__class__(self)
new.value = new.value.format(**params)
return new
def _equality_tuple(self):
return (self.flags, self.tag, self.value)


+ 5
- 0
octodns/record/chunked.py View File

@ -82,3 +82,8 @@ class _ChunkedValue(str):
@property
def rdata_text(self):
return self
def template(self, params):
if '{' not in self:
return self
return self.__class__(self.format(**params))

+ 7
- 0
octodns/record/ds.py View File

@ -164,6 +164,13 @@ class DsValue(EqualityTupleMixin, dict):
f'{self.key_tag} {self.algorithm} {self.digest_type} {self.digest}'
)
def template(self, params):
if '{' not in self.digest:
return self
new = self.__class__(self)
new.digest = new.digest.format(**params)
return new
def _equality_tuple(self):
return (self.key_tag, self.algorithm, self.digest_type, self.digest)


+ 1
- 1
octodns/record/geo_data.py View File

@ -123,7 +123,7 @@ geo_data = {
'TJ': {'name': 'Tajikistan'},
'TL': {'name': 'Timor-Leste'},
'TM': {'name': 'Turkmenistan'},
'TR': {'name': 'Turkey'},
'TR': {'name': 'Türkiye'},
'TW': {'name': 'Taiwan, Province of China'},
'UZ': {'name': 'Uzbekistan'},
'VN': {'name': 'Viet Nam'},


+ 3
- 0
octodns/record/ip.py View File

@ -45,5 +45,8 @@ class _IpValue(str):
def rdata_text(self):
return self
def template(self, params):
return self
_IpAddress = _IpValue

+ 3
- 0
octodns/record/loc.py View File

@ -305,6 +305,9 @@ class LocValue(EqualityTupleMixin, dict):
def rdata_text(self):
return f'{self.lat_degrees} {self.lat_minutes} {self.lat_seconds} {self.lat_direction} {self.long_degrees} {self.long_minutes} {self.long_seconds} {self.long_direction} {self.altitude}m {self.size}m {self.precision_horz}m {self.precision_vert}m'
def template(self, params):
return self
def __hash__(self):
return hash(
(


+ 7
- 0
octodns/record/mx.py View File

@ -101,6 +101,13 @@ class MxValue(EqualityTupleMixin, dict):
def rdata_text(self):
return f'{self.preference} {self.exchange}'
def template(self, params):
if '{' not in self.exchange:
return self
new = self.__class__(self)
new.exchange = new.exchange.format(**params)
return new
def __hash__(self):
return hash((self.preference, self.exchange))


+ 13
- 0
octodns/record/naptr.py View File

@ -138,6 +138,19 @@ class NaptrValue(EqualityTupleMixin, dict):
def rdata_text(self):
return f'{self.order} {self.preference} {self.flags} {self.service} {self.regexp} {self.replacement}'
def template(self, params):
if (
'{' not in self.service
and '{' not in self.regexp
and '{' not in self.replacement
):
return self
new = self.__class__(self)
new.service = new.service.format(**params)
new.regexp = new.regexp.format(**params)
new.replacement = new.replacement.format(**params)
return new
def __hash__(self):
return hash(self.__repr__())


+ 7
- 0
octodns/record/srv.py View File

@ -135,6 +135,13 @@ class SrvValue(EqualityTupleMixin, dict):
def rdata_text(self):
return f"{self.priority} {self.weight} {self.port} {self.target}"
def template(self, params):
if '{' not in self.target:
return self
new = self.__class__(self)
new.target = new.target.format(**params)
return new
def __hash__(self):
return hash(self.__repr__())


+ 7
- 0
octodns/record/sshfp.py View File

@ -105,6 +105,13 @@ class SshfpValue(EqualityTupleMixin, dict):
def rdata_text(self):
return f'{self.algorithm} {self.fingerprint_type} {self.fingerprint}'
def template(self, params):
if '{' not in self.fingerprint:
return self
new = self.__class__(self)
new.fingerprint = new.fingerprint.format(**params)
return new
def __hash__(self):
return hash(self.__repr__())


+ 8
- 0
octodns/record/svcb.py View File

@ -287,6 +287,14 @@ class SvcbValue(EqualityTupleMixin, dict):
params += f'={svcparamvalue}'
return f'{self.svcpriority} {self.targetname}{params}'
def template(self, params):
if '{' not in self.targetname:
return self
new = self.__class__(self)
new.targetname = new.targetname.format(**params)
# TODO: what, if any of the svcparams should be templated
return new
def __hash__(self):
return hash(self.__repr__())


+ 10
- 0
octodns/record/target.py View File

@ -41,6 +41,11 @@ class _TargetValue(str):
def rdata_text(self):
return self
def template(self, params):
if '{' not in self:
return self
return self.__class__(self.format(**params))
#
# much like _TargetValue, but geared towards multiple values
@ -75,3 +80,8 @@ class _TargetsValue(str):
@property
def rdata_text(self):
return self
def template(self, params):
if '{' not in self:
return self
return self.__class__(self.format(**params))

+ 9
- 0
octodns/record/tlsa.py View File

@ -136,6 +136,15 @@ class TlsaValue(EqualityTupleMixin, dict):
def rdata_text(self):
return f'{self.certificate_usage} {self.selector} {self.matching_type} {self.certificate_association_data}'
def template(self, params):
if '{' not in self.certificate_association_data:
return self
new = self.__class__(self)
new.certificate_association_data = (
new.certificate_association_data.format(**params)
)
return new
def _equality_tuple(self):
return (
self.certificate_usage,


+ 8
- 0
octodns/record/urlfwd.py View File

@ -132,6 +132,14 @@ class UrlfwdValue(EqualityTupleMixin, dict):
def rdata_text(self):
return f'"{self.path}" "{self.target}" {self.code} {self.masking} {self.query}'
def template(self, params):
if '{' not in self.path and '{' not in self.target:
return self
new = self.__class__(self)
new.path = new.path.format(**params)
new.target = new.target.format(**params)
return new
def _equality_tuple(self):
return (self.path, self.target, self.code, self.masking, self.query)


+ 30
- 28
requirements-dev.txt View File

@ -1,48 +1,50 @@
# DO NOT EDIT THIS FILE DIRECTLY - use ./script/update-requirements to update
Pygments==2.18.0
Pygments==2.19.2
SecretStorage==3.3.3
black==24.10.0
build==1.2.2.post1
certifi==2024.8.30
certifi==2025.6.15
cffi==1.17.1
charset-normalizer==3.3.2
click==8.1.7
cmarkgfm==2024.1.14
coverage==7.6.1
charset-normalizer==3.4.2
click==8.1.8; python_version<'3.10'
click==8.2.1; python_version>='3.10'
cmarkgfm==2024.11.20
coverage==7.9.1
cryptography==45.0.4
docutils==0.21.2
importlib_metadata==8.5.0
iniconfig==2.0.0
isort==5.13.2
id==1.5.0
iniconfig==2.1.0
isort==6.0.1
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.1.0
keyring==25.4.1
jaraco.functools==4.2.1
jeepney==0.9.0
keyring==25.6.0
markdown-it-py==3.0.0
mdurl==0.1.2
more-itertools==10.5.0
mypy-extensions==1.0.0
nh3==0.2.18
packaging==24.1
more-itertools==10.7.0
mypy_extensions==1.1.0
nh3==0.2.21
packaging==25.0
pathspec==0.12.1
pkginfo==1.10.0
platformdirs==4.3.6
pluggy==1.5.0
platformdirs==4.3.8
pluggy==1.6.0
pprintpp==0.4.0
pycountry-convert==0.7.2
pycountry==24.6.1
pycparser==2.22
pyflakes==3.2.0
pyflakes==3.4.0
pyproject_hooks==1.2.0
pytest-cov==5.0.0
pytest-mock==3.14.0
pytest==8.3.3
pytest-cov==6.2.1
pytest-mock==3.14.1
pytest==8.4.1
pytest_network==0.0.1
readme_renderer==44.0
repoze.lru==0.7
requests-toolbelt==1.0.0
requests==2.32.3
requests==2.32.4
rfc3986==2.0.0
rich==13.9.2
twine==5.1.1
urllib3==2.2.3
wheel==0.44.0
zipp==3.20.2
rich==14.0.0
twine==6.1.0
urllib3==2.5.0
wheel==0.45.1

+ 1
- 1
requirements.txt View File

@ -5,4 +5,4 @@ fqdn==1.5.1
idna==3.10
natsort==8.4.0
python-dateutil==2.9.0.post0
six==1.16.0
six==1.17.0

+ 38
- 7
script/changelog View File

@ -11,7 +11,7 @@ from subprocess import PIPE, run
from sys import argv, exit, path, stderr
from uuid import uuid4
from yaml import safe_load_all
from yaml import safe_load
def create(argv):
@ -39,6 +39,18 @@ def create(argv):
See https://semver.org/ for more info''',
)
parser.add_argument(
'-p',
'--pr',
help='Manually override the PR number for the change, maintainer use only.',
)
parser.add_argument(
'-a',
'--add',
action='store_true',
default=False,
help='git add the newly created changelog entry',
)
parser.add_argument(
'md',
metavar='change-description-markdown',
@ -56,6 +68,9 @@ and links.''',
with open(filepath, 'w') as fh:
fh.write('---\ntype: ')
fh.write(args.type)
if args.pr:
fh.write('\npr: ')
fh.write(args.pr)
fh.write('\n---\n')
fh.write(' '.join(args.md))
@ -63,6 +78,9 @@ and links.''',
f'Created {filepath}, it can be further edited and should be committed to your branch.'
)
if args.add:
run(['git', 'add', filepath])
def check(argv):
if isdir('.changelog'):
@ -97,7 +115,9 @@ class _ChangeMeta:
_pr_cache = None
@classmethod
def get(cls, filepath):
def get(cls, filepath, data):
if 'pr' in data:
return data['pr'], datetime(year=1970, month=1, day=1)
if cls._pr_cache is None:
result = run(
[
@ -130,6 +150,7 @@ class _ChangeMeta:
try:
return cls._pr_cache[filepath]
except KeyError:
# couldn't find a PR with the changelog file in it
return None, datetime(year=1970, month=1, day=1)
@ -141,8 +162,12 @@ def _get_changelogs():
continue
filepath = join(dirname, filename)
with open(filepath) as fh:
data, md = safe_load_all(fh)
pr, time = _ChangeMeta.get(filepath)
pieces = fh.read().split('---\n')
data = safe_load(pieces[1])
md = pieces[2]
if md[-1] == '\n':
md = md[:-1]
pr, time = _ChangeMeta.get(filepath, data)
if not pr:
continue
ret.append(
@ -155,8 +180,8 @@ def _get_changelogs():
}
)
ordering = {'major': 0, 'minor': 1, 'patch': 2, 'none': 3, '': 3}
ret.sort(key=lambda c: (ordering[c['type']], c['time']))
ordering = {'major': 3, 'minor': 2, 'patch': 1, 'none': 0, '': 0}
ret.sort(key=lambda c: (ordering[c['type']], c['time']), reverse=True)
return ret
@ -195,6 +220,9 @@ def bump(argv):
action='store_true',
help='Write changelog update and bump version number',
)
parser.add_argument(
'title', nargs='+', help='A short title/quip for the release title'
)
args = parser.parse_args(argv)
@ -215,7 +243,7 @@ def bump(argv):
buf.write(' - ')
buf.write(datetime.now().strftime('%Y-%m-%d'))
buf.write(' - ')
buf.write(' '.join(argv[1:]))
buf.write(' '.join(args.title))
buf.write('\n')
current_type = None
@ -225,6 +253,9 @@ def bump(argv):
continue
_type = changelog['type']
if _type == 'none':
# these aren't included in the listing
continue
if _type != current_type:
buf.write('\n')
buf.write(_type.capitalize())


+ 4
- 0
script/release View File

@ -44,6 +44,10 @@ echo "Created clean room $TMP_DIR and archived $VERSION into it"
(cd "$TMP_DIR" && python -m build --sdist --wheel)
if [ ! -d dist ]; then
mkdir dist/
fi
cp $TMP_DIR/dist/*$VERSION.tar.gz $TMP_DIR/dist/*$VERSION*.whl dist/
echo "Copied $TMP_DIR/dists into ./dist"


+ 12
- 0
script/update-requirements View File

@ -42,6 +42,18 @@ dev_frozen = sorted(
[p for p in dev_frozen if not p.startswith(our_package_name)]
)
# special handling for click until python 3.9 is gone due to it dropping
# support for active versions early
i = [i for i, r in enumerate(dev_frozen) if r.startswith('click==')][0]
dev_frozen = (
dev_frozen[:i]
+ [
"click==8.1.8; python_version<'3.10'",
f"{dev_frozen[i]}; python_version>='3.10'",
]
+ dev_frozen[i + 1 :]
)
print_packages(frozen, 'frozen')
print_packages(dev_frozen, 'dev_frozen')


+ 227
- 0
tests/test_octodns_processor_templating.py View File

@ -0,0 +1,227 @@
#
#
#
from unittest import TestCase
from unittest.mock import call, patch
from octodns.processor.templating import Templating
from octodns.record import Record, ValueMixin, ValuesMixin
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):
return next(r for r in zone.records if r.name == name)
class TemplatingTest(TestCase):
def test_cname(self):
templ = Templating('test')
zone = Zone('unit.tests.', [])
cname = Record.new(
zone,
'cname',
{
'type': 'CNAME',
'ttl': 42,
'value': '_cname.{zone_name}something.else.',
},
lenient=True,
)
zone.add_record(cname)
noop = Record.new(
zone,
'noop',
{
'type': 'CNAME',
'ttl': 42,
'value': '_noop.nothing_to_do.something.else.',
},
lenient=True,
)
zone.add_record(noop)
got = templ.process_source_zone(zone, None)
cname = _find(got, 'cname')
self.assertEqual('_cname.unit.tests.something.else.', cname.value)
noop = _find(got, 'noop')
self.assertEqual('_noop.nothing_to_do.something.else.', noop.value)
def test_txt(self):
templ = Templating('test')
zone = Zone('unit.tests.', [])
txt = Record.new(
zone,
'txt',
{
'type': 'TXT',
'ttl': 42,
'value': 'There are {zone_num_records} record(s) in {zone_name}',
},
)
zone.add_record(txt)
noop = Record.new(
zone,
'noop',
{'type': 'TXT', 'ttl': 43, 'value': 'Nothing to template here.'},
)
zone.add_record(noop)
got = templ.process_source_zone(zone, None)
txt = _find(got, 'txt')
self.assertEqual('There are 2 record(s) in unit.tests.', txt.values[0])
noop = _find(got, 'noop')
self.assertEqual('Nothing to template here.', noop.values[0])
def test_no_template(self):
templ = Templating('test')
zone = Zone('unit.tests.', [])
s = Record.new(zone, 's', {'type': 'S', 'ttl': 42, 'value': 'string'})
zone.add_record(s)
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.', [])
record_source = DummySource('record')
txt = Record.new(
zone,
'txt',
{
'type': 'TXT',
'ttl': 42,
'value': 'There are {zone_num_records} record(s) in {zone_name}',
},
source=record_source,
)
zone.add_record(txt)
templ.process_source_zone(
zone, sources=[record_source, DummySource('other')]
)
mock_template.assert_called_once()
self.assertEqual(
call(
{
'record_name': 'txt',
'record_decoded_name': 'txt',
'record_encoded_name': 'txt',
'record_fqdn': 'txt.unit.tests.',
'record_decoded_fqdn': 'txt.unit.tests.',
'record_encoded_fqdn': 'txt.unit.tests.',
'record_type': 'TXT',
'record_ttl': 42,
'record_source_id': 'record',
'zone_name': 'unit.tests.',
'zone_decoded_name': 'unit.tests.',
'zone_encoded_name': 'unit.tests.',
'zone_num_records': 1,
'zone_source_ids': 'record, other',
}
),
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])

+ 17
- 0
tests/test_octodns_record_caa.py View File

@ -285,3 +285,20 @@ class TestRecordCaa(TestCase):
{'type': 'CAA', 'ttl': 600, 'value': {'tag': 'iodef'}},
)
self.assertEqual(['missing value'], ctx.exception.reasons)
class TestCaaValue(TestCase):
def test_template(self):
value = CaaValue(
{'flags': 0, 'tag': 'issue', 'value': 'ca.example.net'}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = CaaValue(
{'flags': 0, 'tag': 'issue', 'value': 'ca.{needle}.net'}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('ca.42.net', got.value)

+ 12
- 0
tests/test_octodns_record_chunked.py View File

@ -121,3 +121,15 @@ class TestChunkedValue(TestCase):
sc = self.SmallerChunkedMixin(['0123456789'])
self.assertEqual(['"01234567" "89"'], sc.chunked_values)
def test_template(self):
s = 'this.has.no.templating.'
value = _ChunkedValue(s)
got = value.template({'needle': 42})
self.assertIs(value, got)
s = 'this.does.{needle}.have.templating.'
value = _ChunkedValue(s)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('this.does.42.have.templating.', got)

+ 27
- 0
tests/test_octodns_record_ds.py View File

@ -259,3 +259,30 @@ class TestRecordDs(TestCase):
self.assertEqual(DsValue(values[1]), a.values[1].data)
self.assertEqual('1 2 3 99148c44', a.values[1].rdata_text)
self.assertEqual('1 2 3 99148c44', a.values[1].__repr__())
class TestDsValue(TestCase):
def test_template(self):
value = DsValue(
{
'key_tag': 0,
'algorithm': 1,
'digest_type': 2,
'digest': 'abcdef0123456',
}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = DsValue(
{
'key_tag': 0,
'algorithm': 1,
'digest_type': 2,
'digest': 'abcd{needle}ef0123456',
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('abcd42ef0123456', got.digest)

+ 8
- 0
tests/test_octodns_record_ip.py View File

@ -27,3 +27,11 @@ class TestRecordIp(TestCase):
zone = Zone('unit.tests.', [])
a = ARecord(zone, 'a', {'ttl': 42, 'value': '1.2.3.4'})
self.assertEqual('1.2.3.4', a.values[0].rdata_text)
class TestIpValue(TestCase):
def test_template(self):
value = Ipv4Value('1.2.3.4')
# template is a noop
self.assertIs(value, value.template({'needle': 42}))

+ 23
- 0
tests/test_octodns_record_loc.py View File

@ -715,3 +715,26 @@ class TestRecordLoc(TestCase):
self.assertEqual(
['invalid value for size "99999999.99"'], ctx.exception.reasons
)
class TestLocValue(TestCase):
def test_template(self):
value = LocValue(
{
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
)
# loc value template is a noop
self.assertIs(value, value.template({}))

+ 13
- 0
tests/test_octodns_record_mx.py View File

@ -268,3 +268,16 @@ class TestRecordMx(TestCase):
},
)
self.assertEqual('.', record.values[0].exchange)
class TestMxValue(TestCase):
def test_template(self):
value = MxValue({'preference': 10, 'exchange': 'smtp1.'})
got = value.template({'needle': 42})
self.assertIs(value, got)
value = MxValue({'preference': 10, 'exchange': 'smtp1.{needle}.'})
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('smtp1.42.', got.exchange)

+ 33
- 0
tests/test_octodns_record_naptr.py View File

@ -449,3 +449,36 @@ class TestRecordNaptr(TestCase):
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v})
self.assertEqual(['unrecognized flags "X"'], ctx.exception.reasons)
class TestNaptrValue(TestCase):
def test_template(self):
value = NaptrValue(
{
'order': 10,
'preference': 11,
'flags': 'X',
'service': 'Y',
'regexp': 'Z',
'replacement': '.',
}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = NaptrValue(
{
'order': 10,
'preference': 11,
'flags': 'X',
'service': 'Y{needle}',
'regexp': 'Z{needle}',
'replacement': '.{needle}',
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('Y42', got.service)
self.assertEqual('Z42', got.regexp)
self.assertEqual('.42', got.replacement)

+ 27
- 0
tests/test_octodns_record_srv.py View File

@ -450,3 +450,30 @@ class TestRecordSrv(TestCase):
['Invalid SRV target "100 foo.bar.com." is not a valid FQDN.'],
ctx.exception.reasons,
)
class TestSrvValue(TestCase):
def test_template(self):
value = SrvValue(
{
'priority': 10,
'weight': 11,
'port': 12,
'target': 'no_placeholders',
}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = SrvValue(
{
'priority': 10,
'weight': 11,
'port': 12,
'target': 'has_{needle}_placeholder',
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('has_42_placeholder', got.target)

+ 21
- 0
tests/test_octodns_record_sshfp.py View File

@ -333,3 +333,24 @@ class TestRecordSshfp(TestCase):
},
)
self.assertEqual(['missing fingerprint'], ctx.exception.reasons)
class TestSshFpValue(TestCase):
def test_template(self):
value = SshfpValue(
{'algorithm': 10, 'fingerprint_type': 11, 'fingerprint': 'abc123'}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = SshfpValue(
{
'algorithm': 10,
'fingerprint_type': 11,
'fingerprint': 'ab{needle}c123',
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('ab42c123', got.fingerprint)

+ 15
- 0
tests/test_octodns_record_svcb.py View File

@ -673,3 +673,18 @@ class TestRecordSvcb(TestCase):
],
ctx.exception.reasons,
)
class TestSrvValue(TestCase):
def test_template(self):
value = SvcbValue({'svcpriority': 0, 'targetname': 'foo.example.com.'})
got = value.template({'needle': 42})
self.assertIs(value, got)
value = SvcbValue(
{'svcpriority': 0, 'targetname': 'foo.{needle}.example.com.'}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('foo.42.example.com.', got.targetname)

+ 31
- 1
tests/test_octodns_record_target.py View File

@ -5,7 +5,7 @@
from unittest import TestCase
from octodns.record.alias import AliasRecord
from octodns.record.target import _TargetValue
from octodns.record.target import _TargetsValue, _TargetValue
from octodns.zone import Zone
@ -28,3 +28,33 @@ class TestRecordTarget(TestCase):
zone = Zone('unit.tests.', [])
a = AliasRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'})
self.assertEqual('some.target.', a.value.rdata_text)
class TestTargetValue(TestCase):
def test_template(self):
s = 'this.has.no.templating.'
value = _TargetValue(s)
got = value.template({'needle': 42})
self.assertIs(value, got)
s = 'this.does.{needle}.have.templating.'
value = _TargetValue(s)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('this.does.42.have.templating.', got)
class TestTargetsValue(TestCase):
def test_template(self):
s = 'this.has.no.templating.'
value = _TargetsValue(s)
got = value.template({'needle': 42})
self.assertIs(value, got)
s = 'this.does.{needle}.have.templating.'
value = _TargetsValue(s)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('this.does.42.have.templating.', got)

+ 29
- 0
tests/test_octodns_record_tlsa.py View File

@ -429,3 +429,32 @@ class TestRecordTlsa(TestCase):
'invalid matching_type "{value["matching_type"]}"',
ctx.exception.reasons,
)
class TestTlsaValue(TestCase):
def test_template(self):
value = TlsaValue(
{
'certificate_usage': 1,
'selector': 1,
'matching_type': 1,
'certificate_association_data': 'ABABABABABABABABAB',
}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = TlsaValue(
{
'certificate_usage': 1,
'selector': 1,
'matching_type': 1,
'certificate_association_data': 'ABAB{needle}ABABABABABABAB',
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual(
'ABAB42ABABABABABABAB', got.certificate_association_data
)

+ 30
- 0
tests/test_octodns_record_urlfwd.py View File

@ -483,3 +483,33 @@ class TestRecordUrlfwd(TestCase):
{'ttl': 32, 'value': UrlfwdValue.parse_rdata_text(rdata)},
)
self.assertEqual(rdata, record.values[0].rdata_text)
class TestUrlfwdValue(TestCase):
def test_template(self):
value = UrlfwdValue(
{
'path': '/',
'target': 'http://foo',
'code': 301,
'masking': 2,
'query': 0,
}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = UrlfwdValue(
{
'path': '/{needle}',
'target': 'http://foo.{needle}',
'code': 301,
'masking': 2,
'query': 0,
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('/42', got.path)
self.assertEqual('http://foo.42', got.target)

Loading…
Cancel
Save