From e96948080f84c679f6eab2a8cfd9f78fa1d56530 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Thu, 19 Mar 2026 00:28:39 +0100 Subject: [PATCH] Revamp zone sync and add tests --- README.md | 2 +- mdb/management/commands/zone-sync.py | 180 -------------------------- mdb/management/commands/zone_sync.py | 91 +++++++++++++ mdb/services/__init__.py | 0 mdb/services/zone_sync.py | 170 ++++++++++++++++++++++++ pyproject.toml | 9 +- tests/__init__.py | 0 tests/conftest.py | 64 +++++++++ tests/test_zone_sync.py | 185 +++++++++++++++++++++++++++ tests/test_zone_sync_command.py | 109 ++++++++++++++++ uv.lock | 166 +++++++++++++++++++++++- 11 files changed, 792 insertions(+), 184 deletions(-) delete mode 100644 mdb/management/commands/zone-sync.py create mode 100644 mdb/management/commands/zone_sync.py create mode 100644 mdb/services/__init__.py create mode 100644 mdb/services/zone_sync.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_zone_sync.py create mode 100644 tests/test_zone_sync_command.py diff --git a/README.md b/README.md index d6956ad..b16d968 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ A Django app for managing computer equipment. ## Installation - sudo apt install libmysqlclient-dev python-dev libldap2-dev libsasl2-dev bind9utils + sudo apt install libmysqlclient-dev python-dev libldap2-dev libsasl2-dev bind9-utils virtualenv venv . venv/bin/activate pip install -r requirements.txt diff --git a/mdb/management/commands/zone-sync.py b/mdb/management/commands/zone-sync.py deleted file mode 100644 index ee88e8d..0000000 --- a/mdb/management/commands/zone-sync.py +++ /dev/null @@ -1,180 +0,0 @@ -import os -import sys -from itertools import chain -from difflib import context_diff -from django.core.management.base import BaseCommand -from django.core.mail import mail_admins -from subprocess import getstatusoutput - -from mdb.models import Domain, Ip4Subnet, Ip6Subnet - - -class Command(BaseCommand): - args = '[--force] [--debug]' - help = 'Generates the bind configuration' - - rndc_bin = "/usr/sbin/rndc" - reload_command = "%s reload" % rndc_bin - freeze_command = "%s freeze" % rndc_bin - thaw_command = "%s thaw" % rndc_bin - commands_in_order = [freeze_command, reload_command, thaw_command] - checkzone_bin = "/usr/sbin/named-checkzone" - checkzone_dir = "/tmp/mdb-checkzone" - debug = False - force = False - debug_dir = '/tmp/mdb-debug' - changes = {} - - def add_arguments(self, parser): - parser.add_argument('--debug', - action='store_true', - dest='debug', - default=False, - help='Debug mode. Writes to /tmp and does not reload.') - parser.add_argument('--force', - action='store_true', - dest='force', - default=False, - help='Write zone files even if serial is unchanged.') - - def handle(self, *args, **options): - self.debug = options['debug'] - self.force = options['force'] - - # do the binaries exist? - self.validate_binaries_exist() - - # create directories if needed - os.path.isdir(self.checkzone_dir) or os.mkdir(self.checkzone_dir) - self.debug and (os.path.isdir(self.debug_dir) or os.mkdir(self.debug_dir)) - - # update zones with new serials - self.update_zone_serials() - - # join errors, diffs and zone names - errors = "\n\n\n".join( - [v['errors'] for k, v in self.changes.items() if v['errors']]) - diffs = "\n\n\n".join( - [v['diff'] for k, v in self.changes.items() if v['diff']]) - zones = ", ".join(self.changes.keys()) - - # reload and send email with changes - if self.changes and not self.debug: - sys.stdout.write("reloading zones... ") - for command in self.commands_in_order: - status, output = getstatusoutput(command) - if status != 0: - sys.stdout.write("fail\n") - message = ( - "Failed to reload zones, please inspect...\n\n" + - "%s@%s$ %s" % ( - os.getlogin(), os.uname()[1], command) + - "\n%s\n\n\n%s\n\n\n%s" % (output, errors, diffs)) - mail_admins( - subject="FAIL: bind (%s changed: %s)" % (len(self.changes), zones), - message=message, - html_message='
%s
' % message) - sys.exit(1) - - sys.stdout.write("ok\n") - message = ( - "Successfully updated bind zone files\n\n" + - "%s\n\n%s" % (errors, diffs)) - mail_admins( - subject="Success: bind (%s changed: %s)" % (len(self.changes), zones), - message=message, - html_message='
%s
' % message) - sys.exit(0) - - def update_zone_serials(self): - for zone in chain(Domain.objects.all(), - Ip4Subnet.objects.all(), - Ip6Subnet.objects.all()): - if zone.domain_serial == zone.domain_active_serial and not self.force: - continue - - if self.update_zone(zone): - self.changes[zone.domain_name]['success'] = True - - def validate_binaries_exist(self): - for f in (self.checkzone_bin, self.rndc_bin): - if not os.path.isfile(f) and not self.debug: - print("ERROR: no such file %s, exiting..." % f) - if not self.debug: - sys.exit(1) - - def update_zone(self, zone): - zonetype = getattr(zone._meta, 'verbose_name', None) or zone._meta.model_name - - self.changes[zone.domain_name] = { - 'old_serial': zone.domain_active_serial, - 'new_serial': zone.domain_serial, - 'success': False, - 'errors': None, - 'diff': None, - } - - print("updating %s %s [%d -> %d]" % ( - zonetype, - zone.domain_name, - zone.domain_active_serial, - zone.domain_serial)) - - # generate zone file contents - new = zone.zone_file_contents() - - # read current file contents - try: - with open(zone.domain_filename, 'r') as f: - old = f.read() - except IOError: - old = '' - - self.changes[zone.domain_name]['diff'] = self.calculate_diff(new, old, zone) - - # validate with zonecheck - sys.stdout.write("\t- validating... ") - status, output = self.check_zone(zone, new) - if status != 0: - sys.stdout.write("fail\n") - self.changes[zone.domain_name]['errors'] = output - return False - - sys.stdout.write("ok\n") - - # write to zone file - sys.stdout.write("\t- writing zone file... ") - - if not self.debug: - zonefile = zone.domain_filename - else: - zonefile = self.debug_dir + zone.domain_filename - path = "/".join(zonefile.split('/')[:-1]) - os.path.isdir(path) or os.makedirs(path) - - with open(zonefile, 'w') as f: - f.write(new) - - # update serial - if not self.debug: - zone.domain_active_serial = zone.domain_serial - zone.save() - - sys.stdout.write("ok\n") - self.changes[zone.domain_name]['success'] = True - return True - - def calculate_diff(self, new, old, zone): - return "\n".join(context_diff( - a=old.splitlines(), - b=new.splitlines(), - fromfile=str(zone.domain_active_serial) + '-' + zone.domain_name, - tofile=str(zone.domain_serial) + '-' + zone.domain_name, - lineterm='')) - - def check_zone(self, zone, contents): - zonefile = self.checkzone_dir + '/' + zone.domain_name - with open(zonefile, 'w') as f: - f.write(contents) - - return getstatusoutput("%s %s %s" % (self.checkzone_bin, zone.domain_name, zonefile)) diff --git a/mdb/management/commands/zone_sync.py b/mdb/management/commands/zone_sync.py new file mode 100644 index 0000000..79b9192 --- /dev/null +++ b/mdb/management/commands/zone_sync.py @@ -0,0 +1,91 @@ +import os + +from django.core.mail import mail_admins +from django.core.management.base import BaseCommand + +from mdb.services.zone_sync import ZoneSyncService + + +class Command(BaseCommand): + args = "[--force] [--debug]" + help = "Generates the bind configuration" + + def add_arguments(self, parser): + parser.add_argument( + "--debug", + action="store_true", + dest="debug", + default=False, + help="Debug mode. Writes to /tmp and does not reload.", + ) + parser.add_argument( + "--force", + action="store_true", + dest="force", + default=False, + help="Write zone files even if serial is unchanged.", + ) + parser.add_argument( + "--zone", + dest="zone", + default=None, + metavar="ZONE", + help="Sync a single zone by name instead of all zones.", + ) + + def handle(self, *args, **options): + debug = options["debug"] + force = options["force"] + zone_name = options["zone"] + + service = ZoneSyncService(debug=debug, force=force) + + service.validate_binaries_exist() + + os.path.isdir(service.checkzone_dir) or os.mkdir(service.checkzone_dir) + debug and (os.path.isdir(service.debug_dir) or os.mkdir(service.debug_dir)) + + if zone_name: + zone = service.find_zone(zone_name) + if zone is None: + self.stderr.write("error: zone '%s' not found\n" % zone_name) + raise SystemExit(1) + service.sync_zone(zone) + else: + service.sync_all_zones() + + if service.changes and not debug: + errors = "\n\n\n".join( + [v["errors"] for v in service.changes.values() if v["errors"]] + ) + diffs = "\n\n\n".join( + [v["diff"] for v in service.changes.values() if v["diff"]] + ) + zones = ", ".join(service.changes.keys()) + self.reload_and_notify(service, errors, diffs, zones) + + def reload_and_notify(self, service, errors, diffs, zones): + self.stdout.write("reloading zones... ") + success, failed_command, output = service.run_reload_commands() + + if not success: + self.stdout.write("fail\n") + message = ( + "Failed to reload zones, please inspect...\n\n" + + "%s@%s$ %s" % (os.getlogin(), os.uname()[1], failed_command) + + "\n%s\n\n\n%s\n\n\n%s" % (output, errors, diffs) + ) + mail_admins( + subject="FAIL: bind (%s changed: %s)" % (len(service.changes), zones), + message=message, + html_message="
%s
" % message, + ) + raise SystemExit(1) + + self.stdout.write("ok\n") + message = "Successfully updated bind zone files\n\n%s\n\n%s" % (errors, diffs) + mail_admins( + subject="Success: bind (%s changed: %s)" % (len(service.changes), zones), + message=message, + html_message="
%s
" % message, + ) diff --git a/mdb/services/__init__.py b/mdb/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mdb/services/zone_sync.py b/mdb/services/zone_sync.py new file mode 100644 index 0000000..318fc56 --- /dev/null +++ b/mdb/services/zone_sync.py @@ -0,0 +1,170 @@ +import os +import shutil +import sys +import tempfile +from difflib import context_diff +from itertools import chain +from subprocess import getstatusoutput + +from mdb.models import Domain, Ip4Subnet, Ip6Subnet + + +class ZoneSyncService: + rndc_bin = shutil.which("rndc") or "/usr/sbin/rndc" + reload_command = "%s reload" % rndc_bin + freeze_command = "%s freeze" % rndc_bin + thaw_command = "%s thaw" % rndc_bin + commands_in_order = [freeze_command, reload_command, thaw_command] + checkzone_bin = shutil.which("named-checkzone") or "/usr/sbin/named-checkzone" + compilezone_bin = shutil.which("named-compilezone") or "/usr/sbin/named-compilezone" + checkzone_dir = "/tmp/mdb-checkzone" + debug_dir = "/tmp/mdb-debug" + + def __init__(self, debug=False, force=False): + self.debug = debug + self.force = force + self.changes = {} + + def validate_binaries_exist(self): + for f in (self.checkzone_bin, self.compilezone_bin, self.rndc_bin): + if not os.path.isfile(f) and not self.debug: + print("ERROR: no such file %s, exiting..." % f) + sys.exit(1) + + def run_reload_commands(self): + """Execute freeze/reload/thaw in order. + + Returns (success, failed_command, output). On success, failed_command + and output are empty strings. + """ + for command in self.commands_in_order: + status, output = getstatusoutput(command) + if status != 0: + return False, command, output + return True, "", "" + + def find_zone(self, name): + for model in (Domain, Ip4Subnet, Ip6Subnet): + try: + return model.objects.get(domain_name=name) + except model.DoesNotExist: + pass + return None + + def sync_all_zones(self): + for zone in chain( + Domain.objects.all(), Ip4Subnet.objects.all(), Ip6Subnet.objects.all() + ): + if zone.domain_serial == zone.domain_active_serial and not self.force: + continue + if self.sync_zone(zone): + self.changes[zone.domain_name]["success"] = True + + def sync_zone(self, zone): + zonetype = getattr(zone._meta, "verbose_name", None) or zone._meta.model_name + + self.changes[zone.domain_name] = { + "old_serial": zone.domain_active_serial, + "new_serial": zone.domain_serial, + "success": False, + "errors": None, + "diff": None, + } + + print( + "updating %s %s [%d -> %d]" + % ( + zonetype, + zone.domain_name, + zone.domain_active_serial, + zone.domain_serial, + ) + ) + + # Normalize to BIND's canonical format so the on-disk file and our + # generated content always use the same format. + new = self.normalize_zone(zone, zone.zone_file_contents()) + + try: + with open(zone.domain_filename, "r") as f: + old = f.read() + except IOError: + old = "" + + self.changes[zone.domain_name]["diff"] = self.calculate_diff(new, old, zone) + + sys.stdout.write("\t- validating... ") + status, output = self.check_zone(zone, new) + if status != 0: + sys.stdout.write("fail\n") + self.changes[zone.domain_name]["errors"] = output + return False + + sys.stdout.write("ok\n") + sys.stdout.write("\t- writing zone file... ") + + if not self.debug: + zonefile = zone.domain_filename + else: + zonefile = self.debug_dir + zone.domain_filename + path = "/".join(zonefile.split("/")[:-1]) + os.path.isdir(path) or os.makedirs(path) + + with open(zonefile, "w") as f: + f.write(new) + + if not self.debug: + zone.domain_active_serial = zone.domain_serial + zone.save() + + sys.stdout.write("ok\n") + self.changes[zone.domain_name]["success"] = True + return True + + def normalize_zone(self, zone, contents): + """Convert zone file contents to BIND's canonical format. + + Uses named-compilezone to produce the same format that rndc freeze + writes, so subsequent freeze/thaw cycles won't reformat the file and + cause spurious diffs. Falls back to the original contents if + named-compilezone is unavailable or fails. + """ + if not os.path.isfile(self.compilezone_bin): + return contents + + with tempfile.NamedTemporaryFile(mode="w", suffix=".zone", delete=False) as f: + f.write(contents) + tmpfile = f.name + + try: + cmd = "%s -f text -F text -s relative -i none -o - %s %s" % ( + self.compilezone_bin, + zone.domain_name, + tmpfile, + ) + status, output = getstatusoutput(cmd + " 2>/dev/null") + if status == 0: + return output + return contents + finally: + os.unlink(tmpfile) + + def calculate_diff(self, new, old, zone): + return "\n".join( + context_diff( + a=old.splitlines(), + b=new.splitlines(), + fromfile=str(zone.domain_active_serial) + "-" + zone.domain_name, + tofile=str(zone.domain_serial) + "-" + zone.domain_name, + lineterm="", + ) + ) + + def check_zone(self, zone, contents): + zonefile = self.checkzone_dir + "/" + zone.domain_name + with open(zonefile, "w") as f: + f.write(contents) + + return getstatusoutput( + "%s %s %s" % (self.checkzone_bin, zone.domain_name, zonefile) + ) diff --git a/pyproject.toml b/pyproject.toml index 7092e5c..c1ab576 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Machine Database - A Django app for managing computer equipment" authors = [ { name = "EDB", email = "edb@neuf.no" }, ] -requires-python = "~=3.10" +requires-python = ">=3.10" license = "GPL-2.0-or-later" dependencies = [ "django>=5.2.1", @@ -13,6 +13,7 @@ dependencies = [ "django-extensions>=4.1", "djangorestframework>=3.16.0", "fabric>=2.7.1", + "freezegun>=1.5.5", "gunicorn>=23.0.0", "ipaddress>=1.0.23,<2.0.0", "mysqlclient>=2.0.3,<3.0.0", @@ -22,9 +23,15 @@ dependencies = [ [dependency-groups] dev = [ "fabric>=2.6.0,<3.0.0", + "pytest>=9.0.2", + "pytest-django>=4.12.0", "ruff>=0.11.9,<1.0.0", ] +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "mdbsite.settings" +python_files = ["tests/test_*.py"] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a1b65db --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,64 @@ +import pytest +from django.core.management import call_command + +from mdb.models import ( + Domain, + DomainAAAARecord, + DomainARecord, + DomainCnameRecord, + DomainTxtRecord, + MailExchange, + Nameserver, +) +from mdb.services.zone_sync import ZoneSyncService + + +@pytest.fixture +def domain(db): + call_command("loaddata", "test_data", verbosity=0) + return Domain.objects.get(domain_name="edb.technology") + + +@pytest.fixture +def service(tmp_path): + svc = ZoneSyncService(debug=True) + svc.checkzone_dir = str(tmp_path) + return svc + + +@pytest.fixture +def db_domain(db, tmp_path): + domain = Domain.objects.create( + domain_name="example.org", + domain_soa="ns1.example.org", + domain_admin="admin@example.org", + domain_ipaddr="1.2.3.4", + domain_ip6addr="2001:db8::1", + domain_filename=str(tmp_path / "db.example.org"), + domain_ttl=3600, + domain_serial=2024010101, + ) + ns1 = Nameserver.objects.create(hostname="ns1.example.org") + ns2 = Nameserver.objects.create(hostname="ns2.example.org") + domain.domain_nameservers.set([ns1, ns2]) + mx = MailExchange.objects.create(priority=10, hostname="mail.example.org") + domain.domain_mailexchanges.set([mx]) + DomainARecord.objects.create(domain=domain, name="ns1", target="1.2.3.4") + DomainARecord.objects.create(domain=domain, name="ns2", target="5.6.7.8") + DomainARecord.objects.create(domain=domain, name="mail", target="9.10.11.12") + DomainARecord.objects.create(domain=domain, name="www", target="1.2.3.4") + DomainAAAARecord.objects.create(domain=domain, name="www", target="2001:db8::1") + DomainCnameRecord.objects.create(domain=domain, name="ftp", target="www.example.org.") + DomainTxtRecord.objects.create(domain=domain, name="@", target='"v=spf1 mx ~all"') + return domain + + +def load_command_class(): + path = ( + pathlib.Path(__file__).parent.parent + / "mdb/management/commands/zone-sync.py" + ) + spec = importlib.util.spec_from_file_location("zone_sync_command", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.Command diff --git a/tests/test_zone_sync.py b/tests/test_zone_sync.py new file mode 100644 index 0000000..efe2c34 --- /dev/null +++ b/tests/test_zone_sync.py @@ -0,0 +1,185 @@ +import os +from unittest.mock import MagicMock + +import pytest +from freezegun import freeze_time + +from mdb.services.zone_sync import ZoneSyncService + +EXPECTED_ZONE_FILE_CONTENTS = """\ +; serial:2024010101 +; zone file for example.org +; 2024-01-01 00:00:00 +; filename: {filename} +$TTL 3600 +@ IN SOA ns1.example.org. admin.example.org. ( +\t2024010101\t; serial +\t28800\t; refresh +\t7200\t; retry +\t604800\t; expire +\t86400 )\t; minimum ttl +; +@\tIN\tNS\tns1.example.org. +@\tIN\tNS\tns2.example.org. +@\t\tMX\t10 mail.example.org. +@\tIN\tA\t1.2.3.4 +@\tIN\tAAAA\t2001:db8::1 +; SRV records +; A records +ns1 IN A 1.2.3.4 +ns2 IN A 5.6.7.8 +mail IN A 9.10.11.12 +www IN A 1.2.3.4 +; AAAA records +www IN AAAA 2001:db8::1 +; CNAME records \nftp\tIN\tCNAME\twww.example.org. +; TXT records \n@ TXT "v=spf1 mx ~all" +; HOST records +""" + +EXPECTED_NORMALIZED = ( + "$TTL 3600\t; 1 hour\n" + "example.org.\t\tIN SOA\tns1.example.org. admin.example.org. (\n" + "\t\t\t\t2024010101 ; serial\n" + "\t\t\t\t28800 ; refresh (8 hours)\n" + "\t\t\t\t7200 ; retry (2 hours)\n" + "\t\t\t\t604800 ; expire (1 week)\n" + "\t\t\t\t86400 ; minimum (1 day)\n" + "\t\t\t\t)\n" + "\t\t\tNS\tns1.example.org.\n" + "\t\t\tNS\tns2.example.org.\n" + "\t\t\tA\t1.2.3.4\n" + "\t\t\tMX\t10 mail.example.org.\n" + '\t\t\tTXT\t"v=spf1 mx ~all"\n' + "\t\t\tAAAA\t2001:db8::1\n" + "ftp.example.org.\tCNAME\twww.example.org.\n" + "mail.example.org.\tA\t9.10.11.12\n" + "ns1.example.org.\tA\t1.2.3.4\n" + "ns2.example.org.\tA\t5.6.7.8\n" + "www.example.org.\tA\t1.2.3.4\n" + "\t\t\tAAAA\t2001:db8::1" +) + + +SAMPLE_ZONE = """\ +$TTL 3600 +example.com. IN SOA ns1.example.com. admin.example.com. ( + 2024010101 ; serial + 3600 ; refresh + 900 ; retry + 604800 ; expire + 86400 ; minimum +) +example.com. IN NS ns1.example.com. +example.com. IN NS ns2.example.com. +example.com. IN MX 10 mail.example.com. +example.com. IN MX 20 mail2.example.com. +example.com. IN A 1.2.3.4 +example.com. IN AAAA 2001:db8::1 +example.com. IN TXT "v=spf1 mx ~all" +ns1.example.com. IN A 1.2.3.4 +ns2.example.com. IN A 5.6.7.8 +mail.example.com. IN A 9.10.11.12 +www.example.com. IN CNAME example.com. +_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com" +""" + + +def make_zone(domain_name="example.com", active_serial=2024010101, serial=2024010102): + zone = MagicMock() + zone.domain_name = domain_name + zone.domain_active_serial = active_serial + zone.domain_serial = serial + return zone + + +def test_calculate_diff_identical_returns_empty(service): + zone = make_zone() + assert service.calculate_diff("line\n", "line\n", zone) == "" + + +def test_calculate_diff_format(service): + zone = make_zone(domain_name="example.com", active_serial=1, serial=2) + diff = service.calculate_diff("new line\n", "old line\n", zone) + assert "*** 1-example.com" in diff + assert "--- 2-example.com" in diff + assert "! old line" in diff + assert "! new line" in diff + + +def test_normalize_zone_is_idempotent(service): + zone = make_zone() + normalized = service.normalize_zone(zone, SAMPLE_ZONE) + assert service.normalize_zone(zone, normalized) == normalized + + +def test_normalize_zone_fallback_when_binary_missing(service): + service.compilezone_bin = "/nonexistent/named-compilezone" + zone = make_zone() + assert service.normalize_zone(zone, SAMPLE_ZONE) == SAMPLE_ZONE + + +def test_check_zone_valid(service): + zone = make_zone() + status, _ = service.check_zone(zone, SAMPLE_ZONE) + assert status == 0 + + +def test_check_zone_invalid(service): + zone = make_zone() + status, _ = service.check_zone(zone, "this is not a valid zone file") + assert status != 0 + + +@freeze_time("2024-01-01 00:00:00") +def test_zone_file_contents_includes_all_record_types(db_domain): + content = db_domain.zone_file_contents() + assert content == EXPECTED_ZONE_FILE_CONTENTS.format( + filename=db_domain.domain_filename + ) + + +def test_zone_file_contents_normalizes_without_error(db_domain, service): + normalized = service.normalize_zone(db_domain, db_domain.zone_file_contents()) + assert normalized == EXPECTED_NORMALIZED + + +def test_check_zone_accepts_zone_file_contents(db_domain, service): + content = service.normalize_zone(db_domain, db_domain.zone_file_contents()) + status, output = service.check_zone(db_domain, content) + assert status == 0, output + assert "loaded serial 2024010101" in output + assert output.endswith("OK") + + +def test_find_zone_returns_matching_domain(db_domain, service): + assert service.find_zone("example.org") == db_domain + + +@pytest.mark.django_db +def test_find_zone_returns_none_for_unknown(service): + assert service.find_zone("nonexistent.example") is None + + +def test_sync_zone_writes_zone_file_and_updates_serial(db_domain, tmp_path): + svc = ZoneSyncService(debug=False) + svc.checkzone_dir = str(tmp_path) + + svc.sync_zone(db_domain) + + assert os.path.exists(db_domain.domain_filename) + db_domain.refresh_from_db() + assert db_domain.domain_active_serial == db_domain.domain_serial + + +def test_sync_zone_records_success_and_diff(db_domain, tmp_path): + svc = ZoneSyncService(debug=False) + svc.checkzone_dir = str(tmp_path) + + svc.sync_zone(db_domain) + + change = svc.changes["example.org"] + assert change["success"] is True + assert change["errors"] is None + assert change["old_serial"] == 0 + assert change["new_serial"] == 2024010101 diff --git a/tests/test_zone_sync_command.py b/tests/test_zone_sync_command.py new file mode 100644 index 0000000..ccaf9ba --- /dev/null +++ b/tests/test_zone_sync_command.py @@ -0,0 +1,109 @@ +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest +from mdb.management.commands.zone_sync import Command as CommandClass + + +@pytest.fixture +def cmd(): + c = CommandClass() + c.stdout = StringIO() + c.stderr = StringIO() + return c + + +def make_service(changes=None): + svc = MagicMock() + svc.changes = changes or { + "example.org": {"errors": None, "diff": "- old\n+ new", "success": True} + } + svc.run_reload_commands.return_value = (True, "", "") + return svc + + +# --- run_reload_commands --- + +def test_run_reload_commands_calls_all_on_success(service): + with patch("mdb.services.zone_sync.getstatusoutput", return_value=(0, "")) as mock: + success, _, _ = service.run_reload_commands() + assert success is True + assert mock.call_count == 3 + + +def test_run_reload_commands_stops_on_first_failure(service): + with patch( + "mdb.services.zone_sync.getstatusoutput", + side_effect=[(0, ""), (1, "connection refused"), (0, "")], + ) as mock: + success, failed_cmd, output = service.run_reload_commands() + assert success is False + assert "reload" in failed_cmd + assert output == "connection refused" + assert mock.call_count == 2 # thaw never reached + + +# --- reload_and_notify --- + +def test_reload_and_notify_success_writes_ok(cmd): + svc = make_service() + with patch("mdb.management.commands.zone_sync.mail_admins"): + cmd.reload_and_notify(svc, errors="", diffs="diff", zones="example.org") + assert "ok" in cmd.stdout.getvalue() + + +def test_reload_and_notify_success_sends_mail(cmd): + svc = make_service() + with patch("mdb.management.commands.zone_sync.mail_admins") as mock_mail: + cmd.reload_and_notify(svc, errors="", diffs="diff", zones="example.org") + mock_mail.assert_called_once() + assert "Success" in mock_mail.call_args.kwargs["subject"] + + +def test_reload_and_notify_failure_writes_fail_and_exits(cmd): + svc = make_service() + svc.run_reload_commands.return_value = (False, "/usr/sbin/rndc freeze", "timed out") + with patch("mdb.management.commands.zone_sync.mail_admins"): + with patch("os.getlogin", return_value="testuser"): + with pytest.raises(SystemExit) as exc: + cmd.reload_and_notify(svc, errors="", diffs="", zones="example.org") + assert exc.value.code == 1 + assert "fail" in cmd.stdout.getvalue() + + +def test_reload_and_notify_failure_mail_includes_command_and_output(cmd): + svc = make_service() + svc.run_reload_commands.return_value = (False, "/usr/sbin/rndc freeze", "timed out") + with patch("mdb.management.commands.zone_sync.mail_admins") as mock_mail: + with patch("os.getlogin", return_value="testuser"): + with pytest.raises(SystemExit): + cmd.reload_and_notify(svc, errors="", diffs="", zones="example.org") + subject = mock_mail.call_args.kwargs["subject"] + message = mock_mail.call_args.kwargs["message"] + assert "FAIL" in subject + assert "rndc freeze" in message + assert "timed out" in message + + +# --- handle --- + +@pytest.mark.django_db +def test_handle_exits_if_zone_not_found(cmd): + with pytest.raises(SystemExit) as exc: + cmd.handle(debug=True, force=False, zone="nonexistent.example") + assert exc.value.code == 1 + assert "nonexistent.example" in cmd.stderr.getvalue() + + +@pytest.mark.django_db +def test_handle_skips_reload_in_debug_mode(cmd, db_domain): + with patch.object(cmd, "reload_and_notify") as mock_reload: + cmd.handle(debug=True, force=True, zone="example.org") + mock_reload.assert_not_called() + + +@pytest.mark.django_db +def test_handle_calls_reload_and_notify_when_changes_exist(cmd, db_domain): + with patch.object(cmd, "reload_and_notify") as mock_reload: + cmd.handle(debug=False, force=True, zone="example.org") + mock_reload.assert_called_once() diff --git a/uv.lock b/uv.lock index bf60bf9..558b617 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 -revision = 2 -requires-python = ">=3.10, <4" +revision = 3 +requires-python = ">=3.10" [[package]] name = "asgiref" @@ -138,6 +138,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "cryptography" version = "44.0.3" @@ -236,6 +245,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/3e/2448e93f4f87fc9a9f35e73e3c05669e0edd0c2526834686e949bb1fd303/djangorestframework-3.16.0-py3-none-any.whl", hash = "sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361", size = 1067305, upload-time = "2025-03-28T14:18:39.489Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "fabric" version = "2.7.1" @@ -250,6 +271,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/ef/26dc626427009c575dab62a4cf0d0ef38620a2c1be2a2db571a16bc56a29/fabric-2.7.1-py2.py3-none-any.whl", hash = "sha256:7610362318ef2d391cc65d4befb684393975d889ed5720f23499394ec0e136fa", size = 53497, upload-time = "2022-07-14T22:39:33.5Z" }, ] +[[package]] +name = "freezegun" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, +] + [[package]] name = "gunicorn" version = "23.0.0" @@ -262,6 +295,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "invoke" version = "1.7.3" @@ -290,6 +332,7 @@ dependencies = [ { name = "django-extensions" }, { name = "djangorestframework" }, { name = "fabric" }, + { name = "freezegun" }, { name = "gunicorn" }, { name = "ipaddress" }, { name = "mysqlclient" }, @@ -299,6 +342,8 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "fabric" }, + { name = "pytest" }, + { name = "pytest-django" }, { name = "ruff" }, ] @@ -309,6 +354,7 @@ requires-dist = [ { name = "django-extensions", specifier = ">=4.1" }, { name = "djangorestframework", specifier = ">=3.16.0" }, { name = "fabric", specifier = ">=2.7.1" }, + { name = "freezegun", specifier = ">=1.5.5" }, { name = "gunicorn", specifier = ">=23.0.0" }, { name = "ipaddress", specifier = ">=1.0.23,<2.0.0" }, { name = "mysqlclient", specifier = ">=2.0.3,<3.0.0" }, @@ -318,6 +364,8 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "fabric", specifier = ">=2.6.0,<3.0.0" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-django", specifier = ">=4.12.0" }, { name = "ruff", specifier = ">=0.11.9,<1.0.0" }, ] @@ -369,6 +417,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/eb/4af4bcd5b8731366b676192675221c5324394a580dfae469d498313b5c4a/pathlib2-2.3.7.post1-py2.py3-none-any.whl", hash = "sha256:5266a0fd000452f1b3467d782f079a4343c63aaa119221fbdc4e39577489ca5b", size = 18027, upload-time = "2022-02-10T18:01:07.751Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -399,6 +456,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pynacl" version = "1.5.0" @@ -419,6 +485,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141, upload-time = "2022-01-07T22:06:01.861Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-ldap" version = "3.4.4" @@ -485,6 +593,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, ] +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + [[package]] name = "typing-extensions" version = "4.13.2"