Compare commits
17
Commits
d3b6024cd8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
284e5bd4a0
|
||
|
|
8f7e34a762
|
||
|
|
ff27b75101
|
||
|
|
571523354e
|
||
|
|
bc46a33201
|
||
|
|
782c9465bf
|
||
|
|
0e38b91fc5
|
||
|
|
ad42f86ed7
|
||
|
|
f0ecdb41bc
|
||
|
|
a8a4c76d98
|
||
|
|
3b589f6085
|
||
|
|
58937e1105
|
||
|
|
d43aa43186
|
||
|
|
cb2784eec5
|
||
|
|
d7c97df832
|
||
|
|
d535e1b080
|
||
|
|
284dfdbdf4
|
No files matched your search
@@ -1,38 +0,0 @@
|
||||
name: checks
|
||||
|
||||
# Checks only, nothing here deploys. deploy.yaml calls it before deploying.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# bind9-utils provides named-checkzone, used by the zone sync tests.
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends bind9-utils
|
||||
|
||||
- name: Install mise
|
||||
run: |
|
||||
curl -fsSL https://mise.run | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install pinned tools
|
||||
run: mise install
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: mise exec -- uv sync --locked --no-group prod
|
||||
|
||||
- name: Unit tests
|
||||
run: mise exec -- uv run --no-sync pytest
|
||||
@@ -0,0 +1,61 @@
|
||||
name: CI
|
||||
|
||||
# Tests and prek on every push and pull request. A push to main that passes both deploys
|
||||
# to sega, while the repository variable DEPLOY_SEGA is true.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# bind9-utils provides named-checkzone, used by the zone sync tests.
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends bind9-utils
|
||||
|
||||
- name: Install mise
|
||||
run: |
|
||||
curl -fsSL https://mise.run | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install pinned tools
|
||||
run: mise install
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: mise exec -- uv sync --locked --no-group prod
|
||||
|
||||
- name: Unit tests
|
||||
run: mise exec -- uv run --no-sync pytest
|
||||
|
||||
# The hooks in prek.toml.
|
||||
prek:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: j178/prek-action@v1
|
||||
|
||||
# sega's authorized_keys sends this key to mdb-deploy, which reads the sha from the ssh
|
||||
# command line, builds that commit on the host and switches to it (edb-infra's
|
||||
# roles/mdb.md).
|
||||
deploy-sega:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_SEGA == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- tests
|
||||
- prek
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
|
||||
eval $(ssh-agent -s)
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add -
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
|
||||
ssh mdb-deploy@sega.neuf.no "${{ github.sha }}"
|
||||
@@ -1,33 +0,0 @@
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
if: vars.DEPLOY_SEGA == 'true'
|
||||
uses: ./.gitea/workflows/checks.yaml
|
||||
prek:
|
||||
if: vars.DEPLOY_SEGA == 'true'
|
||||
uses: ./.gitea/workflows/prek.yaml
|
||||
|
||||
# sega's authorized_keys sends this key to mdb-deploy, which reads the sha from the ssh
|
||||
# command line, builds that commit on the host and switches to it (edb-infra's
|
||||
# roles/mdb.md).
|
||||
deploy-sega:
|
||||
if: vars.DEPLOY_SEGA == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- checks
|
||||
- prek
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
|
||||
eval $(ssh-agent -s)
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add -
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
|
||||
ssh mdb-deploy@sega.neuf.no "${{ github.sha }}"
|
||||
@@ -1,14 +0,0 @@
|
||||
name: prek
|
||||
|
||||
# The hooks in prek.toml. deploy.yaml calls it before deploying.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
prek:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: j178/prek-action@v1
|
||||
@@ -0,0 +1,10 @@
|
||||
# Agents
|
||||
|
||||
## Commits
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <subject>`,
|
||||
with the scope optional. The types in use are `feat`, `fix`, `test`, `ci`, `chore`, `style`,
|
||||
`refactor` and `docs`. For example:
|
||||
|
||||
- `fix(zone_sync): thaw zones when the sync is killed`
|
||||
- `ci: one workflow for tests, prek and the deploy to sega`
|
||||
@@ -28,8 +28,8 @@ Run migrations and start the development server:
|
||||
MDB runs on sega under edb-infra's `mdb` role (`roles/mdb.md` there): gunicorn from a
|
||||
release directory per commit, its settings in the file `MDB_SETTINGS` names
|
||||
(`/etc/mdb/settings.py`), which `mdbsite/settings.py` executes last. A push to `main` that
|
||||
passes the checks deploys itself while the repository variable `DEPLOY_SEGA` is `true`
|
||||
(`.gitea/workflows/deploy.yaml`). The DNS and DHCP sync runs as root on sega:
|
||||
passes the tests and prek deploys itself while the repository variable `DEPLOY_SEGA` is `true`
|
||||
(`.gitea/workflows/ci.yaml`). The DNS and DHCP sync runs as root on sega:
|
||||
`sudo mdb-sync`.
|
||||
|
||||
## Development tasks
|
||||
@@ -42,5 +42,5 @@ Dump test data:
|
||||
|
||||
- Creates DHCPD configuration files
|
||||
- Creates DNS zone files for BIND
|
||||
- Creates PXE config files (with random secret for use with Puppet autosign)
|
||||
- Netboots hosts marked PXE installable, by setting their DHCP boot filename
|
||||
- Partial IPv6 support
|
||||
+6
-5
@@ -54,7 +54,7 @@ class InterfaceInline(admin.TabularInline):
|
||||
form = InterfaceForm
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related()
|
||||
return super().get_queryset(request).select_related("host", "domain")
|
||||
|
||||
|
||||
@admin.register(Host)
|
||||
@@ -153,7 +153,11 @@ class HostAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related()
|
||||
return (
|
||||
super()
|
||||
.get_queryset(request)
|
||||
.select_related("host_type", "operating_system")
|
||||
)
|
||||
|
||||
|
||||
class Ip4AddressInline(admin.TabularInline):
|
||||
@@ -318,9 +322,6 @@ class OperatingSystemAdmin(admin.ModelAdmin):
|
||||
"name",
|
||||
"version",
|
||||
"arch",
|
||||
"pxe_kernel",
|
||||
"pxe_initrd",
|
||||
"pxe_preseed_config_url",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -2740,9 +2740,6 @@
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"pxe_initrd": "path-to-initrd-for-os",
|
||||
"pxe_kernel": "path-to-kernel-for-os",
|
||||
"pxe_preseed_config_url": "http://url-to-preseed.config",
|
||||
"arch": "x86_64",
|
||||
"name": "Ubuntu",
|
||||
"version": "16.04-beta-final"
|
||||
|
||||
+29
-15
@@ -10,8 +10,8 @@ class SerialNumberInput(NumberInput):
|
||||
"""Number input for DNS SOA serials (YYYYMMDDXX).
|
||||
|
||||
Renders wide enough to show the full 10-digit serial and adds a button
|
||||
that prefills the field with today's date plus the next available XX
|
||||
counter (incrementing if the field already holds today's serial).
|
||||
that prefills the field with the next serial: today's first, or one more than
|
||||
the current serial when that is higher.
|
||||
"""
|
||||
|
||||
def __init__(self, attrs=None):
|
||||
@@ -35,12 +35,14 @@ if (typeof window.mdbBumpSerial === 'undefined') {
|
||||
var today = '' + now.getFullYear()
|
||||
+ String(now.getMonth() + 1).padStart(2, '0')
|
||||
+ String(now.getDate()).padStart(2, '0');
|
||||
var current = (input.value || '').trim();
|
||||
var xx = 1;
|
||||
if (current.length === 10 && current.slice(0, 8) === today) {
|
||||
xx = Math.min(parseInt(current.slice(8), 10) + 1, 99);
|
||||
// As mdb.utils.format_domain_serial_and_add_one: never lower than the current
|
||||
// serial, so past XX = 99 it carries into the date.
|
||||
var current = parseInt((input.value || '').trim(), 10);
|
||||
var next = parseInt(today + '01', 10);
|
||||
if (!isNaN(current) && current + 1 > next) {
|
||||
next = current + 1;
|
||||
}
|
||||
input.value = today + String(xx).padStart(2, '0');
|
||||
input.value = String(next);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -57,16 +59,25 @@ class Ip4ModelChoiceField(ModelChoiceField):
|
||||
|
||||
|
||||
class InterfaceForm(forms.ModelForm):
|
||||
model = Interface
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = "__all__"
|
||||
|
||||
def clean(self):
|
||||
macaddr = self.cleaned_data["macaddr"]
|
||||
ip4address = self.cleaned_data["ip4address"]
|
||||
cleaned_data = super().clean()
|
||||
# Missing when the field itself failed validation, and the address is optional.
|
||||
macaddr = cleaned_data.get("macaddr")
|
||||
ip4address = cleaned_data.get("ip4address")
|
||||
if not macaddr or ip4address is None:
|
||||
return cleaned_data
|
||||
|
||||
for interface in Interface.objects.filter(macaddr=macaddr):
|
||||
subnet = interface.ip4address.subnet
|
||||
if subnet == ip4address.subnet and interface != self.instance:
|
||||
raise ValidationError("Macaddr already in this subnet")
|
||||
# dhcpd matches MAC addresses whatever their case.
|
||||
clashes = Interface.objects.filter(
|
||||
macaddr__iexact=macaddr, ip4address__subnet=ip4address.subnet
|
||||
).exclude(pk=self.instance.pk)
|
||||
if clashes.exists():
|
||||
raise ValidationError("Macaddr already in this subnet")
|
||||
return cleaned_data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -74,4 +85,7 @@ class InterfaceForm(forms.ModelForm):
|
||||
# Join Ip4Address with tables interface and host,
|
||||
# before printing the select labels that walk those two relations.
|
||||
queryset = Ip4Address.objects.select_related("interface", "interface__host")
|
||||
self.fields["ip4address"] = Ip4ModelChoiceField(queryset=queryset)
|
||||
# Not required, as on the model: a host can have interfaces without IPv4.
|
||||
self.fields["ip4address"] = Ip4ModelChoiceField(
|
||||
queryset=queryset, required=False
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
import getpass
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
@@ -64,7 +65,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write("fail\n")
|
||||
message = (
|
||||
"Failed to restart isc-dhcp-server, please inspect...\n\n"
|
||||
f"{os.getlogin()}@{os.uname()[1]}$ {service.restart_command}\n{output}"
|
||||
f"{getpass.getuser()}@{os.uname()[1]}$ {service.restart_command}\n{output}"
|
||||
)
|
||||
discord.notify(
|
||||
subject=f"⚠️ FAIL: dhcpd {old_serial} -> {config.active_serial}",
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import getpass
|
||||
import os
|
||||
import signal
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from mdb.services import discord
|
||||
from mdb.services.zone_sync import ZoneSyncService
|
||||
|
||||
# What stops a sync run by hand: a dropped ssh session, a kill, Ctrl-C.
|
||||
STOPPING_SIGNALS = (signal.SIGHUP, signal.SIGTERM, signal.SIGINT)
|
||||
|
||||
|
||||
class Interrupted(Exception):
|
||||
def __init__(self, signum):
|
||||
self.signum = signum
|
||||
super().__init__(signal.Signals(signum).name)
|
||||
|
||||
|
||||
def raise_interrupted(signum, frame):
|
||||
raise Interrupted(signum)
|
||||
|
||||
|
||||
def set_handlers(handler):
|
||||
for signum in STOPPING_SIGNALS:
|
||||
signal.signal(signum, handler)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
args = "[--force] [--debug]"
|
||||
@@ -45,6 +65,9 @@ class Command(BaseCommand):
|
||||
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 not debug:
|
||||
self.thaw_after_killed_sync(service)
|
||||
|
||||
if zone_name:
|
||||
zone = service.find_zone(zone_name)
|
||||
if zone is None:
|
||||
@@ -62,14 +85,75 @@ class Command(BaseCommand):
|
||||
service.sync_zone(zone)
|
||||
return
|
||||
|
||||
self.freeze(service, zones)
|
||||
previous = {signum: signal.getsignal(signum) for signum in STOPPING_SIGNALS}
|
||||
try:
|
||||
for zone in zones:
|
||||
service.sync_zone(zone)
|
||||
except BaseException:
|
||||
service.reload()
|
||||
raise
|
||||
self.reload_and_notify(service, service.changes)
|
||||
# From the freeze to the thaw, a signal must not stop the sync in between:
|
||||
# ignored while rndc runs, it stops the writes for a reload and thaw.
|
||||
set_handlers(signal.SIG_IGN)
|
||||
self.freeze(service, zones)
|
||||
set_handlers(raise_interrupted)
|
||||
try:
|
||||
for zone in zones:
|
||||
service.sync_zone(zone)
|
||||
except BaseException as e:
|
||||
set_handlers(signal.SIG_IGN)
|
||||
self.reload_after_interruption(service, zones, e)
|
||||
if isinstance(e, Interrupted):
|
||||
raise SystemExit(128 + e.signum) from e
|
||||
raise
|
||||
set_handlers(signal.SIG_IGN)
|
||||
self.reload_and_notify(service, service.changes)
|
||||
finally:
|
||||
for signum, handler in previous.items():
|
||||
signal.signal(signum, handler)
|
||||
|
||||
def thaw_after_killed_sync(self, service):
|
||||
since = service.frozen_since()
|
||||
if since is None:
|
||||
return
|
||||
|
||||
self.stdout.write("a previous sync left the zones frozen, thawing... ")
|
||||
summary = (
|
||||
f"A zone sync killed after {since:%Y-%m-%d %H:%M} left every zone frozen"
|
||||
)
|
||||
success, failed_command, output = service.reload()
|
||||
if not success:
|
||||
self.stdout.write("fail\n")
|
||||
self.notify_failure(
|
||||
f"{summary}, and thawing them failed", failed_command, output, []
|
||||
)
|
||||
self.stdout.write("ok\n")
|
||||
discord.notify(
|
||||
subject="⚠️ Zones thawed after a killed zone sync",
|
||||
message=f"{summary}, refusing dynamic updates until now. "
|
||||
"They are reloaded and thawed.",
|
||||
)
|
||||
|
||||
def reload_after_interruption(self, service, zones, error):
|
||||
reason = str(error) if isinstance(error, Interrupted) else repr(error)
|
||||
written = [z for z, c in service.changes.items() if c["success"]]
|
||||
not_written = [z.domain_name for z in zones if z.domain_name not in written]
|
||||
|
||||
self.stdout.write(f"stopped by {reason}, reloading zones... ")
|
||||
success, failed_command, output = service.reload()
|
||||
if not success:
|
||||
self.stdout.write("fail\n")
|
||||
self.notify_failure(
|
||||
f"Zone sync stopped by {reason}, and reloading the zones failed; "
|
||||
"they may still be frozen",
|
||||
failed_command,
|
||||
output,
|
||||
not_written,
|
||||
)
|
||||
self.stdout.write("ok\n")
|
||||
message = f"Zone sync stopped by {reason}. The zones are reloaded and thawed."
|
||||
if written:
|
||||
message += "\n\nWritten:\n" + "\n".join("- " + z for z in written)
|
||||
if not_written:
|
||||
message += "\n\nNot written, run mdb-sync again:\n" + "\n".join(
|
||||
"- " + z for z in not_written
|
||||
)
|
||||
discord.notify(subject="⚠️ Zone sync interrupted, zones thawed", message=message)
|
||||
|
||||
def freeze(self, service, zones):
|
||||
self.stdout.write("freezing zones... ")
|
||||
@@ -77,7 +161,7 @@ class Command(BaseCommand):
|
||||
if not success:
|
||||
self.stdout.write("fail\n")
|
||||
# A failed freeze may have frozen some zones before stopping.
|
||||
service.run_commands([service.thaw_command])
|
||||
service.thaw()
|
||||
self.notify_failure(
|
||||
"Failed to freeze zones, nothing was written",
|
||||
failed_command,
|
||||
@@ -120,7 +204,7 @@ class Command(BaseCommand):
|
||||
self, summary, failed_command, output, zones, errors="", diffs=""
|
||||
):
|
||||
zone_list = "\n".join("- " + z for z in zones)
|
||||
message = f"{summary}, please inspect...\n\n{os.getlogin()}@{os.uname()[1]}$ {failed_command}\n{output}\n{zone_list}"
|
||||
message = f"{summary}, please inspect...\n\n{getpass.getuser()}@{os.uname()[1]}$ {failed_command}\n{output}\n{zone_list}"
|
||||
if errors:
|
||||
message += "\n\n" + errors
|
||||
discord.notify(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 6.1.2 on 2026-11-04 20:26
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mdb', '0014_classless_reverse_zone_names'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='operatingsystem',
|
||||
name='pxe_initrd',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='operatingsystem',
|
||||
name='pxe_kernel',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='operatingsystem',
|
||||
name='pxe_preseed_config_url',
|
||||
),
|
||||
]
|
||||
+18
-9
@@ -4,9 +4,17 @@ import ipaddress
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from mdb.pxe import host_as_pxe_files
|
||||
from mdb.validators import validate_hostname, validate_macaddr
|
||||
|
||||
# The boot file of a PXE installable host. UEFI x86-64 clients, PXE architecture 7 or 9
|
||||
# in their vendor class, get the UEFI loader instead.
|
||||
PXE_FILENAME = "pxelinux.0"
|
||||
PXE_EFI_FILENAME = "grubx64.efi"
|
||||
PXE_EFI_CLIENT = " or ".join(
|
||||
f'substring(option vendor-class-identifier, 0, 20) = "PXEClient:Arch:{arch}"'
|
||||
for arch in ("00007", "00009")
|
||||
)
|
||||
|
||||
|
||||
class Nameserver(models.Model):
|
||||
hostname = models.CharField(max_length=256)
|
||||
@@ -251,7 +259,15 @@ class DhcpConfig(models.Model):
|
||||
content += (
|
||||
f"\tfixed-address {_if.host.hostname}.{_if.domain.domain_name};\n"
|
||||
)
|
||||
if len(_if.pxe_filename) > 0:
|
||||
if _if.pxe_filename == PXE_FILENAME:
|
||||
content += (
|
||||
f"\tif {PXE_EFI_CLIENT} {{\n"
|
||||
f'\t\tfilename "{PXE_EFI_FILENAME}";\n'
|
||||
"\t} else {\n"
|
||||
f'\t\tfilename "{PXE_FILENAME}";\n'
|
||||
"\t}\n"
|
||||
)
|
||||
elif _if.pxe_filename:
|
||||
content += f'\tfilename "{_if.pxe_filename}";\n'
|
||||
content += "}\n"
|
||||
|
||||
@@ -500,10 +516,6 @@ class OperatingSystem(models.Model):
|
||||
max_length=255, choices=OS_ARCHITECTURES, blank=True, null=True
|
||||
)
|
||||
|
||||
pxe_kernel = models.CharField(max_length=255, blank=True, null=True)
|
||||
pxe_initrd = models.CharField(max_length=255, blank=True, null=True)
|
||||
pxe_preseed_config_url = models.URLField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name + " " + self.version + " (" + self.arch + ")"
|
||||
|
||||
@@ -540,9 +552,6 @@ class Host(models.Model):
|
||||
def __str__(self):
|
||||
return self.hostname
|
||||
|
||||
def as_pxe_files(self):
|
||||
return host_as_pxe_files(self)
|
||||
|
||||
|
||||
class Interface(models.Model):
|
||||
name = models.CharField(max_length=128)
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import ipaddress
|
||||
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
def render_pxelinux_cfg(_if, host):
|
||||
"""Renders pxelinux.cfg based on a host and the set of host interfaces.
|
||||
If the host is tied to an OS with PXE fields set, it will use these instead of defaults."""
|
||||
# Default config
|
||||
kernel = settings.MDB_PXE_KERNEL
|
||||
initrd = settings.MDB_PXE_INITRD
|
||||
preseed_config_url = settings.MDB_PXE_PRESEED_URL
|
||||
|
||||
# Per host config (via OS model)
|
||||
_os = host.operating_system
|
||||
pxe_attrs = [_os.pxe_initrd, _os.pxe_kernel, _os.pxe_preseed_config_url]
|
||||
if None not in pxe_attrs and "" not in pxe_attrs:
|
||||
kernel = _os.pxe_kernel
|
||||
initrd = _os.pxe_initrd
|
||||
preseed_config_url = _os.pxe_preseed_config_url
|
||||
|
||||
context = {
|
||||
"kernel": kernel,
|
||||
"initrd": initrd,
|
||||
"preseed_config_url": preseed_config_url,
|
||||
"host": host,
|
||||
"interface": _if,
|
||||
}
|
||||
|
||||
return render_to_string("pxelinux.cfg", context=context)
|
||||
|
||||
|
||||
def get_pxe_filename(interface, filename_format="mac_addr"):
|
||||
"""
|
||||
Mac or IP address based PXE filename
|
||||
Ref: http://www.syslinux.org/wiki/index.php/PXELINUX#Examples
|
||||
"""
|
||||
ip_addr_in_hex = f"{int(ipaddress.IPv4Address(interface.ip4address.address)):02X}"
|
||||
formats = {
|
||||
"ip_addr": ip_addr_in_hex,
|
||||
"mac_addr": "01-{}".format(interface.macaddr.lower().replace(":", "-")),
|
||||
}
|
||||
|
||||
return formats.get(filename_format, formats["mac_addr"])
|
||||
|
||||
|
||||
def host_as_pxe_files(host):
|
||||
"""Returns a list of filename,content for each host interface with an IPv4 address."""
|
||||
from mdb.models import Host
|
||||
|
||||
assert isinstance(host, Host)
|
||||
|
||||
pxe_files = []
|
||||
for _if in host.interface_set.exclude(ip4address__isnull=True):
|
||||
filename = get_pxe_filename(_if)
|
||||
content = render_pxelinux_cfg(_if, host)
|
||||
pxe_files.append((filename, content))
|
||||
|
||||
return pxe_files
|
||||
@@ -4,6 +4,8 @@ import requests
|
||||
from django.conf import settings
|
||||
|
||||
MAX_CONTENT_LENGTH = 2000
|
||||
# Seconds; a hung webhook must not hold up the sync that called it.
|
||||
TIMEOUT = 10
|
||||
|
||||
|
||||
def notify(subject, message, preformatted=None):
|
||||
@@ -31,7 +33,9 @@ def notify(subject, message, preformatted=None):
|
||||
|
||||
|
||||
def _send_json(url, content):
|
||||
resp = requests.post(url, json={"username": "MDB", "content": content})
|
||||
resp = requests.post(
|
||||
url, json={"username": "MDB", "content": content}, timeout=TIMEOUT
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
@@ -41,5 +45,6 @@ def _send_as_file(url, subject, content):
|
||||
url,
|
||||
data={"payload_json": json.dumps(payload)},
|
||||
files={"files[0]": ("notification.txt", content.encode(), "text/plain")},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -1,8 +1,10 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from difflib import context_diff
|
||||
from itertools import chain
|
||||
from subprocess import getstatusoutput
|
||||
@@ -43,17 +45,50 @@ class ZoneSyncService:
|
||||
return False, command, output
|
||||
return True, "", ""
|
||||
|
||||
@property
|
||||
def frozen_marker(self):
|
||||
"""Exists from just before rndc freeze until the zones are thawed.
|
||||
|
||||
A sync killed in between (SIGKILL, the OOM killer) leaves it behind, and the
|
||||
next sync thaws the zones it finds frozen.
|
||||
"""
|
||||
return os.path.join(self.checkzone_dir, "zones-frozen")
|
||||
|
||||
def frozen_since(self):
|
||||
"""When a sync that never thawed froze the zones, or None."""
|
||||
try:
|
||||
return datetime.fromtimestamp(os.path.getmtime(self.frozen_marker))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
def freeze(self):
|
||||
"""Stop dynamic updates and flush each dynamic zone's journal to its file.
|
||||
|
||||
Must run before zone files are written: freeze dumps the zone BIND
|
||||
holds in memory, which would overwrite a file written earlier.
|
||||
"""
|
||||
with open(self.frozen_marker, "w") as f:
|
||||
f.write(f"{os.getpid()}\n")
|
||||
return self.run_commands([self.freeze_command])
|
||||
|
||||
def thaw(self):
|
||||
"""Resume dynamic updates, which a frozen zone refuses (ACME dns-01 among them)."""
|
||||
result = self.run_commands([self.thaw_command])
|
||||
if result[0]:
|
||||
try:
|
||||
os.unlink(self.frozen_marker)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return result
|
||||
|
||||
def reload(self):
|
||||
"""Load the written zone files and resume dynamic updates."""
|
||||
return self.run_commands([self.reload_command, self.thaw_command])
|
||||
"""Load the written zone files and resume dynamic updates.
|
||||
|
||||
Thaws even when the reload fails, and reports the first failure.
|
||||
"""
|
||||
reloaded = self.run_commands([self.reload_command])
|
||||
thawed = self.thaw()
|
||||
return reloaded if not reloaded[0] else thawed
|
||||
|
||||
def find_zone(self, name):
|
||||
for model in (Domain, Ip4Subnet, Ip6Subnet):
|
||||
@@ -124,8 +159,7 @@ class ZoneSyncService:
|
||||
path = "/".join(zonefile.split("/")[:-1])
|
||||
os.path.isdir(path) or os.makedirs(path)
|
||||
|
||||
with open(zonefile, "w") as f:
|
||||
f.write(new)
|
||||
self.write_zone_file(zonefile, new)
|
||||
|
||||
if not self.debug:
|
||||
zone.domain_active_serial = zone.domain_serial
|
||||
@@ -135,6 +169,36 @@ class ZoneSyncService:
|
||||
self.changes[zone.domain_name]["success"] = True
|
||||
return True
|
||||
|
||||
def write_zone_file(self, path, contents):
|
||||
"""Replace the file at path with contents, whole or not at all.
|
||||
|
||||
named may read the file at any time, and a write in place could leave it half
|
||||
written. The new file takes the old one's owner and mode: bind owns the zone
|
||||
files, and this runs as root.
|
||||
"""
|
||||
directory, name = os.path.split(path)
|
||||
fd, tmp = tempfile.mkstemp(dir=directory or ".", prefix=f".{name}.")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(contents)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
os.chmod(tmp, 0o644)
|
||||
else:
|
||||
os.chmod(tmp, stat.S_IMODE(st.st_mode))
|
||||
if (st.st_uid, st.st_gid) != (os.getuid(), os.getgid()):
|
||||
os.chown(tmp, st.st_uid, st.st_gid)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def read_serial(self, contents):
|
||||
"""Return the SOA serial of a zone file, or None if there is none."""
|
||||
match = re.search(r"^\s*(\d+)\s*;\s*serial", contents, re.MULTILINE)
|
||||
|
||||
+19
-36
@@ -1,13 +1,11 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_save, pre_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from mdb.models import Host, Interface, Ip4Address, Ip4Subnet, Ip6Subnet
|
||||
from mdb.models import PXE_FILENAME, Host, Interface, Ip4Address, Ip4Subnet, Ip6Subnet
|
||||
from mdb.utils import format_domain_serial_and_add_one
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -90,9 +88,9 @@ def update_domain_serial_when_change_to_host(sender, instance, created, **kwargs
|
||||
|
||||
|
||||
@receiver(post_save, sender=Host)
|
||||
def create_pxe_key_and_write_pxe_files_when_host_changes(
|
||||
sender, instance, created, **kwargs
|
||||
):
|
||||
def set_pxe_boot_when_host_changes(sender, instance, created, **kwargs):
|
||||
"""An installable host netboots into the menu; unticking it stops that.
|
||||
A filename other than pxelinux's is left alone either way."""
|
||||
host = instance
|
||||
|
||||
if not host.pxe_key:
|
||||
@@ -101,47 +99,32 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(
|
||||
Host.objects.filter(pk=host.pk).update(pxe_key=pxe_key)
|
||||
host.pxe_key = pxe_key
|
||||
|
||||
for pxe_file_name, pxe_file in host.as_pxe_files():
|
||||
path = os.path.join(settings.MDB_PXE_TFTP_ROOT, pxe_file_name)
|
||||
for _if in host.interface_set.exclude(ip4address__isnull=True):
|
||||
changed = False
|
||||
if host.pxe_installable:
|
||||
# Write pxeconfig file to disk
|
||||
with open(path, "w+") as f:
|
||||
f.write(pxe_file)
|
||||
logger.info(f"Created or updated {path}")
|
||||
os.chmod(path, 0o644) # Not writeable for all
|
||||
|
||||
# Also set necessary fields for pxe installation
|
||||
interfaces = host.interface_set.exclude(ip4address__isnull=True)
|
||||
|
||||
for _if in interfaces:
|
||||
changed = False
|
||||
if not _if.pxe_filename:
|
||||
_if.pxe_filename = "pxelinux.0" # don't overwrite
|
||||
changed = True
|
||||
if not _if.dhcp_client:
|
||||
_if.dhcp_client = True
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
_if.save()
|
||||
|
||||
else:
|
||||
if os.path.exists(path):
|
||||
# Delete pxeconfig file from disk
|
||||
os.unlink(path)
|
||||
logger.info(f"deleted {path}")
|
||||
if not _if.pxe_filename:
|
||||
_if.pxe_filename = PXE_FILENAME
|
||||
changed = True
|
||||
if not _if.dhcp_client:
|
||||
_if.dhcp_client = True
|
||||
changed = True
|
||||
elif _if.pxe_filename == PXE_FILENAME:
|
||||
_if.pxe_filename = ""
|
||||
changed = True
|
||||
if changed:
|
||||
_if.save()
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Interface)
|
||||
def update_domain_serial_when_interface_deleted(sender, instance, **kwargs):
|
||||
if instance.domain is not None:
|
||||
domain = instance.domain
|
||||
domain.domain_serial += 1
|
||||
domain.domain_serial = format_domain_serial_and_add_one(domain.domain_serial)
|
||||
domain.save()
|
||||
|
||||
if instance.ip4address is not None:
|
||||
subnet = instance.ip4address.subnet
|
||||
subnet.domain_serial += 1
|
||||
subnet.domain_serial = format_domain_serial_and_add_one(subnet.domain_serial)
|
||||
subnet.save()
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
default ninja
|
||||
prompt 0
|
||||
timeout 0
|
||||
|
||||
label ninja
|
||||
kernel {{kernel}}
|
||||
append ramdisk_size=14984 locale=en_US console-setup/ask_detect=false keyboard-configuration/layoutcode=no netcfg/wireless_wep= netcfg_choose_interface=eth0 netcfg/get_hostname= preseed/url={{preseed_config_url}} vga=normal snop={{host.pxe_key}} initrd={{initrd}} -- quiet
|
||||
+11
-29
@@ -1,34 +1,16 @@
|
||||
import datetime
|
||||
import re
|
||||
|
||||
|
||||
def format_domain_serial_and_add_one(serial):
|
||||
today = datetime.datetime.now()
|
||||
res = re.findall(
|
||||
rf"^{today.year:4d}{today.month:02d}{today.day:02d}(\d\d)$",
|
||||
str(serial),
|
||||
re.DOTALL,
|
||||
)
|
||||
"""Return the serial after `serial`, in the YYYYMMDDnn form where it can be.
|
||||
|
||||
if len(res) == 0:
|
||||
""" This probably means that the serial is malformed
|
||||
or the date is wrong. We assume that if the date is wrong,
|
||||
it is in the past. Just create a new serial starting from 1."""
|
||||
return f"{today.year:4d}{today.month:02d}{today.day:02d}01"
|
||||
elif len(res) == 1:
|
||||
""" The serial contains todays date, just update it. """
|
||||
try:
|
||||
number = int(res[0])
|
||||
except ValueError:
|
||||
number = 1
|
||||
if number >= 99:
|
||||
""" This is bad... Just keep the number on 99.
|
||||
We also send a mail to sysadmins telling them that
|
||||
something is wrong..."""
|
||||
|
||||
else:
|
||||
number += 1
|
||||
return f"{today.year:4d}{today.month:02d}{today.day:02d}{number:02d}"
|
||||
else:
|
||||
""" Just return the first serial for today. """
|
||||
return f"{today.year:4d}{today.month:02d}{today.day:02d}01"
|
||||
The serial must always increase, or named and dhcpd ignore the change. Today's first
|
||||
serial is used while it is higher. Otherwise the serial goes up by one: past nn = 99
|
||||
it carries into the date (2026110299 -> 2026110300), and a serial already ahead of
|
||||
today, from that carry or a dynamic update, keeps counting from where it is.
|
||||
"""
|
||||
today_first = int(datetime.date.today().strftime("%Y%m%d")) * 100 + 1
|
||||
try:
|
||||
return max(int(serial) + 1, today_first)
|
||||
except TypeError, ValueError:
|
||||
return today_first
|
||||
+3
-9
@@ -26,7 +26,6 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||
MEDIA_URL = "/media/"
|
||||
ADMIN_MEDIA_PREFIX = "/media/"
|
||||
|
||||
# Static
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
||||
@@ -40,6 +39,7 @@ STATICFILES_FINDERS = (
|
||||
SECRET_KEY = "@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl"
|
||||
|
||||
MIDDLEWARE = (
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
# Serves STATIC_ROOT, so production needs nothing in front but a proxy.
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
@@ -47,6 +47,7 @@ MIDDLEWARE = (
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
)
|
||||
|
||||
ROOT_URLCONF = "mdbsite.urls"
|
||||
@@ -120,18 +121,11 @@ LOGGING = {
|
||||
# REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||
"PAGINATE_BY": 10,
|
||||
}
|
||||
|
||||
# PXE
|
||||
MDB_PXE_TFTP_ROOT = "/var/lib/tftpboot/pxelinux/pxelinux.cfg/"
|
||||
MDB_PXE_KERNEL = "ubuntu/14_04/amd64/alternate/linux"
|
||||
MDB_PXE_INITRD = "ubuntu/14_04/amd64/alternate/initrd.gz"
|
||||
MDB_PXE_PRESEED_URL = "http://158.36.190.194/ubuntu/preseed_1404.cfg"
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN = os.getenv("SENTRY_DSN")
|
||||
SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONENT", "dev")
|
||||
SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONMENT", "dev")
|
||||
|
||||
ADMIN_SITE_NAME = "MDB"
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[tools]
|
||||
python = "3.14"
|
||||
uv = "latest"
|
||||
prek = "latest" # git hooks, see prek.toml
|
||||
|
||||
[env]
|
||||
# settings.py leaves DEBUG off unless this says otherwise.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Git hooks, run by prek (https://prek.j178.dev). `prek install` once per clone; CI runs
|
||||
# them all (.gitea/workflows/prek.yaml).
|
||||
# them all (.gitea/workflows/ci.yaml).
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/astral-sh/ruff-pre-commit"
|
||||
rev = "v0.15.6" # keep in sync with ruff in uv.lock
|
||||
rev = "v0.17.3" # keep in sync with ruff in uv.lock
|
||||
hooks = [
|
||||
{ id = "ruff-check", args = ["--fix"] },
|
||||
{ id = "ruff-format" },
|
||||
@@ -12,7 +12,7 @@ hooks = [
|
||||
[[repos]]
|
||||
repo = "https://github.com/adamchainz/django-upgrade"
|
||||
rev = "1.33.0"
|
||||
hooks = [{ id = "django-upgrade", args = ["--target-version", "6.0"] }]
|
||||
hooks = [{ id = "django-upgrade", args = ["--target-version", "6.1"] }]
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/pre-commit/pre-commit-hooks"
|
||||
|
||||
+12
-11
@@ -5,16 +5,15 @@ description = "Machine Database - A Django app for managing computer equipment"
|
||||
authors = [
|
||||
{ name = "EDB", email = "edb@neuf.no" },
|
||||
]
|
||||
requires-python = ">=3.14"
|
||||
requires-python = ">=3.14,<3.15"
|
||||
license = "GPL-2.0-or-later"
|
||||
dependencies = [
|
||||
"django>=6.0.3",
|
||||
"django>=6.1.2",
|
||||
"django-extensions>=4.1",
|
||||
"djangorestframework>=3.17.0",
|
||||
"gunicorn>=25.1.0",
|
||||
"ipaddress>=1.0.23,<2.0.0",
|
||||
"requests>=2.32.5",
|
||||
"sentry-sdk>=2.55.0",
|
||||
"djangorestframework>=3.18.3",
|
||||
"gunicorn>=26.2.0",
|
||||
"requests>=2.34.2",
|
||||
"sentry-sdk>=2.73.0",
|
||||
"whitenoise>=6.12,<7",
|
||||
]
|
||||
|
||||
@@ -22,13 +21,13 @@ dependencies = [
|
||||
# Only needed on the server (LDAP is configured in production's settings).
|
||||
# Kept separate so CI can skip building python-ldap from source.
|
||||
prod = [
|
||||
"django-auth-ldap>=5.3.0",
|
||||
"django-auth-ldap>=5.4.0",
|
||||
]
|
||||
dev = [
|
||||
"freezegun>=1.5.5",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-django>=4.12.0",
|
||||
"ruff>=0.15.6,<1.0.0",
|
||||
"pytest>=9.2.0",
|
||||
"pytest-django>=4.14.0",
|
||||
"ruff>=0.17.3,<1.0.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -52,6 +51,8 @@ ignore = [
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "mdbsite.settings"
|
||||
python_files = ["tests/test_*.py"]
|
||||
# WhiteNoise, since tests run without collectstatic.
|
||||
filterwarnings = ["ignore:No directory at:UserWarning"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -41,3 +41,63 @@ def test_validate_puppet_host_by_secret(api_client, user_with_token, host):
|
||||
res = api_client.post(reverse("api-validate-host-secret"), data, format="json")
|
||||
assert res.status_code == 200, res.content
|
||||
assert not Host.objects.get(pk=host.pk).pxe_installable
|
||||
|
||||
|
||||
def post_validate(api_client, token, certname, pxe_key):
|
||||
api_client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
|
||||
return api_client.post(
|
||||
reverse("api-validate-host-secret"),
|
||||
{"certname": certname, "pxe_key": pxe_key},
|
||||
format="json",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("change", "certname", "error"),
|
||||
[
|
||||
({}, "nosuchhost.edb.technology", "not found"),
|
||||
({}, "tut.nosuchdomain.example", "not found"),
|
||||
({"pxe_installable": False}, "tut.edb.technology", "not installable"),
|
||||
({"pxe_key": ""}, "tut.edb.technology", "has no pxe_key"),
|
||||
],
|
||||
)
|
||||
def test_validate_rejects(api_client, user_with_token, host, change, certname, error):
|
||||
_, token = user_with_token
|
||||
Host.objects.filter(pk=host.pk).update(**change)
|
||||
|
||||
res = post_validate(api_client, token, certname, host.pxe_key)
|
||||
|
||||
assert res.status_code == 400
|
||||
assert error in str(res.content)
|
||||
|
||||
|
||||
def test_validate_rejects_wrong_key(api_client, user_with_token, host):
|
||||
_, token = user_with_token
|
||||
res = post_validate(api_client, token, "tut.edb.technology", "wrong")
|
||||
assert res.status_code == 400
|
||||
assert "does not match" in str(res.content)
|
||||
# A wrong guess must not use up the installation.
|
||||
host.refresh_from_db()
|
||||
assert host.pxe_installable
|
||||
assert host.pxe_key
|
||||
|
||||
|
||||
def test_validate_key_works_only_once(api_client, user_with_token, host):
|
||||
_, token = user_with_token
|
||||
key = host.pxe_key
|
||||
assert (
|
||||
post_validate(api_client, token, "tut.edb.technology", key).status_code == 200
|
||||
)
|
||||
assert (
|
||||
post_validate(api_client, token, "tut.edb.technology", key).status_code == 400
|
||||
)
|
||||
|
||||
|
||||
def test_validate_requires_a_token(api_client, host):
|
||||
res = api_client.post(
|
||||
reverse("api-validate-host-secret"),
|
||||
{"certname": "tut.edb.technology", "pxe_key": host.pxe_key},
|
||||
format="json",
|
||||
)
|
||||
assert res.status_code == 401
|
||||
assert Host.objects.get(pk=host.pk).pxe_installable
|
||||
@@ -115,7 +115,7 @@ def test_restart_and_notify_success_passes_diff_as_preformatted(cmd):
|
||||
def test_restart_and_notify_failure_writes_fail_and_exits(cmd):
|
||||
svc, config = make_service(restart_success=False, restart_output="Job failed")
|
||||
with patch("mdb.management.commands.dhcp_sync.discord"):
|
||||
with patch("os.getlogin", return_value="testuser"):
|
||||
with patch("getpass.getuser", return_value="testuser"):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cmd.restart_and_notify(svc, config, old_serial=1, diff="")
|
||||
assert exc.value.code == 1
|
||||
@@ -125,7 +125,7 @@ def test_restart_and_notify_failure_writes_fail_and_exits(cmd):
|
||||
def test_restart_and_notify_failure_discord_includes_command_and_output(cmd):
|
||||
svc, config = make_service(restart_success=False, restart_output="Job failed")
|
||||
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
|
||||
with patch("os.getlogin", return_value="testuser"):
|
||||
with patch("getpass.getuser", return_value="testuser"):
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.restart_and_notify(svc, config, old_serial=1, diff="")
|
||||
subject = mock_discord.notify.call_args.kwargs["subject"]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from mdb.forms import InterfaceForm
|
||||
from mdb.models import Host, Interface, Ip4Address
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures(db):
|
||||
call_command("loaddata", "test_data", verbosity=0)
|
||||
|
||||
|
||||
def form(instance=None, **overrides):
|
||||
data = {
|
||||
"name": "eth1",
|
||||
"macaddr": "00:11:22:33:44:55",
|
||||
"host": Host.objects.get().pk,
|
||||
"domain": 1,
|
||||
"ip4address": Ip4Address.objects.get(address="10.0.3.10").pk,
|
||||
"pxe_filename": "",
|
||||
} | overrides
|
||||
return InterfaceForm(data=data, instance=instance)
|
||||
|
||||
|
||||
def test_valid(fixtures):
|
||||
f = form()
|
||||
assert f.is_valid(), f.errors
|
||||
|
||||
|
||||
def test_invalid_mac_is_a_form_error(fixtures):
|
||||
# Used to raise KeyError, a 500 in the admin.
|
||||
f = form(macaddr="not a mac")
|
||||
assert not f.is_valid()
|
||||
assert "macaddr" in f.errors
|
||||
|
||||
|
||||
def test_interface_without_ip4address(fixtures):
|
||||
f = form(ip4address="")
|
||||
assert f.is_valid(), f.errors
|
||||
|
||||
|
||||
@pytest.mark.parametrize("macaddr", ["f0:de:f1:58:be:1a", "F0:DE:F1:58:BE:1A"])
|
||||
def test_mac_already_in_subnet(fixtures, macaddr):
|
||||
f = form(macaddr=macaddr)
|
||||
assert not f.is_valid()
|
||||
assert f.non_field_errors() == ["Macaddr already in this subnet"]
|
||||
|
||||
|
||||
def test_mac_in_use_by_interface_without_ip4address(fixtures):
|
||||
# Used to raise AttributeError on the other interface's missing address.
|
||||
Interface.objects.update(ip4address=None)
|
||||
f = form(macaddr="f0:de:f1:58:be:1a")
|
||||
assert f.is_valid(), f.errors
|
||||
|
||||
|
||||
def test_editing_interface_does_not_clash_with_itself(fixtures):
|
||||
interface = Interface.objects.get()
|
||||
f = form(
|
||||
instance=interface,
|
||||
name="eth0",
|
||||
macaddr=interface.macaddr,
|
||||
ip4address=interface.ip4address.pk,
|
||||
)
|
||||
assert f.is_valid(), f.errors
|
||||
@@ -0,0 +1,142 @@
|
||||
"""The dhcpd.conf and reverse zones generated from the test_data fixture."""
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from freezegun import freeze_time
|
||||
|
||||
from mdb.models import DhcpConfig, Interface, Ip4Subnet, Ip6Subnet
|
||||
from mdb.services.zone_sync import ZoneSyncService
|
||||
|
||||
EXPECTED_DHCPD_CONF = """\
|
||||
# Autogenerated configuration 2024-01-01 00:00:00
|
||||
default-lease-time 600;
|
||||
max-lease-time 7200;
|
||||
log-facility local7;
|
||||
ddns-update-style none;
|
||||
|
||||
# clients
|
||||
subnet 10.0.3.0 netmask 255.255.255.0 {
|
||||
\toption domain-name "edb.technology";
|
||||
\toption domain-name-servers 158.36.190.194, 158.36.190.199, 8.8.8.8;
|
||||
\toption routers 158.36.186.1;
|
||||
\tdeny unknown-clients;
|
||||
\trange 10.0.3.2 10.0.3.254;
|
||||
}
|
||||
|
||||
host tut {
|
||||
\thardware ethernet f0:de:f1:58:be:1a;
|
||||
\tfixed-address tut.edb.technology;
|
||||
\tif substring(option vendor-class-identifier, 0, 20) = "PXEClient:Arch:00007" \
|
||||
or substring(option vendor-class-identifier, 0, 20) = "PXEClient:Arch:00009" {
|
||||
\t\tfilename "grubx64.efi";
|
||||
\t} else {
|
||||
\t\tfilename "pxelinux.0";
|
||||
\t}
|
||||
}
|
||||
"""
|
||||
|
||||
SOA_AND_NS = """\
|
||||
$TTL 3600
|
||||
@ IN SOA sega.neuf.no. sysadmin.neuf.no. (
|
||||
\t{serial}\t; serial
|
||||
\t28800\t; refresh
|
||||
\t7200\t; retry
|
||||
\t604800\t; expire
|
||||
\t86400 )\t; minimum ttl
|
||||
;
|
||||
;
|
||||
@\tIN\tNS\tsega.neuf.no.
|
||||
@\tIN\tNS\tatari.neuf.no.
|
||||
;
|
||||
"""
|
||||
|
||||
EXPECTED_IP4_REVERSE = (
|
||||
SOA_AND_NS.format(serial=2024010101)
|
||||
+ "2 \tIN\tPTR\ttut.edb.technology.\n"
|
||||
)
|
||||
|
||||
EXPECTED_IP6_REVERSE = (
|
||||
SOA_AND_NS.format(serial=1)
|
||||
+ "2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0\tPTR\ttut.edb.technology.\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures(db):
|
||||
# Loading the interface bumps the subnet's serial to the frozen day's first.
|
||||
with freeze_time("2024-01-01 00:00:00"):
|
||||
call_command("loaddata", "test_data", verbosity=0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(tmp_path):
|
||||
svc = ZoneSyncService()
|
||||
svc.checkzone_dir = str(tmp_path)
|
||||
return svc
|
||||
|
||||
|
||||
def body(zone_file):
|
||||
"""A zone file without its header comments, which name the file and the time."""
|
||||
return zone_file[zone_file.index("$TTL") :]
|
||||
|
||||
|
||||
@freeze_time("2024-01-01 00:00:00")
|
||||
def test_dhcpd_configuration(fixtures):
|
||||
assert DhcpConfig.objects.get().dhcpd_configuration() == EXPECTED_DHCPD_CONF
|
||||
|
||||
|
||||
@freeze_time("2024-01-01 00:00:00")
|
||||
def test_dhcpd_configuration_authoritative(fixtures):
|
||||
DhcpConfig.objects.update(authoritative=True)
|
||||
conf = DhcpConfig.objects.get().dhcpd_configuration()
|
||||
assert conf.splitlines()[1] == "authoritative;"
|
||||
|
||||
|
||||
def test_dhcpd_configuration_leaves_out_non_dhcp_hosts(fixtures):
|
||||
Interface.objects.update(dhcp_client=False)
|
||||
assert "host tut" not in DhcpConfig.objects.get().dhcpd_configuration()
|
||||
|
||||
|
||||
def test_dhcpd_configuration_without_pxe_filename(fixtures):
|
||||
Interface.objects.update(pxe_filename="")
|
||||
conf = DhcpConfig.objects.get().dhcpd_configuration()
|
||||
assert "host tut {" in conf
|
||||
assert "filename" not in conf
|
||||
|
||||
|
||||
def test_dhcpd_configuration_with_custom_pxe_filename(fixtures):
|
||||
Interface.objects.update(pxe_filename="custom.efi")
|
||||
conf = DhcpConfig.objects.get().dhcpd_configuration()
|
||||
assert '\tfilename "custom.efi";\n' in conf
|
||||
assert "vendor-class-identifier" not in conf
|
||||
|
||||
|
||||
def test_dhcpd_configuration_without_dynamic_range(fixtures):
|
||||
Ip4Subnet.objects.update(dhcp_dynamic=False)
|
||||
assert "range" not in DhcpConfig.objects.get().dhcpd_configuration()
|
||||
|
||||
|
||||
def test_ip4_reverse_zone(fixtures, service):
|
||||
zone = Ip4Subnet.objects.get()
|
||||
contents = zone.zone_file_contents()
|
||||
assert body(contents) == EXPECTED_IP4_REVERSE
|
||||
status, output = service.check_zone(zone, contents)
|
||||
assert status == 0, output
|
||||
|
||||
|
||||
def test_ip4_reverse_zone_leaves_out_unassigned_addresses(fixtures):
|
||||
Interface.objects.update(ip4address=None)
|
||||
assert "PTR" not in Ip4Subnet.objects.get().zone_file_contents()
|
||||
|
||||
|
||||
def test_ip6_reverse_zone(fixtures, service):
|
||||
zone = Ip6Subnet.objects.get()
|
||||
contents = zone.zone_file_contents()
|
||||
assert body(contents) == EXPECTED_IP6_REVERSE
|
||||
status, output = service.check_zone(zone, contents)
|
||||
assert status == 0, output
|
||||
|
||||
|
||||
def test_ip6_reverse_zone_name():
|
||||
zone = Ip6Subnet(network="2001:16d8:eeb6:3", netmask=64)
|
||||
assert zone.reverse_zone_name == "3.0.0.0.6.b.e.e.8.d.6.1.1.0.0.2.ip6.arpa"
|
||||
+70
-25
@@ -1,9 +1,9 @@
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
|
||||
from mdb.models import Host, OperatingSystem
|
||||
from mdb.pxe import render_pxelinux_cfg
|
||||
from mdb.models import Host, Interface
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -16,30 +16,75 @@ def host(db_with_fixtures):
|
||||
return Host.objects.first()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_render_pxeconfig(host):
|
||||
interface = host.interface_set.exclude(ip4address__isnull=True).first()
|
||||
config = render_pxelinux_cfg(interface, host)
|
||||
def test_saving_installable_host_boots_it_from_the_network(host):
|
||||
Interface.objects.update(pxe_filename="", dhcp_client=False)
|
||||
host.save()
|
||||
|
||||
assert settings.MDB_PXE_INITRD in config
|
||||
assert settings.MDB_PXE_KERNEL in config
|
||||
assert settings.MDB_PXE_PRESEED_URL in config
|
||||
interface = host.interface_set.get()
|
||||
assert interface.pxe_filename == "pxelinux.0"
|
||||
assert interface.dhcp_client
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_render_pxeconfig_with_os_pxe_config(host):
|
||||
_os = OperatingSystem.objects.get(pk=2) # OS with all pxe vars set
|
||||
Host.objects.filter(pk=host.pk).update(operating_system=_os)
|
||||
host = Host.objects.first()
|
||||
|
||||
interface = host.interface_set.exclude(ip4address__isnull=True).first()
|
||||
config = render_pxelinux_cfg(interface, host)
|
||||
|
||||
assert _os.pxe_initrd in config
|
||||
assert _os.pxe_kernel in config
|
||||
assert _os.pxe_preseed_config_url in config
|
||||
def test_saving_host_keeps_custom_pxe_filename(host):
|
||||
Interface.objects.update(pxe_filename="custom.efi")
|
||||
host.save()
|
||||
assert host.interface_set.get().pxe_filename == "custom.efi"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_host_as_pxe_files(host):
|
||||
assert len(host.as_pxe_files()) == 1
|
||||
def test_saving_non_installable_host_stops_netboot(host):
|
||||
Interface.objects.update(pxe_filename="pxelinux.0", dhcp_client=True)
|
||||
host.pxe_installable = False
|
||||
host.save()
|
||||
|
||||
interface = host.interface_set.get()
|
||||
assert interface.pxe_filename == ""
|
||||
# The host keeps its address.
|
||||
assert interface.dhcp_client
|
||||
|
||||
|
||||
def test_saving_non_installable_host_keeps_custom_pxe_filename(host):
|
||||
Interface.objects.update(pxe_filename="custom.efi")
|
||||
host.pxe_installable = False
|
||||
host.save()
|
||||
assert host.interface_set.get().pxe_filename == "custom.efi"
|
||||
|
||||
|
||||
def test_saving_host_without_key_generates_one(host):
|
||||
host.pxe_key = ""
|
||||
host.save()
|
||||
assert len(Host.objects.get(pk=host.pk).pxe_key) == 32
|
||||
|
||||
|
||||
def test_admin_marks_hosts_installable(host, admin_client):
|
||||
Host.objects.update(pxe_installable=False)
|
||||
Interface.objects.update(pxe_filename="")
|
||||
|
||||
res = admin_client.post(
|
||||
reverse("admin:mdb_host_changelist"),
|
||||
{"action": "set_installable", "_selected_action": [host.pk]},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert Host.objects.get(pk=host.pk).pxe_installable
|
||||
assert host.interface_set.get().pxe_filename == "pxelinux.0"
|
||||
assert not [m for m in res.context["messages"] if m.level == messages.WARNING]
|
||||
|
||||
|
||||
def test_admin_warns_about_interfaces_without_ip(host, admin_client):
|
||||
Host.objects.update(pxe_installable=False)
|
||||
Interface.objects.create(
|
||||
name="eth1", macaddr="00:11:22:33:44:55", host=host, domain_id=1
|
||||
)
|
||||
|
||||
res = admin_client.post(
|
||||
reverse("admin:mdb_host_changelist"),
|
||||
{"action": "set_installable", "_selected_action": [host.pk]},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
warnings = [str(m) for m in res.context["messages"] if m.level == messages.WARNING]
|
||||
assert warnings == [
|
||||
"Host tut might not be installable since interface(s) eth1 has no IP set."
|
||||
]
|
||||
assert Host.objects.get(pk=host.pk).pxe_installable
|
||||
+20
-1
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from mdb.models import DhcpConfig, Ip4Subnet, Ip6Subnet
|
||||
from mdb.models import DhcpConfig, Domain, Interface, Ip4Subnet, Ip6Subnet
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pre_save signals: populate domain_name once, then leave it frozen
|
||||
@@ -65,3 +66,21 @@ def test_ip6_signal_sets_domain_name():
|
||||
domain_filename="db.reverse-v6",
|
||||
)
|
||||
assert subnet.domain_name == "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pre_delete Interface: the zones it was in get a new serial
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@freeze_time("2026-11-02")
|
||||
def test_deleting_interface_raises_zone_serials(domain):
|
||||
subnet = Interface.objects.get(pk=1).ip4address.subnet
|
||||
Domain.objects.filter(pk=domain.pk).update(domain_serial=2026110299)
|
||||
Ip4Subnet.objects.filter(pk=subnet.pk).update(domain_serial=2015010101)
|
||||
|
||||
Interface.objects.get(pk=1).delete()
|
||||
|
||||
# Past 99 the serial carries into the date instead of going back.
|
||||
assert Domain.objects.get(pk=domain.pk).domain_serial == 2026110300
|
||||
assert Ip4Subnet.objects.get(pk=subnet.pk).domain_serial == 2026110201
|
||||
@@ -0,0 +1,32 @@
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from mdb.utils import format_domain_serial_and_add_one
|
||||
|
||||
|
||||
@freeze_time("2026-11-02")
|
||||
@pytest.mark.parametrize(
|
||||
("serial", "expected"),
|
||||
[
|
||||
(2026110205, 2026110206), # today: next number
|
||||
(2025010107, 2026110201), # an earlier day: today's first
|
||||
(1, 2026110201), # not a date at all
|
||||
(2026110298, 2026110299),
|
||||
(2026110299, 2026110300), # used to stay at 99, so the change never synced
|
||||
(2026110300, 2026110301), # ahead of today: never goes back
|
||||
(2026110505, 2026110506),
|
||||
(None, 2026110201),
|
||||
("garbage", 2026110201),
|
||||
],
|
||||
)
|
||||
def test_next_serial(serial, expected):
|
||||
assert format_domain_serial_and_add_one(serial) == expected
|
||||
|
||||
|
||||
@freeze_time("2026-11-02")
|
||||
def test_serial_always_increases():
|
||||
serial = 2026110290
|
||||
for _ in range(30):
|
||||
new = format_domain_serial_and_add_one(serial)
|
||||
assert new > serial
|
||||
serial = new
|
||||
+52
-1
@@ -1,12 +1,13 @@
|
||||
import os
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from mdb.models import (
|
||||
DhcpConfig,
|
||||
DomainARecord,
|
||||
Host,
|
||||
HostType,
|
||||
Interface,
|
||||
@@ -320,3 +321,53 @@ def test_zone_file_contents_includes_ip4_in_spf_record_when_interface_has_addres
|
||||
add_host(db_domain, "mailhost", allow_email=True, ip4address=address)
|
||||
content = db_domain.zone_file_contents()
|
||||
assert f'mailhost\tTXT\t"v=spf1 mx ip4:{address.address} -all"\n' in content
|
||||
|
||||
|
||||
def test_sync_zone_does_not_write_a_zone_that_fails_validation(db_domain, tmp_path):
|
||||
with open(db_domain.domain_filename, "w") as f:
|
||||
f.write("the live zone\n")
|
||||
DomainARecord.objects.create(domain=db_domain, name="bad", target="not-an-address")
|
||||
svc = ZoneSyncService(debug=False)
|
||||
svc.checkzone_dir = str(tmp_path)
|
||||
|
||||
assert svc.sync_zone(db_domain) is False
|
||||
|
||||
with open(db_domain.domain_filename) as f:
|
||||
assert f.read() == "the live zone\n"
|
||||
db_domain.refresh_from_db()
|
||||
assert db_domain.domain_active_serial == 0
|
||||
change = svc.changes["example.org"]
|
||||
assert change["success"] is False
|
||||
assert "not-an-address" in change["errors"]
|
||||
|
||||
|
||||
def test_write_zone_file_replaces_file_and_keeps_its_mode(service, tmp_path):
|
||||
path = tmp_path / "db.example.org"
|
||||
path.write_text("old\n")
|
||||
path.chmod(0o664)
|
||||
inode = path.stat().st_ino
|
||||
|
||||
service.write_zone_file(str(path), "new\n")
|
||||
|
||||
assert path.read_text() == "new\n"
|
||||
assert path.stat().st_mode & 0o777 == 0o664
|
||||
assert path.stat().st_ino != inode # renamed over, not written in place
|
||||
assert os.listdir(tmp_path) == ["db.example.org"]
|
||||
|
||||
|
||||
def test_write_zone_file_creates_missing_file(service, tmp_path):
|
||||
path = tmp_path / "db.example.org"
|
||||
service.write_zone_file(str(path), "new\n")
|
||||
assert path.read_text() == "new\n"
|
||||
assert path.stat().st_mode & 0o777 == 0o644
|
||||
|
||||
|
||||
def test_write_zone_file_leaves_old_file_when_write_fails(service, tmp_path):
|
||||
path = tmp_path / "db.example.org"
|
||||
path.write_text("old\n")
|
||||
|
||||
with patch("os.fsync", side_effect=OSError("disk full")), pytest.raises(OSError):
|
||||
service.write_zone_file(str(path), "new\n")
|
||||
|
||||
assert path.read_text() == "old\n"
|
||||
assert os.listdir(tmp_path) == ["db.example.org"]
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import signal
|
||||
from io import StringIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -8,6 +9,13 @@ from mdb.management.commands.zone_sync import Command as CommandClass
|
||||
from mdb.services.zone_sync import ZoneSyncService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def checkzone_dir(tmp_path, monkeypatch):
|
||||
"""Where the frozen marker lives; not the real /tmp/mdb-checkzone."""
|
||||
monkeypatch.setattr(ZoneSyncService, "checkzone_dir", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmd():
|
||||
c = CommandClass()
|
||||
@@ -99,7 +107,7 @@ def test_reload_and_notify_failure_writes_fail_and_exits(cmd):
|
||||
svc = make_service()
|
||||
svc.reload.return_value = (False, "/usr/sbin/rndc reload", "timed out")
|
||||
with patch("mdb.management.commands.zone_sync.discord"):
|
||||
with patch("os.getlogin", return_value="testuser"):
|
||||
with patch("getpass.getuser", return_value="testuser"):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cmd.reload_and_notify(svc, svc.changes)
|
||||
assert exc.value.code == 1
|
||||
@@ -110,7 +118,7 @@ def test_reload_and_notify_failure_discord_includes_command_and_output(cmd):
|
||||
svc = make_service()
|
||||
svc.reload.return_value = (False, "/usr/sbin/rndc reload", "timed out")
|
||||
with patch("mdb.management.commands.zone_sync.discord") as mock_discord:
|
||||
with patch("os.getlogin", return_value="testuser"):
|
||||
with patch("getpass.getuser", return_value="testuser"):
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.reload_and_notify(svc, svc.changes)
|
||||
subject = mock_discord.notify.call_args.kwargs["subject"]
|
||||
@@ -185,15 +193,15 @@ def test_handle_writes_nothing_when_freeze_fails(cmd, db_domain):
|
||||
patch.object(
|
||||
ZoneSyncService, "freeze", return_value=(False, "rndc freeze", "no")
|
||||
),
|
||||
patch.object(ZoneSyncService, "run_commands") as mock_run,
|
||||
patch.object(ZoneSyncService, "thaw", return_value=(True, "", "")) as thaw,
|
||||
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
|
||||
patch("os.getlogin", return_value="testuser"),
|
||||
patch("getpass.getuser", return_value="testuser"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
assert not os.path.exists(db_domain.domain_filename)
|
||||
mock_run.assert_called_once_with([ZoneSyncService.thaw_command])
|
||||
thaw.assert_called_once()
|
||||
assert "- example.org" in mock_discord.notify.call_args.kwargs["message"]
|
||||
|
||||
|
||||
@@ -201,10 +209,161 @@ def test_handle_writes_nothing_when_freeze_fails(cmd, db_domain):
|
||||
def test_handle_thaws_when_sync_raises(cmd, db_domain):
|
||||
with (
|
||||
patch.object(ZoneSyncService, "freeze", return_value=(True, "", "")),
|
||||
patch.object(ZoneSyncService, "reload") as mock_reload,
|
||||
patch.object(
|
||||
ZoneSyncService, "reload", return_value=(True, "", "")
|
||||
) as mock_reload,
|
||||
patch.object(ZoneSyncService, "sync_zone", side_effect=RuntimeError),
|
||||
patch("mdb.management.commands.zone_sync.discord"),
|
||||
pytest.raises(RuntimeError),
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
mock_reload.assert_called_once()
|
||||
|
||||
|
||||
# --- stopped and killed syncs ---
|
||||
|
||||
|
||||
def rndc(results=None):
|
||||
"""Patch rndc: every command succeeds unless results maps it to (status, output)."""
|
||||
results = results or {}
|
||||
calls = []
|
||||
|
||||
def run(command):
|
||||
calls.append(command.rsplit(" ", 1)[-1])
|
||||
return results.get(command, (0, ""))
|
||||
|
||||
return calls, patch("mdb.services.zone_sync.getstatusoutput", side_effect=run)
|
||||
|
||||
|
||||
def test_reload_thaws_even_when_reload_fails(service):
|
||||
calls, rndc_patch = rndc({ZoneSyncService.reload_command: (1, "timed out")})
|
||||
with rndc_patch:
|
||||
success, failed_command, output = service.reload()
|
||||
assert calls == ["reload", "thaw"]
|
||||
assert (success, failed_command, output) == (
|
||||
False,
|
||||
ZoneSyncService.reload_command,
|
||||
"timed out",
|
||||
)
|
||||
|
||||
|
||||
def test_marker_exists_only_while_frozen(service):
|
||||
calls, rndc_patch = rndc()
|
||||
with rndc_patch:
|
||||
service.freeze()
|
||||
assert service.frozen_since() is not None
|
||||
service.reload()
|
||||
assert service.frozen_since() is None
|
||||
|
||||
|
||||
def test_marker_stays_when_thaw_fails(service):
|
||||
calls, rndc_patch = rndc({ZoneSyncService.thaw_command: (1, "no")})
|
||||
with rndc_patch:
|
||||
service.freeze()
|
||||
service.reload()
|
||||
assert service.frozen_since() is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("signum", [signal.SIGHUP, signal.SIGTERM, signal.SIGINT])
|
||||
def test_handle_thaws_and_warns_when_stopped_by_signal(cmd, db_domain, signum):
|
||||
def killed(zone):
|
||||
os.kill(os.getpid(), signum)
|
||||
|
||||
calls, rndc_patch = rndc()
|
||||
previous = signal.getsignal(signum)
|
||||
with (
|
||||
rndc_patch,
|
||||
patch.object(ZoneSyncService, "sync_zone", side_effect=killed),
|
||||
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
|
||||
pytest.raises(SystemExit) as exc,
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
assert exc.value.code == 128 + signum
|
||||
assert calls == ["freeze", "reload", "thaw"]
|
||||
assert ZoneSyncService().frozen_since() is None
|
||||
kwargs = mock_discord.notify.call_args.kwargs
|
||||
assert kwargs["subject"] == "⚠️ Zone sync interrupted, zones thawed"
|
||||
assert signal.Signals(signum).name in kwargs["message"]
|
||||
assert "Not written, run mdb-sync again:\n- example.org" in kwargs["message"]
|
||||
assert signal.getsignal(signum) == previous
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_ignores_signals_while_rndc_runs(cmd, db_domain):
|
||||
seen = []
|
||||
|
||||
def run(commands):
|
||||
seen.append((commands, signal.getsignal(signal.SIGHUP)))
|
||||
return True, "", ""
|
||||
|
||||
with (
|
||||
patch.object(ZoneSyncService, "run_commands", side_effect=run),
|
||||
patch("mdb.management.commands.zone_sync.discord"),
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
# Signals still stop named-checkzone and the writes, between freeze and reload.
|
||||
assert seen == [
|
||||
([ZoneSyncService.freeze_command], signal.SIG_IGN),
|
||||
([ZoneSyncService.reload_command], signal.SIG_IGN),
|
||||
([ZoneSyncService.thaw_command], signal.SIG_IGN),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_warns_when_reload_after_signal_fails(cmd, db_domain):
|
||||
calls, rndc_patch = rndc({ZoneSyncService.thaw_command: (1, "no")})
|
||||
with (
|
||||
rndc_patch,
|
||||
patch.object(
|
||||
ZoneSyncService,
|
||||
"sync_zone",
|
||||
side_effect=lambda zone: os.kill(os.getpid(), signal.SIGHUP),
|
||||
),
|
||||
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
|
||||
patch("getpass.getuser", return_value="testuser"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
kwargs = mock_discord.notify.call_args.kwargs
|
||||
assert kwargs["subject"] == "⚠️ FAIL: bind"
|
||||
assert "may still be frozen" in kwargs["message"]
|
||||
assert ZoneSyncService().frozen_since() is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_thaws_zones_a_killed_sync_left_frozen(cmd, checkzone_dir):
|
||||
(checkzone_dir / "zones-frozen").write_text("1234\n")
|
||||
calls, rndc_patch = rndc()
|
||||
with (
|
||||
rndc_patch,
|
||||
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
assert calls == ["reload", "thaw"]
|
||||
assert ZoneSyncService().frozen_since() is None
|
||||
kwargs = mock_discord.notify.call_args.kwargs
|
||||
assert kwargs["subject"] == "⚠️ Zones thawed after a killed zone sync"
|
||||
assert "left every zone frozen" in kwargs["message"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_stops_when_thawing_a_killed_sync_fails(cmd, db_domain, checkzone_dir):
|
||||
(checkzone_dir / "zones-frozen").write_text("1234\n")
|
||||
calls, rndc_patch = rndc({ZoneSyncService.reload_command: (1, "no")})
|
||||
with (
|
||||
rndc_patch,
|
||||
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
|
||||
patch("getpass.getuser", return_value="testuser"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
assert calls == ["reload", "thaw"] # nothing frozen again, nothing written
|
||||
assert not os.path.exists(db_domain.domain_filename)
|
||||
assert "thawing them failed" in mock_discord.notify.call_args.kwargs["message"]
|
||||
@@ -1,64 +1,85 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
requires-python = ">=3.14"
|
||||
requires-python = "==3.14.*"
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.11.1"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/90/f5fce14e95fccf53b58e8e2c47cd84d40d105d64073ae1ac75166db150e0/asgiref-3.13.0.tar.gz", hash = "sha256:034cc8cbf72d00553b4be256703ac545120b4e50af13b49dec99da899aeeecb4", size = 52941, upload-time = "2026-10-23T08:13:14.715Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b0/ec211e1f7966247cb6469158d856bac9aa4c2fe13621bd6a352e4f850453/asgiref-3.13.0-py3-none-any.whl", hash = "sha256:f9596a2434512686473d50f96ff6207fbba49ae1a785dfac614df4aeb085674d", size = 27593, upload-time = "2026-10-23T08:13:13.394Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.4.26"
|
||||
version = "2026.10.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.6"
|
||||
version = "3.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -72,29 +93,29 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "6.0.3"
|
||||
version = "6.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/24/20d187a22ac821288b53c53b8f696019dcb0c7b4f973f052ade19392c74d/django-6.1.2.tar.gz", hash = "sha256:a1e92451ccb8b514e91bbb3b6d186d20b4030558f116b5d9de6535455ff210b7", size = 11251425, upload-time = "2026-10-06T12:53:29.622Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/83/90ff2dbfac7b519ed77620bdbb827cd2d1a83faf00d076bf95056428bce7/django-6.1.2-py3-none-any.whl", hash = "sha256:141efee6ec64d1db6db90683bf734c550102450f444fb099063b0be1bd27d991", size = 8420684, upload-time = "2026-10-06T12:53:17.381Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-auth-ldap"
|
||||
version = "5.3.0"
|
||||
version = "5.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "python-ldap" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/6d/d3ceb4b49e7153811a4b2d92bbe198a5ef2e2820469add3d6dc129ef2fab/django_auth_ldap-5.3.0.tar.gz", hash = "sha256:743d8107b146240b46f7e97207dc06cb11facc0cd70dce490b7ca09dd5643d19", size = 55272, upload-time = "2025-12-26T15:00:14.272Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/3a/ff3c0ac9cd315a4b872b0c913463643ce2df1306b10db1e054136edf579a/django_auth_ldap-5.4.0.tar.gz", hash = "sha256:66740dbfba0f41ea6c030bc53974787cbc2f27ed1f3796ebf9fe0cbc9917605e", size = 56416, upload-time = "2026-10-20T21:29:07.937Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/91/38ba24b9d76925ce166b2eebe1b4ea460063b8ba8cf91d39d97ee3bad517/django_auth_ldap-5.3.0-py3-none-any.whl", hash = "sha256:aa880415983149b072f876d976ef8ec755a438090e176817998263a6ed9e1038", size = 20975, upload-time = "2025-12-26T15:00:12.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/95/93bf708f2cba96e5c2ab6aabc3694c7c3dbdd25df1461af2e16a09d1bce8/django_auth_ldap-5.4.0-py3-none-any.whl", hash = "sha256:8a8e38add49be404e885e94216ff8e7611e11e55bf6cb6576c3b560a6a25dc20", size = 20893, upload-time = "2026-10-20T21:29:06.383Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -111,14 +132,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework"
|
||||
version = "3.17.0"
|
||||
version = "3.18.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/46/615ad5425c7b941e6fd3f382d25b1aca680022200d8e5b87bfc665c4425d/djangorestframework-3.17.0.tar.gz", hash = "sha256:456fd992a33f9e64c9d0f47e85d9787db0efb44f894c1e513315b5e74765bd4c", size = 905671, upload-time = "2026-03-18T20:33:42.861Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/b2/1cee76b188301f7d03169ca77fab2b3d3ec5d01b3469189f944af2d7f11a/djangorestframework-3.18.3.tar.gz", hash = "sha256:446a9b352e7eff630421ab3f2328bd2401b109a9470afa4a31189994911ed030", size = 923563, upload-time = "2026-10-06T21:33:51.521Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ee/01dfe0e712d37007a4417bf7ba518a9e6830d4de009ed4bfdfebd45b99cf/djangorestframework-3.17.0-py3-none-any.whl", hash = "sha256:d84fe85f30b7ac6e8c0076ce9ff635e4eaedca5912f8d7d2926ce448c08533ba", size = 898818, upload-time = "2026-03-18T20:33:41.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/dd/de5f4543765bec94e036a5e9593a42f8fa7efec8de78eb56b9ca834385f3/djangorestframework-3.18.3-py3-none-any.whl", hash = "sha256:8544bb674846731b1e3c9b309236ee1dc412905a0aa725be2ec193ca950a7d12", size = 903986, upload-time = "2026-10-06T21:33:49.917Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -135,41 +156,29 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "25.1.0"
|
||||
version = "26.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
version = "2.3.1"
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||
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 = "ipaddress"
|
||||
version = "1.0.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/9a/3e9da40ea28b8210dd6504d3fe9fe7e013b62bf45902b458d1cdc3c34ed9/ipaddress-1.0.23.tar.gz", hash = "sha256:b7f8e0369580bb4a24d5ba1d7cc29660a4a6987763faf1d8a8046830e020e7e2", size = 32958, upload-time = "2019-10-18T01:30:24.58Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f8/49697181b1651d8347d24c095ce46c7346c37335ddc7d255833e7cde674d/ipaddress-1.0.23-py2.py3-none-any.whl", hash = "sha256:6e0f4a39e66cb5bb9a137b00276a2eff74f93b71dcbdad6f10ff7df9d3557fcc", size = 18159, upload-time = "2019-10-18T01:30:27.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -182,7 +191,6 @@ dependencies = [
|
||||
{ name = "django-extensions" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "ipaddress" },
|
||||
{ name = "requests" },
|
||||
{ name = "sentry-sdk" },
|
||||
{ name = "whitenoise" },
|
||||
@@ -201,50 +209,49 @@ prod = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "django", specifier = ">=6.0.3" },
|
||||
{ name = "django", specifier = ">=6.1.2" },
|
||||
{ name = "django-extensions", specifier = ">=4.1" },
|
||||
{ name = "djangorestframework", specifier = ">=3.17.0" },
|
||||
{ name = "gunicorn", specifier = ">=25.1.0" },
|
||||
{ name = "ipaddress", specifier = ">=1.0.23,<2.0.0" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
{ name = "sentry-sdk", specifier = ">=2.55.0" },
|
||||
{ name = "djangorestframework", specifier = ">=3.18.3" },
|
||||
{ name = "gunicorn", specifier = ">=26.2.0" },
|
||||
{ name = "requests", specifier = ">=2.34.2" },
|
||||
{ name = "sentry-sdk", specifier = ">=2.73.0" },
|
||||
{ name = "whitenoise", specifier = ">=6.12,<7" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "freezegun", specifier = ">=1.5.5" },
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
{ name = "pytest-django", specifier = ">=4.12.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.6,<1.0.0" },
|
||||
{ name = "pytest", specifier = ">=9.2.0" },
|
||||
{ name = "pytest-django", specifier = ">=4.14.0" },
|
||||
{ name = "ruff", specifier = ">=0.17.3,<1.0.0" },
|
||||
]
|
||||
prod = [{ name = "django-auth-ldap", specifier = ">=5.3.0" }]
|
||||
prod = [{ name = "django-auth-ldap", specifier = ">=5.4.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
version = "1.7.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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
version = "0.6.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/7e/3c0da41061a2021cac7cd524fd57092adbe5b515e8bf9005702e366802e2/pyasn1-0.6.5.tar.gz", hash = "sha256:6b11616a6bcf3a40e635f082bb232fa57b5e0dd7f517bd7ddd69ec3794508a47", size = 155152, upload-time = "2026-10-27T06:32:49.125Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1f/3eefd5c84c35cae44549ce1c7f7e58f7945b34f5a6bce07a135438522073/pyasn1-0.6.5-py3-none-any.whl", hash = "sha256:4017b0fce125096b8573b39c989fdb1a4b19cf761e683398184550fea0e66587", size = 84807, upload-time = "2026-10-27T06:32:48.045Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -261,16 +268,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
version = "2.21.0"
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -279,48 +286,48 @@ dependencies = [
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-django"
|
||||
version = "4.12.0"
|
||||
version = "4.14.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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" }
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/39/b3927f3616f6223832f9b760e4208e8a7c7e1dea2b4d9ad7d32a5d214c3a/python_dateutil-4.0.0.tar.gz", hash = "sha256:d7295ff14fdc0f92f662100bfb6c551d160ca090f2939f1ec5b9794265c03f06", size = 250419, upload-time = "2026-11-01T07:02:42.426Z" }
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/a9/52b89563d154433d79b51ed1eb4842bf7334cb2696891a7fe135618853d7/python_dateutil-4.0.0-py3-none-any.whl", hash = "sha256:a5e246f79eb80475e691bf93862cead98decd8a498cf53ba0516fcdc0f50dc1c", size = 92853, upload-time = "2026-11-01T07:02:40.797Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-ldap"
|
||||
version = "3.4.4"
|
||||
version = "3.4.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
{ name = "pyasn1-modules" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/8b/1eeb4025dc1d3ac2f16678f38dec9ebdde6271c74955b72db5ce7a4dbfbd/python-ldap-3.4.4.tar.gz", hash = "sha256:7edb0accec4e037797705f3a05cbf36a9fde50d08c8f67f2aef99a2628fab828", size = 377889, upload-time = "2023-11-17T21:14:16.32Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/45/489fcf46984f916ef165cdc6a2a1f1ca01ab6f486d47169256ebe44bdd62/python_ldap-3.4.8.tar.gz", hash = "sha256:18dc7460470c6ff64ed5c04ee21c56dbfee7ab433a53213ba91e407eea44c34c", size = 387710, upload-time = "2026-09-16T12:41:20.15Z" }
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -328,83 +335,74 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.6"
|
||||
version = "0.17.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.55.0"
|
||||
version = "2.73.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/b8/285293dc60fc198fffc3fcdbc7c6d4e646e0f74e61461c355d40faa64ceb/sentry_sdk-2.55.0.tar.gz", hash = "sha256:3774c4d8820720ca4101548131b9c162f4c9426eb7f4d24aca453012a7470f69", size = 424505, upload-time = "2026-03-17T14:15:51.707Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/3b/1e47acf2456781c26e12007379ecc154bf310b8f64d40390d78b791d55c3/sentry_sdk-2.73.0.tar.gz", hash = "sha256:4a2ab97b16e4693c325ab361c7bbbb346dbc7d0bc0150922d795a25bac8d5280", size = 1079078, upload-time = "2026-10-22T09:08:49.63Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/66/20465097782d7e1e742d846407ea7262d338c6e876ddddad38ca8907b38f/sentry_sdk-2.55.0-py2.py3-none-any.whl", hash = "sha256:97026981cb15699394474a196b88503a393cbc58d182ece0d3abe12b9bd978d4", size = 449284, upload-time = "2026-03-17T14:15:49.604Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/88/cabf98d017d7b940720e514e5f8ee02e3e6f7495caf93c30735a38387ec4/sentry_sdk-2.73.0-py3-none-any.whl", hash = "sha256:0bcfa6928ea5adbb89591858b514d27adac27c7a3cccc31b20ed10e0b09eda9e", size = 549011, upload-time = "2026-10-22T09:08:47.88Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.3"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/e0/361c764ea58cc1a75f7b4b424a74b08449db8bfa5e9199c7275999fdd1b9/sqlparse-0.6.1.tar.gz", hash = "sha256:ca722573f848ff2d30e40ea30f53a104b7dea7635ea9285891263eb2c58fb6e7", size = 182236, upload-time = "2026-11-01T18:06:45.951Z" }
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/ff/2fee788e82e75be8f210c65c5d103bf44c880e3769c884652a4cd911b8ba/sqlparse-0.6.1-py3-none-any.whl", hash = "sha256:3be5a72d04c873d690d7c6dedd33d6fae2ef82759881ceec02fc2efd0cba564f", size = 51200, upload-time = "2026-11-01T18:06:47.434Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.2"
|
||||
version = "2026.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz", hash = "sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d", size = 201017, upload-time = "2026-10-28T09:57:09.137Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl", hash = "sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec", size = 348210, upload-time = "2026-10-28T09:57:07.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.4.0"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user