cf53 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cf53-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: cf53
3
+ Version: 0.1.0
4
+ Summary: Cloudflare DNS client for humans
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: click>=8.3.1
8
+ Requires-Dist: cloudflare>=4.3.1
9
+ Requires-Dist: rich>=14.2.0
10
+
11
+ # cf53
12
+
13
+ A human-oriented cloudflare DNS updater. This provide a way to IaC cloudflare DNS records with a simple toml file, and get a good visual feedback of the diffs to make it actual.
14
+
15
+ Think terraform, but smarter. No need to keep a local state or copy/paste ids around, just say what records you want, provide an API token, that's it.
@@ -0,0 +1,17 @@
1
+ from . import local, record
2
+ from .diff import Diff
3
+ from .local import load_zones_dir
4
+ from .main import main
5
+ from .record import Record, RecordType
6
+ from .remote import load_remote_zones
7
+
8
+ __all__ = [
9
+ "Diff",
10
+ "Record",
11
+ "RecordType",
12
+ "load_remote_zones",
13
+ "load_zones_dir",
14
+ "local",
15
+ "main",
16
+ "record",
17
+ ]
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .main import main
4
+
5
+ main()
@@ -0,0 +1,3 @@
1
+ from rich.console import Console
2
+
3
+ console = Console()
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Self
5
+
6
+ from .console import console
7
+ from .record import Record, RecordType
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Update:
12
+ initial: Record
13
+ desired: Record
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Diff:
18
+ domain: str
19
+ zone_id: str
20
+ to_create: set[Record]
21
+ to_update: set[Update]
22
+ to_delete: set[Record]
23
+ in_sync: set[Record]
24
+
25
+ @classmethod
26
+ def from_zones(
27
+ cls,
28
+ *,
29
+ domain: str,
30
+ zone_id: str,
31
+ local_zone: set[Record],
32
+ remote_zone: set[Record],
33
+ ) -> Self:
34
+ to_create = set()
35
+ to_update = set()
36
+ to_delete = set()
37
+ in_sync = set()
38
+
39
+ # lookup to match remote records semantically identical to local records, where
40
+ # records match in this way we can do an update rather than create/delete
41
+ unmatched_remote_records: dict[
42
+ tuple[RecordType, str, str, int | None], Record
43
+ ] = {_semantic_key(r): r for r in remote_zone}
44
+
45
+ # local records must be created or matched to a remote record to update in-place
46
+ for local_record in local_zone:
47
+ key = _semantic_key(local_record)
48
+
49
+ if matching_remote_record := unmatched_remote_records.get(key):
50
+ del unmatched_remote_records[key]
51
+
52
+ if local_record == matching_remote_record:
53
+ in_sync.add(matching_remote_record)
54
+ else:
55
+ to_update.add(
56
+ Update(
57
+ initial=matching_remote_record,
58
+ desired=local_record,
59
+ )
60
+ )
61
+ else:
62
+ to_create.add(local_record)
63
+
64
+ to_delete = set(unmatched_remote_records.values())
65
+
66
+ return cls(
67
+ domain=domain,
68
+ zone_id=zone_id,
69
+ to_create=to_create,
70
+ to_update=to_update,
71
+ to_delete=to_delete,
72
+ in_sync=in_sync,
73
+ )
74
+
75
+ def print(self) -> None:
76
+ if not self:
77
+ console.print(f"✓ {self.domain}", style="dim white")
78
+ return
79
+
80
+ console.print(f"~ {self.domain}", style="bold blue")
81
+ for r in self.to_create:
82
+ self._print_record(r, icon="+", style="bold green")
83
+ for update in self.to_update:
84
+ r = update.desired
85
+ self._print_record(r, icon="~", style="bold yellow")
86
+ for r in self.to_delete:
87
+ self._print_record(r, icon="-", style="bold red")
88
+
89
+ def _print_record(self, r: Record, icon: str, style: str):
90
+ proxied = " proxied"
91
+ if not r.proxied:
92
+ proxied = "not proxied"
93
+ priority = ""
94
+ if r.type == "mx":
95
+ priority = f"{r.priority:4} "
96
+ ttl = int(r.ttl)
97
+ if ttl == 1:
98
+ ttl = "auto"
99
+ console.print(f" {icon} {r.type.upper():5} {r.name}", style=style)
100
+ console.print(f' {proxied} | {ttl:4} | "{r.comment}"')
101
+ console.print(f" {priority}{r.content}", style="italic")
102
+
103
+ def __bool__(self) -> bool:
104
+ return any((self.to_create, self.to_delete, self.to_update))
105
+
106
+
107
+ def _semantic_key(record: Record) -> tuple[RecordType, str, str, int | None]:
108
+ return (record.type, record.name, record.content, record.priority)
@@ -0,0 +1,51 @@
1
+ from collections.abc import Iterable
2
+ from pathlib import Path
3
+ import tomllib
4
+
5
+ from .record import Record, RecordType
6
+
7
+
8
+ def load_zones_dir(
9
+ *,
10
+ zones_dir: Path,
11
+ default_comment: str = "",
12
+ only_domains: Iterable[str] | None = None,
13
+ ) -> dict[str, set[Record]]:
14
+ """Map of domains to records in <zones_dir>/<domain>.toml"""
15
+ zones: dict[str, set[Record]] = {}
16
+ only_domains = set(only_domains or set())
17
+
18
+ for toml_file in zones_dir.glob("*.toml"):
19
+ domain = toml_file.stem
20
+
21
+ if only_domains and domain not in only_domains:
22
+ continue
23
+
24
+ with open(toml_file, "rb") as f:
25
+ toml = tomllib.load(f)
26
+
27
+ zones[toml_file.stem] = {
28
+ Record(
29
+ type=record_type,
30
+ name=_normalize_name(record["name"], domain),
31
+ content=f'"{record["content"]}"'
32
+ if record_type == "txt"
33
+ else record["content"],
34
+ ttl=record.get("ttl", 1), # 1=auto
35
+ proxied=record.get("proxied", False),
36
+ comment=record.get("comment", default_comment),
37
+ priority=record.get("priority") if record_type == "mx" else None,
38
+ )
39
+ for record_type in RecordType
40
+ for record in toml.get(record_type.value, [])
41
+ }
42
+
43
+ return zones
44
+
45
+
46
+ def _normalize_name(toml_name: str, domain: str) -> str:
47
+ if toml_name == "@":
48
+ return domain
49
+ if not toml_name.endswith(domain):
50
+ return f"{toml_name}.{domain}"
51
+ return toml_name
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from cloudflare import AsyncCloudflare
9
+
10
+ from .console import console
11
+ from .diff import Diff
12
+ from .local import load_zones_dir
13
+ from .reconcile import reconcile_diffs
14
+ from .remote import load_remote_zones
15
+
16
+
17
+ @click.command()
18
+ @click.argument("zones_dir", type=click.Path(exists=True, file_okay=False))
19
+ @click.option(
20
+ "--domain",
21
+ "domains",
22
+ multiple=True,
23
+ help="Only process these domains (can be specified multiple times)",
24
+ )
25
+ @click.option(
26
+ "--default-comment",
27
+ "default_comment",
28
+ help="Comment to append to any record not explicitly commented",
29
+ )
30
+ def main(zones_dir: str, domains: tuple[str, ...], default_comment: str = "") -> None:
31
+ zones_dir_path = Path(zones_dir)
32
+ client = _client()
33
+
34
+ with console.status("fetching zones..."):
35
+ local_zones = load_zones_dir(
36
+ zones_dir=zones_dir_path,
37
+ default_comment=default_comment,
38
+ only_domains=domains,
39
+ )
40
+ remote_zones = asyncio.run(
41
+ load_remote_zones(client=client, domains=set(local_zones.keys()))
42
+ )
43
+
44
+ diffs = [
45
+ Diff.from_zones(
46
+ domain=domain,
47
+ zone_id=zone_id,
48
+ local_zone=local_zones[domain],
49
+ remote_zone=remote_zone,
50
+ )
51
+ for (domain, zone_id), remote_zone in remote_zones.items()
52
+ ]
53
+
54
+ for diff in diffs:
55
+ diff.print()
56
+
57
+ if any(diffs) and _user_confirmation():
58
+ reconcile_diffs(diffs, client)
59
+
60
+
61
+ def _user_confirmation() -> bool:
62
+ while True:
63
+ response = input("Apply these changes? [y/N] ").strip().lower()
64
+ if response in ("y", "yes"):
65
+ return True
66
+ elif response in ("n", "no", ""):
67
+ return False
68
+
69
+
70
+ def _client() -> AsyncCloudflare:
71
+ if not (api_token := os.environ.get("CLOUDFLARE_API_TOKEN")):
72
+ raise RuntimeError("CLOUDFLARE_API_TOKEN must be defined")
73
+ return AsyncCloudflare(api_token=api_token)
@@ -0,0 +1,68 @@
1
+ import asyncio
2
+ from collections.abc import Iterable
3
+
4
+ from cloudflare import AsyncCloudflare
5
+
6
+ from .diff import Diff
7
+
8
+
9
+ async def _reconcile_diff(diff: Diff, client: AsyncCloudflare) -> None:
10
+ async with asyncio.TaskGroup() as tg:
11
+ tg.create_task(_apply_creates_async(diff, client))
12
+ tg.create_task(_apply_updates_async(diff, client))
13
+ tg.create_task(_apply_deletes_async(diff, client))
14
+
15
+
16
+ def reconcile_diffs(diffs: Iterable[Diff], client: AsyncCloudflare) -> None:
17
+ async def main():
18
+ async with asyncio.TaskGroup() as tg:
19
+ for diff in diffs:
20
+ tg.create_task(_reconcile_diff(diff, client))
21
+
22
+ asyncio.run(main())
23
+
24
+
25
+ async def _apply_creates_async(diff: Diff, client: AsyncCloudflare) -> None:
26
+ for record in diff.to_create:
27
+ record_data = {
28
+ "type": record.type.value,
29
+ "name": record.name,
30
+ "content": record.content,
31
+ "ttl": record.ttl,
32
+ "proxied": record.proxied,
33
+ "comment": record.comment,
34
+ }
35
+ if record.priority is not None:
36
+ record_data["priority"] = record.priority
37
+
38
+ await client.dns.records.create(zone_id=diff.zone_id, **record_data) # type: ignore[no-matching-overload]
39
+
40
+
41
+ async def _apply_updates_async(diff: Diff, client: AsyncCloudflare) -> None:
42
+ for update in diff.to_update:
43
+ record_data = {
44
+ "type": update.desired.type.value,
45
+ "name": update.desired.name,
46
+ "content": update.desired.content,
47
+ "ttl": update.desired.ttl,
48
+ "proxied": update.desired.proxied,
49
+ "comment": update.desired.comment,
50
+ }
51
+
52
+ if update.desired.priority is not None:
53
+ record_data["priority"] = update.desired.priority
54
+
55
+ await client.dns.records.update( # type: ignore[no-matching-overload]
56
+ update.initial.cloudflare_id,
57
+ zone_id=diff.zone_id,
58
+ **record_data,
59
+ )
60
+
61
+
62
+ async def _apply_deletes_async(diff: Diff, client: AsyncCloudflare) -> None:
63
+ for record in diff.to_delete:
64
+ assert record.cloudflare_id
65
+ await client.dns.records.delete(
66
+ record.cloudflare_id,
67
+ zone_id=diff.zone_id,
68
+ )
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum, auto
5
+ from typing import Any
6
+
7
+
8
+ class RecordType(StrEnum):
9
+ a = auto()
10
+ aaaa = auto()
11
+ mx = auto()
12
+ txt = auto()
13
+ cname = auto()
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Record:
18
+ type: RecordType
19
+ name: str
20
+ content: str
21
+ ttl: int
22
+ proxied: bool
23
+ comment: str
24
+ priority: int | None = None
25
+ cloudflare_id: str | None = None
26
+
27
+ def __post_init__(self) -> None:
28
+ if self.priority and not self.type == "mx":
29
+ raise ValueError("priority can only be set for mx records")
30
+
31
+ def __eq__(self, other: Any) -> bool:
32
+ """Equality should ignore a `None` cloudflare_id."""
33
+ if not isinstance(other, Record):
34
+ return False
35
+ return (
36
+ self.type == other.type
37
+ and self.name == other.name
38
+ and self.content == other.content
39
+ and self.ttl == other.ttl
40
+ and self.proxied == other.proxied
41
+ and self.comment == other.comment
42
+ and self.priority == other.priority
43
+ and (
44
+ self.cloudflare_id == other.cloudflare_id
45
+ or self.cloudflare_id is None
46
+ or other.cloudflare_id is None
47
+ )
48
+ )
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from cloudflare import AsyncCloudflare
6
+
7
+ from .record import Record, RecordType
8
+
9
+
10
+ async def load_remote_zones(
11
+ client: AsyncCloudflare, domains: set[str]
12
+ ) -> dict[tuple[str, str], set[Record]]:
13
+ """Map of (domain, zone_id) to records in cloudflare"""
14
+ domain_to_zone_id = {
15
+ zone.name: zone.id
16
+ for zone in _unpage(await client.zones.list())
17
+ if zone.name in domains
18
+ }
19
+
20
+ tasks = []
21
+ async with asyncio.TaskGroup() as tg:
22
+ for domain, zone_id in domain_to_zone_id.items():
23
+ task = tg.create_task(_load_remote_zone(client, domain, zone_id))
24
+ tasks.append(task)
25
+
26
+ remote_zones: dict[tuple[str, str], set[Record]] = {}
27
+ for task in tasks:
28
+ domain, records = task.result()
29
+ remote_zones[(domain, domain_to_zone_id[domain])] = records
30
+
31
+ return remote_zones
32
+
33
+
34
+ async def _load_remote_zone(
35
+ client, domain: str, zone_id: str
36
+ ) -> tuple[str, set[Record]]:
37
+ records_page = await client.dns.records.list(zone_id=zone_id)
38
+ return (
39
+ domain,
40
+ {
41
+ Record(
42
+ type=RecordType(record.type.lower()),
43
+ name=record.name,
44
+ content=record.content,
45
+ ttl=record.ttl,
46
+ proxied=record.proxied,
47
+ comment=record.comment,
48
+ priority=int(record.priority) if hasattr(record, "priority") else None,
49
+ cloudflare_id=record.id,
50
+ )
51
+ for record in _unpage(records_page)
52
+ },
53
+ )
54
+
55
+
56
+ def _unpage(page):
57
+ for item in page:
58
+ values_type, values = item
59
+ if values_type != "result":
60
+ continue
61
+ yield from values
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: cf53
3
+ Version: 0.1.0
4
+ Summary: Cloudflare DNS client for humans
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: click>=8.3.1
8
+ Requires-Dist: cloudflare>=4.3.1
9
+ Requires-Dist: rich>=14.2.0
10
+
11
+ # cf53
12
+
13
+ A human-oriented cloudflare DNS updater. This provide a way to IaC cloudflare DNS records with a simple toml file, and get a good visual feedback of the diffs to make it actual.
14
+
15
+ Think terraform, but smarter. No need to keep a local state or copy/paste ids around, just say what records you want, provide an API token, that's it.
@@ -0,0 +1,18 @@
1
+ pyproject.toml
2
+ readme.md
3
+ cf53/__init__.py
4
+ cf53/__main__.py
5
+ cf53/console.py
6
+ cf53/diff.py
7
+ cf53/local.py
8
+ cf53/main.py
9
+ cf53/reconcile.py
10
+ cf53/record.py
11
+ cf53/remote.py
12
+ cf53.egg-info/PKG-INFO
13
+ cf53.egg-info/SOURCES.txt
14
+ cf53.egg-info/dependency_links.txt
15
+ cf53.egg-info/requires.txt
16
+ cf53.egg-info/top_level.txt
17
+ tests/test_diff.py
18
+ tests/test_local.py
@@ -0,0 +1,3 @@
1
+ click>=8.3.1
2
+ cloudflare>=4.3.1
3
+ rich>=14.2.0
@@ -0,0 +1 @@
1
+ cf53
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "cf53"
3
+ version = "0.1.0"
4
+ description = "Cloudflare DNS client for humans"
5
+ readme = "readme.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "click>=8.3.1",
9
+ "cloudflare>=4.3.1",
10
+ "rich>=14.2.0",
11
+ ]
12
+
13
+ [dependency-groups]
14
+ dev = [
15
+ "pyrefly>=0.45.2",
16
+ "pytest>=9.0.2",
17
+ "ruff>=0.14.9",
18
+ ]
19
+
20
+ [tool.ruff]
21
+ line-length = 88
22
+ src = ["cf53"]
23
+
24
+ [tool.ruff.lint]
25
+ select = ["E", "F", "I", "UP", "B", "C4", "T20", "RUF"]
26
+ ignore = []
27
+
28
+ [tool.ruff.lint.isort]
29
+ force-sort-within-sections = true
30
+ force-to-top = ["from __future__ import annotations"]
cf53-0.1.0/readme.md ADDED
@@ -0,0 +1,5 @@
1
+ # cf53
2
+
3
+ A human-oriented cloudflare DNS updater. This provide a way to IaC cloudflare DNS records with a simple toml file, and get a good visual feedback of the diffs to make it actual.
4
+
5
+ Think terraform, but smarter. No need to keep a local state or copy/paste ids around, just say what records you want, provide an API token, that's it.
cf53-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,134 @@
1
+ from copy import replace
2
+
3
+ from cf53.diff import Diff
4
+ from cf53.record import Record, RecordType
5
+
6
+
7
+ def test_trivial_add():
8
+ local_records = {
9
+ Record(
10
+ type=RecordType.a,
11
+ name="example.com",
12
+ content="1.2.3.4",
13
+ ttl=3600,
14
+ proxied=False,
15
+ comment="test record",
16
+ )
17
+ }
18
+ remote_records = set()
19
+
20
+ diff = Diff.from_zones(
21
+ domain="example.com",
22
+ zone_id="test_zone_id",
23
+ local_zone=local_records,
24
+ remote_zone=remote_records,
25
+ )
26
+
27
+ assert len(diff.to_create) == 1
28
+ assert len(diff.to_update) == 0
29
+ assert len(diff.to_delete) == 0
30
+ assert len(diff.in_sync) == 0
31
+ created_record = next(iter(diff.to_create))
32
+ assert created_record.type == RecordType.a
33
+ assert created_record.name == "example.com"
34
+ assert created_record.content == "1.2.3.4"
35
+ assert created_record.ttl == 3600
36
+ assert created_record.proxied is False
37
+ assert created_record.comment == "test record"
38
+
39
+
40
+ def test_trivial_delete():
41
+ local_records = set()
42
+ remote_records = {
43
+ Record(
44
+ type=RecordType.a,
45
+ name="example.com",
46
+ content="1.2.3.4",
47
+ ttl=3600,
48
+ proxied=False,
49
+ comment="test record",
50
+ cloudflare_id="foo",
51
+ )
52
+ }
53
+
54
+ diff = Diff.from_zones(
55
+ domain="example.com",
56
+ zone_id="test_zone_id",
57
+ local_zone=local_records,
58
+ remote_zone=remote_records,
59
+ )
60
+
61
+ assert len(diff.to_create) == 0
62
+ assert len(diff.to_update) == 0
63
+ assert len(diff.to_delete) == 1
64
+ assert len(diff.in_sync) == 0
65
+ delete_record = next(iter(diff.to_delete))
66
+ assert delete_record.type == RecordType.a
67
+ assert delete_record.name == "example.com"
68
+ assert delete_record.content == "1.2.3.4"
69
+ assert delete_record.ttl == 3600
70
+ assert delete_record.proxied is False
71
+ assert delete_record.comment == "test record"
72
+
73
+
74
+ def test_trivial_match_in_sync():
75
+ matching_record = Record(
76
+ type=RecordType.a,
77
+ name="example.com",
78
+ content="1.2.3.4",
79
+ ttl=3600,
80
+ proxied=False,
81
+ comment="test record",
82
+ )
83
+ with_id = replace(
84
+ matching_record, # type: ignore[bad-argument-type]
85
+ cloudflare_id="foo",
86
+ )
87
+
88
+ local_records = {matching_record}
89
+ remote_records = {with_id}
90
+
91
+ diff = Diff.from_zones(
92
+ domain="example.com",
93
+ zone_id="test_zone_id",
94
+ local_zone=local_records,
95
+ remote_zone=remote_records,
96
+ )
97
+
98
+ assert len(diff.to_create) == 0
99
+ assert len(diff.to_update) == 0
100
+ assert len(diff.to_delete) == 0
101
+ assert len(diff.in_sync) == 1
102
+ synced_record = next(iter(diff.in_sync))
103
+ assert synced_record == with_id
104
+
105
+
106
+ def test_trivial_match_to_update():
107
+ local_record = Record(
108
+ type=RecordType.a,
109
+ name="example.com",
110
+ content="1.2.3.4",
111
+ ttl=3600,
112
+ proxied=False,
113
+ comment="test record",
114
+ )
115
+ remote_record = replace(
116
+ local_record, # type: ignore[bad-argument-type]
117
+ cloudflare_id="foo",
118
+ comment="something else",
119
+ )
120
+
121
+ diff = Diff.from_zones(
122
+ domain="example.com",
123
+ zone_id="test_zone_id",
124
+ local_zone={local_record},
125
+ remote_zone={remote_record},
126
+ )
127
+
128
+ assert len(diff.to_create) == 0
129
+ assert len(diff.to_update) == 1
130
+ assert len(diff.to_delete) == 0
131
+ assert len(diff.in_sync) == 0
132
+ update = next(iter(diff.to_update))
133
+ assert update.initial == remote_record
134
+ assert update.desired == local_record
@@ -0,0 +1,219 @@
1
+ from cf53 import Record, RecordType, load_zones_dir
2
+
3
+ single_record_content = """
4
+ [[a]]
5
+ name = "example.com"
6
+ content = "127.0.0.1"
7
+ """
8
+
9
+ single_record_expected = {
10
+ Record(
11
+ type=RecordType.a,
12
+ name="example.com",
13
+ content="127.0.0.1",
14
+ ttl=1,
15
+ proxied=False,
16
+ comment="",
17
+ )
18
+ }
19
+
20
+
21
+ many_records_content = """
22
+ [[a]]
23
+ name = "example.net"
24
+ content = "192.168.1.1"
25
+ ttl=500
26
+
27
+ [[a]]
28
+ name = "foo.example.net"
29
+ content = "192.168.1.1"
30
+ proxied = true
31
+ comment = "for foo"
32
+
33
+ [[aaaa]]
34
+ name = "example.net"
35
+ content = "2001:db8::1"
36
+
37
+ [[cname]]
38
+ name = "www"
39
+ content = "example.net"
40
+
41
+ [[txt]]
42
+ name = "example.net"
43
+ content = "v=spf1 include:_spf.google.com ~all"
44
+
45
+ [[mx]]
46
+ name = "example.net"
47
+ content = "mail.example.net"
48
+ priority = 10
49
+ """
50
+
51
+ many_records_expected = {
52
+ Record(
53
+ type=RecordType.a,
54
+ name="example.net",
55
+ content="192.168.1.1",
56
+ ttl=500,
57
+ proxied=False,
58
+ comment="",
59
+ ),
60
+ Record(
61
+ type=RecordType.a,
62
+ name="foo.example.net",
63
+ content="192.168.1.1",
64
+ ttl=1,
65
+ proxied=True,
66
+ comment="for foo",
67
+ ),
68
+ Record(
69
+ type=RecordType.aaaa,
70
+ name="example.net",
71
+ content="2001:db8::1",
72
+ ttl=1,
73
+ proxied=False,
74
+ comment="",
75
+ ),
76
+ Record(
77
+ type=RecordType.cname,
78
+ name="www.example.net",
79
+ content="example.net",
80
+ ttl=1,
81
+ proxied=False,
82
+ comment="",
83
+ ),
84
+ Record(
85
+ type=RecordType.txt,
86
+ name="example.net",
87
+ content='"v=spf1 include:_spf.google.com ~all"',
88
+ ttl=1,
89
+ proxied=False,
90
+ comment="",
91
+ ),
92
+ Record(
93
+ type=RecordType.mx,
94
+ name="example.net",
95
+ content="mail.example.net",
96
+ ttl=1,
97
+ proxied=False,
98
+ comment="",
99
+ priority=10,
100
+ ),
101
+ }
102
+
103
+ denormalized_name_records = """
104
+ [[a]]
105
+ name = "@"
106
+ content = "127.0.0.1"
107
+
108
+ [[txt]]
109
+ name = "_dmarc"
110
+ content = "v=DMARC1; p=quarantine;"
111
+ """
112
+
113
+ denormalized_name_expected = {
114
+ Record(
115
+ type=RecordType.a,
116
+ name="example.org",
117
+ content="127.0.0.1",
118
+ ttl=1,
119
+ proxied=False,
120
+ comment="",
121
+ ),
122
+ Record(
123
+ type=RecordType.txt,
124
+ name="_dmarc.example.org",
125
+ content='"v=DMARC1; p=quarantine;"',
126
+ ttl=1,
127
+ proxied=False,
128
+ comment="",
129
+ ),
130
+ }
131
+
132
+ default_comment_records = """
133
+ [[a]]
134
+ name = "example.ca"
135
+ content = "1.2.3.4"
136
+ comment = "domain apex"
137
+ [[a]]
138
+ name = "foo.example.ca"
139
+ content = "1.2.3.4"
140
+ """
141
+
142
+ default_comment_expected = {
143
+ Record(
144
+ type=RecordType.a,
145
+ name="example.ca",
146
+ content="1.2.3.4",
147
+ ttl=1,
148
+ proxied=False,
149
+ comment="domain apex",
150
+ ),
151
+ Record(
152
+ type=RecordType.a,
153
+ name="foo.example.ca",
154
+ content="1.2.3.4",
155
+ ttl=1,
156
+ proxied=False,
157
+ comment="default comment",
158
+ ),
159
+ }
160
+
161
+
162
+ def test_empty(build_zones_dir):
163
+ zones_dir = build_zones_dir({"example.com.toml": ""})
164
+ zones = load_zones_dir(zones_dir=zones_dir)
165
+ assert zones == {"example.com": set()}
166
+
167
+
168
+ def test_single_record(build_zones_dir):
169
+ zones_dir = build_zones_dir({"example.com.toml": single_record_content})
170
+ zones = load_zones_dir(zones_dir=zones_dir)
171
+ assert zones == {"example.com": single_record_expected}
172
+
173
+
174
+ def test_many_records(build_zones_dir):
175
+ zones_dir = build_zones_dir({"example.net.toml": many_records_content})
176
+ zones = load_zones_dir(zones_dir=zones_dir)
177
+ assert zones == {"example.net": many_records_expected}
178
+
179
+
180
+ def test_denormalized_names(build_zones_dir):
181
+ zones_dir = build_zones_dir({"example.org.toml": denormalized_name_records})
182
+ zones = load_zones_dir(zones_dir=zones_dir)
183
+ assert zones == {"example.org": denormalized_name_expected}
184
+
185
+
186
+ def test_many_zone_files(build_zones_dir):
187
+ zones_dir = build_zones_dir(
188
+ {
189
+ "example.com.toml": single_record_content,
190
+ "example.net.toml": many_records_content,
191
+ "example.org.toml": denormalized_name_records,
192
+ }
193
+ )
194
+ zones = load_zones_dir(zones_dir=zones_dir)
195
+ assert zones == {
196
+ "example.com": single_record_expected,
197
+ "example.net": many_records_expected,
198
+ "example.org": denormalized_name_expected,
199
+ }
200
+
201
+
202
+ def test_default_comment(build_zones_dir):
203
+ zones_dir = build_zones_dir({"example.ca.toml": default_comment_records})
204
+ zones = load_zones_dir(zones_dir=zones_dir, default_comment="default comment")
205
+ assert zones == {"example.ca": default_comment_expected}
206
+
207
+
208
+ def test_domain_seletion(build_zones_dir):
209
+ zones_dir = build_zones_dir(
210
+ {
211
+ "example.net.toml": many_records_content,
212
+ "example.ca.toml": default_comment_records,
213
+ }
214
+ )
215
+ zones = load_zones_dir(
216
+ zones_dir=zones_dir,
217
+ only_domains={"example.net"},
218
+ )
219
+ assert zones == {"example.net": many_records_expected}