Revamp zone sync and add tests
This commit is contained in:
1 parent
0123dd1e3b
commit
e96948080f
11 files changed
+792
-184
No files matched your search
@@ -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
|
||||
|
||||
@@ -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='<pre>%s</pre>' % 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='<pre>%s</pre>' % 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))
|
||||
@@ -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="<pre>%s</pre>" % 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="<pre>%s</pre>" % message,
|
||||
)
|
||||
Whitespace-only changes.
@@ -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)
|
||||
)
|
||||
+8
-1
@@ -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"
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user