diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml index 67e4e083..59668f5e 100644 --- a/.github/workflows/container-image.yml +++ b/.github/workflows/container-image.yml @@ -1,6 +1,8 @@ name: Container image on: push: + branches: [master] + tags: ['v*'] paths-ignore: - 'ci/**' - 'README.md' @@ -8,6 +10,10 @@ on: types: [opened, reopened, synchronize] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + defaults: run: shell: bash @@ -89,7 +95,10 @@ jobs: cp -r mreg-cli/ci /tmp C=$(cat ci/MREG-CLI_COMMIT) cd mreg-cli - git -c advice.detachedHead=false checkout $C + # A default shallow clone can contain the commit object without all of + # its trees. Fetch the pinned revision explicitly before checkout. + git fetch --depth=1 origin "$C" + git -c advice.detachedHead=false checkout FETCH_HEAD cp --no-clobber /tmp/ci/* ci/ - name: Run the tests run: mreg-cli/ci/run_testsuite_and_record_V2.sh @@ -130,13 +139,26 @@ jobs: run: docker load --input mreg.tgz - name: Start mreg run: | - docker run --rm -t --network host --detach --name mreg \ + docker run -t --network host --detach --name mreg \ -e MREG_DB_HOST=localhost -e MREG_DB_PASSWORD=mreg -e MREG_DB_USER=mreg \ mreg - - name: Wait for mreg to create the database schema and start up - run: sleep 10s + - name: Wait for mreg to become ready + run: | + for attempt in {1..30}; do + if curl --silent --show-error \ + http://127.0.0.1:8000/api/meta/health/heartbeat >/dev/null; then + exit 0 + fi + if [ "$(docker inspect --format '{{.State.Running}}' mreg)" != "true" ]; then + docker logs mreg + exit 1 + fi + sleep 2 + done + docker logs mreg + exit 1 - name: Create a user - run: docker exec -t mreg uv run /app/manage.py create_mreg_superuser --username test --password test123 + run: docker exec -t mreg python /app/manage.py create_mreg_superuser --username test --password test123 - name: Authenticate using curl shell: bash run: | @@ -148,10 +170,16 @@ jobs: --write-out %{http_code} \ > /tmp/http_status_code.txt 2> /tmp/curl_errors.txt STATUS=$(cat /tmp/http_status_code.txt) - if [ $STATUS -ge 400 ]; then + if [ "$STATUS" != "200" ]; then cat /tmp/curl_output.txt exit 1 fi + - name: Show mreg logs + if: always() + run: docker logs mreg + - name: Stop mreg + if: always() + run: docker rm --force mreg publish: name: Publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 05b9b6a6..cbba188b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,7 @@ on: push: + branches: [master] + tags: ['v*'] paths-ignore: - 'ci/**' - 'README.md' @@ -8,11 +10,43 @@ on: types: [opened, reopened, synchronize] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + #env: # UV_FROZEN: 1 name: CI jobs: + treetop-bundle: + name: TreeTop bundle + runs-on: ubuntu-latest + env: + TREETOP_BUNDLE_VERSION: 0.0.5 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install treetop-bundle + run: | + curl --fail --silent --show-error --location \ + --output treetop-bundle-x86_64-linux-musl.tar.gz \ + "https://github.com/treetop-policy-engine/treetop-bundle/releases/download/v${TREETOP_BUNDLE_VERSION}/treetop-bundle-x86_64-linux-musl.tar.gz" + curl --fail --silent --show-error --location \ + --output SHA256SUMS \ + "https://github.com/treetop-policy-engine/treetop-bundle/releases/download/v${TREETOP_BUNDLE_VERSION}/SHA256SUMS" + sha256sum --check --ignore-missing SHA256SUMS + tar --extract --gzip --file treetop-bundle-x86_64-linux-musl.tar.gz + chmod +x treetop-bundle + - name: Validate reproducible unsigned bundle + run: scripts/check-treetop-bundle.sh + env: + TREETOP_BUNDLE_BIN: ./treetop-bundle + - name: Check generated Cedar contracts + run: python scripts/generate-treetop-schema.py --check + - name: Check generated permission policy + run: python scripts/generate-treetop-policy.py --check + test: name: Test runs-on: ${{ matrix.os }} @@ -35,8 +69,6 @@ jobs: matrix: os: [ubuntu-latest] python-version: - - "3.10" - - "3.11" - "3.12" - "3.13" - "3.14" @@ -68,7 +100,7 @@ jobs: export MREG_DB_NAME=mreg MREG_DB_USER=mreg MREG_DB_PASSWORD=postgres uv run manage.py spectacular --validate --file openapi.yml - name: Upload OpenAPI schema - if: matrix.python-version == '3.10' + if: matrix.python-version == '3.12' uses: actions/upload-artifact@v7 with: name: openapi.yml @@ -90,8 +122,6 @@ jobs: matrix: os: [ubuntu-latest] python-version: - - "3.10" - - "3.11" - "3.12" - "3.13" - "3.14" diff --git a/README.md b/README.md index 9f075a5f..6e1e00ed 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,26 @@ mreg supports configuration via environment variables with the `MREG_` prefix. T | `MREG_REQUESTS_THRESHOLD_VERY_SLOW` | `5000` | Very slow request threshold (ms) | | `MREG_REQUESTS_LOG_LEVEL_VERY_SLOW` | `CRITICAL` | Log level for very slow requests | +### TreeTop Authorization + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `MREG_POLICY_MODE` | `shadow` | `off`, synchronous observational `shadow`, or synchronous authoritative `enforce` | +| `MREG_POLICY_PARITY_ENABLED` | `True` | Deprecated compatibility flag used only when `MREG_POLICY_MODE` is unset | +| `MREG_POLICY_BASE_URL` | `""` | TreeTop REST base URL; an empty value disables calls | +| `MREG_POLICY_NAMESPACE` | `MREG` | Cedar namespace used for principals, actions, and resources | +| `MREG_POLICY_TIMEOUT_SECONDS` | `5.0` | TreeTop client timeout in seconds | +| `MREG_POLICY_CIRCUIT_FAILURES` | `5` | Consecutive synchronous failures that open a worker circuit | +| `MREG_POLICY_CIRCUIT_RESET_SECONDS` | `30.0` | Open-circuit cooldown | +| `MREG_POLICY_PARITY_LOG_LEVEL` | `WARNING` | Dedicated parity logger level | +| `MREG_POLICY_PARITY_LOG_DETAILS` | `False` | Include sensitive principal/resource details in parity logs | +| `MREG_POLICY_ROLLOUT_MIN_COMPARISONS` | `10000` | Minimum observations required by the enforcement gate | +| `MREG_POLICY_ROLLOUT_MAX_MISMATCH_RATE` | `0.001` | Maximum accepted mismatch ratio | +| `MREG_POLICY_ROLLOUT_MAX_ERROR_RATE` | `0.001` | Maximum accepted policy error ratio | + +TreeTop bundle generation reads the three existing MREG policy endpoints with +`MREG_API_BASE_URL` and `MREG_API_TOKEN`; see [the policy documentation](docs/policies.md#bundle-source-and-build). + ### Network Policy Configuration | Variable | Default | Description | diff --git a/ci/MREG-CLI_COMMIT b/ci/MREG-CLI_COMMIT new file mode 100644 index 00000000..9c6de612 --- /dev/null +++ b/ci/MREG-CLI_COMMIT @@ -0,0 +1 @@ +7ace29daec005c30f33fbb14bfb9254cad1d8bc8 diff --git a/docs/env.md b/docs/env.md index ebcb3ef7..e81f596b 100644 --- a/docs/env.md +++ b/docs/env.md @@ -12,6 +12,111 @@ Must be one of the following: - `ERROR` - `CRITICAL` +## `MREG_POLICY_PARITY_LOG_LEVEL` + +Log level for the dedicated `mreg.policy.parity` logger. Default: `WARNING` + +This controls parity discrepancy logs independently from `MREG_LOG_LEVEL`, so +legacy-vs-policy mismatches can be surfaced even when the general app logger is +more restrictive. + +Must be one of the following: + +- `DEBUG` +- `INFO` +- `WARNING` +- `ERROR` +- `CRITICAL` + +## `MREG_POLICY_MODE` + +Controls how MREG uses TreeTop. Default: `shadow` + +- `off`: use legacy permissions and make no TreeTop calls. +- `shadow`: call TreeTop synchronously once per protected request, compare the + complete endpoint decision, and return the legacy decision. +- `enforce`: make that same synchronous endpoint decision authoritative. + +`enforce` requires a non-empty `MREG_POLICY_BASE_URL`; invalid values or a +missing enforcement URL stop Django during configuration rather than silently +falling back. + +## `MREG_POLICY_PARITY_ENABLED` + +Deprecated compatibility flag. Default: `True` + +When `MREG_POLICY_MODE` is unset, true maps to `shadow` and false maps to `off`. +An explicit mode always takes precedence. + +## `MREG_POLICY_BASE_URL` + +Base URL for the TreeTop policy engine REST service. Default: empty (disabled) + +If unset or empty, no policy requests are made in `off`/`shadow` operation. It +is a configuration error in `enforce` mode. + +Example: `http://localhost:9999` + +## `MREG_POLICY_NAMESPACE` + +Namespace used when constructing policy principal/action IDs. Default: `MREG` + +Use Cedar-style `::` separators (commas are also accepted). + +Example: `MREG` or `org::MREG` + +## `MREG_POLICY_PARITY_LOG_DETAILS` + +Boolean flag controlling whether parity logs include principal names, groups, +resource IDs, and resource attributes. Default: `False` + +Keep this disabled unless detailed parity investigation is necessary. These +fields may contain operationally sensitive data. Parity events use the normal +console and rotating `MREG_LOG_FILE_NAME` handlers. + +## `MREG_POLICY_TIMEOUT_SECONDS` + +Timeout in seconds for calls to TreeTop. Default: `5.0` + +Both active modes wait for the result because authorization must finish before +request processing continues. `shadow` differs only in which decision is +returned. `enforce` always fails closed on timeout, invalid response, circuit +rejection, or other TreeTop failure; there is no legacy fallback. + +## Synchronous circuit breaker + +- `MREG_POLICY_CIRCUIT_FAILURES` (`5`): consecutive failures before the + process-local worker circuit opens. +- `MREG_POLICY_CIRCUIT_RESET_SECONDS` (`30.0`): cooldown before one half-open + probe is allowed. + +The client timeout remains `MREG_POLICY_TIMEOUT_SECONDS` (`5.0`). Each +application process owns its client and thread-safe circuit state. An open +circuit returns the legacy decision in `shadow` and denies in `enforce`. + +## TreeTop enforcement rollout gates + +`manage.py check_policy_rollout` evaluates Prometheus telemetry before an +operator enables policy enforcement. Defaults can be tuned with: + +- `MREG_POLICY_ROLLOUT_MIN_COMPARISONS` (`10000`) +- `MREG_POLICY_ROLLOUT_MAX_MISMATCH_RATE` (`0.001`) +- `MREG_POLICY_ROLLOUT_MAX_ERROR_RATE` (`0.001`) + +## TreeTop bundle generation + +These variables are used only by `scripts/generate-treetop-policy.py`; they are +not Django runtime settings: + +- `MREG_API_BASE_URL`: MREG base URL to read policy source data from. When + omitted, the generator uses `treetop/fixtures/policy-source.json`. +- `MREG_API_TOKEN`: API token sent to the three existing MREG endpoints. It is + required when `MREG_API_BASE_URL` is set and is never persisted. +- `MREG_API_TIMEOUT`: per-page API timeout in seconds. Default: `20`. + +Use HTTPS for a remote MREG instance. The token needs authenticated read access +to labels, NetGroup regex permissions, and host-policy roles. + ## `MREG_LOG_FILE_SIZE` Maximum file size of the log file in bytes. Default: `52428800` (50MB). diff --git a/docs/metrics.md b/docs/metrics.md index 975c5e8d..634fb530 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -1,209 +1,86 @@ # Metrics Overview -This document describes the Prometheus metrics exposed by MREG, their purpose, labels, and units. Labels are chosen to keep cardinality low and operationally useful. - -## Endpoint - -Metrics are exposed at the following endpoint: `/api/meta/metrics`. - -## HTTP Metrics - -- Name: mreg_http_requests_total - - Type: Counter - - Labels: method, path, status - - Unit: requests - - Description: Total number of HTTP requests, partitioned by method, normalized path (view name/route), and status code. - -- Name: mreg_http_request_duration_seconds - - Type: Histogram - - Labels: method, path, status - - Unit: seconds - - Description: Request latency from middleware entry to response. - - Buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] - -- Name: mreg_http_inprogress_requests - - Type: Gauge - - Labels: method, path - - Unit: requests - - Description: Number of requests in-flight. - -- Name: mreg_http_request_size_bytes - - Type: Histogram - - Labels: method, path - - Unit: bytes - - Description: Size of HTTP request payload. Uses `CONTENT_LENGTH` when present; otherwise not observed to avoid loading bodies. - - Buckets: [512, 1k, 2k, 4k, 8k, 16k, 64k, 256k, 1M, 4M] - -- Name: mreg_http_response_size_bytes - - Type: Histogram - - Labels: method, path, status - - Unit: bytes - - Description: Size of HTTP response payload. Uses `Content-Length` when set; skips observation for streaming or unknown sizes. - - Buckets: [512, 1k, 2k, 4k, 8k, 16k, 64k, 256k, 1M, 4M] - -- Name: mreg_http_exceptions_total - - Type: Counter - - Labels: method, path, exception - - Unit: exceptions - - Description: Total number of uncaught application exceptions that resulted in 500 responses, partitioned by exception class name. - -- Name: mreg_http_unresolved_requests_total - - Type: Counter - - Labels: method, status - - Unit: requests - - Description: Requests whose normalized path could not be resolved (e.g., 404s). Useful for monitoring spikes in unresolved routes. - -## Database Metrics - -- Name: mreg_db_query_duration_seconds - - Type: Histogram - - Labels: method, path - - Unit: seconds - - Description: Duration of each DB query executed during a request. - -- Name: mreg_db_request_duration_seconds - - Type: Histogram - - Labels: method, path, status - - Unit: seconds - - Description: Total DB time aggregated per HTTP request. - -- Name: mreg_db_queries_per_request - - Type: Histogram - - Labels: method, path, status - - Unit: queries - - Description: Number of DB queries attempted during a single HTTP request (includes attempted queries even if they error). - - Buckets: [1, 2, 3, 5, 8, 13, 21, 34, 55] - -- Name: mreg_db_queries_total - - Type: Counter - - Labels: method, path - - Unit: queries - - Description: Total number of DB queries attempted across all requests. - -- Name: mreg_db_errors_total - - Type: Counter - - Labels: method, path, exception - - Unit: errors - - Description: Total number of DB errors, partitioned by exception class name. - -## LDAP Metrics - -- Name: mreg_ldap_call_duration_seconds - - Type: Histogram - - Labels: operation - - Unit: seconds - - Description: Duration of LDAP operations (initialize, bind, unbind) invoked by the health check. - - Buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5] - -- Name: mreg_ldap_call_failures_total - - Type: Counter - - Labels: operation, exception - - Unit: failures - - Description: LDAP operation failures by operation and exception class (e.g., bind LDAPError). Useful to see if LDAP is flapping or credential/ACL issues arise. - -## Labeling Strategy - -- path: normalized using Django URL resolution to view name (preferred) or route pattern. Falls back to "unresolved" to avoid cardinality explosion from raw paths with IDs. -- method: HTTP method (GET, POST, etc.). -- status: HTTP status code as string (e.g., "200", "404"). -- exception: Python exception class name. We do not include messages or stack traces. - -## Notes - -- Timing uses monotonic clocks to avoid wall-clock skew. -- The metrics endpoint (/api/meta/metrics) is not instrumented and is tolerant to a trailing slash. -- Gauges are carefully paired to prevent underflow. -- For multi-process deployments, ensure Prometheus client multiprocess mode is configured or scrape per-worker and aggregate in Prometheus. -- Avoid building dashboards/alerts on high-cardinality labels; stick to method/path/status/exception. - -## Alerting Examples - -- N+1 query detection - - Goal: Detect endpoints where average queries per request spike above a threshold. - - PromQL: - - Average queries per request per path/method over 5m: - `sum by (method, path) (rate(mreg_db_queries_per_request_sum[5m])) / sum by (method, path) (rate(mreg_db_queries_per_request_count[5m]))` - - Alert when `> 20` (tune to your baseline) - -- Payload size anomalies (response size) - - Goal: Detect endpoints returning unusually large payloads. - - PromQL: - - Average response size bytes per path/method over 5m: - `sum by (method, path) (rate(mreg_http_response_size_bytes_sum[5m])) / sum by (method, path) (rate(mreg_http_response_size_bytes_count[5m]))` - - Alert when `> 1048576` (1 MiB) or when deviates from a baseline (use recording rules or anomaly detection plugins) - -- 5xx spikes by view/exception - - Goal: Track and alert on failures grouped by normalized path and exception type. - - PromQL: - - 5xx rate per path/method over 5m: - `sum by (method, path) (rate(mreg_http_requests_total{status=~"5.."}[5m]))` - - Exceptions by type per path/method over 5m: - `sum by (method, path, exception) (rate(mreg_http_exceptions_total[5m]))` - - Alert on sustained spikes above baseline (e.g., `> 0.1 rps` for 10m) - -## Sample Prometheus alert rules (starter set) - -Tune thresholds to your baseline; these are illustrative. - -```yaml -groups: - - name: mreg-alerts - rules: - - alert: Mreg5xxSpike - expr: sum by (method, path) (rate(mreg_http_requests_total{status=~"5.."}[5m])) > 0.1 - for: 10m - labels: - severity: page - annotations: - summary: "5xx spike on {{ $labels.method }} {{ $labels.path }}" - - - alert: MregLDAPFailures - expr: sum by (operation, exception) (rate(mreg_ldap_call_failures_total[5m])) > 0 - for: 5m - labels: - severity: page - annotations: - summary: "LDAP failures {{ $labels.operation }} {{ $labels.exception }}" - - - alert: MregLDAPLatencyHigh - expr: histogram_quantile( - 0.95, - sum by (le) (rate(mreg_ldap_call_duration_seconds_bucket[5m])) - ) > 1 - for: 5m - labels: - severity: ticket - annotations: - summary: "LDAP latency p95 > 1s" - - - alert: MregNPlusOneSuspect - expr: ( - sum by (method, path) (rate(mreg_db_queries_per_request_sum[5m])) - / sum by (method, path) (rate(mreg_db_queries_per_request_count[5m])) - ) > 20 - for: 10m - labels: - severity: ticket - annotations: - summary: "High queries/request on {{ $labels.method }} {{ $labels.path }}" - - - alert: MregResponseSizeAnomaly - expr: ( - sum by (method, path) (rate(mreg_http_response_size_bytes_sum[5m])) - / sum by (method, path) (rate(mreg_http_response_size_bytes_count[5m])) - ) > 1048576 - for: 10m - labels: - severity: ticket - annotations: - summary: "Large responses on {{ $labels.method }} {{ $labels.path }} (>1MiB avg over 5m)" +MREG exposes Prometheus metrics at `/api/meta/metrics`. The endpoint itself is +not instrumented. Labels intentionally avoid usernames, raw URLs, query +parameters, remote addresses, SQL, and policy resource data. + +## HTTP and dependency metrics + +| Metric | Type | Labels | Meaning | +| --- | --- | --- | --- | +| `mreg_http_requests_total` | Counter | `method`, `path`, `status` | Requests by normalized route and status | +| `mreg_http_request_duration_seconds` | Histogram | `method`, `path`, `status` | End-to-end request latency | +| `mreg_http_inprogress_requests` | Gauge | `method`, `path` | Requests currently in flight | +| `mreg_http_request_size_bytes` | Histogram | `method`, `path` | Known request payload sizes | +| `mreg_http_response_size_bytes` | Histogram | `method`, `path`, `status` | Known non-streaming response sizes | +| `mreg_http_exceptions_total` | Counter | `method`, `path`, `exception` | Uncaught application exceptions | +| `mreg_http_unresolved_requests_total` | Counter | `method`, `status` | Requests whose route could not be normalized | +| `mreg_db_query_duration_seconds` | Histogram | `method`, `path` | Individual database query latency | +| `mreg_db_request_duration_seconds` | Histogram | `method`, `path`, `status` | Database time per request | +| `mreg_db_queries_per_request` | Histogram | `method`, `path`, `status` | Attempted queries per request | +| `mreg_db_queries_total` | Counter | `method`, `path` | Attempted database queries | +| `mreg_db_errors_total` | Counter | `method`, `path`, `exception` | Database errors | +| `mreg_ldap_call_duration_seconds` | Histogram | `operation` | LDAP health-check call latency | +| `mreg_ldap_call_failures_total` | Counter | `operation`, `exception` | LDAP health-check failures | + +`path` is a resolved view name or route pattern, never a raw object URL. Timing +uses monotonic clocks. + +## TreeTop metrics + +| Metric | Type | Labels | Meaning | +| --- | --- | --- | --- | +| `mreg_policy_decisions_total` | Counter | `decision` | Composite TreeTop result: `allow`, `deny`, or `error` | +| `mreg_policy_legacy_decisions_total` | Counter | `decision` | Composite legacy result used for comparison | +| `mreg_policy_parity_results_total` | Counter | `result` | Endpoint comparison: `match`, `mismatch`, or `error` | +| `mreg_policy_authorize_calls_total` | Counter | `status` | Synchronous authorize calls: `success` or `exception` | +| `mreg_policy_authorize_duration_seconds` | Histogram | `status` | Synchronous authorize latency | +| `mreg_policy_failures_total` | Counter | `stage` | Integration failures, currently the `authorize` stage | +| `mreg_policy_enforcement_results_total` | Counter | `result` | Authoritative `allow`, `deny`, or fail-closed `error_deny` | +| `mreg_policy_mode_info` | Gauge | `mode` | Active `off`, `shadow`, or `enforce` mode | +| `mreg_policy_stack_size` | Histogram | none | Cedar leaves in the endpoint stack sent by one call | +| `mreg_policy_stack_conflicts_total` | Counter | none | Attempts to evaluate two different stacks in one request; should remain `0` | +| `mreg_policy_circuit_open` | Gauge | none | Whether a worker's synchronous circuit is open | + +All protected endpoint checks are synchronous in both active modes. `shadow` +returns the legacy result after recording the comparison; `enforce` returns the +TreeTop composite and fails closed. There is no queue, retry worker, persistence +metric, and enforcement failures never return the legacy decision. + +The two design metrics are: + +- `mreg_policy_stack_conflicts_total`: alert on any increase. Identical repeated + checks use the decision cached on the request and never make another call. +- `mreg_policy_stack_size`: identify high-count endpoints whose semantic policy + can be simplified even though transport is already consolidated. + +## Rollout dashboard, alerts, and gate + +- Dashboard: `monitoring/grafana/treetop-parity.json` +- Rules: `monitoring/treetop-alerts.yml` +- Gate: `python manage.py check_policy_rollout --prometheus-url URL` + +The default gate requires at least 10,000 endpoint comparisons, no more than +0.1% mismatches, and no more than 0.1% errors over the selected window. The +alerts cover mismatch/error rates, any authoritative failure, an open circuit, +and violations of the one-stack-per-request invariant. + +Useful PromQL: + +```promql +# Policy mismatch rate +sum(rate(mreg_policy_parity_results_total{result="mismatch"}[30m])) +/ +clamp_min(sum(rate(mreg_policy_parity_results_total{result=~"match|mismatch"}[30m])), 1) + +# p95 synchronous TreeTop latency +histogram_quantile( + 0.95, + sum by (le) (rate(mreg_policy_authorize_duration_seconds_bucket[5m])) +) + +# Average checks in each endpoint stack +rate(mreg_policy_stack_size_sum[5m]) +/ +clamp_min(rate(mreg_policy_stack_size_count[5m]), 1) ``` - -## What's intentionally not labeled - -Almost all labels that could lead to high cardinality or sensitive data exposure are avoided, including but not limited to: - -- User-specific labels (e.g., user ID) to prevent cardinality explosion and privacy concerns. -- Query parameters in paths to avoid high cardinality from unique URLs. -- Remote IP addresses for privacy and cardinality reasons. -- Detailed SQL query information to prevent high cardinality and sensitive data exposure. diff --git a/docs/parity_testing.md b/docs/parity_testing.md new file mode 100644 index 00000000..ccfb0ed9 --- /dev/null +++ b/docs/parity_testing.md @@ -0,0 +1,169 @@ +# Disabling Parity Checking in Tests + +Related documentation: + +- Policy actions and resource/action contracts: [`policies.md`](./policies.md) + +## Problem + +Tests that modify permissions or group memberships mid-test cause the legacy permission system and the TreeTop policy engine to be out of sync. Since TreeTop's policy content is immutable (in this context), these tests cannot maintain parity between the two systems. + +## Solutions + +### Option 1: Context Manager (Recommended for individual test sections) + +Use the `disable_policy_parity()` context manager to temporarily disable parity checking: + +```python +from mreg.api.treetop import disable_policy_parity + +class TestPermissions(MregAPITestCase): + def test_permission_change(self): + # Normal parity checking is active here + self.client.get('/api/v1/hosts/') + + # Disable parity checking for permission modifications + with disable_policy_parity(): + # Add user to a group + user.groups.add(some_group) + + # Make API calls - parity checking is skipped + response = self.client.post('/api/v1/hosts/', data) + self.assertEqual(response.status_code, 201) + + # Parity checking resumes after the context exits +``` + +### Option 2: Test Class Mixin (Recommended for entire test classes) + +Use the `PermissionModifyingTestCase` mixin for test classes that modify permissions throughout: + +```python +from mreg.api.test_utils import PermissionModifyingTestCase + +class TestGroupPermissions(PermissionModifyingTestCase, MregAPITestCase): + """All tests in this class have parity checking disabled.""" + + def test_add_group(self): + # Parity checking is disabled for all tests in this class + user.groups.add(admin_group) + response = self.client.post('/api/v1/hosts/', data) + self.assertEqual(response.status_code, 201) + + def test_remove_group(self): + # Still disabled here + user.groups.remove(admin_group) + response = self.client.post('/api/v1/hosts/', data) + self.assertEqual(response.status_code, 403) +``` + +## When to Use + +Disable parity checking when your test: + +- Adds or removes users from groups +- Changes NetGroupRegexPermission entries +- Modifies any permission-related database state +- Tests permission escalation/de-escalation scenarios + +## When NOT to Use + +Do NOT disable parity checking for: + +- Tests that only read data +- Tests that modify non-permission data (hosts, networks, etc.) +- Tests where both legacy and policy systems should agree + +## Scope Rules (Enforcement Guidance) + +Keep parity disable scope as narrow as possible: + +- Prefer wrapping only the exact mutation and requests that depend on that mutation. +- Do not wrap an entire test module unless the whole module genuinely mutates permission state. +- Do not wrap whole suites by default; this hides real policy regressions. +- Re-enable parity immediately after the mutation scenario has been asserted. + +## Implementation Details + +The `disable_policy_parity()` context manager uses `ContextVar` state. Nested +contexts and concurrently handled requests are isolated from one another. It +disables only `shadow` checks; it is deliberately ignored in `enforce` so test +or application code cannot bypass an authoritative decision accidentally. + +In both `shadow` and `enforce`, one complete endpoint stack is sent +synchronously in one `authorize` call. A stack can contain nested AND/OR rules; +TreeTop evaluates all leaves and MREG composes their results locally. `shadow` +records the comparison and returns the legacy result. `enforce` returns the +TreeTop result and fails closed on every integration failure. + +Request-owned state rejects a second different stack and increments +`mreg_policy_stack_conflicts_total`, making accidental checkpoint-by-checkpoint +calls visible instead of quietly adding request-path latency. A thread-safe +circuit breaker prevents every request from waiting for the full timeout during +an outage. + +## Parity Runbook + +Use this sequence when validating parity changes: + +1. Run full tests with coverage and parity logging enabled. + +```bash +source .env; .venv/bin/tox -e coverage +``` + +2. Query the mismatch metric in Prometheus. + +```promql +mreg_policy_parity_results_total{result="mismatch"} +``` + +3. List mismatch events in the configured application log. + +```bash +rg -n '"event": "policy_stack_result".*"parity": false' logs/app.log +``` + +4. Optional: inspect actions seen in mismatch events. + +```bash +jq -r 'select(.event == "policy_stack_result" and .parity == false) | .context.path' logs/app.log \ + | sort | uniq -c | sort -nr +``` + +Set `MREG_POLICY_PARITY_LOG_DETAILS=True` temporarily in a suitably protected +environment only when principal, group, resource ID, or attribute details are +required for triage. + +5. Run the enforcement readiness gate against the production Prometheus: + +```bash +python manage.py check_policy_rollout \ + --prometheus-url https://prometheus.example.org \ + --window 24h +``` + +Do not enable enforcement until this command passes. Import +`monitoring/grafana/treetop-parity.json` and load +`monitoring/treetop-alerts.yml` before the observation window begins. + +## Mismatch Triage Guide + +Use `legacy_decision`, `policy_decision`, and the request context. Detailed +leaf actions and resource attributes are available only when +`MREG_POLICY_PARITY_LOG_DETAILS` is enabled. + +- `legacy_decision=true`, `policy_decision=false`: + - Missing/too-narrow Cedar allow rule. + - Action name mismatch (for example wrong CRUD token). + - Missing required attributes for Cedar conditions. +- `legacy_decision=false`, `policy_decision=true`: + - Cedar rule is broader than legacy behavior. + - Resource kind fallback produced a more permissive policy path than intended. +- `error` present: + - Policy client/server failure. Resolve connectivity/config first before triaging semantics. +- Unexpected `context.resource_kind`: + - Fix serializer `Meta.model` or declare `policy_resource_kind` explicitly on + the non-model view. View-name inference is intentionally unsupported. + +When fixing mismatches, update code and Cedar together, then rerun the tests until mismatch count is zero. diff --git a/docs/policies.md b/docs/policies.md new file mode 100644 index 00000000..bbc596c0 --- /dev/null +++ b/docs/policies.md @@ -0,0 +1,234 @@ +# TreeTop Authorization + +MREG can evaluate authorization with the TreeTop Cedar policy engine. The +integration is request-scoped, synchronous, bundle-based, and has three modes: + +| Mode | TreeTop call | Returned decision | +| --- | --- | --- | +| `off` | none | legacy MREG permission | +| `shadow` | one synchronous call per protected endpoint | legacy MREG permission | +| `enforce` | one synchronous call per protected endpoint | TreeTop composite; errors deny | + +An empty `MREG_POLICY_BASE_URL` makes the default `shadow` mode behave like +`off`. `enforce` requires a URL at startup and has no legacy error fallback. +Authentication remains local; token acquisition, health checks, metrics, schema, +and admin pages are the explicit policy exemptions. + +## Why there is no queue or async dispatcher + +Authorization must complete before request processing can continue. Queuing the +work would either allow an unauthorised request to proceed or still require the +request to wait for the queue result. An async HTTP client would change how the +thread waits, not remove the dependency. The Django/DRF request path is +synchronous, so MREG uses the synchronous `treetop-client` API directly. + +Shadow mode also waits. This ensures its comparison uses the policy bundle that +was active for the request and exercises the exact latency, timeout, circuit, +and response-validation path that enforcement will use. The former PostgreSQL +outbox, migration, dispatcher, and retry/dead-letter state are intentionally +absent. + +## One endpoint stack and one HTTP call + +An endpoint builds a tree of `PolicyLeaf`, `PolicyAll`, and `PolicyAny` nodes. +Every leaf is included in one batched `authorize` request. MREG then composes the +ordered results locally using the tree's AND/OR structure. Examples include: + +- all old and new targets required for a hostname rename; +- any IP attached to a host matching a NetGroup rule; +- the exact host-policy role together with the candidate hostname and IP; +- DNS-name, reserved-address, ownership, and target checks in the same endpoint + decision. + +State attached to the underlying Django request caches an identical repeated +stack. A second different stack is rejected and increments +`mreg_policy_stack_conflicts_total`: shadow mode returns the legacy result and +enforce mode fails closed. This makes the one-stack invariant independent of +middleware and explicit at the authorization boundary. + +## Principal, action, resource, and facts + +Each leaf sends: + +- a qualified principal such as `MREG::User::"alice"`, with current group + memberships; +- one explicit action such as `MREG::Action::"host_update"`; +- a typed resource such as `MREG::Host::"host.example.org"`; +- contract-typed attributes needed by Cedar. NetGroup and DNS-name checks send + the raw `hostname` and, when available, `ip`; TreeTop derives `nameLabels` + from the bundle. Other endpoints can send business relationship attributes + such as `selfAccess` or `requesterIsOwner`. + +MREG does not send a precomputed `allow` fact. Relationship booleans such as +`selfAccess` and `requesterIsOwner` describe request state; Cedar decides what +those facts mean. + +`mreg/policy/contracts.py` is the dependency-free source of truth for resource +kinds, optional attributes, operations, and actions. `mreg/policy/resources.py` +resolves model/view data to stable IDs and normalized attributes. Unknown kinds +must be registered explicitly; view class names are not an authority fallback. +The generated schema is checked in CI. + +## Mapping mutable database permissions + +User group membership remains dynamic and is sent on every call. Mutable +`NetGroupRegexPermission` rows cannot remain an independent authority when +TreeTop is authoritative. Their equivalents must be reviewed and added to the +deployed bundle: + +| Database field | Bundle representation | +| --- | --- | +| `group` | Cedar principal group | +| `range` | Cedar `ip.isInRange(...)` or exact network condition | +| `regex` | named pattern in `labels.json` | +| `labels` | conversion-only join key to exact `HostPolicyRole` names | + +TreeTop applies all regexes in the bundle to the raw `hostname` fact and adds +`nameLabels`. Cedar checks deterministic generated labels; MREG neither runs +the bundle regex nor sends those labels. Legacy permission and role labels are +not runtime facts. The converter uses them only to discover which exact roles +each network permission used to cover, then writes rules whose resource is that +specific `MREG::HostPolicyRole`. + +In `enforce`, the NetGroupRegexPermission API remains readable but returns HTTP +409 for POST, PUT, PATCH, and DELETE. This prevents the database from appearing +to change authoritative policy. In `off` and `shadow`, writes retain their +legacy behavior so policy authors can stage and compare a migration. Bundle +publication is a separate reviewed deployment operation. + +## Local responsibilities and Cedar responsibilities + +MREG still owns authentication, serializer validation, object lookup, database +transactions, conflicts, and business invariants. Cedar owns authorization for +protected endpoints in `enforce`, including: + +- authenticated reads and explicit introspection actions; +- super/admin/network/group/host-policy roles; +- host, record, BACnet, network, community, zone, label, and host-policy CRUD; +- NetGroup hostname/range rules through derived labels; +- DNS wildcard/underscore restrictions; +- restricted IP assignment; +- host-group ownership and membership changes; +- host-policy role-to-host mapping generated from the legacy permission export. + +## Failure behavior + +The timeout defaults to five seconds. Each application process owns a reusable +client and a thread-safe closed/open/half-open circuit breaker. After the +configured consecutive failures, the circuit rejects calls until its cooldown; +one request then probes the service. + +- `shadow`: log/metric the error and return the legacy result. +- `enforce`: log at critical severity, increment `error_deny`, and deny. + +Malformed result counts and per-result errors are failures just like transport +exceptions. `disable_policy_parity()` can suppress only shadow calls in narrow +test scopes; it cannot bypass enforcement. + +## Bundle source and build + +| Artifact | Path | +| --- | --- | +| Organization manifest | `treetop/data/treetop-bundle.toml` | +| MREG module manifest | `treetop/data/treetop-mreg-module.toml` | +| Global module manifest | `treetop/data/treetop-global-module.toml` | +| Global super policy | `treetop/data/global.cedar` | +| Hand-written endpoint policy | `treetop/data/mreg.cedar` | +| Generated NetGroup/role policy | `treetop/data/netgroup.cedar` | +| Generated TreeTop labels | `treetop/data/labels.json` | +| Conversion report | `treetop/data/netgroup-conversion-report.json` | +| Normalized API snapshot | `treetop/fixtures/policy-source.json` | +| Generated schema | `treetop/data/mreg.cedarschema` | +| Generated archive | `treetop/data/mreg-bundle.tar.gz` | + +Refresh the conversion input directly from the MREG instance whose policy is +being migrated: + +```bash +export MREG_API_BASE_URL=https://mreg.example +export MREG_API_TOKEN='replace-with-an-MREG-API-token' +python scripts/generate-treetop-policy.py +unset MREG_API_TOKEN +``` + +The generator paginates the existing `/api/v1/labels/`, +`/api/v1/permissions/netgroupregex/`, and `/api/v1/hostpolicy/roles/` +endpoints. It authenticates with `Authorization: Token`, resolves label IDs to +names, and writes a deterministic snapshot containing only the fields needed by +the conversion. No `mreg-cli` installation or new export endpoint is required. +Use HTTPS outside a trusted local environment, and use a token with authenticated +read access to all three endpoints. The token is read only from the environment +and is never written to the snapshot. + +The converter validates every CIDR and regular expression, removes duplicate +permission rows, collapses redundant ranges, and emits stable hashed IDs. +Review the snapshot, generated Cedar, and `netgroup-conversion-report.json`, +especially unmatched or unused legacy labels. The checked-in snapshot is a +sanitized example, not production policy. `MREG_API_TIMEOUT` optionally changes +the per-page timeout from 20 seconds. + +Without `MREG_API_BASE_URL`, the generator uses the checked-in snapshot. CI uses +that offline path: + +```bash +python scripts/generate-treetop-policy.py --check +``` + +The restricted-address examples in `mreg.cedar` are also based on the sample +networks. Replace and review them for the deployment before enabling `enforce`. + +Build with `treetop-bundle` 0.0.5: + +```bash +python scripts/generate-treetop-schema.py --check +python scripts/generate-treetop-policy.py --check +TREETOP_BUNDLE_BIN=treetop-bundle scripts/build-treetop-bundle.sh +TREETOP_BUNDLE_BIN=treetop-bundle scripts/check-treetop-bundle.sh +``` + +Bundle output is deterministic and CI compares it byte-for-byte. Bundles are +currently unsigned; the development server explicitly uses +`TREETOP_BUNDLE_SIGNATURE_POLICY=allow-unsigned`. + +No `treetop-client` change is required for bundle support. MREG sends ordinary +authorization requests to `treetop-rest`; the REST server downloads, validates, +atomically loads, and refreshes the bundle. + +## Local setup and rollout + +Start `treetop-rest` 0.0.14 and the bundle file server: + +```bash +docker compose -f treetop/docker-compose.yml up -d +``` + +Observe synchronously first: + +```bash +export MREG_POLICY_MODE=shadow +export MREG_POLICY_BASE_URL=http://localhost:9999 +export MREG_POLICY_NAMESPACE=MREG +``` + +After the bundle mapping is reviewed and the rollout gate passes, enable +authority and restart all workers: + +```bash +export MREG_POLICY_MODE=enforce +``` + +Roll back by setting the mode to `shadow` or `off` and restarting workers. If +MREG itself runs in a container, use a TreeTop URL reachable from that +container—not its own `localhost`. + +## Adding a protected endpoint + +1. Register its typed resource contract and any custom action. +2. Choose the final semantic authorization point. Use the early DRF permission + hook only when all facts are available there; otherwise authorize after + serializer/object resolution. +3. Build the complete AND/OR stack and call `authorize_policy_stack()` once. +4. Add Cedar permits/forbids and derived label rules together. +5. Regenerate the schema and archive. +6. Test legacy behavior, shadow comparison, enforce allow/deny/error behavior, + and the one-stack invariant. diff --git a/docs/testing.md b/docs/testing.md index 8354b900..f6394e29 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -177,6 +177,13 @@ If a test fails only when run in parallel: ## CI/CD Integration +The application image includes the test entrypoint used by CI: + +```bash +docker build -t mreg . +docker run --rm --entrypoint /app/entrypoint-test.sh mreg +``` + The parallel flag is already enabled in `tox.ini` for all test environments: ```ini diff --git a/hostpolicy/api/permissions.py b/hostpolicy/api/permissions.py index c7f8c418..f8b9f90b 100644 --- a/hostpolicy/api/permissions.py +++ b/hostpolicy/api/permissions.py @@ -1,5 +1,7 @@ -from rest_framework.permissions import IsAuthenticated, SAFE_METHODS +from rest_framework.permissions import SAFE_METHODS +from mreg.api.permissions import IsAuthenticated +from mreg.api.treetop import authorize_policy_stack, policy_any, policy_leaf from mreg.models.auth import User from mreg.models.host import Host from mreg.models.network import NetGroupRegexPermission @@ -14,59 +16,115 @@ class IsSuperOrHostPolicyAdminOrReadOnly(IsAuthenticated): def has_permission(self, request, view): if not super().has_permission(request, view): - # Not even reading is allowed if you're not authenticated return False - + user = User.from_request(request) - if request.method in SAFE_METHODS: - return True - if user.is_mreg_superuser_or_hostpolicy_admin: - return True + legacy = True + elif user.is_mreg_superuser_or_hostpolicy_admin: + legacy = True + else: + legacy = self._legacy_role_host_permission(request, view) - # Handle the (possible) absence of 'name' during schema generation - name = view.kwargs.get('name') - if name is None: # pragma: no cover - return False + if request.method not in SAFE_METHODS and view.__class__.__name__ in { + "HostPolicyRoleHostsDetail", + "HostPolicyRoleHostsList", + }: + return self._authorize_role_host_membership( + request=request, + view=view, + legacy=legacy, + ) + if request.method not in SAFE_METHODS and view.__class__.__name__ in { + "HostPolicyRoleAtomsDetail", + "HostPolicyRoleAtomsList", + }: + role_name = str(view.kwargs.get("name") or "any") + return authorize_policy_stack( + legacy, + request=request, + root=policy_leaf( + action="hostpolicy_role_atom_membership_update", + resource_kind="HostPolicyRole", + resource_id=role_name, + resource_attrs={"kind": "host_policy_role", "name": role_name}, + ), + view=view, + permission_class=self.__class__.__name__, + ) + return self.authorize_endpoint( + legacy_decision=legacy, + request=request, + view=view, + data=request.data if isinstance(request.data, dict) else None, + fallback_action="hostpolicy_admin_access", + ) - # Is this request about atoms or something else that isn't a role? - # In that case, non-admin-users shouldn't have access anyway, and we can deny the request. - if not (view.__class__.__name__ == 'HostPolicyRoleHostsDetail' or - view.__class__.__name__ == 'HostPolicyRoleHostsList'): - return False + def _authorize_role_host_membership(self, *, request, view, legacy: bool) -> bool: + role_name = str(view.kwargs.get("name") or "") + hostname = str(view.kwargs.get("host") or request.data.get("name") or "") + ips = tuple( + str(ip) + for ip in Host.objects.filter(name=hostname) + .exclude(ipaddresses__ipaddress=None) + .values_list("ipaddresses__ipaddress", flat=True) + ) + leaves = tuple( + policy_leaf( + action="hostpolicy_role_host_membership_update", + resource_kind="HostPolicyRole", + resource_id=role_name or "any", + resource_attrs={ + "hostname": hostname, + "ip": ip, + }, + ) + for ip in ips + ) + root = ( + policy_any(*leaves) + if leaves + else policy_leaf( + action="hostpolicy_role_host_membership_update", + resource_kind="HostPolicyRole", + resource_id=role_name or "any", + resource_attrs={"hostname": hostname}, + ) + ) + return authorize_policy_stack( + legacy, + request=request, + root=root, + view=view, + permission_class=self.__class__.__name__, + ) - # Find out which labels are attached to this role - role_labels = HostPolicyRole.objects.filter(name=name).values_list('labels__name', flat=True) + @staticmethod + def _legacy_role_host_permission(request, view) -> bool: + name = view.kwargs.get("name") + if name is None: # pragma: no cover + return False + if view.__class__.__name__ not in { + "HostPolicyRoleHostsDetail", + "HostPolicyRoleHostsList", + }: + return False + role_labels = HostPolicyRole.objects.filter(name=name).values_list("labels__name", flat=True) if not any(role_labels): - # if the role doesn't have any labels, there's no possibility of access at this point return False - - # Find all the NetGroupRegexPermission objects that correspond with - # the ipaddress, hostname, and the groups that the user is a member of - # Also, ensure that the hostname is not empty. - hostname = view.kwargs.get('host', request.data.get("name")) - if not hostname: # pragma: no cover + hostname = view.kwargs.get("host", request.data.get("name")) + if not hostname: # pragma: no cover return False - - ips = list(Host.objects.filter( - name=hostname - ).exclude( - ipaddresses__ipaddress=None - ).values_list('ipaddresses__ipaddress', flat=True)) - qs = NetGroupRegexPermission.find_perm(request.user.group_list, hostname, ips) - - # If no permissions matched the host/ip, we deny access - if not qs.exists(): + ips = list( + Host.objects.filter(name=hostname) + .exclude(ipaddresses__ipaddress=None) + .values_list("ipaddresses__ipaddress", flat=True) + ) + permissions = NetGroupRegexPermission.find_perm(request.user.group_list, hostname, ips) + if not permissions.exists(): return False - - # Do any of those permissions have labels that match the labels attached to this role? - # If so, access is granted - perm_labels = qs.values_list('labels__name', flat=True) - if any(label in perm_labels for label in role_labels): - return True - - # If the code got to this point, it means none of the labels matched. - return False + permission_labels = permissions.values_list("labels__name", flat=True) + return any(label in permission_labels for label in role_labels) def has_m2m_change_permission(self, request, view): return True diff --git a/monitoring/grafana/treetop-parity.json b/monitoring/grafana/treetop-parity.json new file mode 100644 index 00000000..df63793b --- /dev/null +++ b/monitoring/grafana/treetop-parity.json @@ -0,0 +1,74 @@ +{ + "annotations": {"list": []}, + "editable": true, + "panels": [ + { + "id": 1, + "title": "Endpoint parity mismatch rate", + "type": "timeseries", + "targets": [{"expr": "sum(rate(mreg_policy_parity_results_total{result=\"mismatch\"}[5m])) / clamp_min(sum(rate(mreg_policy_parity_results_total{result=~\"match|mismatch\"}[5m])), 1)", "legendFormat": "mismatch"}], + "fieldConfig": {"defaults": {"unit": "percentunit", "thresholds": {"steps": [{"color": "green", "value": null}, {"color": "red", "value": 0.001}]}}, "overrides": []}, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 0} + }, + { + "id": 2, + "title": "Endpoint parity error rate", + "type": "timeseries", + "targets": [{"expr": "sum(rate(mreg_policy_parity_results_total{result=\"error\"}[5m])) / clamp_min(sum(rate(mreg_policy_parity_results_total[5m])), 1)", "legendFormat": "errors"}], + "fieldConfig": {"defaults": {"unit": "percentunit", "thresholds": {"steps": [{"color": "green", "value": null}, {"color": "red", "value": 0.001}]}}, "overrides": []}, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 0} + }, + { + "id": 3, + "title": "Endpoint stack conflicts", + "type": "timeseries", + "targets": [{"expr": "sum(increase(mreg_policy_stack_conflicts_total[5m]))", "legendFormat": "conflicts"}], + "fieldConfig": {"defaults": {"thresholds": {"steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}}, "overrides": []}, + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 8} + }, + { + "id": 4, + "title": "Checks per endpoint stack", + "type": "timeseries", + "targets": [{"expr": "rate(mreg_policy_stack_size_sum[5m]) / clamp_min(rate(mreg_policy_stack_size_count[5m]), 1)", "legendFormat": "average"}], + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 8} + }, + { + "id": 5, + "title": "Authorize p95 latency", + "type": "timeseries", + "targets": [{"expr": "histogram_quantile(0.95, sum by (le) (rate(mreg_policy_authorize_duration_seconds_bucket[5m])))", "legendFormat": "p95"}], + "fieldConfig": {"defaults": {"unit": "s"}, "overrides": []}, + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 8} + }, + { + "id": 6, + "title": "Policy mode", + "type": "stat", + "targets": [{"expr": "max by (mode) (mreg_policy_mode_info)", "legendFormat": "{{mode}}"}], + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 16} + }, + { + "id": 7, + "title": "Enforcement decisions", + "type": "timeseries", + "targets": [{"expr": "sum by (result) (rate(mreg_policy_enforcement_results_total[5m]))", "legendFormat": "{{result}}"}], + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 16} + }, + { + "id": 8, + "title": "Worker circuit", + "type": "stat", + "targets": [{"expr": "max(mreg_policy_circuit_open)", "legendFormat": "open"}], + "fieldConfig": {"defaults": {"thresholds": {"steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}}, "overrides": []}, + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 16} + } + ], + "schemaVersion": 41, + "tags": ["mreg", "treetop", "rollout"], + "templating": {"list": []}, + "time": {"from": "now-24h", "to": "now"}, + "title": "MREG TreeTop endpoint rollout", + "uid": "mreg-treetop-parity", + "version": 3 +} diff --git a/monitoring/treetop-alerts.yml b/monitoring/treetop-alerts.yml new file mode 100644 index 00000000..7bd55626 --- /dev/null +++ b/monitoring/treetop-alerts.yml @@ -0,0 +1,46 @@ +groups: + - name: mreg-treetop-rollout + rules: + - alert: MregTreeTopParityMismatchRateHigh + expr: | + sum(rate(mreg_policy_parity_results_total{result="mismatch"}[30m])) + / + clamp_min(sum(rate(mreg_policy_parity_results_total{result=~"match|mismatch"}[30m])), 1) + > 0.001 + for: 30m + labels: + severity: page + annotations: + summary: TreeTop endpoint parity mismatch rate exceeds 0.1% + - alert: MregTreeTopParityErrorRateHigh + expr: | + sum(rate(mreg_policy_parity_results_total{result="error"}[30m])) + / + clamp_min(sum(rate(mreg_policy_parity_results_total[30m])), 1) + > 0.001 + for: 15m + labels: + severity: page + annotations: + summary: TreeTop endpoint parity error rate exceeds 0.1% + - alert: MregTreeTopCircuitOpen + expr: max(mreg_policy_circuit_open) > 0 + for: 2m + labels: + severity: page + annotations: + summary: A synchronous TreeTop worker circuit is open + - alert: MregTreeTopStackConflict + expr: increase(mreg_policy_stack_conflicts_total[5m]) > 0 + for: 0m + labels: + severity: page + annotations: + summary: A request attempted to evaluate two different TreeTop stacks + - alert: MregTreeTopEnforcementFailure + expr: increase(mreg_policy_enforcement_results_total{result="error_deny"}[5m]) > 0 + for: 0m + labels: + severity: page + annotations: + summary: An authoritative TreeTop request failed closed diff --git a/mreg/api/permissions.py b/mreg/api/permissions.py index 185e9b15..de6341a9 100644 --- a/mreg/api/permissions.py +++ b/mreg/api/permissions.py @@ -1,17 +1,40 @@ from __future__ import annotations import ipaddress -from typing import TYPE_CHECKING +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any from rest_framework import exceptions from rest_framework.permissions import IsAuthenticated as DRFIsAuthenticated, SAFE_METHODS from rest_framework.request import Request +from structlog import get_logger + from mreg.api.responses import error_body from mreg.api.v1.serializers import HostSerializer -from mreg.models.host import HostGroup +from mreg.models.host import Host, HostGroup from mreg.models.network import NetGroupRegexPermission, Network -from mreg.models.auth import User +from mreg.models.auth import User, MregAdminGroup +from mreg.api.treetop import ( + PolicyCheck, + PolicyResource, + authorize_policy_stack, + policy_all, + policy_any, + policy_enforcement_enabled, + policy_leaf, + policy_parity, + policy_shadow_enabled, +) +from mreg.policy.contracts import MEMBERSHIP_ACTIONS, snake_case +from mreg.policy.resources import ( + adapter_for_kind, + crud_operation_from_method, + policy_action_from_view, + resource_id_from_view, + resource_kind_from_view, + stringify_attribute, +) # NOTE: We _must_ import `rest_framework.generics` in an `if TYPE_CHECKING:` # block because DRF does some dynamic import shenanigans on runtime using @@ -22,6 +45,303 @@ from rest_framework.serializers import Serializer from mreg.models.base import BaseModel +logger = get_logger() + +DEFAULT_RESOURCE_ATTRS = {"kind": "generic", "id": "any"} + + +class ParityMixin: + """Translate legacy permission results into explicit policy contracts.""" + + _MEMBERSHIP_ACTIONS = { + MregAdminGroup.SUPERUSER: MEMBERSHIP_ACTIONS["superuser"], + MregAdminGroup.ADMINUSER: MEMBERSHIP_ACTIONS["admin"], + MregAdminGroup.GROUP_ADMIN: MEMBERSHIP_ACTIONS["group_admin"], + MregAdminGroup.NETWORK_ADMIN: MEMBERSHIP_ACTIONS["network_admin"], + MregAdminGroup.DNS_WILDCARD: MEMBERSHIP_ACTIONS["dns_wildcard"], + MregAdminGroup.DNS_UNDERSCORE: MEMBERSHIP_ACTIONS["dns_underscore"], + MregAdminGroup.HOSTPOLICY_ADMIN: MEMBERSHIP_ACTIONS["hostpolicy_admin"], + } + + @staticmethod + def _stringify_attr_value(value: Any) -> str: + """Convert attribute values to strings for TreeTop resource attributes.""" + return stringify_attribute(value) + + @staticmethod + def _snake_case(value: str) -> str: + """Normalize model/resource names to snake_case action/resource tokens.""" + return snake_case(value) + + def _resource_kind_from_view( + self, + *, + view: "GenericAPIView", + validated_serializer: "Serializer | None" = None, + obj: Any = None, + ) -> str: + return resource_kind_from_view(view=view, validated_serializer=validated_serializer, obj=obj) + + def _resource_id_from_view( + self, + *, + view: "GenericAPIView", + validated_serializer: "Serializer | None" = None, + obj: Any = None, + data: Mapping[str, Any] | None = None, + default: str = "any", + ) -> str: + """Resolve a stable resource identifier through its registered adapter.""" + kind = self._resource_kind_from_view(view=view, validated_serializer=validated_serializer, obj=obj) + return resource_id_from_view( + view=view, + kind=kind, + validated_serializer=validated_serializer, + obj=obj, + data=data, + default=default, + ) + + def _crud_operation_from_method(self, method: str) -> str: + """Map an HTTP method to a CRUD operation token.""" + return crud_operation_from_method(method) + + def _crud_action(self, resource_kind: str, operation: str) -> str: + """Build a policy action name like `_`.""" + contract = adapter_for_kind(resource_kind).contract + if operation not in contract.operations: + raise ValueError(f"{resource_kind} does not declare the {operation} policy operation") + return f"{self._snake_case(resource_kind)}_{operation}" + + def _policy_action_from_view( + self, + *, + view: "GenericAPIView", + resource_kind: str, + operation: str, + ) -> str: + """Resolve an explicit custom action or the model-backed CRUD action.""" + return policy_action_from_view(view=view, resource_kind=resource_kind, operation=operation) + + def _normalize_resource_attrs( + self, + *, + resource_kind: str, + attrs: Mapping[str, Any] | None, + ) -> dict[str, str]: + """Normalize resource attributes to string values with a canonical kind.""" + return adapter_for_kind(resource_kind).attributes(attrs) + + def pp( + self, + *, + decision: bool, + action: str, + request: Request, + view: "GenericAPIView", + resource_kind: str = "Generic", + resource_id: str = "any", + resource_attrs: Mapping[str, str] | None = None, + ) -> bool: + """Queue one parity check without changing the legacy decision.""" + return policy_parity( + decision, + request=request, + view=view, + permission_class=self.__class__.__name__, + check=PolicyCheck( + action=action, + resource=PolicyResource( + kind=resource_kind, + id=resource_id, + attrs=resource_attrs or DEFAULT_RESOURCE_ATTRS, + ), + ), + ) + + def pp_generic_action( + self, + attrs: Mapping[str, Any], + decision: bool, + action: str, + request: Request, + view: GenericAPIView, + kind: str = "Generic", + resource_id: str = "any", + ) -> bool: + """Convenience wrapper that normalizes attrs and forwards to pp().""" + return self.pp( + decision=decision, + action=action, + request=request, + view=view, + resource_kind=kind, + resource_id=str(resource_id), + resource_attrs=self._normalize_resource_attrs(resource_kind=kind, attrs=attrs), + ) + + def user_has_permission( + self, membership: MregAdminGroup, request: Request, view: GenericAPIView, exclude_superuser: bool = False + ) -> bool: + """ + Check if the user has a given generic permission level. + """ + user = User.from_request(request) + memberlist = membership.settings_groups_or_raise() + + if not exclude_superuser and membership != MregAdminGroup.SUPERUSER: + memberlist.extend(MregAdminGroup.SUPERUSER.settings_groups_or_raise()) + + is_member = user.is_member_of_any(memberlist) + + return is_member + + def user_is_superuser(self, request: Request, view: GenericAPIView) -> bool: + """ + Check if the user is a superuser. + """ + return self.user_has_permission( + membership=MregAdminGroup.SUPERUSER, + request=request, + view=view, + ) + + def user_is_admin(self, request: Request, view: GenericAPIView) -> bool: + """ + Check if the user is an admin. + """ + return self.user_has_permission( + membership=MregAdminGroup.ADMINUSER, + request=request, + view=view, + ) + + def user_is_network_admin(self, request: Request, view: GenericAPIView) -> bool: + """ + Check if the user is a network admin. + """ + return self.user_has_permission( + membership=MregAdminGroup.NETWORK_ADMIN, + request=request, + view=view, + ) + + def user_is_dns_wildcard_admin(self, request: Request, view: GenericAPIView) -> bool: + """ + Check if the user is a DNS wildcard admin. + """ + return self.user_has_permission( + membership=MregAdminGroup.DNS_WILDCARD, + request=request, + view=view, + ) + + def user_is_dns_underscore_admin(self, request: Request, view: GenericAPIView) -> bool: + """ + Check if the user is a DNS underscore admin. + """ + return self.user_has_permission( + membership=MregAdminGroup.DNS_UNDERSCORE, + request=request, + view=view, + ) + + def user_is_hostgroup_admin(self, request: Request, view: GenericAPIView) -> bool: + """ + Check if the user is a hostgroup admin. + """ + return self.user_has_permission( + membership=MregAdminGroup.GROUP_ADMIN, + request=request, + view=view, + ) + + def user_is_any(self, *memberships: MregAdminGroup, request: Request, view: GenericAPIView) -> bool: + """ + Check if the user is a member of any of the given groups. + """ + for membership in memberships: + if self.user_has_permission(membership, request, view): + return True + return False + + def authorize_memberships( + self, + *memberships: MregAdminGroup, + legacy_decision: bool, + request: Request, + view: GenericAPIView, + ) -> bool: + """Authorize an OR of membership actions in one TreeTop call.""" + leaves = tuple( + policy_leaf( + action=self._MEMBERSHIP_ACTIONS[membership], + resource_kind="Generic", + resource_id="any", + resource_attrs=DEFAULT_RESOURCE_ATTRS, + ) + for membership in memberships + ) + root = leaves[0] if len(leaves) == 1 else policy_any(*leaves) + return authorize_policy_stack( + legacy_decision, + request=request, + root=root, + view=view, + permission_class=self.__class__.__name__, + ) + + def authorize_endpoint( + self, + *, + legacy_decision: bool, + request: Request, + view: GenericAPIView, + validated_serializer: Serializer | None = None, + obj: Any = None, + data: Mapping[str, Any] | None = None, + fallback_action: str = "authenticated_access", + ) -> bool: + """Authorize one ordinary endpoint operation as a single-leaf stack.""" + try: + resource_kind = self._resource_kind_from_view( + view=view, + validated_serializer=validated_serializer, + obj=obj, + ) + operation = self._crud_operation_from_method(request.method) + action = self._policy_action_from_view( + view=view, + resource_kind=resource_kind, + operation=operation, + ) + resource_id = self._resource_id_from_view( + view=view, + validated_serializer=validated_serializer, + obj=obj, + data=data, + ) + attrs = self._normalize_resource_attrs( + resource_kind=resource_kind, + attrs=data, + ) + except ValueError: + resource_kind = "Generic" + action = fallback_action + resource_id = str(next(iter(getattr(view, "kwargs", {}).values()), "any")) + attrs = DEFAULT_RESOURCE_ATTRS + return authorize_policy_stack( + legacy_decision, + request=request, + root=policy_leaf( + action=action, + resource_kind=resource_kind, + resource_id=resource_id, + resource_attrs=attrs, + ), + view=view, + permission_class=self.__class__.__name__, + ) class CRUDPermissionsMixin: @@ -43,18 +363,129 @@ def has_destroy_permission(self, request: Request, view: GenericAPIView, validat return False -class IsAuthenticated(DRFIsAuthenticated, CRUDPermissionsMixin): +class IsAuthenticated(DRFIsAuthenticated, CRUDPermissionsMixin, ParityMixin): """ Allows access only to authenticated users. """ + + def deny_superuser_only_names(self, data=None, name=None, view=None, request=None): + """Check for superuser only names. If match, return True.""" + import mreg.api.v1.views as v1_views + + if data is not None: + name = data.get("name", "") + if not name: + if "host" in data: + name = data["host"].name + + name = (name or "").strip() # Guarantee coercion to string + + if not request: # pragma: no cover + return False + + if not view: # pragma: no cover + return False + + # Underscore is allowed for non-superuser in SRV records, + # and for members of in all records. + if ( + "_" in name + and not isinstance(view, (v1_views.SrvDetail, v1_views.SrvList)) + and not self.user_is_dns_underscore_admin(request, view) + ): + return True + + # Except for super-users, only members of the DNS wildcard group can create wildcard records. + # And then only below subdomains, like *.sub.example.com + if "*" in name and (not self.user_is_dns_wildcard_admin(request, view) or name.count(".") < 3): + return True + + return False + + def deny_reserved_ipaddress(self, ip: str, request: Request, view: GenericAPIView) -> bool: + """Check if an ip address is reserved, and if so, only permit + NETWORK_ADMIN_GROUP members.""" + + if self.user_is_network_admin(request, view): + return False + + network = Network.objects.filter(network__net_contains=ip).first() + if not network: + return False + + return network.is_reserved_ipaddress(ip) + + def deny_restricted_ipaddress(self, ip: str, request: Request, view: GenericAPIView) -> bool: + """Check all IP restrictions applied while assigning an address.""" + if self.deny_reserved_ipaddress(ip, request, view): + return True + if self.user_is_network_admin(request, view): + return False + network = Network.objects.filter(network__net_contains=ip).first() + if not network: + return False + address = ipaddress.ip_address(ip) + return address in { + network.network.network_address, + network.network.broadcast_address, + } + pass +class IsAuthenticatedWithPolicy(IsAuthenticated): + """Authenticate locally, then authorize the endpoint once in TreeTop.""" + + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + return self.authorize_endpoint( + legacy_decision=True, + request=request, + view=view, + data=request.data if isinstance(request.data, Mapping) else None, + ) + + +class UserInfoPermission(IsAuthenticated): + """Authorize access to the requesting user's or another user's details.""" + + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + user = User.from_request(request) + target_username = request.query_params.get("username") or user.username + self_access = target_username == user.username + legacy = self_access or user.is_mreg_superuser_or_admin or user.is_mreg_hostgroup_admin + return authorize_policy_stack( + legacy, + request=request, + root=policy_leaf( + action="user_info_read", + resource_kind="Generic", + resource_id=str(target_username), + resource_attrs={ + "kind": "generic", + "name": str(target_username), + "selfAccess": str(self_access).lower(), + }, + ), + view=view, + permission_class=self.__class__.__name__, + ) + + class IsAuthenticatedAndReadOnly(IsAuthenticated): def has_permission(self, request, view): if not super().has_permission(request, view): return False - return request.method in SAFE_METHODS + if request.method not in SAFE_METHODS: + return False + return self.authorize_endpoint( + legacy_decision=True, + request=request, + view=view, + ) class IsSuperGroupMember(IsAuthenticated): @@ -65,7 +496,13 @@ class IsSuperGroupMember(IsAuthenticated): def has_permission(self, request, view): if not super().has_permission(request, view): return False - return User.from_request(request).is_mreg_superuser + + return self.pp( + decision=User.from_request(request).is_mreg_superuser, + action="is_superuser", + request=request, + view=view, + ) class IsSuperOrAdminOrReadOnly(IsAuthenticated): @@ -77,8 +514,15 @@ def has_permission(self, request, view): if not super().has_permission(request, view): return False if request.method in SAFE_METHODS: - return True - return User.from_request(request).is_mreg_superuser_or_admin + return self.authorize_endpoint(legacy_decision=True, request=request, view=view) + legacy = self.user_is_admin(request=request, view=view) + return self.authorize_endpoint( + legacy_decision=legacy, + request=request, + view=view, + data=request.data if isinstance(request.data, Mapping) else None, + fallback_action=self._MEMBERSHIP_ACTIONS[MregAdminGroup.ADMINUSER], + ) class IsSuperOrNetworkAdminMember(IsAuthenticated): @@ -90,73 +534,84 @@ def has_permission(self, request, view): if not super().has_permission(request, view): return False - user = User.from_request(request) - if user.is_mreg_superuser: - return True - if user.is_mreg_network_admin: - return True - return False + legacy = self.user_is_any( + MregAdminGroup.SUPERUSER, + MregAdminGroup.NETWORK_ADMIN, + request=request, + view=view, + ) + return self.authorize_endpoint( + legacy_decision=legacy, + request=request, + view=view, + data=request.data if isinstance(request.data, Mapping) else None, + fallback_action=self._MEMBERSHIP_ACTIONS[MregAdminGroup.NETWORK_ADMIN], + ) -class IsSuperOrGroupAdminOrReadOnly(IsAuthenticated): - """ - Permit user if in super or group admin group, else read only. - """ +class IsSuperOrReadOnly(IsAuthenticated): + """Authorize safe reads or superuser-only mutations with one stack.""" def has_permission(self, request, view): if not super().has_permission(request, view): return False - user = User.from_request(request) - if request.method in SAFE_METHODS: - return True - return user.is_mreg_superuser or user.is_mreg_hostgroup_admin - + legacy = request.method in SAFE_METHODS or self.user_is_superuser(request, view) + return self.authorize_endpoint( + legacy_decision=legacy, + request=request, + view=view, + data=request.data if isinstance(request.data, Mapping) else None, + fallback_action=self._MEMBERSHIP_ACTIONS[MregAdminGroup.SUPERUSER], + ) -def _deny_superuser_only_names(data=None, name=None, view=None, request=None): - """Check for superuser only names. If match, return True.""" - import mreg.api.v1.views - if data is not None: - name = data.get('name', '') - if not name: - if 'host' in data: - name = data['host'].name - - if not request: # pragma: no cover - return False - - user = User.from_request(request) - - # Underscore is allowed for non-superuser in SRV records, - # and for members of in all records. - if '_' in name and not isinstance(view, (mreg.api.v1.views.SrvDetail, - mreg.api.v1.views.SrvList)) \ - and not user.is_mreg_dns_underscore_admin: - return True +class IsNetworkAdminOrReadOnly(IsAuthenticated): + """Authorize safe reads or network-admin mutations with one stack.""" - # Except for super-users, only members of the DNS wildcard group can create wildcard records. - # And then only below subdomains, like *.sub.example.com - if '*' in name and (not user.is_mreg_dns_wildcard_admin or name.count('.') < 3): - return True + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + legacy = request.method in SAFE_METHODS or self.user_is_any( + MregAdminGroup.SUPERUSER, + MregAdminGroup.NETWORK_ADMIN, + request=request, + view=view, + ) + return self.authorize_endpoint( + legacy_decision=legacy, + request=request, + view=view, + data=request.data if isinstance(request.data, Mapping) else None, + fallback_action=self._MEMBERSHIP_ACTIONS[MregAdminGroup.NETWORK_ADMIN], + ) - return False +class IsSuperOrGroupAdminOrReadOnly(IsAuthenticated): + """ + Permit user if in super or group admin group, else read only. + """ -def is_reserved_ip(ip): - network = Network.objects.filter(network__net_contains=ip).first() - if network: - return any(ip == str(i) for i in network.get_reserved_ipaddresses()) - return False + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + if request.method in SAFE_METHODS: + return self.authorize_endpoint(legacy_decision=True, request=request, view=view) + + legacy = self.user_is_any( + MregAdminGroup.SUPERUSER, + MregAdminGroup.GROUP_ADMIN, + request=request, + view=view, + ) + return self.authorize_endpoint( + legacy_decision=legacy, + request=request, + view=view, + data=request.data if isinstance(request.data, Mapping) else None, + fallback_action=self._MEMBERSHIP_ACTIONS[MregAdminGroup.GROUP_ADMIN], + ) -def _deny_reserved_ipaddress(ip, request): - """Check if an ip address is reserved, and if so, only permit - NETWORK_ADMIN_GROUP members.""" - if is_reserved_ip(ip): - if User.from_request(request).is_mreg_network_admin: - return False - return True - return False class IsGrantedNetGroupRegexPermission(IsAuthenticated): """ Permit user if the user has been granted access through a @@ -172,141 +627,656 @@ def has_permission(self, request, view): # just do some preliminary checks. if not super().has_permission(request, view): return False + user = User.from_request(request) if request.method in SAFE_METHODS: - return True + resource_kind = self._resource_kind_from_view(view=view) + return self.pp_generic_action( + decision=True, + action=self._policy_action_from_view( + view=view, + resource_kind=resource_kind, + operation="read", + ), + kind=resource_kind, + resource_id=self._resource_id_from_view(view=view), + attrs={"path": request.path}, + request=request, + view=view, + ) + if user.is_mreg_superuser_or_admin: return True + + if policy_enforcement_enabled() or policy_shadow_enabled(): + return True + # Will do do more object checks later, but initially refuse any # unwarranted requests. qs = NetGroupRegexPermission.objects.filter(group__in=user.group_list) # If the view has a network in the URL, use the network itself as part # of the permission check. This is URL only, so the user cannot manipulate # this input in the request body. - network_in_url = view.kwargs.get('network') + network_in_url = view.kwargs.get("network") if network_in_url: qs = qs.filter(range=network_in_url) if qs.exists(): return True return False + def _target_policy_node( + self, + *, + hostname: str, + ips: Sequence[str], + action: str, + resource_kind: str, + resource_id: str, + policy_name: str | None = None, + ): + """Build an OR of leaves containing only the raw target name and IP.""" + checked_name = str(policy_name or hostname) + values = tuple(ips) or (None,) + leaves = [] + for ip in values: + attrs = {"hostname": checked_name} + if ip is not None: + attrs["ip"] = str(ip) + leaves.append( + policy_leaf( + action=action, + resource_kind=resource_kind, + resource_id=resource_id, + resource_attrs=attrs, + ) + ) + return leaves[0] if len(leaves) == 1 else policy_any(*leaves) + + def _required_resource_kind( + self, + *, + view: GenericAPIView, + validated_serializer: Serializer | None = None, + obj: Any = None, + ) -> str: + """Translate an unregistered target into the legacy permission error.""" + try: + return self._resource_kind_from_view( + view=view, + validated_serializer=validated_serializer, + obj=obj, + ) + except ValueError as exc: + raise exceptions.PermissionDenied(f"Unhandled view: {view}") from exc + + def has_perm( + self, + user, + hostname, + ips, + request: Request, + view: GenericAPIView, + require_ip=True, + action: str | None = None, + resource_kind: str = "Host", + resource_id: str | None = None, + legacy_decision: bool | None = None, + ): + """Evaluate all hostname/IP candidates in one synchronous policy stack.""" + legacy = ( + bool(NetGroupRegexPermission.find_perm(user.group_list, hostname, ips, require_ip)) + if legacy_decision is None + else bool(legacy_decision) + ) + operation = self._crud_operation_from_method(request.method) + resolved_action = action or self._crud_action(resource_kind, operation) + resolved_resource_id = str(resource_id or hostname or "any") + root = self._target_policy_node( + hostname=str(hostname), + ips=tuple(str(ip) for ip in ips), + action=resolved_action, + resource_kind=resource_kind, + resource_id=resolved_resource_id, + ) + return authorize_policy_stack( + legacy, + request=request, + root=root, + view=view, + permission_class=self.__class__.__name__, + ) + + def has_obj_perm( + self, + user: User, + obj: str, + request: Request, + view: GenericAPIView, + action: str | None = None, + resource_kind: str = "Host", + resource_id: str | None = None, + ) -> bool: + """Resolve hostname/IPs from an object and delegate to has_perm().""" + return self.has_perm( + user, + *self._get_hostname_and_ips(obj), + request=request, + view=view, + action=action, + resource_kind=resource_kind, + resource_id=resource_id, + ) + + def _flatten_policy_attrs(self, data: Mapping[str, Any], *, resource_kind: str) -> dict[str, str]: + """Adapt serializer data through the resource's registered adapter.""" + return adapter_for_kind(resource_kind).attributes(data) + @staticmethod - def has_perm(user, hostname, ips, require_ip=True): - return bool(NetGroupRegexPermission.find_perm(user.group_list, - hostname, ips, require_ip)) + def _legacy_target_permission(user: User, hostname: str, ips: Sequence[str], *, require_ip: bool = True) -> bool: + return bool(NetGroupRegexPermission.find_perm(user.group_list, hostname, ips, require_ip)) + + def _has_create_target_permission( + self, + *, + user: User, + request: Request, + view: GenericAPIView, + data: Mapping[str, Any], + action: str, + resource_kind: str, + resource_id: str, + restriction_denied: bool, + ) -> bool: + """Build and authorize the complete create stack in one call.""" + import mreg.api.v1.views as v1_views + + ip_value = data.get("ipaddress") + host = data.get("host") + standalone_name = str(data.get("name") or "") + standalone_target = not host and not isinstance( + view, + ( + v1_views.CnameList, + v1_views.HostList, + v1_views.IpaddressList, + v1_views.PtrOverrideList, + ), + ) + if isinstance(view, v1_views.CnameList): + name = self._stringify_attr_value(data["name"]) + node = self._target_policy_node( + hostname=name, + ips=(), + action=action, + resource_kind=resource_kind, + resource_id=name, + ) + target_legacy = self._legacy_target_permission(user, name, (), require_ip=False) + nodes = [node] + else: + if isinstance(view, v1_views.HostList): + hostname = str(getattr(host, "name", None) or data.get("name") or "") + if not hostname: + return False + ips = [ip_value] if ip_value else [] + elif isinstance(view, (v1_views.IpaddressList, v1_views.PtrOverrideList)): + if not (ip_value and host): + return False + hostname = host.name + ips = [ip_value] + elif host: + hostname, ips = self._get_hostname_and_ips(host) + elif standalone_name: + hostname, ips = standalone_name, [] + else: + raise exceptions.PermissionDenied(f"Unhandled view: {view}") + + if not hostname: + return False + nodes = [ + self._target_policy_node( + hostname=str(hostname), + ips=tuple(str(ip) for ip in ips), + action=action, + resource_kind=resource_kind, + resource_id=self._stringify_attr_value(hostname), + policy_name=self._stringify_attr_value(data.get("name") or hostname), + ) + ] + target_legacy = self._legacy_target_permission(user, hostname, ips) + if isinstance(view, (v1_views.IpaddressList, v1_views.PtrOverrideList)): + old_hostname, old_ips = self._get_hostname_and_ips(host) + nodes.insert( + 0, + self._target_policy_node( + hostname=str(old_hostname), + ips=tuple(str(ip) for ip in old_ips), + action=action, + resource_kind=resource_kind, + resource_id=resource_id, + ), + ) + target_legacy = target_legacy and self._legacy_target_permission( + user, + old_hostname, + old_ips, + ) - def has_obj_perm(self, user, obj): - return self.has_perm(user, *self._get_hostname_and_ips(obj)) + role_legacy = user.is_mreg_superuser or (user.is_mreg_admin and not standalone_target) + legacy = user.is_mreg_superuser or (not restriction_denied and (role_legacy or target_legacy)) + root = nodes[0] if len(nodes) == 1 else policy_all(*nodes) + return authorize_policy_stack( + legacy, + request=request, + root=root, + view=view, + permission_class=self.__class__.__name__, + ) def has_create_permission(self, request, view, validated_serializer): - import mreg.api.v1.views + """Authorize create operations using CRUD parity actions and legacy rules.""" user = User.from_request(request) - if user.is_mreg_superuser: - return True - - hostname = None - ips = [] - data = validated_serializer.validated_data - if _deny_superuser_only_names(data=data, view=view, request=request): - return False - if 'ipaddress' in data: - if _deny_reserved_ipaddress(data['ipaddress'], request): - return False - if user.is_mreg_admin: - return True - if isinstance(view, (mreg.api.v1.views.IpaddressList, - mreg.api.v1.views.PtrOverrideList)): - if 'host' in data: - if not self.has_obj_perm(user, data['host']): - return False - if isinstance(view, mreg.api.v1.views.CnameList): - # only check the cname, don't care about ip addresses - return self.has_perm(user, data['name'], (), require_ip=False) - if isinstance(view, (mreg.api.v1.views.HostList, - mreg.api.v1.views.IpaddressList, - mreg.api.v1.views.PtrOverrideList)): - # HostList does not require ipaddress, but if none, the permissions - # will not match, so just refuse it. - ip = data.get('ipaddress', None) - if ip is None: - return False - ips.append(ip) - hostname = data['host'].name - elif 'host' in data: - hostname, ips = self._get_hostname_and_ips(data['host']) - else: - raise exceptions.PermissionDenied(f"Unhandled view: {view}") - - if ips and hostname: - return self.has_perm(user, hostname, ips) - return False + data: dict[str, Any] = validated_serializer.validated_data # type: ignore + logger.debug( + "create_permission_check", + user=user.username, + view=view.__class__.__name__, + fields=sorted(data), + ) + + resource_kind = self._required_resource_kind( + view=view, + validated_serializer=validated_serializer, + ) + action = self._policy_action_from_view( + view=view, + resource_kind=resource_kind, + operation="create", + ) + resource_id = self._resource_id_from_view( + view=view, + validated_serializer=validated_serializer, + data=data, + ) + ip_value = data.get("ipaddress") + + restriction_denied = self.deny_superuser_only_names( + data=data, + view=view, + request=request, + ) or bool( + ip_value + and self.deny_restricted_ipaddress( + ip=ip_value, + view=view, + request=request, + ) + ) + return self._has_create_target_permission( + user=user, + request=request, + view=view, + data=data, + action=action, + resource_kind=resource_kind, + resource_id=resource_id, + restriction_denied=restriction_denied, + ) def has_destroy_permission(self, request, view, validated_serializer): - import mreg.api.v1.views + """Authorize delete operations using CRUD parity actions and legacy rules.""" + import mreg.api.v1.views as v1_views + user = User.from_request(request) - if user.is_mreg_superuser: - return True - obj = view.get_object() - if isinstance(view, mreg.api.v1.views.HostDetail): - pass - elif hasattr(obj, 'host'): - obj = obj.host + target_obj = view.get_object() + host_obj = target_obj + standalone_target = False + if not isinstance(view, v1_views.HostDetail) and hasattr(target_obj, "host"): + host_obj = target_obj.host + elif not isinstance(view, v1_views.HostDetail): + standalone_target = True + + resource_kind = self._required_resource_kind(view=view, obj=target_obj) + action = self._policy_action_from_view( + view=view, + resource_kind=resource_kind, + operation="delete", + ) + resource_id = self._resource_id_from_view(view=view, obj=target_obj) + if standalone_target: + hostname = str(getattr(target_obj, "name", resource_id)) + ips = [] else: - raise exceptions.PermissionDenied(f"Unhandled view: {view}") - if _deny_superuser_only_names(name=obj.name, view=view, request=request): - return False - if hasattr(obj, 'ipaddress'): - if _deny_reserved_ipaddress(obj.ipaddress, request): - return False - if user.is_mreg_admin: - return True - return self.has_obj_perm(user, obj) + hostname, ips = self._get_hostname_and_ips(host_obj) + restriction_denied = self.deny_superuser_only_names( + name=host_obj.name, + view=view, + request=request, + ) or bool( + hasattr(host_obj, "ipaddress") + and self.deny_reserved_ipaddress( + ip=host_obj.ipaddress, + view=view, + request=request, + ) + ) + target_legacy = self._legacy_target_permission(user, hostname, ips) + legacy = user.is_mreg_superuser or (not restriction_denied and ((user.is_mreg_admin and not standalone_target) or target_legacy)) + return authorize_policy_stack( + legacy, + request=request, + root=self._target_policy_node( + hostname=hostname, + ips=tuple(str(ip) for ip in ips), + action=action, + resource_kind=resource_kind, + resource_id=resource_id, + policy_name=self._stringify_attr_value(getattr(target_obj, "name", None) or hostname), + ), + view=view, + permission_class=self.__class__.__name__, + ) + + def _host_detail_update_stack( + self, + *, + user: User, + target_obj: Any, + data: Mapping[str, Any], + action: str, + resource_kind: str, + ): + hostname, ips = self._get_hostname_and_ips(target_obj) + nodes = [ + self._target_policy_node( + hostname=hostname, + ips=tuple(str(ip) for ip in ips), + action=action, + resource_kind=resource_kind, + resource_id=self._stringify_attr_value(hostname), + policy_name=self._stringify_attr_value(getattr(target_obj, "name", None) or hostname), + ) + ] + legacy = self._legacy_target_permission(user, hostname, ips) + if "name" in data: + new_name = self._stringify_attr_value(data["name"]) + nodes.insert( + 0, + self._target_policy_node( + hostname=new_name, + ips=tuple(str(ip) for ip in ips), + action=action, + resource_kind=resource_kind, + resource_id=new_name, + policy_name=new_name, + ), + ) + legacy = legacy and self._legacy_target_permission(user, new_name, ips) + return (nodes[0] if len(nodes) == 1 else policy_all(*nodes), legacy) + + def _related_host_update_stack( + self, + *, + user: User, + target_obj: Any, + data: Mapping[str, Any], + action: str, + resource_kind: str, + resource_id: str, + ): + hosts = [target_obj.host] + if "host" in data and data["host"] != target_obj.host: + hosts.insert(0, data["host"]) + nodes = [] + legacy_values = [] + for host in hosts: + hostname, ips = self._get_hostname_and_ips(host) + nodes.append( + self._target_policy_node( + hostname=hostname, + ips=tuple(str(ip) for ip in ips), + action=action, + resource_kind=resource_kind, + resource_id=resource_id, + policy_name=self._stringify_attr_value(data.get("name") or getattr(target_obj, "name", None) or hostname), + ) + ) + legacy_values.append(self._legacy_target_permission(user, hostname, ips)) + return (nodes[0] if len(nodes) == 1 else policy_all(*nodes), all(legacy_values)) def has_update_permission(self, request, view, validated_serializer): - import mreg.api.v1.views + """Authorize update operations using CRUD parity actions and legacy rules.""" + import mreg.api.v1.views as v1_views + user = User.from_request(request) - if user.is_mreg_superuser: - return True - data = validated_serializer.validated_data - if _deny_superuser_only_names(data=data, view=view, request=request): - return False - if 'ipaddress' in data: - if _deny_reserved_ipaddress(data['ipaddress'], request): - return False - if user.is_mreg_admin: - return True - obj = view.get_object() - if isinstance(view, mreg.api.v1.views.HostDetail): - hostname, ips = self._get_hostname_and_ips(obj) - # If renaming a host, make sure the user has permission to both the - # new and and old hostname. - if 'name' in data: - if not self.has_perm(user, data['name'], ips): - return False - return self.has_perm(user, hostname, ips) - elif hasattr(obj, 'host'): - # If changing host object, make sure the user has permission the - # new one. - if 'host' in data and data['host'] != obj.host: - if not self.has_obj_perm(user, data['host']): - return False - return self.has_obj_perm(user, obj.host) - # Testing these kinds of should-never-happen codepaths is hard. - # We have to basically mock a complete API call and then break it. - raise exceptions.PermissionDenied(f"Unhandled view: {view}") # pragma: no cover + data: dict[str, Any] = validated_serializer.validated_data # type: ignore + target_obj = view.get_object() + standalone_target = not isinstance(view, v1_views.HostDetail) and not hasattr(target_obj, "host") + + resource_kind = self._required_resource_kind( + view=view, + validated_serializer=validated_serializer, + obj=target_obj, + ) + action = self._policy_action_from_view( + view=view, + resource_kind=resource_kind, + operation="update", + ) + resource_id = self._resource_id_from_view( + view=view, + validated_serializer=validated_serializer, + obj=target_obj, + data=data, + ) + + restriction_denied = self.deny_superuser_only_names( + data=data, + view=view, + request=request, + ) or bool( + "ipaddress" in data + and self.deny_restricted_ipaddress( + ip=data["ipaddress"], + view=view, + request=request, + ) + ) + + if isinstance(view, v1_views.HostDetail): + root, target_legacy = self._host_detail_update_stack( + user=user, + target_obj=target_obj, + data=data, + action=action, + resource_kind=resource_kind, + ) + elif hasattr(target_obj, "host"): + root, target_legacy = self._related_host_update_stack( + user=user, + target_obj=target_obj, + data=data, + action=action, + resource_kind=resource_kind, + resource_id=resource_id, + ) + else: + current_name = str(getattr(target_obj, "name", resource_id)) + nodes = [ + self._target_policy_node( + hostname=current_name, + ips=(), + action=action, + resource_kind=resource_kind, + resource_id=resource_id, + policy_name=str(data.get("name") or current_name), + ) + ] + if data.get("name") and data["name"] != current_name: + new_name = str(data["name"]) + nodes.insert( + 0, + self._target_policy_node( + hostname=new_name, + ips=(), + action=action, + resource_kind=resource_kind, + resource_id=new_name, + policy_name=new_name, + ), + ) + root = nodes[0] if len(nodes) == 1 else policy_all(*nodes) + target_legacy = False + + legacy = user.is_mreg_superuser or (not restriction_denied and ((user.is_mreg_admin and not standalone_target) or target_legacy)) + return authorize_policy_stack( + legacy, + request=request, + root=root, + view=view, + permission_class=self.__class__.__name__, + ) def _get_hostname_and_ips(self, hostobject): + """Extract a host's canonical name and all attached IP addresses.""" ips = [] host = HostSerializer(hostobject) - for i in host.data['ipaddresses']: - ips.append(i['ipaddress']) - return host.data['name'], ips + for i in host.data["ipaddresses"]: + ips.append(i["ipaddress"]) + return host.data["name"], ips -class HostGroupPermission(IsAuthenticated): +class IsGrantedNetGroupRegexOrNetworkAdmin(IsGrantedNetGroupRegexPermission): + """Combine the former DRF OR expression into one endpoint decision.""" + + def has_permission(self, request, view): + if not DRFIsAuthenticated.has_permission(self, request, view): + return False + user = User.from_request(request) + legacy = request.method in SAFE_METHODS or user.is_mreg_superuser_or_admin + if not legacy: + qs = NetGroupRegexPermission.objects.filter(group__in=user.group_list) + if network_in_url := view.kwargs.get("network"): + qs = qs.filter(range=network_in_url) + legacy = qs.exists() or user.is_mreg_network_admin + resource_kind = self._resource_kind_from_view(view=view) + operation = self._crud_operation_from_method(request.method) + action = self._policy_action_from_view( + view=view, + resource_kind=resource_kind, + operation=operation, + ) + network = str(view.kwargs.get("network") or "") + attrs = self._normalize_resource_attrs( + resource_kind=resource_kind, + attrs=request.data if isinstance(request.data, Mapping) else None, + ) + if network: + attrs["network"] = network + return authorize_policy_stack( + legacy, + request=request, + root=policy_leaf( + action=action, + resource_kind=resource_kind, + resource_id=self._resource_id_from_view( + view=view, + data=request.data if isinstance(request.data, Mapping) else None, + ), + resource_attrs=attrs, + ), + view=view, + permission_class=self.__class__.__name__, + ) + + +class HostContactsPermission(IsGrantedNetGroupRegexPermission): + """Authorize a host-contact endpoint against its complete host target.""" + + def has_permission(self, request, view): + if not DRFIsAuthenticated.has_permission(self, request, view): + return False + user = User.from_request(request) + hostname = str(view.kwargs.get("name") or "") + host = Host.objects.filter(name=hostname).first() + ips = self._get_hostname_and_ips(host)[1] if host is not None else [] + action = { + "GET": "host_contacts_read", + "HEAD": "host_contacts_read", + "OPTIONS": "host_contacts_read", + "POST": "host_contacts_create", + "DELETE": "host_contacts_delete", + }.get(request.method, "host_contacts_read") + restriction_denied = request.method not in SAFE_METHODS and self.deny_superuser_only_names( + name=hostname, + view=view, + request=request, + ) + target_legacy = self._legacy_target_permission(user, hostname, ips) + legacy = ( + request.method in SAFE_METHODS or user.is_mreg_superuser or (not restriction_denied and (user.is_mreg_admin or target_legacy)) + ) + return authorize_policy_stack( + legacy, + request=request, + root=self._target_policy_node( + hostname=hostname, + ips=tuple(str(ip) for ip in ips), + action=action, + resource_kind="Host", + resource_id=hostname or "any", + ), + view=view, + permission_class=self.__class__.__name__, + ) + + +class BACnetPermission(IsGrantedNetGroupRegexPermission): + """Authorize BACnet reads and mutations against the attached host.""" + def has_permission(self, request, view): + if not DRFIsAuthenticated.has_permission(self, request, view): + return False + user = User.from_request(request) + host = None + if request.method == "POST": + host_id = request.data.get("host") + hostname = request.data.get("hostname") + if host_id is not None: + host = Host.objects.filter(pk=host_id).first() + elif hostname: + host = Host.objects.filter(name=hostname).first() + elif view.kwargs.get("id") is not None: + try: + obj = view.get_queryset().filter(pk=view.kwargs["id"]).first() + except (TypeError, ValueError): + obj = None + host = getattr(obj, "host", None) + + hostname = str(getattr(host, "name", "any")) + ips = self._get_hostname_and_ips(host)[1] if host is not None else [] + operation = self._crud_operation_from_method(request.method) + action = self._crud_action("BACnetID", operation) + target_legacy = bool(host is not None and self._legacy_target_permission(user, hostname, ips)) + legacy = request.method in SAFE_METHODS or user.is_mreg_superuser_or_admin or target_legacy + return authorize_policy_stack( + legacy, + request=request, + root=self._target_policy_node( + hostname=hostname, + ips=tuple(str(ip) for ip in ips), + action=action, + resource_kind="BACnetID", + resource_id=str(request.data.get("id") or view.kwargs.get("id") or "any"), + ), + view=view, + permission_class=self.__class__.__name__, + ) + + +class HostGroupPermission(IsAuthenticated): def has_permission(self, request, view): # This method is called before the view is executed, so # just do some preliminary checks. @@ -314,6 +1284,8 @@ def has_permission(self, request, view): return False user = User.from_request(request) if request.method in SAFE_METHODS: + return self.authorize_endpoint(legacy_decision=True, request=request, view=view) + if policy_enforcement_enabled() or policy_shadow_enabled(): return True if user.is_mreg_superuser or user.is_mreg_hostgroup_admin: return True @@ -325,51 +1297,111 @@ def has_permission(self, request, view): @staticmethod def _request_user_is_owner(hostgroup, request): - owners = list(set(hostgroup.owners.values_list('name', flat=True))) + owners = list(set(hostgroup.owners.values_list("name", flat=True))) return User.from_request(request).is_member_of_any(owners) + def _authorize_hostgroup( + self, + *, + legacy: bool, + request: Request, + view: GenericAPIView, + hostgroup: HostGroup, + action: str, + requester_is_owner: bool, + owner_mutation: bool = False, + description_update: bool = False, + ) -> bool: + return authorize_policy_stack( + legacy, + request=request, + root=policy_leaf( + action=action, + resource_kind="HostGroup", + resource_id=str(hostgroup.name), + resource_attrs={ + "kind": "host_group", + "name": str(hostgroup.name), + "requesterIsOwner": str(requester_is_owner).lower(), + "ownerMutation": str(owner_mutation).lower(), + "descriptionUpdate": str(description_update).lower(), + }, + ), + view=view, + permission_class=self.__class__.__name__, + ) + def has_m2m_change_permission(self, request, view): user = User.from_request(request) - if user.is_mreg_superuser or user.is_mreg_hostgroup_admin: - return True - return self._request_user_is_owner(view.object, request) + requester_is_owner = self._request_user_is_owner(view.object, request) + owner_mutation = getattr(view, "m2m_field", None) == "owners" + legacy = user.is_mreg_superuser or user.is_mreg_hostgroup_admin + if not owner_mutation: + legacy = legacy or requester_is_owner + return self._authorize_hostgroup( + legacy=legacy, + request=request, + view=view, + hostgroup=view.object, + action="hostgroup_membership_update", + requester_is_owner=requester_is_owner, + owner_mutation=owner_mutation, + ) # patch will only happen on HostGroupDetail def has_update_permission(self, request, view, validated_serializer): user = User.from_request(request) - if user.is_mreg_superuser or user.is_mreg_hostgroup_admin: - return True - if 'description' in validated_serializer.validated_data: - return self._request_user_is_owner(view.get_object(), request) - return False + obj = view.get_object() + requester_is_owner = self._request_user_is_owner(obj, request) + legacy = user.is_mreg_superuser or user.is_mreg_hostgroup_admin + if not legacy and "description" in validated_serializer.validated_data: + legacy = requester_is_owner + return self._authorize_hostgroup( + legacy=legacy, + request=request, + view=view, + hostgroup=obj, + action="host_group_update", + requester_is_owner=requester_is_owner, + description_update="description" in validated_serializer.validated_data, + ) def has_destroy_permission(self, request, view, validated_serializer): user = User.from_request(request) - if user.is_mreg_superuser or user.is_mreg_hostgroup_admin: - return True - return False + legacy = user.is_mreg_superuser or user.is_mreg_hostgroup_admin + hostgroup = view.get_object() + return self._authorize_hostgroup( + legacy=legacy, + request=request, + view=view, + hostgroup=hostgroup, + action="host_group_delete", + requester_is_owner=self._request_user_is_owner(hostgroup, request), + ) class IsGrantedReservedAddressPermission(IsAuthenticated): def has_ipaddress_permission(self, request: Request, view: GenericAPIView, validated_serializer: Serializer): + if policy_enforcement_enabled(): + return True user = User.from_request(request) - if (user.is_mreg_superuser_or_admin or user.is_mreg_network_admin): + if user.is_mreg_superuser_or_admin or user.is_mreg_network_admin: return True - data = validated_serializer.validated_data + data = validated_serializer.validated_data if not data or not (ip := data.get("ipaddress")): return True - + try: ipaddr = ipaddress.ip_address(ip) except ValueError: # invalid IP, let serializer handle it - return True + return True try: network: Network = Network.objects.get(network__net_contains=ip) except Network.DoesNotExist: - pass # network not in mreg + pass # network not in mreg else: if ipaddr in (network.network.broadcast_address, network.network.network_address): raise exceptions.PermissionDenied( @@ -384,8 +1416,8 @@ def has_update_permission(self, request: Request, view: GenericAPIView, validate return self.has_ipaddress_permission(request, view, validated_serializer) def has_destroy_permission(self, request: Request, view: GenericAPIView, validated_serializer: BaseModel) -> bool: - # Deleting will never assign IPs. - # Furthermore, the permissions check in `perform_destroy` passes + # Deleting will never assign IPs. + # Furthermore, the permissions check in `perform_destroy` passes # in a `BaseModel` instance instead of a serializer when checking # destroy permissions, so we cannot access any sort of validated data. return self.has_permission(request, view) diff --git a/mreg/api/test_utils.py b/mreg/api/test_utils.py new file mode 100644 index 00000000..d594eeaa --- /dev/null +++ b/mreg/api/test_utils.py @@ -0,0 +1,30 @@ +"""Django test utilities for permission scenarios.""" + +from mreg.api.treetop import disable_policy_parity + + +class PermissionModifyingTestCase: + """Mixin for test classes that modify permissions during tests. + + This mixin automatically disables parity checking for all tests in the class + since modifying permissions mid-test would cause the legacy and policy + systems to be out of sync. + + Usage: + class TestSomePermissions(PermissionModifyingTestCase, TestCase): + def test_something(self): + # This test can safely modify permissions + user.groups.add(some_group) + # Parity checking will be skipped + """ + + def setUp(self) -> None: + """Set up test with parity checking disabled.""" + self._parity_context = disable_policy_parity() + self._parity_context.__enter__() + super().setUp() # type: ignore[misc] + + def tearDown(self) -> None: + """Clean up parity checking context.""" + self._parity_context.__exit__(None, None, None) + super().tearDown() # type: ignore[misc] diff --git a/mreg/api/tests/__init__.py b/mreg/api/tests/__init__.py new file mode 100644 index 00000000..42d30c0e --- /dev/null +++ b/mreg/api/tests/__init__.py @@ -0,0 +1 @@ +"""API-level regression tests.""" diff --git a/mreg/api/tests/test_metrics.py b/mreg/api/tests/test_metrics.py index e43a5ac6..265a2432 100644 --- a/mreg/api/tests/test_metrics.py +++ b/mreg/api/tests/test_metrics.py @@ -1,415 +1,383 @@ -import pytest import ldap +import re from unittest.mock import Mock, patch from rest_framework.test import APIClient from django.contrib.auth import get_user_model -import re +from django.http import HttpResponse +from django.test import RequestFactory, TestCase from typing import Any from mreg.models.host import Host, Ipaddress from mreg.middleware.metrics import PrometheusRequestMiddleware -def _parse_prometheus_metric(content: str, metric_name: str) -> dict: - """Parse Prometheus text format and extract metrics by name.""" - result = {} +def _parse_prometheus_metric(content: str, metric_name: str) -> dict[str, float]: + """Parse Prometheus text exposition and return samples for one metric.""" + result: dict[str, float] = {} pattern = rf"^{re.escape(metric_name)}(\{{[^}}]*\}})?\s+([0-9.e+-]+)$" - for line in content.split('\n'): - if line.startswith('#'): + for line in content.split("\n"): + if line.startswith("#"): continue match = re.match(pattern, line) if match: - labels = match.group(1) or '' - value = float(match.group(2)) - result[labels] = value + result[match.group(1) or ""] = float(match.group(2)) return result +class MetricsTests(TestCase): + def test_metrics_endpoint_exposes_prometheus_metrics(self) -> None: + """Test that metrics endpoint returns Prometheus-formatted output.""" + client = APIClient() + + User = get_user_model() + user = User.objects.create_user(username="metrics_test_user", password="x") + client.force_authenticate(user=user) + + r: Any = client.get("/api/meta/health/heartbeat") + assert r.status_code == 200 + + metrics: Any = client.get("/api/meta/metrics") + assert metrics.status_code == 200 + assert "text/plain" in metrics["Content-Type"] + assert b"mreg_http_requests_total" in metrics.content + + def test_request_count_increments_by_status(self) -> None: + """Test that request count increments with correct status labels.""" + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="count_test_user", password="x") + client.force_authenticate(user=user) + + client.get("/api/meta/health/heartbeat") + + metrics_resp: Any = client.get("/api/meta/metrics") + raw = metrics_resp.content.decode("utf-8") + + counts = _parse_prometheus_metric(raw, "mreg_http_requests_total") + + assert len(counts) > 0, f"No metrics recorded: {counts}" + assert any('status="200"' in k for k in counts.keys()), f"No 200 status in: {counts}" + # Accept either view name or route pattern + assert any("HealthHeartbeat" in k or "meta/health/heartbeat" in k for k in counts.keys()), f"No heartbeat endpoint in: {counts}" + + def test_db_metrics_recorded_with_values(self) -> None: + """Test that DB metrics are recorded when requests interact with the database.""" + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="db_test_user", password="x") + client.force_authenticate(user=user) + + host = Host.objects.create( + name="db_metric_test.example.com", + ttl=3600, + comment="test", + ) + Ipaddress.objects.create(host=host, ipaddress="10.10.10.10") # type: ignore[attr-defined] + + resp: Any = client.get(f"/api/v1/hosts/{host.name}") + assert resp.status_code == 200 + + metrics_resp: Any = client.get("/api/meta/metrics") + raw = metrics_resp.content.decode("utf-8") + + assert b"mreg_db_query_duration_seconds" in metrics_resp.content + assert b"mreg_db_request_duration_seconds" in metrics_resp.content + + db_query_metrics = _parse_prometheus_metric(raw, "mreg_db_query_duration_seconds_sum") + assert len(db_query_metrics) > 0, "No DB query metrics recorded" + assert any(v > 0 for v in db_query_metrics.values()), f"Expected positive DB durations, got {db_query_metrics}" + + def test_db_query_count_metrics(self) -> None: + """Test that DB query count per request and total counters are recorded.""" + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="db_count_user", password="x") + client.force_authenticate(user=user) + + host = Host.objects.create( + name="db_count_test.example.com", + ttl=3600, + comment="test", + ) + Ipaddress.objects.create(host=host, ipaddress="10.10.10.20") # type: ignore[attr-defined] -@pytest.mark.django_db -def test_metrics_endpoint_exposes_prometheus_metrics() -> None: - """Test that metrics endpoint returns Prometheus-formatted output.""" - client = APIClient() - - User = get_user_model() - user = User.objects.create_user(username="metrics_test_user", password="x") - client.force_authenticate(user=user) - - r: Any = client.get("/api/meta/health/heartbeat") - assert r.status_code == 200 - - metrics: Any = client.get("/api/meta/metrics") - assert metrics.status_code == 200 - assert "text/plain" in metrics["Content-Type"] - assert b"mreg_http_requests_total" in metrics.content - - -@pytest.mark.django_db -def test_request_count_increments_by_status() -> None: - """Test that request count increments with correct status labels.""" - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="count_test_user", password="x") - client.force_authenticate(user=user) - - client.get("/api/meta/health/heartbeat") - - metrics_resp: Any = client.get("/api/meta/metrics") - raw = metrics_resp.content.decode("utf-8") - - counts = _parse_prometheus_metric(raw, "mreg_http_requests_total") - - assert len(counts) > 0, f"No metrics recorded: {counts}" - assert any("status=\"200\"" in k for k in counts.keys()), f"No 200 status in: {counts}" - # Accept either view name or route pattern - assert any( - "HealthHeartbeat" in k or "meta/health/heartbeat" in k - for k in counts.keys() - ), f"No heartbeat endpoint in: {counts}" - - -@pytest.mark.django_db -def test_db_metrics_recorded_with_values() -> None: - """Test that DB metrics are recorded when requests interact with the database.""" - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="db_test_user", password="x") - client.force_authenticate(user=user) - - host = Host.objects.create( - name="db_metric_test.example.com", - contact="test@example.com", - ttl=3600, - comment="test", - ) - Ipaddress.objects.create(host=host, ipaddress="10.10.10.10") # type: ignore[attr-defined] - - resp: Any = client.get(f"/api/v1/hosts/{host.name}") - assert resp.status_code == 200 - - metrics_resp: Any = client.get("/api/meta/metrics") - raw = metrics_resp.content.decode("utf-8") - - assert b"mreg_db_query_duration_seconds" in metrics_resp.content - assert b"mreg_db_request_duration_seconds" in metrics_resp.content - - db_query_metrics = _parse_prometheus_metric(raw, "mreg_db_query_duration_seconds_sum") - assert len(db_query_metrics) > 0, "No DB query metrics recorded" - assert any(v > 0 for v in db_query_metrics.values()), f"Expected positive DB durations, got {db_query_metrics}" - - -@pytest.mark.django_db -def test_db_query_count_metrics() -> None: - """Test that DB query count per request and total counters are recorded.""" - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="db_count_user", password="x") - client.force_authenticate(user=user) - - host = Host.objects.create( - name="db_count_test.example.com", - contact="test@example.com", - ttl=3600, - comment="test", - ) - Ipaddress.objects.create(host=host, ipaddress="10.10.10.20") # type: ignore[attr-defined] - - resp: Any = client.get(f"/api/v1/hosts/{host.name}") - assert resp.status_code == 200 - - metrics_resp: Any = client.get("/api/meta/metrics") - raw = metrics_resp.content.decode("utf-8") - - per_req_count = _parse_prometheus_metric(raw, "mreg_db_queries_per_request_count") - assert len(per_req_count) > 0, "Expected DB queries-per-request histogram count series" - assert any(v >= 1 for v in per_req_count.values()), f"Expected >=1 queries per request: {per_req_count}" - - total_counter = _parse_prometheus_metric(raw, "mreg_db_queries_total") - assert len(total_counter) > 0, "Expected total DB queries counter series" - assert any(v >= 1 for v in total_counter.values()), f"Expected total DB queries >= 1: {total_counter}" - - -@pytest.mark.django_db -def test_request_latency_recorded() -> None: - """Test that request latency histogram is recorded with values.""" - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="latency_test_user", password="x") - client.force_authenticate(user=user) - - client.get("/api/meta/health/heartbeat") - - metrics_resp: Any = client.get("/api/meta/metrics") - raw = metrics_resp.content.decode("utf-8") - - latency_sum = _parse_prometheus_metric(raw, "mreg_http_request_duration_seconds_sum") - assert len(latency_sum) > 0, "No request latency metrics recorded" - assert any(v > 0 for v in latency_sum.values()), "Expected positive request durations" - - latency_count = _parse_prometheus_metric(raw, "mreg_http_request_duration_seconds_count") - assert len(latency_count) > 0, "No request count metrics recorded" - assert any(v >= 1 for v in latency_count.values()), "Expected at least 1 request counted" - - -@pytest.mark.django_db -def test_request_and_response_size_histograms() -> None: - """Test request and response size histograms are recorded.""" - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="size_metrics_user", password="x") - client.force_authenticate(user=user) - - # Simple GET with no body (request size ~0), small response - r: Any = client.get("/api/meta/health/heartbeat") - assert r.status_code == 200 - - metrics_resp: Any = client.get("/api/meta/metrics") - raw = metrics_resp.content.decode("utf-8") - - req_size_count = _parse_prometheus_metric(raw, "mreg_http_request_size_bytes_count") - assert len(req_size_count) > 0, "Expected request size histogram count series" - assert any(v >= 1 for v in req_size_count.values()), "Expected request size count >= 1" - - resp_size_count = _parse_prometheus_metric(raw, "mreg_http_response_size_bytes_count") - assert len(resp_size_count) > 0, "Expected response size histogram count series" - assert any(v >= 1 for v in resp_size_count.values()), "Expected response size count >= 1" - - -@pytest.mark.django_db -def test_metrics_endpoint_not_instrumented() -> None: - """Test that the metrics endpoint itself is not instrumented (no recursion). - - Compares totals before and after repeated metrics scrapes; should not change. - """ - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="metrics_skip_test_user", password="x") - client.force_authenticate(user=user) - - # Baseline - baseline_resp: Any = client.get("/api/meta/metrics") - assert baseline_resp.status_code == 200 - baseline_raw = baseline_resp.content.decode("utf-8") - baseline_counts = _parse_prometheus_metric(baseline_raw, "mreg_http_requests_total") - baseline_total = sum(baseline_counts.values()) if baseline_counts else 0.0 - - # Repeated metrics scrapes - for _ in range(3): - resp: Any = client.get("/api/meta/metrics") + resp: Any = client.get(f"/api/v1/hosts/{host.name}") assert resp.status_code == 200 - # Compare - final_resp: Any = client.get("/api/meta/metrics") - final_raw = final_resp.content.decode("utf-8") - final_counts = _parse_prometheus_metric(final_raw, "mreg_http_requests_total") - final_total = sum(final_counts.values()) if final_counts else 0.0 - - assert final_total == baseline_total, ( - f"Metrics endpoint should not change request totals (baseline={baseline_total}, final={final_total})" - ) - - -@pytest.mark.django_db -def test_metrics_endpoint_trailing_slash_not_instrumented() -> None: - """Test that metrics endpoint with trailing slash is also not instrumented. - - Accepts 200/301/302/404 but ensures counters don't change. - """ - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="metrics_trailing_slash_user", password="x") - client.force_authenticate(user=user) - - # Baseline - baseline_resp: Any = client.get("/api/meta/metrics") - assert baseline_resp.status_code == 200 - baseline_raw = baseline_resp.content.decode("utf-8") - baseline_counts = _parse_prometheus_metric(baseline_raw, "mreg_http_requests_total") - baseline_total = sum(baseline_counts.values()) if baseline_counts else 0.0 - - # Scrape with trailing slash (may be 200/3xx/404 depending on URL config) - for _ in range(3): - resp: Any = client.get("/api/meta/metrics/") - assert resp.status_code in (200, 301, 302, 404) - - # Compare - final_resp: Any = client.get("/api/meta/metrics") - final_raw = final_resp.content.decode("utf-8") - final_counts = _parse_prometheus_metric(final_raw, "mreg_http_requests_total") - final_total = sum(final_counts.values()) if final_counts else 0.0 - - assert final_total == baseline_total, ( - f"Trailing slash metrics fetch should not change totals (baseline={baseline_total}, final={final_total})" - ) - - -@pytest.mark.django_db -def test_request_without_resolver_match_uses_path() -> None: - """Test that requests use view names or routes for low cardinality labeling.""" - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="path_test_user", password="x") - client.force_authenticate(user=user) - - # Make a request to an endpoint - resp: Any = client.get("/api/meta/health/heartbeat") - assert resp.status_code == 200 - - metrics_resp: Any = client.get("/api/meta/metrics") - raw = metrics_resp.content.decode("utf-8") - - counts = _parse_prometheus_metric(raw, "mreg_http_requests_total") - # Verify we have metrics with view name or route for low cardinality - # (accepts either resolved view name or route template, never raw path with object IDs) - assert any( - "HealthHeartbeat" in k or "meta/health/heartbeat" in k - for k in counts.keys() - ), f"Expected view name or route label in metrics: {counts}" - - -@pytest.mark.django_db -def test_inprogress_gauge_decrements_on_success() -> None: - """Test that in-progress gauge is decremented after request completes.""" - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="inprogress_test_user", password="x") - client.force_authenticate(user=user) - - # Make multiple sequential requests - for _ in range(2): + metrics_resp: Any = client.get("/api/meta/metrics") + raw = metrics_resp.content.decode("utf-8") + + per_req_count = _parse_prometheus_metric(raw, "mreg_db_queries_per_request_count") + assert len(per_req_count) > 0, "Expected DB queries-per-request histogram count series" + assert any(v >= 1 for v in per_req_count.values()), f"Expected >=1 queries per request: {per_req_count}" + + total_counter = _parse_prometheus_metric(raw, "mreg_db_queries_total") + assert len(total_counter) > 0, "Expected total DB queries counter series" + assert any(v >= 1 for v in total_counter.values()), f"Expected total DB queries >= 1: {total_counter}" + + def test_request_latency_recorded(self) -> None: + """Test that request latency histogram is recorded with values.""" + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="latency_test_user", password="x") + client.force_authenticate(user=user) + + client.get("/api/meta/health/heartbeat") + + metrics_resp: Any = client.get("/api/meta/metrics") + raw = metrics_resp.content.decode("utf-8") + + latency_sum = _parse_prometheus_metric(raw, "mreg_http_request_duration_seconds_sum") + assert len(latency_sum) > 0, "No request latency metrics recorded" + assert any(v > 0 for v in latency_sum.values()), "Expected positive request durations" + + latency_count = _parse_prometheus_metric(raw, "mreg_http_request_duration_seconds_count") + assert len(latency_count) > 0, "No request count metrics recorded" + assert any(v >= 1 for v in latency_count.values()), "Expected at least 1 request counted" + + def test_request_and_response_size_histograms(self) -> None: + """Test request and response size histograms are recorded.""" + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="size_metrics_user", password="x") + client.force_authenticate(user=user) + + # An explicit content length is observed without forcing request-body access. + r: Any = client.get("/api/meta/health/heartbeat", CONTENT_LENGTH="0") + assert r.status_code == 200 + + # Response sizes are recorded only when an upstream view/middleware sets + # Content-Length; exercise that explicit contract directly. + middleware = PrometheusRequestMiddleware( + lambda _request: HttpResponse(b"ok", headers={"Content-Length": "2"}) + ) + middleware(RequestFactory().get("/api/meta/health/heartbeat")) + + metrics_resp: Any = client.get("/api/meta/metrics") + raw = metrics_resp.content.decode("utf-8") + + req_size_count = _parse_prometheus_metric(raw, "mreg_http_request_size_bytes_count") + assert len(req_size_count) > 0, "Expected request size histogram count series" + assert any(v >= 1 for v in req_size_count.values()), "Expected request size count >= 1" + + resp_size_count = _parse_prometheus_metric(raw, "mreg_http_response_size_bytes_count") + assert len(resp_size_count) > 0, "Expected response size histogram count series" + assert any(v >= 1 for v in resp_size_count.values()), "Expected response size count >= 1" + + def test_metrics_endpoint_not_instrumented(self) -> None: + """Test that the metrics endpoint itself is not instrumented (no recursion). + + Compares totals before and after repeated metrics scrapes; should not change. + """ + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="metrics_skip_test_user", password="x") + client.force_authenticate(user=user) + + # Baseline + baseline_resp: Any = client.get("/api/meta/metrics") + assert baseline_resp.status_code == 200 + baseline_raw = baseline_resp.content.decode("utf-8") + baseline_counts = _parse_prometheus_metric(baseline_raw, "mreg_http_requests_total") + baseline_total = sum(baseline_counts.values()) if baseline_counts else 0.0 + + # Repeated metrics scrapes + for _ in range(3): + resp: Any = client.get("/api/meta/metrics") + assert resp.status_code == 200 + + # Compare + final_resp: Any = client.get("/api/meta/metrics") + final_raw = final_resp.content.decode("utf-8") + final_counts = _parse_prometheus_metric(final_raw, "mreg_http_requests_total") + final_total = sum(final_counts.values()) if final_counts else 0.0 + + assert final_total == baseline_total, ( + f"Metrics endpoint should not change request totals (baseline={baseline_total}, final={final_total})" + ) + + def test_metrics_endpoint_trailing_slash_not_instrumented(self) -> None: + """Test that metrics endpoint with trailing slash is also not instrumented. + + Accepts 200/301/302/404 but ensures counters don't change. + """ + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="metrics_trailing_slash_user", password="x") + client.force_authenticate(user=user) + + # Baseline + baseline_resp: Any = client.get("/api/meta/metrics") + assert baseline_resp.status_code == 200 + baseline_raw = baseline_resp.content.decode("utf-8") + baseline_counts = _parse_prometheus_metric(baseline_raw, "mreg_http_requests_total") + baseline_total = sum(baseline_counts.values()) if baseline_counts else 0.0 + + # Scrape with trailing slash (may be 200/3xx/404 depending on URL config) + for _ in range(3): + resp: Any = client.get("/api/meta/metrics/") + assert resp.status_code in (200, 301, 302, 404) + + # Compare + final_resp: Any = client.get("/api/meta/metrics") + final_raw = final_resp.content.decode("utf-8") + final_counts = _parse_prometheus_metric(final_raw, "mreg_http_requests_total") + final_total = sum(final_counts.values()) if final_counts else 0.0 + + assert final_total == baseline_total, ( + f"Trailing slash metrics fetch should not change totals (baseline={baseline_total}, final={final_total})" + ) + + def test_request_without_resolver_match_uses_path(self) -> None: + """Test that requests use view names or routes for low cardinality labeling.""" + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="path_test_user", password="x") + client.force_authenticate(user=user) + + # Make a request to an endpoint resp: Any = client.get("/api/meta/health/heartbeat") assert resp.status_code == 200 - metrics_resp: Any = client.get("/api/meta/metrics") - raw = metrics_resp.content.decode("utf-8") - - # Check that inprogress gauge exists and has value (should be 0 or 1 at metric collection time) - _parse_prometheus_metric(raw, "mreg_http_inprogress_requests") - # The gauge should exist in metrics (even if current value is 0) - assert b"mreg_http_inprogress_requests" in metrics_resp.content, \ - "In-progress gauge should be recorded" - - -@pytest.mark.django_db -def test_db_metrics_resilience_to_errors() -> None: - """Test that DB metrics recording is resilient to exceptions.""" - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="db_error_test_user", password="x") - client.force_authenticate(user=user) - - # Make a request that will trigger DB queries - resp: Any = client.get("/api/meta/health/heartbeat") - assert resp.status_code == 200 - - metrics_resp: Any = client.get("/api/meta/metrics") - # Verify metrics endpoint still responds even if internal errors occurred - assert metrics_resp.status_code == 200 - assert b"mreg_db_request_duration_seconds" in metrics_resp.content - - -@pytest.mark.django_db -def test_normalize_path_fallback_to_path_info() -> None: - """Test that _normalize_path prevents cardinality explosion from unresolved paths.""" - - - middleware = PrometheusRequestMiddleware(lambda r: Mock(status_code=200)) - - # Create a request with an unresolvable path (404) - request = Mock(spec=["resolver_match", "path_info"]) - request.resolver_match = None - request.path_info = "/invalid/path/that/does/not/exist" - - result = middleware._normalize_path(request) - # Should return 'unresolved' instead of raw path to prevent cardinality explosion - assert result == "unresolved" - - # Test that valid paths are resolved properly - request2 = Mock(spec=["resolver_match", "path_info"]) - request2.path_info = "/api/meta/health/heartbeat" - - result2 = middleware._normalize_path(request2) - # Should resolve to either view name or route, never raw path_info - assert result2 != request2.path_info # Never returns raw path - # Accept view_name (has dots) or route template - assert result2 in ["unresolved", "meta/health/heartbeat"] or "." in result2 - - -@pytest.mark.django_db -def test_unresolved_path_counter_records_404s() -> None: - """Requests to unknown paths should increment unresolved counter with 404 status.""" - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="unresolved_counter_user", password="x") - client.force_authenticate(user=user) - - # Baseline - baseline: Any = client.get("/api/meta/metrics") - assert baseline.status_code == 200 - raw0 = baseline.content.decode("utf-8") - base_unresolved = _parse_prometheus_metric(raw0, "mreg_http_unresolved_requests_total") - base_total = sum(base_unresolved.values()) if base_unresolved else 0.0 - - # Hit an unknown path - r404: Any = client.get("/definitely/not/a/real/endpoint") - assert r404.status_code == 404 - - # Check counter increased - metrics_resp: Any = client.get("/api/meta/metrics") - raw = metrics_resp.content.decode("utf-8") - unresolved = _parse_prometheus_metric(raw, "mreg_http_unresolved_requests_total") - final_total = sum(unresolved.values()) if unresolved else 0.0 - assert final_total >= base_total + 1, f"Expected unresolved counter to increase (base={base_total}, final={final_total})" - # Ensure 404 label appears - assert any("status=\"404\"" in k for k in unresolved.keys()), f"Expected 404 status label: {unresolved}" - - -@pytest.mark.django_db -@patch("mreg.api.views.LDAPBackend") -def test_ldap_metrics_success(mock_backend: Any) -> None: - """LDAP health check should record call duration metrics per operation.""" - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="ldap_metrics_user", password="x") - client.force_authenticate(user=user) - - mock_connection = Mock() - mock_backend.return_value.ldap.initialize.return_value = mock_connection - - resp: Any = client.get("/api/meta/health/ldap") - assert resp.status_code == 200 - - metrics_resp: Any = client.get("/api/meta/metrics") - raw = metrics_resp.content.decode("utf-8") - - latency = _parse_prometheus_metric(raw, "mreg_ldap_call_duration_seconds_sum") - assert any("operation=\"initialize\"" in k for k in latency.keys()), f"Expected initialize op metric: {latency}" - assert any("operation=\"bind\"" in k for k in latency.keys()), f"Expected bind op metric: {latency}" - assert any("operation=\"unbind\"" in k for k in latency.keys()), f"Expected unbind op metric: {latency}" - - -@pytest.mark.django_db -@patch("mreg.api.views.LDAPBackend") -def test_ldap_metrics_failure_counter(mock_backend: Any) -> None: - """LDAP failures should increment the failure counter with exception label.""" - client = APIClient() - User = get_user_model() - user = User.objects.create_user(username="ldap_metrics_fail_user", password="x") - client.force_authenticate(user=user) - - mock_connection = Mock() - mock_connection.simple_bind_s.side_effect = ldap.LDAPError("bind failed") - mock_backend.return_value.ldap.initialize.return_value = mock_connection - - resp: Any = client.get("/api/meta/health/ldap") - assert resp.status_code == 503 - - metrics_resp: Any = client.get("/api/meta/metrics") - raw = metrics_resp.content.decode("utf-8") - - failures = _parse_prometheus_metric(raw, "mreg_ldap_call_failures_total") - assert any( - "operation=\"bind\"" in k and "exception=\"LDAPError\"" in k - for k in failures.keys() - ), f"Expected LDAPError bind failure metric: {failures}" + metrics_resp: Any = client.get("/api/meta/metrics") + raw = metrics_resp.content.decode("utf-8") + + counts = _parse_prometheus_metric(raw, "mreg_http_requests_total") + # Verify we have metrics with view name or route for low cardinality + # (accepts either resolved view name or route template, never raw path with object IDs) + assert any("HealthHeartbeat" in k or "meta/health/heartbeat" in k for k in counts.keys()), ( + f"Expected view name or route label in metrics: {counts}" + ) + + def test_inprogress_gauge_decrements_on_success(self) -> None: + """Test that in-progress gauge is decremented after request completes.""" + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="inprogress_test_user", password="x") + client.force_authenticate(user=user) + + # Make multiple sequential requests + for _ in range(2): + resp: Any = client.get("/api/meta/health/heartbeat") + assert resp.status_code == 200 + + metrics_resp: Any = client.get("/api/meta/metrics") + raw = metrics_resp.content.decode("utf-8") + + # Check that inprogress gauge exists and has value (should be 0 or 1 at metric collection time) + _parse_prometheus_metric(raw, "mreg_http_inprogress_requests") + # The gauge should exist in metrics (even if current value is 0) + assert b"mreg_http_inprogress_requests" in metrics_resp.content, "In-progress gauge should be recorded" + + def test_db_metrics_resilience_to_errors(self) -> None: + """Test that DB metrics recording is resilient to exceptions.""" + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="db_error_test_user", password="x") + client.force_authenticate(user=user) + + # Make a request that will trigger DB queries + resp: Any = client.get("/api/meta/health/heartbeat") + assert resp.status_code == 200 + + metrics_resp: Any = client.get("/api/meta/metrics") + # Verify metrics endpoint still responds even if internal errors occurred + assert metrics_resp.status_code == 200 + assert b"mreg_db_request_duration_seconds" in metrics_resp.content + + def test_normalize_path_fallback_to_path_info(self) -> None: + """Test that _normalize_path prevents cardinality explosion from unresolved paths.""" + + middleware = PrometheusRequestMiddleware(lambda r: Mock(status_code=200)) + + # Create a request with an unresolvable path (404) + request = Mock(spec=["resolver_match", "path_info"]) + request.resolver_match = None + request.path_info = "/invalid/path/that/does/not/exist" + + result = middleware._normalize_path(request) + # Should return 'unresolved' instead of raw path to prevent cardinality explosion + assert result == "unresolved" + + # Test that valid paths are resolved properly + request2 = Mock(spec=["resolver_match", "path_info"]) + request2.path_info = "/api/meta/health/heartbeat" + + result2 = middleware._normalize_path(request2) + # Should resolve to either view name or route, never raw path_info + assert result2 != request2.path_info # Never returns raw path + # Accept view_name (has dots) or route template + assert result2 in ["unresolved", "meta/health/heartbeat"] or "." in result2 + + def test_unresolved_path_counter_records_404s(self) -> None: + """Requests to unknown paths should increment unresolved counter with 404 status.""" + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="unresolved_counter_user", password="x") + client.force_authenticate(user=user) + + # Baseline + baseline: Any = client.get("/api/meta/metrics") + assert baseline.status_code == 200 + raw0 = baseline.content.decode("utf-8") + base_unresolved = _parse_prometheus_metric(raw0, "mreg_http_unresolved_requests_total") + base_total = sum(base_unresolved.values()) if base_unresolved else 0.0 + + # Hit an unknown path + r404: Any = client.get("/definitely/not/a/real/endpoint") + assert r404.status_code == 404 + + # Check counter increased + metrics_resp: Any = client.get("/api/meta/metrics") + raw = metrics_resp.content.decode("utf-8") + unresolved = _parse_prometheus_metric(raw, "mreg_http_unresolved_requests_total") + final_total = sum(unresolved.values()) if unresolved else 0.0 + assert final_total >= base_total + 1, f"Expected unresolved counter to increase (base={base_total}, final={final_total})" + # Ensure 404 label appears + assert any('status="404"' in k for k in unresolved.keys()), f"Expected 404 status label: {unresolved}" + + @patch("mreg.api.views.LDAPBackend") + def test_ldap_metrics_success(self, mock_backend: Any) -> None: + """LDAP health check should record call duration metrics per operation.""" + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="ldap_metrics_user", password="x") + client.force_authenticate(user=user) + + mock_connection = Mock() + mock_backend.return_value.ldap.initialize.return_value = mock_connection + + resp: Any = client.get("/api/meta/health/ldap") + assert resp.status_code == 200 + + metrics_resp: Any = client.get("/api/meta/metrics") + raw = metrics_resp.content.decode("utf-8") + + latency = _parse_prometheus_metric(raw, "mreg_ldap_call_duration_seconds_sum") + assert any('operation="initialize"' in k for k in latency.keys()), f"Expected initialize op metric: {latency}" + assert any('operation="bind"' in k for k in latency.keys()), f"Expected bind op metric: {latency}" + assert any('operation="unbind"' in k for k in latency.keys()), f"Expected unbind op metric: {latency}" + + @patch("mreg.api.views.LDAPBackend") + def test_ldap_metrics_failure_counter(self, mock_backend: Any) -> None: + """LDAP failures should increment the failure counter with exception label.""" + client = APIClient() + User = get_user_model() + user = User.objects.create_user(username="ldap_metrics_fail_user", password="x") + client.force_authenticate(user=user) + + mock_connection = Mock() + mock_connection.simple_bind_s.side_effect = ldap.LDAPError("bind failed") + mock_backend.return_value.ldap.initialize.return_value = mock_connection + + resp: Any = client.get("/api/meta/health/ldap") + assert resp.status_code == 503 + + metrics_resp: Any = client.get("/api/meta/metrics") + raw = metrics_resp.content.decode("utf-8") + + failures = _parse_prometheus_metric(raw, "mreg_ldap_call_failures_total") + assert any('operation="bind"' in k and 'exception="LDAPError"' in k for k in failures.keys()), ( + f"Expected LDAPError bind failure metric: {failures}" + ) diff --git a/mreg/api/treetop.py b/mreg/api/treetop.py new file mode 100644 index 00000000..56100691 --- /dev/null +++ b/mreg/api/treetop.py @@ -0,0 +1,596 @@ +"""Synchronous TreeTop authorization for endpoint permission stacks.""" + +from __future__ import annotations + +import atexit +import ipaddress +import logging +import os +import threading +from collections.abc import Iterator, Mapping +from contextlib import contextmanager, suppress +from contextvars import ContextVar +from dataclasses import dataclass +from time import monotonic +from typing import TypeAlias + +from django.conf import settings +from django.views import View +from prometheus_client import Counter, Gauge, Histogram +from rest_framework.request import Request +import structlog +from treetop_client.client import TreeTopClient +from treetop_client.models import ( + Action, + AuthorizeResultBrief, + Request as TreeTopRequest, + Resource as TreeTopResource, + ResourceAttribute, + ResourceAttributeType, + User as TreeTopUser, +) + +from mreg.models.auth import User as MregUser +from mreg.policy.config import PolicyMode +from mreg.policy.contracts import ENDPOINT_ATTRIBUTE_TYPES + + +logger = structlog.get_logger("mreg.policy.parity") + +POLICY_MODE = PolicyMode(getattr(settings, "POLICY_MODE", "shadow")) +POLICY_PARITY_ENABLED = getattr(settings, "POLICY_PARITY_ENABLED", POLICY_MODE == PolicyMode.SHADOW) +POLICY_BASE_URL = (getattr(settings, "POLICY_BASE_URL", "") or "").strip() +POLICY_NAMESPACE = getattr(settings, "POLICY_NAMESPACE", ["MREG"]) +POLICY_PARITY_LOG_DETAILS = getattr(settings, "POLICY_PARITY_LOG_DETAILS", False) +POLICY_TIMEOUT_SECONDS = getattr(settings, "POLICY_TIMEOUT_SECONDS", 5.0) +POLICY_CIRCUIT_FAILURES = getattr(settings, "POLICY_CIRCUIT_FAILURES", 5) +POLICY_CIRCUIT_RESET_SECONDS = getattr(settings, "POLICY_CIRCUIT_RESET_SECONDS", 30.0) + + +POLICY_DECISIONS_TOTAL = Counter( + "mreg_policy_decisions_total", + "Composite decisions returned by TreeTop.", + ["decision"], +) +POLICY_LEGACY_DECISIONS_TOTAL = Counter( + "mreg_policy_legacy_decisions_total", + "Composite legacy decisions evaluated for policy comparison.", + ["decision"], +) +POLICY_PARITY_RESULTS_TOTAL = Counter( + "mreg_policy_parity_results_total", + "Composite comparison outcomes between legacy and TreeTop.", + ["result"], +) +POLICY_AUTHORIZE_CALLS_TOTAL = Counter( + "mreg_policy_authorize_calls_total", + "Synchronous calls to the TreeTop authorize endpoint.", + ["status"], +) +POLICY_FAILURES_TOTAL = Counter( + "mreg_policy_failures_total", + "Policy integration failures by stage.", + ["stage"], +) +POLICY_ENFORCEMENT_RESULTS_TOTAL = Counter( + "mreg_policy_enforcement_results_total", + "Synchronous authoritative policy outcomes.", + ["result"], +) +POLICY_MODE_INFO = Gauge( + "mreg_policy_mode_info", + "Configured MREG policy decision mode.", + ["mode"], +) +POLICY_MODE_INFO.labels(mode=POLICY_MODE.value).set(1) +POLICY_AUTHORIZE_DURATION_SECONDS = Histogram( + "mreg_policy_authorize_duration_seconds", + "Duration of synchronous policy authorize calls.", + ["status"], + buckets=[0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], +) +POLICY_STACK_SIZE = Histogram( + "mreg_policy_stack_size", + "Number of Cedar checks in one endpoint policy stack.", + buckets=[1, 2, 3, 5, 8, 13, 21], +) +POLICY_STACK_CONFLICTS_TOTAL = Counter( + "mreg_policy_stack_conflicts_total", + "Attempts to evaluate two different endpoint policy stacks in one request.", +) +POLICY_CIRCUIT_OPEN = Gauge( + "mreg_policy_circuit_open", + "Whether this worker's synchronous TreeTop circuit is open.", +) + + +@dataclass(frozen=True, slots=True) +class PolicyResource: + """One typed Cedar resource.""" + + kind: str + id: str + attrs: Mapping[str, str] + + def __post_init__(self) -> None: + if not self.kind.strip(): + raise ValueError("Policy resource kind cannot be empty") + if not self.id: + raise ValueError("Policy resource ID cannot be empty") + if not self.attrs: + raise ValueError("Policy resource attributes cannot be empty") + + +@dataclass(frozen=True, slots=True) +class PolicyCheck: + """One action/resource leaf evaluated by Cedar.""" + + action: str + resource: PolicyResource + + def __post_init__(self) -> None: + if not self.action.strip(): + raise ValueError("Policy action cannot be empty") + + +@dataclass(frozen=True, slots=True) +class PolicyLeaf: + """Leaf in an endpoint permission tree.""" + + check: PolicyCheck + + +@dataclass(frozen=True, slots=True) +class PolicyAll: + """Require every child policy node to allow.""" + + children: tuple[PolicyNode, ...] + + def __post_init__(self) -> None: + if not self.children: + raise ValueError("PolicyAll requires at least one child") + + +@dataclass(frozen=True, slots=True) +class PolicyAny: + """Require at least one child policy node to allow.""" + + children: tuple[PolicyNode, ...] + + def __post_init__(self) -> None: + if not self.children: + raise ValueError("PolicyAny requires at least one child") + + +PolicyNode: TypeAlias = PolicyLeaf | PolicyAll | PolicyAny + + +def policy_leaf( + *, + action: str, + resource_kind: str, + resource_id: str, + resource_attrs: Mapping[str, str], +) -> PolicyLeaf: + """Build one validated leaf without exposing wire-model details.""" + return PolicyLeaf( + PolicyCheck( + action=action, + resource=PolicyResource( + kind=resource_kind, + id=str(resource_id), + attrs=resource_attrs, + ), + ) + ) + + +def policy_all(*nodes: PolicyNode) -> PolicyAll: + return PolicyAll(tuple(nodes)) + + +def policy_any(*nodes: PolicyNode) -> PolicyAny: + return PolicyAny(tuple(nodes)) + + +@dataclass(slots=True) +class _RequestPolicyState: + fingerprint: tuple[object, ...] | None = None + policy_decision: bool | None = None + error: str | None = None + + +_REQUEST_POLICY_STATE_ATTRIBUTE = "_mreg_policy_state" +_shadow_disabled_depth: ContextVar[int] = ContextVar( + "mreg_policy_shadow_disabled_depth", + default=0, +) + + +class _SynchronousCircuitBreaker: + """Thread-safe closed/open/half-open circuit for request-path calls.""" + + def __init__(self, failure_threshold: int, reset_seconds: float) -> None: + self.failure_threshold = max(1, int(failure_threshold)) + self.reset_seconds = max(0.1, float(reset_seconds)) + self._lock = threading.Lock() + self._failures = 0 + self._open_until = 0.0 + self._probe_in_flight = False + + def allow_call(self) -> bool: + now = monotonic() + with self._lock: + if self._open_until == 0.0: + POLICY_CIRCUIT_OPEN.set(0) + return True + if now < self._open_until or self._probe_in_flight: + POLICY_CIRCUIT_OPEN.set(1) + return False + self._probe_in_flight = True + POLICY_CIRCUIT_OPEN.set(1) + return True + + def success(self) -> None: + with self._lock: + self._failures = 0 + self._open_until = 0.0 + self._probe_in_flight = False + POLICY_CIRCUIT_OPEN.set(0) + + def failure(self) -> None: + now = monotonic() + with self._lock: + self._probe_in_flight = False + self._failures += 1 + if self._open_until or self._failures >= self.failure_threshold: + self._open_until = now + self.reset_seconds + POLICY_CIRCUIT_OPEN.set(1) + _safe_log( + logging.ERROR, + "policy_circuit_open", + reset_seconds=self.reset_seconds, + consecutive_failures=self._failures, + ) + + +_circuit = _SynchronousCircuitBreaker(POLICY_CIRCUIT_FAILURES, POLICY_CIRCUIT_RESET_SECONDS) +_client: TreeTopClient | None = None +_client_pid: int | None = None +_client_lock = threading.Lock() + + +def _get_treetop_client() -> TreeTopClient: + global _client, _client_pid + pid = os.getpid() + with _client_lock: + if _client is None or _client_pid != pid: + if _client is not None: + with suppress(Exception): + _client.close() + _client = TreeTopClient( + base_url=POLICY_BASE_URL, + timeout=float(POLICY_TIMEOUT_SECONDS), + ) + _client_pid = pid + return _client + + +def close_policy_client() -> None: + """Close transports owned by this process.""" + global _client, _client_pid + with _client_lock: + client = _client + _client = None + _client_pid = None + if client is None: + return + with suppress(Exception): + client.close() + + +atexit.register(close_policy_client) + + +def _safe_log(level: int, event: str, **context: object) -> None: + with suppress(Exception): + logger.log(level, event, **context) + + +def _record_failure(stage: str, error: str, **context: object) -> None: + with suppress(Exception): + POLICY_FAILURES_TOTAL.labels(stage=stage).inc() + _safe_log(logging.ERROR, "policy_integration_error", stage=stage, error=error, **context) + + +@contextmanager +def disable_policy_parity(): + """Disable synchronous shadow comparisons in a narrow test scope.""" + token = _shadow_disabled_depth.set(_shadow_disabled_depth.get() + 1) + try: + yield + finally: + _shadow_disabled_depth.reset(token) + + +def _current_policy_mode() -> PolicyMode: + return POLICY_MODE if isinstance(POLICY_MODE, PolicyMode) else PolicyMode(POLICY_MODE) + + +def policy_enforcement_enabled() -> bool: + """Return whether TreeTop decisions are authoritative.""" + return _current_policy_mode() == PolicyMode.ENFORCE + + +def policy_shadow_enabled() -> bool: + """Return whether synchronous shadow evaluation is active in this scope.""" + return bool( + _current_policy_mode() == PolicyMode.SHADOW and POLICY_PARITY_ENABLED and POLICY_BASE_URL and _shadow_disabled_depth.get() == 0 + ) + + +def _policy_is_configured() -> bool: + mode = _current_policy_mode() + if mode == PolicyMode.OFF: + return False + if mode == PolicyMode.SHADOW: + return policy_shadow_enabled() + return True + + +def _corr_id(request: Request) -> str | None: + return request.headers.get("X-Correlation-ID") or request.META.get("HTTP_X_CORRELATION_ID") + + +def _qualified_resource_kind(kind: str) -> str: + return "::".join([*POLICY_NAMESPACE, kind]) if POLICY_NAMESPACE else kind + + +def _build_resource_attrs(resource_attrs: Mapping[str, str]) -> dict[str, ResourceAttribute]: + attrs: dict[str, ResourceAttribute] = {} + for key, value in resource_attrs.items(): + normalized = str(value) + cedar_type = ENDPOINT_ATTRIBUTE_TYPES.get(key) + if cedar_type == "Bool": + if normalized.lower() not in {"true", "false"}: + raise ValueError(f"Policy attribute {key!r} must be a boolean") + attrs[key] = ResourceAttribute.new(normalized.lower(), ResourceAttributeType.BOOLEAN) + elif cedar_type == "ipaddr": + attrs[key] = ResourceAttribute.new(str(ipaddress.ip_address(normalized)), ResourceAttributeType.IP) + else: + attrs[key] = ResourceAttribute.new(normalized, ResourceAttributeType.STRING) + return attrs + + +def _build_policy_request( + user: MregUser, + check: PolicyCheck, + *, + request_id: str, +) -> TreeTopRequest: + return TreeTopRequest( + id=request_id, + principal=TreeTopUser.new( + str(user.username), + POLICY_NAMESPACE, + groups=list(user.group_list), + ), + action=Action.new(check.action, POLICY_NAMESPACE), + resource=TreeTopResource.new( + kind=_qualified_resource_kind(check.resource.kind), + id=check.resource.id, + attrs=_build_resource_attrs(check.resource.attrs), + ), + ) + + +def _iter_leaves(node: PolicyNode) -> Iterator[PolicyLeaf]: + if isinstance(node, PolicyLeaf): + yield node + return + for child in node.children: + yield from _iter_leaves(child) + + +def _evaluate_tree(node: PolicyNode, decisions: Iterator[bool]) -> bool: + if isinstance(node, PolicyLeaf): + return next(decisions) + values = tuple(_evaluate_tree(child, decisions) for child in node.children) + if isinstance(node, PolicyAll): + return all(values) + return any(values) + + +def _node_fingerprint(node: PolicyNode) -> tuple[object, ...]: + if isinstance(node, PolicyLeaf): + resource = node.check.resource + return ( + "leaf", + node.check.action, + resource.kind, + resource.id, + tuple(sorted((str(key), str(value)) for key, value in resource.attrs.items())), + ) + return ( + "all" if isinstance(node, PolicyAll) else "any", + tuple(_node_fingerprint(child) for child in node.children), + ) + + +def _request_policy_state(request: Request) -> _RequestPolicyState: + """Return state owned by the underlying Django request.""" + owner = getattr(request, "_request", request) + state = getattr(owner, _REQUEST_POLICY_STATE_ATTRIBUTE, None) + if state is None: + state = _RequestPolicyState() + setattr(owner, _REQUEST_POLICY_STATE_ATTRIBUTE, state) + return state + + +def _result_decision(result: AuthorizeResultBrief, index: int) -> bool: + if result.index != index: + raise RuntimeError(f"Authorization result index {result.index} does not match {index}") + if result.id != f"mreg-{index}": + raise RuntimeError(f"Authorization result {index} has unexpected id={result.id!r}") + if not result.is_success(): + raise RuntimeError(result.error or f"Authorization result {index} failed with status={result.status}") + return result.is_allowed() + + +def _authorize_stack( + *, + request: Request, + root: PolicyNode, + context: dict[str, object], +) -> bool: + leaves = tuple(_iter_leaves(root)) + if not leaves: + raise RuntimeError("Endpoint policy stack is empty") + fingerprint = _node_fingerprint(root) + state = _request_policy_state(request) + if state.fingerprint is not None: + if state.fingerprint != fingerprint: + with suppress(Exception): + POLICY_STACK_CONFLICTS_TOTAL.inc() + raise RuntimeError("A second different endpoint policy stack was evaluated in one request") + if state.error is not None: + raise RuntimeError(state.error) + if state.policy_decision is None: + raise RuntimeError("Cached endpoint policy stack has no decision") + return state.policy_decision + + state.fingerprint = fingerprint + if not POLICY_BASE_URL: + state.error = "MREG_POLICY_BASE_URL is not configured" + raise RuntimeError(state.error) + if not _circuit.allow_call(): + state.error = "TreeTop circuit breaker is open" + raise RuntimeError(state.error) + + user = MregUser.from_request(request) + policy_requests = [_build_policy_request(user, leaf.check, request_id=f"mreg-{index}") for index, leaf in enumerate(leaves)] + POLICY_STACK_SIZE.observe(float(len(policy_requests))) + started = monotonic() + try: + response = _get_treetop_client().authorize( + policy_requests, + correlation_id=_corr_id(request), + ) + if len(response.results) != len(leaves): + raise RuntimeError(f"TreeTop returned {len(response.results)} results for {len(leaves)} checks") + ordered_results = sorted(response.results, key=lambda result: result.index) + decisions = tuple(_result_decision(result, index) for index, result in enumerate(ordered_results)) + except Exception as exc: + _circuit.failure() + POLICY_AUTHORIZE_CALLS_TOTAL.labels(status="exception").inc() + POLICY_AUTHORIZE_DURATION_SECONDS.labels(status="exception").observe(monotonic() - started) + error = f"{type(exc).__name__}: {exc}" + state.error = error + raise RuntimeError(error) from exc + + _circuit.success() + POLICY_AUTHORIZE_CALLS_TOTAL.labels(status="success").inc() + POLICY_AUTHORIZE_DURATION_SECONDS.labels(status="success").observe(monotonic() - started) + policy_decision = _evaluate_tree(root, iter(decisions)) + state.policy_decision = policy_decision + if POLICY_PARITY_LOG_DETAILS: + context["checks"] = [ + { + "action": leaf.check.action, + "resource_kind": leaf.check.resource.kind, + "resource_id": leaf.check.resource.id, + "resource_attrs": dict(leaf.check.resource.attrs), + "decision": decisions[index], + } + for index, leaf in enumerate(leaves) + ] + return policy_decision + + +def authorize_policy_stack( + legacy_decision: bool, + *, + request: Request, + root: PolicyNode, + view: View | None = None, + permission_class: str | None = None, +) -> bool: + """Synchronously evaluate one endpoint stack and apply the configured mode.""" + mode = _current_policy_mode() + legacy_decision = bool(legacy_decision) + if not _policy_is_configured(): + return legacy_decision + + context: dict[str, object] = { + "path": request.path, + "method": request.method, + "permission": permission_class or (view and view.__class__.__name__), + "view": view and view.__class__.__name__, + "correlation_id": _corr_id(request), + "mode": mode.value, + } + try: + policy_decision = _authorize_stack(request=request, root=root, context=context) + except Exception as exc: + error = str(exc) + _record_failure("authorize", error, **context) + _record_parity(legacy_decision, None, error, context) + if mode == PolicyMode.ENFORCE: + with suppress(Exception): + POLICY_ENFORCEMENT_RESULTS_TOTAL.labels(result="error_deny").inc() + _safe_log(logging.CRITICAL, "policy_enforcement_failure", enforced_decision=False, error=error, **context) + return False + return legacy_decision + + _record_parity(legacy_decision, policy_decision, None, context) + if mode == PolicyMode.ENFORCE: + with suppress(Exception): + POLICY_ENFORCEMENT_RESULTS_TOTAL.labels(result="allow" if policy_decision else "deny").inc() + return policy_decision + return legacy_decision + + +def _record_parity( + legacy_decision: bool, + policy_decision: bool | None, + error: str | None, + context: dict[str, object], +) -> None: + with suppress(Exception): + POLICY_LEGACY_DECISIONS_TOTAL.labels(decision="allow" if legacy_decision else "deny").inc() + policy_label = "error" if policy_decision is None else "allow" if policy_decision else "deny" + POLICY_DECISIONS_TOTAL.labels(decision=policy_label).inc() + if error is not None or policy_decision is None: + result = "error" + elif legacy_decision == policy_decision: + result = "match" + else: + result = "mismatch" + POLICY_PARITY_RESULTS_TOTAL.labels(result=result).inc() + _safe_log( + logging.INFO if result == "match" else logging.WARNING, + "policy_stack_result", + parity=result == "match", + legacy_decision=legacy_decision, + policy_decision=policy_decision, + error=error, + context=context, + ) + + +def policy_parity( + decision: bool, + *, + request: Request, + check: PolicyCheck, + view: View | None = None, + permission_class: str | None = None, +) -> bool: + """Compatibility wrapper for a single-leaf endpoint stack.""" + return authorize_policy_stack( + decision, + request=request, + root=PolicyLeaf(check), + view=view, + permission_class=permission_class, + ) diff --git a/mreg/api/urls.py b/mreg/api/urls.py index 6781b622..4371836f 100644 --- a/mreg/api/urls.py +++ b/mreg/api/urls.py @@ -13,4 +13,3 @@ path('meta/health/heartbeat', views.HealthHeartbeat.as_view()), path('meta/health/ldap', views.HealthLDAP.as_view()), ] - diff --git a/mreg/api/v1/tests/test_host_permissions.py b/mreg/api/v1/tests/test_host_permissions.py index 4d975915..9280390f 100644 --- a/mreg/api/v1/tests/test_host_permissions.py +++ b/mreg/api/v1/tests/test_host_permissions.py @@ -126,6 +126,7 @@ def test_can_not_change_host_out_of_permissions(self): def _post_and_get(name, ipaddress, client=self.client): data = {'name': name, 'ipaddress': ipaddress} ret = client.post('/api/v1/hosts/', data) + assert ret.status_code == 201 return self.assert_get(ret['Location']) Network.objects.create(network='10.2.0.0/25') diff --git a/mreg/api/v1/tests/test_logging.py b/mreg/api/v1/tests/test_logging.py index 60352090..dfdd3d25 100644 --- a/mreg/api/v1/tests/test_logging.py +++ b/mreg/api/v1/tests/test_logging.py @@ -143,7 +143,6 @@ def mock_get_response(_): # Check that the body was logged as '' self.assertEqual(cap_logs[0]["content"], "") - class TestLoggingMiddleware(MregAPITestCase): """Test logging middleware.""" diff --git a/mreg/api/v1/tests/test_parity_disable_example.py b/mreg/api/v1/tests/test_parity_disable_example.py new file mode 100644 index 00000000..1ae00dcf --- /dev/null +++ b/mreg/api/v1/tests/test_parity_disable_example.py @@ -0,0 +1,127 @@ +"""Example tests demonstrating how to disable parity checking for permission-modifying tests. + +This file serves as documentation and can be used as a template. + +Note: This file contains example code and is not meant to be run as actual tests. +Type checking is disabled for simplicity. +""" +# type: ignore + +from django.contrib.auth.models import Group + +from mreg.api.v1.tests.tests import MregAPITestCase +from mreg.api.treetop import disable_policy_parity +from mreg.api.test_utils import PermissionModifyingTestCase +from mreg.models.network import NetGroupRegexPermission + + +class ExamplePermissionTestWithContextManager(MregAPITestCase): + """Example: Using context manager to disable parity checking for specific test sections.""" + + def test_user_gains_permission_mid_test(self): + """Test that a user gains access when added to a group. + + This test modifies permissions mid-test, so we disable parity checking + during the modification and subsequent API calls. + """ + # Create a group and permission + group = Group.objects.create(name="example_group") + NetGroupRegexPermission.objects.create(group="example_group", range="10.0.0.0/24", regex=r".*\.example\.org$") + + # Get a non-privileged user client + client = self.get_token_client(superuser=False, adminuser=False) + + # First, verify user cannot create host (should fail) + # This is still subject to parity checking (no modifications yet) + response = client.post("/api/v1/hosts/", {"name": "test.example.org", "ipaddress": "10.0.0.1"}) + self.assertEqual(response.status_code, 403) + + # Now we're going to modify permissions, so disable parity checking + with disable_policy_parity(): + # Add user to the permission group + self.user.groups.add(group) + + # Now the user should have permission + # (parity checking is disabled because legacy and policy are out of sync) + response = client.post("/api/v1/hosts/", {"name": "test2.example.org", "ipaddress": "10.0.0.2"}) + self.assertEqual(response.status_code, 201) + + # Clean up + host_url = response["Location"] + client.delete(host_url) + + # Parity checking resumes after the context exits + # (though we typically don't make more permission-sensitive calls after this) + + +class ExamplePermissionTestWithMixin(PermissionModifyingTestCase, MregAPITestCase): + """Example: Using mixin to disable parity checking for entire test class. + + Use this approach when ALL tests in a class modify permissions. + """ + + def test_add_user_to_group(self): + """All tests in this class have parity checking disabled automatically.""" + group = Group.objects.create(name="test_group") + + # Parity checking is already disabled by the mixin + self.user.groups.add(group) + + # Make API calls without worrying about parity + response = self.client.get("/api/v1/hosts/") + self.assertEqual(response.status_code, 200) + + def test_remove_user_from_group(self): + """Another test - still no parity checking.""" + group = Group.objects.create(name="test_group") + self.user.groups.add(group) + + # Remove from group + self.user.groups.remove(group) + + # Make API calls + response = self.client.get("/api/v1/hosts/") + self.assertEqual(response.status_code, 200) + + +class ExampleComplexPermissionTest(MregAPITestCase): + """Example: Complex test with multiple permission changes.""" + + def test_permission_escalation_and_deescalation(self): + """Test user gaining and losing permissions multiple times. + + This shows how to use multiple context managers in sequence. + """ + # Create multiple groups with different permissions + group1 = Group.objects.create(name="group1") + group2 = Group.objects.create(name="group2") + + NetGroupRegexPermission.objects.create(group="group1", range="10.0.0.0/24", regex=r".*\.example\.org$") + NetGroupRegexPermission.objects.create(group="group2", range="10.0.1.0/24", regex=r".*\.example\.com$") + + client = self.get_token_client(superuser=False, adminuser=False) + + # User starts with no permissions + response = client.post("/api/v1/hosts/", {"name": "test.example.org", "ipaddress": "10.0.0.1"}) + self.assertEqual(response.status_code, 403) + + # Gain permission to .org domain + with disable_policy_parity(): + self.user.groups.add(group1) + + response = client.post("/api/v1/hosts/", {"name": "test.example.org", "ipaddress": "10.0.0.2"}) + self.assertEqual(response.status_code, 201) + client.delete(response["Location"]) + + # Switch to different permission group + with disable_policy_parity(): + self.user.groups.remove(group1) + self.user.groups.add(group2) + + # Should now have access to .com but not .org + response = client.post("/api/v1/hosts/", {"name": "test.example.com", "ipaddress": "10.0.1.1"}) + self.assertEqual(response.status_code, 201) + client.delete(response["Location"]) + + response = client.post("/api/v1/hosts/", {"name": "test.example.org", "ipaddress": "10.0.0.3"}) + self.assertEqual(response.status_code, 403) diff --git a/mreg/api/v1/tests/test_permissions.py b/mreg/api/v1/tests/test_permissions.py index abb20466..f7b1ff72 100644 --- a/mreg/api/v1/tests/test_permissions.py +++ b/mreg/api/v1/tests/test_permissions.py @@ -50,6 +50,7 @@ def set_attr(name: str): user.configure_mock(**group_attrs) user.group_list = [] + user.username = "mockuser" return user @@ -87,9 +88,10 @@ def test_unhandled_view( self, mock_get_hostname_and_ips, mock_has_obj_perm, - mock_user_from_request + mock_user_from_request, ): user = get_mock_user() # Regular user + user.is_member_of_any.return_value = False request = get_mock_request(user, mock_user_from_request) # Mock view that is not an instance of any of the checked classes @@ -97,6 +99,7 @@ def test_unhandled_view( # Mock object that doesn't have 'host' attribute view.get_object = mock.Mock(return_value=None) + view.__class__.__name__ = "MockView" # Mock serializer with data that doesn't have 'host' or 'ipaddress' serializer = mock.Mock() diff --git a/mreg/api/v1/views.py b/mreg/api/v1/views.py index 2e1a5706..71b38a37 100644 --- a/mreg/api/v1/views.py +++ b/mreg/api/v1/views.py @@ -23,11 +23,12 @@ from mreg.types import IPAllocationMethod from mreg.api.responses import created_response, error_response +from mreg.api.treetop import policy_enforcement_enabled from mreg.api.permissions import ( - IsAuthenticatedAndReadOnly, + HostContactsPermission, + IsNetworkAdminOrReadOnly, IsGrantedNetGroupRegexPermission, IsSuperOrAdminOrReadOnly, - IsSuperOrNetworkAdminMember, IsGrantedReservedAddressPermission, ) @@ -556,6 +557,14 @@ class HostContactsView(HostPermissionsUpdateDestroy, APIView): DELETE: Remove one or more contacts (expects {"emails": ["email1@example.com", ...]}) """ + policy_resource_kind = "Host" + policy_actions = { + "read": "host_contacts_read", + "create": "host_contacts_create", + "delete": "host_contacts_delete", + } + permission_classes = (HostContactsPermission,) + def get_host(self, name): """Get the host object by name.""" return get_object_or_404(Host, name=name.lower()) @@ -949,7 +958,7 @@ class NetworkList(MregListCreateAPIView): queryset = Network.objects.all().prefetch_related("excluded_ranges") serializer_class = NetworkSerializer - permission_classes = (IsSuperOrNetworkAdminMember | IsAuthenticatedAndReadOnly,) + permission_classes = (IsNetworkAdminOrReadOnly,) lookup_field = "network" location_lookup_safe = "/:" filterset_class = NetworkFilterSet @@ -975,7 +984,7 @@ class NetworkDetail(MregRetrieveUpdateDestroyAPIView): queryset = Network.objects.all() serializer_class = NetworkSerializer - permission_classes = (IsSuperOrNetworkAdminMember | IsAuthenticatedAndReadOnly,) + permission_classes = (IsNetworkAdminOrReadOnly,) lookup_field = "network" location_lookup_safe = "/:" @@ -1022,7 +1031,7 @@ class NetworkExcludedRangeList(MregListCreateAPIView): """ serializer_class = NetworkExcludedRangeSerializer - permission_classes = (IsSuperOrNetworkAdminMember | IsAuthenticatedAndReadOnly,) + permission_classes = (IsNetworkAdminOrReadOnly,) def get_queryset(self): """ @@ -1050,7 +1059,7 @@ class NetworkExcludedRangeDetail(MregRetrieveUpdateDestroyAPIView): """ serializer_class = NetworkExcludedRangeSerializer - permission_classes = (IsSuperOrNetworkAdminMember | IsAuthenticatedAndReadOnly,) + permission_classes = (IsNetworkAdminOrReadOnly,) lookup_field = "pk" def get_queryset(self): @@ -1241,6 +1250,14 @@ class NetGroupRegexPermissionList(MregListCreateAPIView): permission_classes = (IsSuperOrAdminOrReadOnly,) filterset_class = NetGroupRegexPermissionFilterSet + def post(self, request, *args, **kwargs): + if policy_enforcement_enabled(): + return error_response( + "NetGroupRegexPermission is bundle-managed while TreeTop enforcement is enabled.", + status.HTTP_409_CONFLICT, + ) + return super().post(request, *args, **kwargs) + class NetGroupRegexPermissionDetail(MregRetrieveUpdateDestroyAPIView): """ """ @@ -1249,6 +1266,27 @@ class NetGroupRegexPermissionDetail(MregRetrieveUpdateDestroyAPIView): serializer_class = NetGroupRegexPermissionSerializer permission_classes = (IsSuperOrAdminOrReadOnly,) + def _reject_bundle_managed_write(self): + return error_response( + "NetGroupRegexPermission is bundle-managed while TreeTop enforcement is enabled.", + status.HTTP_409_CONFLICT, + ) + + def put(self, request, *args, **kwargs): + if policy_enforcement_enabled(): + return self._reject_bundle_managed_write() + return super().put(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + if policy_enforcement_enabled(): + return self._reject_bundle_managed_write() + return super().patch(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + if policy_enforcement_enabled(): + return self._reject_bundle_managed_write() + return super().delete(request, *args, **kwargs) + def _get_iprange(kwargs): """ diff --git a/mreg/api/v1/views_bacnet.py b/mreg/api/v1/views_bacnet.py index 0f5a0e88..da554964 100644 --- a/mreg/api/v1/views_bacnet.py +++ b/mreg/api/v1/views_bacnet.py @@ -6,7 +6,7 @@ MregListCreateAPIView, MregRetrieveUpdateDestroyAPIView, ) -from mreg.api.permissions import IsGrantedNetGroupRegexPermission +from mreg.api.permissions import BACnetPermission from mreg.models.host import Host, BACnetID from . import serializers @@ -16,7 +16,7 @@ class BACnetIDList(MregListCreateAPIView): queryset = BACnetID.objects.order_by("id") serializer_class = serializers.BACnetIDSerializer - permission_classes = (IsGrantedNetGroupRegexPermission,) + permission_classes = (BACnetPermission,) lookup_field = "id" filterset_fields = "id" filterset_class = BACnetIDFilterSet @@ -65,7 +65,7 @@ def post(self, request, *args, **kwargs): class BACnetIDDetail(MregRetrieveUpdateDestroyAPIView): queryset = BACnetID.objects.all() serializer_class = serializers.BACnetIDSerializer - permission_classes = (IsGrantedNetGroupRegexPermission,) + permission_classes = (BACnetPermission,) lookup_field = "id" # Don't allow patch or put requests diff --git a/mreg/api/v1/views_hostgroups.py b/mreg/api/v1/views_hostgroups.py index 6846263c..4f69640c 100644 --- a/mreg/api/v1/views_hostgroups.py +++ b/mreg/api/v1/views_hostgroups.py @@ -8,7 +8,6 @@ from mreg.api.permissions import (HostGroupPermission, IsSuperOrGroupAdminOrReadOnly) from mreg.models.host import Host, HostGroup -from mreg.models.auth import User from mreg.mixins import LowerCaseLookupMixin @@ -28,13 +27,8 @@ class HostGroupM2MPermissions(M2MPermissions): def check_m2m_update_permission(self, request): for permission in self.get_permissions(): - if isinstance(self, (HostGroupOwnersList, HostGroupOwnersDetail)): - user = User.from_request(request) - if not (user.is_mreg_superuser or user.is_mreg_hostgroup_admin): - self.permission_denied(request) - else: - if not permission.has_m2m_change_permission(request, self): - self.permission_denied(request) + if not permission.has_m2m_change_permission(request, self): + self.permission_denied(request) class HostGroupLogMixin(HistoryLog): diff --git a/mreg/api/v1/views_network_policy.py b/mreg/api/v1/views_network_policy.py index 660b54da..ff47e1d8 100644 --- a/mreg/api/v1/views_network_policy.py +++ b/mreg/api/v1/views_network_policy.py @@ -23,7 +23,10 @@ from mreg.api.responses import created_response_at_url from mreg.api.v1.views import JSONContentTypeMixin, HistoryLog -from mreg.api.permissions import IsGrantedNetGroupRegexPermission, IsSuperOrNetworkAdminMember +from mreg.api.permissions import ( + IsGrantedNetGroupRegexOrNetworkAdmin, + IsSuperOrNetworkAdminMember, +) from mreg.api.v1.endpoints import URL class CommunityLogMixin(HistoryLog): @@ -131,7 +134,7 @@ class NetworkPolicyAttributeDetail(JSONContentTypeMixin, generics.RetrieveUpdate class NetworkCommunityList(JSONContentTypeMixin, CommunityLogMixin, generics.ListCreateAPIView): serializer_class = CommunitySerializer - permission_classes = (IsGrantedNetGroupRegexPermission | IsSuperOrNetworkAdminMember,) + permission_classes = (IsGrantedNetGroupRegexOrNetworkAdmin,) filterset_class = CommunityFilterSet def get_queryset(self): @@ -176,7 +179,7 @@ def create(self, request, *args, **kwargs): # Retrieve, update, or delete a specific Community under a specific Network class NetworkCommunityDetail(JSONContentTypeMixin, CommunityLogMixin, generics.RetrieveUpdateDestroyAPIView): serializer_class = CommunitySerializer - permission_classes = (IsGrantedNetGroupRegexPermission | IsSuperOrNetworkAdminMember,) + permission_classes = (IsGrantedNetGroupRegexOrNetworkAdmin,) def get_queryset(self): network = self.kwargs.get("network") @@ -213,7 +216,7 @@ def get_policy_and_community(self): # List all hosts in a specific community, or add a host to a community class NetworkCommunityHostList(HostInCommunityMixin, generics.ListCreateAPIView): serializer_class = HostSerializer - permission_classes = (IsGrantedNetGroupRegexPermission | IsSuperOrNetworkAdminMember,) + permission_classes = (IsGrantedNetGroupRegexOrNetworkAdmin,) def get_queryset(self): if "network" not in self.kwargs or "cpk" not in self.kwargs: @@ -269,7 +272,7 @@ def create(self, request, *args, **kwargs): # Retrieve or delete a specific host in a specific community class NetworkCommunityHostDetail(HostInCommunityMixin, generics.RetrieveDestroyAPIView): serializer_class = HostSerializer - permission_classes = (IsGrantedNetGroupRegexPermission | IsSuperOrNetworkAdminMember,) + permission_classes = (IsGrantedNetGroupRegexOrNetworkAdmin,) def get_queryset(self): if "network" not in self.kwargs or "cpk" not in self.kwargs: diff --git a/mreg/api/v1/views_zones.py b/mreg/api/v1/views_zones.py index 42db4121..97c725f9 100644 --- a/mreg/api/v1/views_zones.py +++ b/mreg/api/v1/views_zones.py @@ -19,7 +19,7 @@ from mreg.mixins import LowerCaseLookupMixin from mreg.api.responses import created_response, error_response -from mreg.api.permissions import (IsSuperGroupMember, IsAuthenticatedAndReadOnly) +from mreg.api.permissions import IsSuperOrReadOnly from .serializers import (ForwardZoneByHostnameSerializer, ForwardZoneDelegationSerializer, ForwardZoneSerializer, ReverseZoneDelegationSerializer, ReverseZoneSerializer) @@ -90,7 +90,7 @@ class ZoneList(generics.ListCreateAPIView): """ lookup_field = 'name' - permission_classes = (IsSuperGroupMember | IsAuthenticatedAndReadOnly, ) + permission_classes = (IsSuperOrReadOnly,) def get_queryset(self): qs = super().get_queryset() @@ -142,7 +142,7 @@ class ZoneDelegationList(generics.ListCreateAPIView): """ lookup_field = 'name' - permission_classes = (IsSuperGroupMember | IsAuthenticatedAndReadOnly, ) + permission_classes = (IsSuperOrReadOnly,) def get_queryset(self): if self.lookup_field not in self.kwargs: @@ -201,7 +201,7 @@ class ZoneDetail(LowerCaseLookupMixin, MregRetrieveUpdateDestroyAPIView): """ lookup_field = 'name' - permission_classes = (IsSuperGroupMember | IsAuthenticatedAndReadOnly, ) + permission_classes = (IsSuperOrReadOnly,) def patch(self, request, *args, **kwargs): query = self.kwargs[self.lookup_field] @@ -258,7 +258,7 @@ class ReverseZoneDetail(ZoneDetail): class ZoneDelegationDetail(LowerCaseLookupMixin, MregRetrieveUpdateDestroyAPIView): lookup_field = 'delegation' - permission_classes = (IsSuperGroupMember | IsAuthenticatedAndReadOnly, ) + permission_classes = (IsSuperOrReadOnly,) def get_queryset(self): parentname = self.kwargs['name'] @@ -321,7 +321,7 @@ class ZoneNameServerDetail(MregRetrieveUpdateDestroyAPIView): """ lookup_field = 'name' - permission_classes = (IsSuperGroupMember | IsAuthenticatedAndReadOnly, ) + permission_classes = (IsSuperOrReadOnly,) def get(self, request, *args, **kwargs): zone = self.get_object() diff --git a/mreg/api/views.py b/mreg/api/views.py index 75bd4718..6f6e95d4 100644 --- a/mreg/api/views.py +++ b/mreg/api/views.py @@ -14,17 +14,25 @@ from django.contrib.auth.models import update_last_login from rest_framework import serializers, status from rest_framework.authtoken.views import ObtainAuthToken -from rest_framework.exceptions import AuthenticationFailed, NotFound, PermissionDenied -from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import AuthenticationFailed, NotFound from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView from django.http import HttpResponse from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema -from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest +from prometheus_client import ( + CONTENT_TYPE_LATEST, + Counter, + Histogram, + generate_latest, +) from mreg.__about__ import __version__ as mreg_version -from mreg.api.permissions import IsSuperOrNetworkAdminMember +from mreg.api.permissions import ( + IsAuthenticatedWithPolicy, + IsSuperOrNetworkAdminMember, + UserInfoPermission, +) from mreg.api.serializers import ( HealthHeartbeatSerializer, MetaVersionsSerializer, @@ -141,7 +149,7 @@ def post(self, request: Request, *args: Any, **kwargs: Any): class TokenLogout(APIView): - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticatedWithPolicy,) @extend_schema(request=None, responses={status.HTTP_200_OK: None}) def post(self, request: Request): @@ -152,7 +160,7 @@ def post(self, request: Request): class TokenIsValid(APIView): - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticatedWithPolicy,) @extend_schema(responses={status.HTTP_200_OK: None}) def get(self, request: Request): @@ -164,7 +172,7 @@ def get(self, request: Request): class UserInfo(APIView): - permission_classes = (IsAuthenticated,) + permission_classes = (UserInfoPermission,) @extend_schema( parameters=[ @@ -187,8 +195,6 @@ def get(self, request: Request): target_user = req_user if username and username != req_user.username: - if not (req_user.is_mreg_superuser_or_admin or req_user.is_mreg_hostgroup_admin): - raise PermissionDenied("You do not have permission to view other users' details.") try: target_user = User.objects.get(username=username) except User.DoesNotExist: @@ -248,7 +254,7 @@ def get(self, request: Request): ### class MregVersion(APIView): - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticatedWithPolicy,) @extend_schema(responses={status.HTTP_200_OK: MregVersionSerializer}) def get(self, request: Request): @@ -280,6 +286,8 @@ def get(self, request: Request): class HealthHeartbeat(APIView): + permission_classes = () + @extend_schema(responses={status.HTTP_200_OK: HealthHeartbeatSerializer}) def get(self, request: Request): uptime = int(time.time() - start_time) @@ -291,6 +299,8 @@ def get(self, request: Request): class HealthLDAP(APIView): + permission_classes = () + @extend_schema( responses={ status.HTTP_200_OK: None, diff --git a/mreg/management/commands/check_policy_rollout.py b/mreg/management/commands/check_policy_rollout.py new file mode 100644 index 00000000..f598e578 --- /dev/null +++ b/mreg/management/commands/check_policy_rollout.py @@ -0,0 +1,39 @@ +"""Fail unless TreeTop parity telemetry is ready for enforcement.""" + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError, CommandParser + +from mreg.policy.rollout import RolloutThresholds, evaluate_rollout, fetch_rollout_snapshot + + +class Command(BaseCommand): + help = "Check Prometheus parity signals against the TreeTop enforcement rollout gates" + + def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument("--prometheus-url", required=True) + parser.add_argument("--window", default="24h") + parser.add_argument("--timeout", type=float, default=10.0) + + def handle(self, *args, **options): # type: ignore[no-untyped-def] + thresholds = RolloutThresholds( + min_comparisons=settings.POLICY_ROLLOUT_MIN_COMPARISONS, + max_mismatch_rate=settings.POLICY_ROLLOUT_MAX_MISMATCH_RATE, + max_error_rate=settings.POLICY_ROLLOUT_MAX_ERROR_RATE, + ) + try: + snapshot = fetch_rollout_snapshot( + options["prometheus_url"], + window=options["window"], + timeout=options["timeout"], + ) + except Exception as exc: + raise CommandError(f"Unable to query Prometheus: {exc}") from exc + evaluation = evaluate_rollout(snapshot, thresholds) + summary = ( + f"comparisons={snapshot.comparisons:g} " + f"mismatch_rate={snapshot.mismatch_rate:.6f} " + f"error_rate={snapshot.error_rate:.6f}" + ) + if not evaluation.ready: + raise CommandError(f"TreeTop rollout gate failed: {'; '.join(evaluation.reasons)} ({summary})") + self.stdout.write(self.style.SUCCESS(f"TreeTop rollout gate passed: {summary}")) diff --git a/mreg/management/commands/create_citext_extension.py b/mreg/management/commands/create_citext_extension.py index 59f65406..f260c4fc 100644 --- a/mreg/management/commands/create_citext_extension.py +++ b/mreg/management/commands/create_citext_extension.py @@ -1,19 +1,20 @@ -from django.core.management.base import BaseCommand, CommandError -from django.db import connection from sys import stdout + from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import connection from psycopg import connect class Command(BaseCommand): - help = 'Create the CITEXT extension in the database.' + help = "Create the CITEXT extension in the database." def add_arguments(self, parser): # optional argument parser.add_argument( - '--database', + "--database", type=str, - help='Database name', + help="Database name", ) def handle(self, *args, **options): @@ -21,21 +22,22 @@ def handle(self, *args, **options): stdout.flush() try: con = connection - if options['database']: + if options["database"]: stdout.write(f"Connecting to database {options['database']}\n") stdout.flush() con = connect( - host=settings.DATABASES['default']['HOST'], - user=settings.DATABASES['default']['USER'], - password=settings.DATABASES['default']['PASSWORD'], - dbname=options['database'] + host=settings.DATABASES["default"]["HOST"], + port=settings.DATABASES["default"]["PORT"], + user=settings.DATABASES["default"]["USER"], + password=settings.DATABASES["default"]["PASSWORD"], + dbname=options["database"], ) with con.cursor() as cursor: cursor.execute("CREATE EXTENSION IF NOT EXISTS citext") - stdout.write(cursor.statusmessage+"\n") + stdout.write(cursor.statusmessage + "\n") stdout.flush() con.commit() except Exception as e: stdout.write(e.__str__()) stdout.flush() - raise CommandError('Failed to create the CITEXT extension in the database.') + raise CommandError("Failed to create the CITEXT extension in the database.") from e diff --git a/mreg/middleware/logging_http.py b/mreg/middleware/logging_http.py index a7e9724e..9232e11b 100644 --- a/mreg/middleware/logging_http.py +++ b/mreg/middleware/logging_http.py @@ -219,4 +219,4 @@ def log_exception(self, request: HttpRequest, exception: Exception, start_time: scope.set_extra("request_body", self._get_body(request)) # Capture the exception - sentry_sdk.capture_exception(exception) \ No newline at end of file + sentry_sdk.capture_exception(exception) diff --git a/mreg/models/host.py b/mreg/models/host.py index 55a1c0b3..63e7988f 100644 --- a/mreg/models/host.py +++ b/mreg/models/host.py @@ -256,7 +256,7 @@ def _resolve_ip(self, ip: Optional[Union['Ipaddress', str]] = None) -> Optional[ except Ipaddress.DoesNotExist: raise NotAcceptable("No IP address found on this host with the provided value.") return ip - + @transaction.atomic def add_to_community( self, @@ -321,7 +321,7 @@ def remove_from_community( mappings = HostCommunityMapping.objects.filter(host=self, community=community) else: mappings = HostCommunityMapping.objects.filter(host=self, community__name=community) - + # Consume queryset generator to ensure check and delete operations are # performed on the same objects, avoiding read/write race conditions. mappings = list(mappings[:2]) diff --git a/mreg/models/network.py b/mreg/models/network.py index a8b46cd8..131b2414 100644 --- a/mreg/models/network.py +++ b/mreg/models/network.py @@ -74,6 +74,12 @@ def get_reserved_ipaddresses(self): ret.add(network.broadcast_address) return ret + def is_reserved_ipaddress(self, ip: str) -> bool: + """ + Check if the given IP address is reserved for this network. + """ + return any(ip == str(i) for i in self.get_reserved_ipaddresses()) + def get_excluded_ranges_start_end(self): excluded = [] for start_ip, end_ip in self.excluded_ranges.values_list("start_ip", "end_ip"): diff --git a/mreg/policy/__init__.py b/mreg/policy/__init__.py new file mode 100644 index 00000000..173517a3 --- /dev/null +++ b/mreg/policy/__init__.py @@ -0,0 +1 @@ +"""Policy contracts, resource adapters, and rollout tooling.""" diff --git a/mreg/policy/config.py b/mreg/policy/config.py new file mode 100644 index 00000000..44393373 --- /dev/null +++ b/mreg/policy/config.py @@ -0,0 +1,30 @@ +"""Configuration contracts for TreeTop shadow and enforcement modes.""" + +from __future__ import annotations + +from enum import StrEnum + + +class PolicyMode(StrEnum): + """How MREG uses TreeTop decisions.""" + + OFF = "off" + SHADOW = "shadow" + ENFORCE = "enforce" + + +def resolve_policy_mode(raw: str | None, *, legacy_parity_enabled: bool) -> PolicyMode: + """Resolve the explicit mode, falling back to the deprecated boolean.""" + candidate = (raw or "").strip().lower() + if not candidate: + candidate = PolicyMode.SHADOW if legacy_parity_enabled else PolicyMode.OFF + try: + return PolicyMode(candidate) + except ValueError as exc: + raise ValueError("MREG_POLICY_MODE must be one of: off, shadow, enforce") from exc + + +def validate_policy_configuration(mode: PolicyMode, base_url: str) -> None: + """Reject configurations that cannot provide authoritative decisions.""" + if mode == PolicyMode.ENFORCE and not base_url.strip(): + raise ValueError("MREG_POLICY_BASE_URL is required when MREG_POLICY_MODE=enforce") diff --git a/mreg/policy/contracts.py b/mreg/policy/contracts.py new file mode 100644 index 00000000..c6a74f7e --- /dev/null +++ b/mreg/policy/contracts.py @@ -0,0 +1,195 @@ +"""Authoritative MREG policy resource and action contracts. + +This module deliberately has no Django dependencies. Runtime resource adapters +and the Cedar schema generator both consume these declarations so their view of +resource kinds, identifiers, attributes, and actions cannot drift. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +CRUD_OPERATIONS = ("create", "delete", "read", "update") + + +@dataclass(frozen=True) +class ResourceAttributeContract: + """One optional Cedar resource attribute.""" + + name: str + cedar_type: str = "String" + + +@dataclass(frozen=True) +class ResourceContract: + """Policy-facing resource metadata shared by Python and Cedar.""" + + kind: str + operations: tuple[str, ...] = () + attributes: tuple[ResourceAttributeContract, ...] = () + identifier_fields: tuple[str, ...] = ("pk", "id", "name", "cpk", "hostpk", "network") + + @property + def actions(self) -> tuple[str, ...]: + token = snake_case(self.kind) + return tuple(f"{token}_{operation}" for operation in self.operations) + + +def snake_case(value: str) -> str: + """Return the stable action token for a Python/Cedar resource name.""" + import re + + if value.startswith("BACnet"): + value = f"Bacnet{value[len('BACnet') :]}" + value = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", value) + value = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value) + value = value.replace("-", "_") + return re.sub(r"[^a-zA-Z0-9_]+", "_", value).strip("_").lower() or "generic" + + +ENDPOINT_ATTRIBUTES = tuple( + ResourceAttributeContract(name, cedar_type) + for name, cedar_type in ( + ("kind", "String"), + ("id", "String"), + ("name", "String"), + ("path", "String"), + ("hostname", "String"), + ("ip", "ipaddr"), + ("nameLabels", "Set"), + ("selfAccess", "Bool"), + ("requesterIsOwner", "Bool"), + ("ownerMutation", "Bool"), + ("descriptionUpdate", "Bool"), + ("network", "String"), + ) +) + +ENDPOINT_ATTRIBUTE_TYPES = { + attribute.name: attribute.cedar_type for attribute in ENDPOINT_ATTRIBUTES +} + + +RESOURCE_CONTRACTS = ( + ResourceContract("Generic", attributes=ENDPOINT_ATTRIBUTES), + ResourceContract("Host", CRUD_OPERATIONS, ENDPOINT_ATTRIBUTES), + ResourceContract("HostContact", attributes=ENDPOINT_ATTRIBUTES, identifier_fields=("pk", "id", "email")), + ResourceContract( + "Ipaddress", + CRUD_OPERATIONS, + ENDPOINT_ATTRIBUTES, + identifier_fields=("pk", "id", "ipaddress"), + ), + *(ResourceContract(kind, CRUD_OPERATIONS, ENDPOINT_ATTRIBUTES) for kind in ( + "Cname", + "Hinfo", + "Loc", + "Mx", + "Naptr", + "NameServer", + "PtrOverride", + "Sshfp", + "Srv", + "Txt", + "BACnetID", + "Community", + "HostCommunityMapping", + "Label", + "Network", + "NetworkPolicy", + "NetworkPolicyAttribute", + "NetworkPolicyAttributeValue", + "HostGroup", + "NetworkExcludedRange", + "ForwardZone", + "ForwardZoneDelegation", + "ReverseZone", + "ReverseZoneDelegation", + "HostPolicyAtom", + "HostPolicyRole", + "NetGroupRegexPermission", + )), +) + + +RESOURCE_CONTRACT_BY_KIND = {contract.kind: contract for contract in RESOURCE_CONTRACTS} + +MEMBERSHIP_ACTIONS = { + "superuser": "superuser_access", + "admin": "admin_access", + "group_admin": "hostgroup_admin_access", + "network_admin": "network_admin_access", + "dns_wildcard": "dns_wildcard_admin_access", + "dns_underscore": "dns_underscore_admin_access", + "hostpolicy_admin": "hostpolicy_admin_access", +} + +CUSTOM_ACTIONS = frozenset( + { + *MEMBERSHIP_ACTIONS.values(), + "authenticated_access", + "create_label", + "delete_label", + "edit_label", + "host_contacts_read", + "host_contacts_create", + "host_contacts_delete", + "hostgroup_membership_update", + "hostpolicy_role_atom_membership_update", + "hostpolicy_role_host_membership_update", + "ip_broadcast_management", + "ip_gw_management", + "ip_network_management", + "ip_reserved_management", + "ip_restricted_management", + "is_superuser", + "user_info_read", + "view_label", + } +) + +POLICY_ACTIONS = tuple( + sorted( + { + *CUSTOM_ACTIONS, + *(action for contract in RESOURCE_CONTRACTS for action in contract.actions), + } + ) +) + + +def render_cedar_schema() -> str: + """Render the deterministic human-readable Cedar schema.""" + lines = [ + "namespace MREG {", + " entity Group;", + " entity User in [Group];", + "", + ] + for contract in RESOURCE_CONTRACTS: + if contract.attributes: + lines.append(f" entity {contract.kind} = {{") + lines.extend( + f" {attribute.name}?: {attribute.cedar_type}," + for attribute in contract.attributes + ) + lines.append(" };") + else: + lines.append(f" entity {contract.kind};") + lines.extend(("", " action")) + for index, action in enumerate(POLICY_ACTIONS): + suffix = "," if index < len(POLICY_ACTIONS) - 1 else "" + lines.append(f' "{action}"{suffix}') + lines.extend( + ( + " appliesTo {", + " principal: User,", + " resource: [", + ) + ) + for index, contract in enumerate(RESOURCE_CONTRACTS): + suffix = "," if index < len(RESOURCE_CONTRACTS) - 1 else "" + lines.append(f" {contract.kind}{suffix}") + lines.extend((" ]", " };", "}", "")) + return "\n".join(lines) diff --git a/mreg/policy/resources.py b/mreg/policy/resources.py new file mode 100644 index 00000000..fa61f59a --- /dev/null +++ b/mreg/policy/resources.py @@ -0,0 +1,161 @@ +"""Typed adapters from Django/DRF objects to policy resource contracts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Protocol, TypeVar + +from mreg.policy.contracts import RESOURCE_CONTRACT_BY_KIND, ResourceContract, snake_case + + +SourceT = TypeVar("SourceT") + + +class ResourceAdapter(Protocol[SourceT]): + """Contract implemented by policy resource adapters.""" + + contract: ResourceContract + + def identifier(self, *sources: SourceT | Mapping[str, Any] | None, default: str = "any") -> str: ... + + def attributes(self, data: Mapping[str, Any] | None) -> dict[str, str]: ... + + +def stringify_attribute(value: Any) -> str: + """Convert an attribute value to the client wire representation.""" + return "" if value is None else str(value) + + +@dataclass(frozen=True, slots=True) +class ModelResourceAdapter: + """Default adapter for one explicitly registered resource kind.""" + + contract: ResourceContract + + def identifier(self, *sources: Any, default: str = "any") -> str: + for source in sources: + if source is None: + continue + for field_name in self.contract.identifier_fields: + value = source.get(field_name) if isinstance(source, Mapping) else getattr(source, field_name, None) + if value is not None: + return str(value) + return default + + def attributes(self, data: Mapping[str, Any] | None) -> dict[str, str]: + attrs: dict[str, str] = {} + for key, value in (data or {}).items(): + model_fields = getattr(getattr(value, "_meta", None), "fields", None) + if model_fields is not None: + for field in model_fields: + attrs[f"{key}_{field.name}"] = stringify_attribute(getattr(value, field.name, "")) + else: + attrs[str(key)] = stringify_attribute(value) + # Request data cannot spoof the policy resource kind. + attrs["kind"] = snake_case(self.contract.kind) + return attrs + + +@dataclass(frozen=True, slots=True) +class HostResourceAdapter(ModelResourceAdapter): + """Host adapter, named explicitly because host policy attributes are typed.""" + + +@dataclass(frozen=True, slots=True) +class IpaddressResourceAdapter(ModelResourceAdapter): + """IP-address adapter with its IP-oriented identifier precedence.""" + + +def _build_registry() -> dict[str, ModelResourceAdapter]: + registry: dict[str, ModelResourceAdapter] = {} + for kind, contract in RESOURCE_CONTRACT_BY_KIND.items(): + adapter_type: type[ModelResourceAdapter] + if kind == "Host": + adapter_type = HostResourceAdapter + elif kind == "Ipaddress": + adapter_type = IpaddressResourceAdapter + else: + adapter_type = ModelResourceAdapter + registry[kind] = adapter_type(contract) + return registry + + +RESOURCE_ADAPTERS = _build_registry() + + +def adapter_for_kind(kind: str) -> ModelResourceAdapter: + """Return the registered adapter; unknown resources must be explicit.""" + try: + return RESOURCE_ADAPTERS[kind] + except KeyError as exc: + raise ValueError(f"No policy resource adapter registered for {kind}") from exc + + +def resource_kind_from_view(*, view: Any, validated_serializer: Any = None, obj: Any = None) -> str: + """Resolve a registered kind without relying on a view class name.""" + candidates = ( + obj.__class__.__name__ if obj is not None else None, + getattr(getattr(getattr(validated_serializer, "Meta", None), "model", None), "__name__", None), + getattr(getattr(validated_serializer, "instance", None), "__class__", type(None)).__name__ + if getattr(validated_serializer, "instance", None) is not None + else None, + getattr(view, "policy_resource_kind", None), + ) + kind = next((candidate for candidate in candidates if isinstance(candidate, str) and candidate.strip()), None) + if kind is None: + try: + serializer_class = view.get_serializer_class() + except (AttributeError, TypeError) as exc: + raise ValueError(f"{view.__class__.__name__} must declare an explicit policy resource kind") from exc + kind = getattr(getattr(getattr(serializer_class, "Meta", None), "model", None), "__name__", None) + if not kind: + raise ValueError(f"{view.__class__.__name__} serializer must declare Meta.model for policy parity") + adapter_for_kind(kind) + return kind + + +def resource_id_from_view( + *, + view: Any, + kind: str, + validated_serializer: Any = None, + obj: Any = None, + data: Mapping[str, Any] | None = None, + default: str = "any", +) -> str: + """Resolve a stable identifier with adapter-defined precedence.""" + adapter = adapter_for_kind(kind) + serializer_instance = getattr(validated_serializer, "instance", None) + return adapter.identifier(obj, data, serializer_instance, getattr(view, "kwargs", None), default=default) + + +CRUD_METHOD_TO_OPERATION = { + "GET": "read", + "HEAD": "read", + "OPTIONS": "read", + "POST": "create", + "PUT": "update", + "PATCH": "update", + "DELETE": "delete", +} + + +def crud_operation_from_method(method: str) -> str: + try: + return CRUD_METHOD_TO_OPERATION[method.upper()] + except KeyError as exc: + raise ValueError(f"Unsupported HTTP method for policy parity: {method}") from exc + + +def policy_action_from_view(*, view: Any, resource_kind: str, operation: str) -> str: + """Resolve an explicit custom action or a registered CRUD action.""" + explicit_actions = getattr(view, "policy_actions", None) + if isinstance(explicit_actions, Mapping): + explicit_action = explicit_actions.get(operation) + if isinstance(explicit_action, str) and explicit_action.strip(): + return explicit_action + contract = adapter_for_kind(resource_kind).contract + if operation not in contract.operations: + raise ValueError(f"{resource_kind} does not declare the {operation} policy operation") + return f"{snake_case(resource_kind)}_{operation}" diff --git a/mreg/policy/rollout.py b/mreg/policy/rollout.py new file mode 100644 index 00000000..b954259d --- /dev/null +++ b/mreg/policy/rollout.py @@ -0,0 +1,80 @@ +"""Prometheus-backed TreeTop rollout readiness evaluation.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from urllib.parse import urlencode +from urllib.request import urlopen + + +@dataclass(frozen=True, slots=True) +class RolloutThresholds: + min_comparisons: int = 10_000 + max_mismatch_rate: float = 0.001 + max_error_rate: float = 0.001 + + +@dataclass(frozen=True, slots=True) +class RolloutSnapshot: + comparisons: float + mismatches: float + errors: float + + @property + def mismatch_rate(self) -> float: + return self.mismatches / self.comparisons if self.comparisons else 0.0 + + @property + def error_rate(self) -> float: + total = self.comparisons + self.errors + return self.errors / total if total else 0.0 + + +@dataclass(frozen=True, slots=True) +class RolloutEvaluation: + ready: bool + reasons: tuple[str, ...] + + +def evaluate_rollout(snapshot: RolloutSnapshot, thresholds: RolloutThresholds) -> RolloutEvaluation: + """Evaluate every rollout gate and return all failures at once.""" + reasons: list[str] = [] + if snapshot.comparisons < thresholds.min_comparisons: + reasons.append(f"comparisons {snapshot.comparisons:g} < {thresholds.min_comparisons}") + if snapshot.mismatch_rate > thresholds.max_mismatch_rate: + reasons.append(f"mismatch rate {snapshot.mismatch_rate:.6f} > {thresholds.max_mismatch_rate:.6f}") + if snapshot.error_rate > thresholds.max_error_rate: + reasons.append(f"error rate {snapshot.error_rate:.6f} > {thresholds.max_error_rate:.6f}") + return RolloutEvaluation(ready=not reasons, reasons=tuple(reasons)) + + +def _prometheus_value(base_url: str, query: str, timeout: float) -> float: + endpoint = f"{base_url.rstrip('/')}/api/v1/query?{urlencode({'query': query})}" + with urlopen(endpoint, timeout=timeout) as response: # noqa: S310 - operator-provided Prometheus URL + payload = json.load(response) + if payload.get("status") != "success": + raise RuntimeError(f"Prometheus query failed: {payload}") + results = payload.get("data", {}).get("result", []) + if not results: + return 0.0 + return float(results[0]["value"][1]) + + +def fetch_rollout_snapshot( + prometheus_url: str, + *, + window: str = "24h", + timeout: float = 10.0, +) -> RolloutSnapshot: + """Read the composite parity signals required by the rollout gate.""" + queries = { + "comparisons": f'sum(increase(mreg_policy_parity_results_total{{result=~"match|mismatch"}}[{window}]))', + "mismatches": f'sum(increase(mreg_policy_parity_results_total{{result="mismatch"}}[{window}]))', + "errors": f'sum(increase(mreg_policy_parity_results_total{{result="error"}}[{window}]))', + } + values = { + name: _prometheus_value(prometheus_url, query, timeout) + for name, query in queries.items() + } + return RolloutSnapshot(**values) diff --git a/mreg/policy/treetop_generator.py b/mreg/policy/treetop_generator.py new file mode 100644 index 00000000..d2f13bac --- /dev/null +++ b/mreg/policy/treetop_generator.py @@ -0,0 +1,652 @@ +#!/usr/bin/env python3 +"""Generate TreeTop policy data from existing MREG API endpoints.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +import ipaddress +import json +import math +import os +from pathlib import Path +import re +import sys +from typing import Any, Iterable, Mapping, Sequence +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, urljoin, urlparse +from urllib.request import Request, urlopen + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_SNAPSHOT = ROOT / "treetop/fixtures/policy-source.json" +DEFAULT_OUTPUT_DIR = ROOT / "treetop/data" +SNAPSHOT_SCHEMA_VERSION = 1 +API_PAGE_SIZE = 1000 + +LABEL_RESOURCE_KINDS = ( + "MREG::Host", + "MREG::Ipaddress", + "MREG::Cname", + "MREG::Hinfo", + "MREG::Loc", + "MREG::Mx", + "MREG::Naptr", + "MREG::NameServer", + "MREG::PtrOverride", + "MREG::Sshfp", + "MREG::Srv", + "MREG::Txt", + "MREG::BACnetID", + "MREG::HostPolicyRole", +) + +STATIC_NAME_PATTERNS = ( + {"name": "dns_wildcard", "regex": r"\*"}, + {"name": "dns_wildcard_valid_depth", "regex": r"^(?:[^.]+\.){3,}[^.]+$"}, + {"name": "dns_underscore", "regex": "_"}, +) + +IP_SCOPED_ACTIONS = ( + "host_create", + "host_update", + "host_delete", + "host_contacts_create", + "host_contacts_delete", + "ipaddress_create", + "ipaddress_update", + "ipaddress_delete", + "hinfo_create", + "hinfo_update", + "hinfo_delete", + "loc_create", + "loc_update", + "loc_delete", + "mx_create", + "mx_update", + "mx_delete", + "naptr_create", + "naptr_update", + "naptr_delete", + "name_server_create", + "name_server_update", + "name_server_delete", + "ptr_override_create", + "ptr_override_update", + "ptr_override_delete", + "sshfp_create", + "sshfp_update", + "sshfp_delete", + "srv_create", + "srv_update", + "srv_delete", + "txt_create", + "txt_update", + "txt_delete", + "bacnet_id_create", + "bacnet_id_update", + "bacnet_id_delete", +) + +HOSTNAME_SCOPED_ACTIONS = ( + "cname_create", + "cname_update", + "cname_delete", +) + +NETWORK_SCOPED_ACTIONS = ( + "community_create", + "community_update", + "community_delete", + "host_create", + "host_delete", +) + +class ConversionError(ValueError): + """Raised when MREG API data cannot be converted safely.""" + + +@dataclass(frozen=True, order=True) +class NetworkPermission: + network: str + group: str + regex: str + labels: tuple[str, ...] + + +@dataclass(frozen=True, order=True) +class HostPolicyRole: + name: str + labels: tuple[str, ...] + + +@dataclass(frozen=True) +class GeneratedPolicy: + labels: str + cedar: str + report: str + + +def _object(value: Any, context: str) -> Mapping[str, Any]: + if not isinstance(value, dict): + raise ConversionError(f"{context} must be a JSON object") + return value + + +def _array(value: Any, context: str) -> list[Any]: + if not isinstance(value, list): + raise ConversionError(f"{context} must be a JSON array") + return value + + +def _string(row: Mapping[str, Any], field: str, context: str) -> str: + value = row.get(field) + if not isinstance(value, str) or not value: + raise ConversionError(f"{context}.{field} must be a non-empty string") + return value + + +def _label_names(value: Any, context: str) -> tuple[str, ...]: + labels = _array(value, context) + if any(not isinstance(label, str) or not label for label in labels): + raise ConversionError(f"{context} must contain only non-empty strings") + return tuple(sorted(set(labels))) + + +def _permission(row: Mapping[str, Any], context: str) -> NetworkPermission: + network_value = _string(row, "range", context) + group = _string(row, "group", context) + regex = _string(row, "regex", context) + try: + network = str(ipaddress.ip_network(network_value, strict=True)) + except ValueError as exc: + raise ConversionError(f"Invalid permission range {network_value!r}: {exc}") from exc + try: + re.compile(regex) + except re.error as exc: + raise ConversionError(f"Invalid permission regex {regex!r}: {exc}") from exc + return NetworkPermission( + network=network, + group=group, + regex=regex, + labels=_label_names(row.get("labels"), f"{context}.labels"), + ) + + +def _role(row: Mapping[str, Any], context: str) -> HostPolicyRole: + return HostPolicyRole( + name=_string(row, "name", context), + labels=_label_names(row.get("labels"), f"{context}.labels"), + ) + + +def parse_snapshot(text: str) -> tuple[tuple[NetworkPermission, ...], tuple[HostPolicyRole, ...]]: + """Parse the deterministic, normalized snapshot produced from MREG endpoints.""" + try: + payload = _object(json.loads(text), "snapshot") + except json.JSONDecodeError as exc: + raise ConversionError(f"Snapshot is not valid JSON: {exc}") from exc + schema_version = payload.get("schema_version") + if isinstance(schema_version, bool) or schema_version != SNAPSHOT_SCHEMA_VERSION: + raise ConversionError(f"snapshot.schema_version must be {SNAPSHOT_SCHEMA_VERSION}") + + permission_rows = _array(payload.get("permissions"), "snapshot.permissions") + permissions = { + _permission(_object(row, f"snapshot.permissions[{index}]"), f"snapshot.permissions[{index}]") + for index, row in enumerate(permission_rows) + } + if not permissions: + raise ConversionError("snapshot.permissions contains no data rows") + + role_rows = _array(payload.get("roles"), "snapshot.roles") + roles_by_name: dict[str, HostPolicyRole] = {} + for index, value in enumerate(role_rows): + context = f"snapshot.roles[{index}]" + role = _role(_object(value, context), context) + if role.name in roles_by_name: + raise ConversionError(f"Duplicate host-policy role {role.name!r}") + roles_by_name[role.name] = role + if not roles_by_name: + raise ConversionError("snapshot.roles contains no data rows") + return tuple(sorted(permissions)), tuple(sorted(roles_by_name.values())) + + +def serialize_snapshot( + permissions: Iterable[NetworkPermission], + roles: Iterable[HostPolicyRole], +) -> str: + """Serialize only the endpoint fields needed to reproduce generated policy.""" + payload = { + "schema_version": SNAPSHOT_SCHEMA_VERSION, + "permissions": [ + { + "group": permission.group, + "labels": list(permission.labels), + "range": permission.network, + "regex": permission.regex, + } + for permission in sorted(set(permissions)) + ], + "roles": [ + {"labels": list(role.labels), "name": role.name} + for role in sorted(set(roles)) + ], + } + return json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True) + "\n" + + +def snapshot_from_endpoint_rows( + permission_rows: Sequence[Mapping[str, Any]], + role_rows: Sequence[Mapping[str, Any]], + label_rows: Sequence[Mapping[str, Any]], +) -> str: + """Normalize the three existing endpoint responses into one stable snapshot.""" + label_names: dict[int, str] = {} + names_seen: set[str] = set() + for index, row in enumerate(label_rows): + context = f"labels[{index}]" + label_id = row.get("id") + if isinstance(label_id, bool) or not isinstance(label_id, int): + raise ConversionError(f"{context}.id must be an integer") + name = _string(row, "name", context) + if label_id in label_names: + raise ConversionError(f"Duplicate label id {label_id}") + if name in names_seen: + raise ConversionError(f"Duplicate label name {name!r}") + label_names[label_id] = name + names_seen.add(name) + + def resolve_labels(row: Mapping[str, Any], context: str) -> tuple[str, ...]: + label_ids = _array(row.get("labels"), f"{context}.labels") + resolved: set[str] = set() + for label_id in label_ids: + if isinstance(label_id, bool) or not isinstance(label_id, int): + raise ConversionError(f"{context}.labels must contain only integer label ids") + try: + resolved.add(label_names[label_id]) + except KeyError as exc: + raise ConversionError(f"{context} references unknown label id {label_id}") from exc + return tuple(sorted(resolved)) + + permissions: list[NetworkPermission] = [] + for index, row in enumerate(permission_rows): + context = f"permissions[{index}]" + normalized = dict(row) + normalized["labels"] = list(resolve_labels(row, context)) + permissions.append(_permission(normalized, context)) + + roles: list[HostPolicyRole] = [] + for index, row in enumerate(role_rows): + context = f"roles[{index}]" + normalized = dict(row) + normalized["labels"] = list(resolve_labels(row, context)) + roles.append(_role(normalized, context)) + return serialize_snapshot(permissions, roles) + + +def _validated_api_base_url(value: str) -> str: + url = value.rstrip("/") + parsed = urlparse(url) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: + raise ConversionError("MREG API base URL must be an absolute HTTP(S) URL") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ConversionError("MREG API base URL must not contain credentials, a query, or a fragment") + return url + + +def _same_origin(url: str, base_url: str) -> bool: + parsed = urlparse(url) + base = urlparse(base_url) + return (parsed.scheme.lower(), parsed.netloc.lower()) == (base.scheme.lower(), base.netloc.lower()) + + +def _fetch_paginated_rows( + *, + base_url: str, + path: str, + token: str, + timeout: float, + ordering: str, +) -> list[Mapping[str, Any]]: + query = urlencode({"ordering": ordering, "page_size": API_PAGE_SIZE}) + next_url: str | None = f"{urljoin(base_url + '/', path.lstrip('/'))}?{query}" + seen_urls: set[str] = set() + rows: list[Mapping[str, Any]] = [] + + while next_url is not None: + if next_url in seen_urls: + raise ConversionError(f"MREG API pagination loop detected at {next_url}") + if not _same_origin(next_url, base_url): + raise ConversionError(f"MREG API pagination URL changed origin: {next_url}") + seen_urls.add(next_url) + request = Request(next_url, headers={"Accept": "application/json"}) + # Do not allow urllib to copy the API token to a redirected request. + # The endpoint URLs already include their canonical trailing slash. + request.add_unredirected_header("Authorization", f"Token {token}") + try: + with urlopen(request, timeout=timeout) as response: # noqa: S310 - URL scheme and origin are validated. + response_url = response.geturl() + if not _same_origin(response_url, base_url): + raise ConversionError(f"MREG API response changed origin: {response_url}") + payload = _object(json.loads(response.read()), f"response from {next_url}") + except HTTPError as exc: + raise ConversionError(f"MREG API returned HTTP {exc.code} for {next_url}") from exc + except URLError as exc: + raise ConversionError(f"Unable to reach MREG API at {next_url}: {exc.reason}") from exc + except json.JSONDecodeError as exc: + raise ConversionError(f"MREG API returned invalid JSON for {next_url}: {exc}") from exc + + page_rows = _array(payload.get("results"), f"response from {next_url}.results") + rows.extend( + _object(row, f"response from {next_url}.results[{index}]") + for index, row in enumerate(page_rows) + ) + following = payload.get("next") + if following is None: + next_url = None + elif isinstance(following, str) and following: + next_url = urljoin(next_url, following) + else: + raise ConversionError(f"response from {next_url}.next must be a URL or null") + return rows + + +def fetch_policy_snapshot(base_url: str, token: str, timeout: float = 20.0) -> str: + """Fetch current policy inputs from existing MREG endpoints.""" + base_url = _validated_api_base_url(base_url) + token = token.strip() + if not token or "\r" in token or "\n" in token: + raise ConversionError("MREG_API_TOKEN must be a non-empty HTTP header value") + if not math.isfinite(timeout) or timeout <= 0: + raise ConversionError("MREG API timeout must be a finite number greater than zero") + + labels = _fetch_paginated_rows( + base_url=base_url, + path="/api/v1/labels/", + token=token, + timeout=timeout, + ordering="name", + ) + permissions = _fetch_paginated_rows( + base_url=base_url, + path="/api/v1/permissions/netgroupregex/", + token=token, + timeout=timeout, + ordering="range,group", + ) + roles = _fetch_paginated_rows( + base_url=base_url, + path="/api/v1/hostpolicy/roles/", + token=token, + timeout=timeout, + ordering="name", + ) + return snapshot_from_endpoint_rows(permissions, roles, labels) + + +def _stable_name(prefix: str, *parts: str) -> str: + digest = hashlib.sha256("\0".join(parts).encode()).hexdigest()[:12] + return f"{prefix}_{digest}" + + +def _quote(value: str) -> str: + return json.dumps(value, ensure_ascii=False) + + +def _actions(actions: Sequence[str], indent: str = " ") -> str: + values = [f'MREG::Action::{_quote(action)}' for action in actions] + return "[" + (",\n" + indent).join(values) + "]" + + +def _ranges(networks: Sequence[str], *, attribute: str = "ip") -> str: + checks = [f'resource.{attribute}.isInRange(ip({_quote(network)}))' for network in networks] + return "(" + (" ||\n ").join(checks) + ")" + + +def _network_values(networks: Sequence[str]) -> str: + checks = [f'resource.network == {_quote(network)}' for network in networks] + return "(" + (" ||\n ").join(checks) + ")" + + +def _permit_ip_rule(group: str, regex: str, networks: Sequence[str]) -> str: + label = _stable_name("netgroup", regex) + policy_id = _stable_name("netgroup_ip", group, regex) + return f'''@id("MREG.generated.{policy_id}") +permit ( + principal in MREG::Group::{_quote(group)}, + action in + {_actions(IP_SCOPED_ACTIONS)}, + resource +) +when {{ + resource has nameLabels && + resource.nameLabels.contains({_quote(label)}) && + resource has ip && + {_ranges(networks)} +}}; +''' + + +def _permit_hostname_rule(group: str, regex: str) -> str: + label = _stable_name("netgroup", regex) + policy_id = _stable_name("netgroup_hostname", group, regex) + return f'''@id("MREG.generated.{policy_id}") +permit ( + principal in MREG::Group::{_quote(group)}, + action in + {_actions(HOSTNAME_SCOPED_ACTIONS)}, + resource is MREG::Cname +) +when {{ + resource has nameLabels && + resource.nameLabels.contains({_quote(label)}) +}}; +''' + + +def _permit_network_rule(group: str, networks: Sequence[str]) -> str: + policy_id = _stable_name("netgroup_network", group) + return f'''@id("MREG.generated.{policy_id}") +permit ( + principal in MREG::Group::{_quote(group)}, + action in + {_actions(NETWORK_SCOPED_ACTIONS)}, + resource +) +when {{ + resource has network && + {_network_values(networks)} +}}; +''' + + +def _permit_role_rule(group: str, regex: str, role_name: str, networks: Sequence[str]) -> str: + label = _stable_name("netgroup", regex) + policy_id = _stable_name("hostpolicy_role", group, regex, role_name) + return f'''@id("MREG.generated.{policy_id}") +permit ( + principal in MREG::Group::{_quote(group)}, + action == MREG::Action::"hostpolicy_role_host_membership_update", + resource == MREG::HostPolicyRole::{_quote(role_name)} +) +when {{ + resource has nameLabels && + resource.nameLabels.contains({_quote(label)}) && + resource has ip && + {_ranges(networks)} +}}; +''' + + +def _group_values(permissions: Iterable[NetworkPermission], *fields: str) -> dict[tuple[str, ...], set[str]]: + grouped: dict[tuple[str, ...], set[str]] = {} + for permission in permissions: + key = tuple(str(getattr(permission, field)) for field in fields) + grouped.setdefault(key, set()).add(permission.network) + return grouped + + +def _normalized_networks(networks: Iterable[str]) -> tuple[str, ...]: + """Sort and collapse redundant ranges without mixing address families.""" + parsed = [ipaddress.ip_network(network) for network in networks] + collapsed = [ + network + for version in (4, 6) + for network in ipaddress.collapse_addresses( + network for network in parsed if network.version == version + ) + ] + return tuple(str(network) for network in collapsed) + + +def generate_policy(permissions: Sequence[NetworkPermission], roles: Sequence[HostPolicyRole]) -> GeneratedPolicy: + permissions = tuple(sorted(set(permissions))) + roles = tuple(sorted(set(roles))) + regexes = sorted({permission.regex for permission in permissions}) + patterns = [ + {"name": _stable_name("netgroup", regex), "regex": regex} + for regex in regexes + ] + labels = [ + { + "kind": kind, + "field": "hostname", + "output": "nameLabels", + "patterns": [ + *(STATIC_NAME_PATTERNS if kind != "MREG::HostPolicyRole" else ()), + *patterns, + ], + } + for kind in LABEL_RESOURCE_KINDS + ] + + rules: list[str] = [ + "// Generated from the normalized MREG API policy snapshot. Do not edit by hand.\n", + ] + rule_ids: list[str] = [] + + by_group_regex = _group_values(permissions, "group", "regex") + for (group, regex), networks_set in sorted(by_group_regex.items()): + networks = _normalized_networks(networks_set) + rules.append(_permit_ip_rule(group, regex, networks)) + rules.append(_permit_hostname_rule(group, regex)) + rule_ids.extend( + ( + _stable_name("netgroup_ip", group, regex), + _stable_name("netgroup_hostname", group, regex), + ) + ) + + by_group = _group_values(permissions, "group") + for (group,), networks_set in sorted(by_group.items()): + networks = _normalized_networks(networks_set) + rules.append(_permit_network_rule(group, networks)) + rule_ids.append(_stable_name("netgroup_network", group)) + + role_networks: dict[tuple[str, str, str], set[str]] = {} + used_legacy_labels: set[str] = set() + for permission in permissions: + permission_labels = set(permission.labels) + if not permission_labels: + continue + for role in roles: + shared = permission_labels.intersection(role.labels) + if not shared: + continue + used_legacy_labels.update(shared) + key = (permission.group, permission.regex, role.name) + role_networks.setdefault(key, set()).add(permission.network) + + for (group, regex, role_name), networks_set in sorted(role_networks.items()): + rules.append(_permit_role_rule(group, regex, role_name, _normalized_networks(networks_set))) + rule_ids.append(_stable_name("hostpolicy_role", group, regex, role_name)) + + permission_labels = {label for permission in permissions for label in permission.labels} + role_labels = {label for role in roles for label in role.labels} + report_data = { + "permission_rows": len(permissions), + "role_rows": len(roles), + "unique_regexes": len(regexes), + "generated_rules": len(rule_ids), + "generated_role_rules": len(role_networks), + "unused_permission_labels": sorted(permission_labels - used_legacy_labels), + "unmatched_role_labels": sorted(role_labels - permission_labels), + "derived_labels": {regex: _stable_name("netgroup", regex) for regex in regexes}, + "policy_ids": sorted(rule_ids), + } + return GeneratedPolicy( + labels=json.dumps(labels, indent=2, ensure_ascii=False) + "\n", + cedar="\n".join(rules).rstrip() + "\n", + report=json.dumps(report_data, indent=2, ensure_ascii=False, sort_keys=True) + "\n", + ) + + +def _outputs(output_dir: Path, policy: GeneratedPolicy) -> dict[Path, str]: + return { + output_dir / "labels.json": policy.labels, + output_dir / "netgroup.cedar": policy.cedar, + output_dir / "netgroup-conversion-report.json": policy.report, + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--snapshot", type=Path, default=DEFAULT_SNAPSHOT) + parser.add_argument( + "--api-base-url", + default=os.environ.get("MREG_API_BASE_URL"), + help="fetch current inputs from MREG instead of using the checked-in snapshot", + ) + parser.add_argument( + "--api-timeout", + default=os.environ.get("MREG_API_TIMEOUT", "20"), + type=float, + help="per-request MREG API timeout in seconds (default: 20)", + ) + parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) + parser.add_argument("--check", action="store_true", help="fail if generated output differs") + args = parser.parse_args(argv) + + try: + fetched_snapshot = None + if args.api_base_url: + token = os.environ.get("MREG_API_TOKEN", "") + if not token: + raise ConversionError("MREG_API_TOKEN is required when MREG_API_BASE_URL is configured") + fetched_snapshot = fetch_policy_snapshot(args.api_base_url, token, args.api_timeout) + snapshot = fetched_snapshot + else: + snapshot = args.snapshot.read_text() + permissions, roles = parse_snapshot(snapshot) + generated = generate_policy(permissions, roles) + except (ConversionError, OSError) as exc: + print(f"Unable to generate TreeTop policy: {exc}", file=sys.stderr) + return 2 + + outputs = _outputs(args.output_dir, generated) + if args.check: + stale = [path for path, content in outputs.items() if not path.exists() or path.read_text() != content] + if fetched_snapshot is not None and ( + not args.snapshot.exists() or args.snapshot.read_text() != fetched_snapshot + ): + stale.append(args.snapshot) + if stale: + print("Generated TreeTop policy is stale: " + ", ".join(str(path) for path in stale), file=sys.stderr) + return 1 + print("Generated TreeTop permission policy matches the MREG API snapshot") + return 0 + + if fetched_snapshot is not None: + args.snapshot.parent.mkdir(parents=True, exist_ok=True) + args.snapshot.write_text(fetched_snapshot) + print(f"wrote {args.snapshot}") + args.output_dir.mkdir(parents=True, exist_ok=True) + for path, content in outputs.items(): + path.write_text(content) + print(f"wrote {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mreg/tests/test_policy_config.py b/mreg/tests/test_policy_config.py new file mode 100644 index 00000000..7f7fd75e --- /dev/null +++ b/mreg/tests/test_policy_config.py @@ -0,0 +1,35 @@ +from django.test import SimpleTestCase + +from mreg.policy.config import ( + PolicyMode, + resolve_policy_mode, + validate_policy_configuration, +) + + +class PolicyConfigurationTests(SimpleTestCase): + def test_explicit_policy_mode_takes_precedence(self) -> None: + self.assertEqual( + resolve_policy_mode(" enforce ", legacy_parity_enabled=False), + PolicyMode.ENFORCE, + ) + + def test_deprecated_parity_boolean_maps_to_shadow_or_off(self) -> None: + self.assertEqual( + resolve_policy_mode("", legacy_parity_enabled=True), + PolicyMode.SHADOW, + ) + self.assertEqual( + resolve_policy_mode(None, legacy_parity_enabled=False), + PolicyMode.OFF, + ) + + def test_invalid_policy_mode_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "off, shadow, enforce"): + resolve_policy_mode("invalid", legacy_parity_enabled=True) + + def test_enforcement_requires_a_base_url(self) -> None: + with self.assertRaisesRegex(ValueError, "MREG_POLICY_BASE_URL"): + validate_policy_configuration(PolicyMode.ENFORCE, "") + validate_policy_configuration(PolicyMode.ENFORCE, "http://policy") + validate_policy_configuration(PolicyMode.SHADOW, "") diff --git a/mreg/tests/test_policy_contracts.py b/mreg/tests/test_policy_contracts.py new file mode 100644 index 00000000..dd91db3c --- /dev/null +++ b/mreg/tests/test_policy_contracts.py @@ -0,0 +1,141 @@ +from unittest import mock + +from django.test import SimpleTestCase + +from mreg.api.permissions import ParityMixin +from mreg.api.treetop import PolicyCheck, PolicyResource +from mreg.policy.contracts import POLICY_ACTIONS, RESOURCE_CONTRACTS, render_cedar_schema + + +class Host: + pass + + +class _ModelSerializer: + class Meta: + model = Host + + +class _ModelView: + kwargs = {"pk": 42} + + @staticmethod + def get_serializer_class(): + return _ModelSerializer + + +class PolicyContractTests(SimpleTestCase): + def setUp(self): + self.mixin = ParityMixin() + + def test_resource_kind_uses_serializer_model(self): + self.assertEqual( + self.mixin._resource_kind_from_view(view=_ModelView()), + "Host", + ) + + def test_rendered_schema_contains_every_declared_contract(self): + schema = render_cedar_schema() + + self.assertTrue(schema.startswith("namespace MREG {")) + for contract in RESOURCE_CONTRACTS: + self.assertIn(f"entity {contract.kind}", schema) + for action in POLICY_ACTIONS: + self.assertIn(f'"{action}"', schema) + + def test_resource_kind_supports_explicit_non_model_contract(self): + view = mock.Mock(policy_resource_kind="Generic") + view.get_serializer_class.side_effect = AttributeError + + self.assertEqual( + self.mixin._resource_kind_from_view(view=view), + "Generic", + ) + + def test_resource_kind_does_not_guess_from_view_name(self): + class ReportList: + @staticmethod + def get_serializer_class(): + return object + + with self.assertRaisesRegex(ValueError, "Meta.model"): + self.mixin._resource_kind_from_view(view=ReportList()) + + def test_resource_id_has_stable_precedence(self): + obj = Host() + obj.pk = 7 + + self.assertEqual( + self.mixin._resource_id_from_view( + view=_ModelView(), + obj=obj, + data={"id": 8}, + ), + "7", + ) + self.assertEqual( + self.mixin._resource_id_from_view( + view=_ModelView(), + data={"id": 8}, + ), + "8", + ) + self.assertEqual( + self.mixin._resource_id_from_view(view=_ModelView()), + "42", + ) + + def test_unsupported_http_method_is_rejected(self): + with self.assertRaisesRegex(ValueError, "TRACE"): + self.mixin._crud_operation_from_method("TRACE") + + def test_non_crud_action_is_an_explicit_view_contract(self): + view = mock.Mock(policy_actions={"read": "host_contacts_read"}) + + self.assertEqual( + self.mixin._policy_action_from_view( + view=view, + resource_kind="Host", + operation="read", + ), + "host_contacts_read", + ) + + def test_resource_attrs_cannot_override_canonical_kind(self): + attrs = self.mixin._normalize_resource_attrs( + resource_kind="BACnetID", + attrs={"kind": "spoofed", "value": 1}, + ) + + self.assertEqual(attrs, {"kind": "bacnet_id", "value": "1"}) + + @mock.patch("mreg.api.permissions.policy_parity") + def test_pp_builds_typed_policy_contract(self, policy_parity): + policy_parity.return_value = True + request = mock.Mock() + view = _ModelView() + + result = self.mixin.pp( + decision=True, + action="host_read", + request=request, + view=view, + resource_kind="Host", + resource_id="host.example.org", + resource_attrs={"hostname": "host.example.org"}, + ) + + self.assertTrue(result) + check = policy_parity.call_args.kwargs["check"] + self.assertIsInstance(check, PolicyCheck) + self.assertEqual( + check, + PolicyCheck( + action="host_read", + resource=PolicyResource( + kind="Host", + id="host.example.org", + attrs={"hostname": "host.example.org"}, + ), + ), + ) diff --git a/mreg/tests/test_policy_rollout.py b/mreg/tests/test_policy_rollout.py new file mode 100644 index 00000000..d7b500be --- /dev/null +++ b/mreg/tests/test_policy_rollout.py @@ -0,0 +1,73 @@ +from io import BytesIO +from unittest.mock import patch + +from django.test import SimpleTestCase + +from mreg.policy.rollout import ( + RolloutSnapshot, + RolloutThresholds, + _prometheus_value, + evaluate_rollout, + fetch_rollout_snapshot, +) + + +class PolicyRolloutTests(SimpleTestCase): + def test_prometheus_value_handles_value_empty_and_error_responses(self) -> None: + with patch( + "mreg.policy.rollout.urlopen", + return_value=BytesIO(b'{"status":"success","data":{"result":[{"value":[1,"42.5"]}]}}'), + ): + self.assertEqual(_prometheus_value("http://prometheus/", "up == 1", 2), 42.5) + + with patch( + "mreg.policy.rollout.urlopen", + return_value=BytesIO(b'{"status":"success","data":{"result":[]}}'), + ): + self.assertEqual(_prometheus_value("http://prometheus", "absent(up)", 2), 0) + + with ( + patch( + "mreg.policy.rollout.urlopen", + return_value=BytesIO(b'{"status":"error","error":"bad query"}'), + ), + self.assertRaisesRegex(RuntimeError, "Prometheus query failed"), + ): + _prometheus_value("http://prometheus", "invalid", 2) + + @patch("mreg.policy.rollout._prometheus_value", side_effect=[100, 1, 2]) + def test_fetch_rollout_snapshot_queries_every_gate(self, prometheus_value) -> None: + snapshot = fetch_rollout_snapshot("http://prometheus", window="6h", timeout=4) + + self.assertEqual(snapshot, RolloutSnapshot(100, 1, 2)) + self.assertEqual(prometheus_value.call_count, 3) + self.assertTrue(all(call.args[0] == "http://prometheus" for call in prometheus_value.call_args_list)) + self.assertTrue(all(call.args[2] == 4 for call in prometheus_value.call_args_list)) + self.assertIn("[6h]", prometheus_value.call_args_list[0].args[1]) + + def test_ready_snapshot_passes_every_gate(self) -> None: + result = evaluate_rollout( + RolloutSnapshot( + comparisons=20_000, + mismatches=1, + errors=1, + ), + RolloutThresholds(), + ) + + self.assertTrue(result.ready) + self.assertEqual(result.reasons, ()) + + def test_failed_snapshot_reports_every_broken_gate(self) -> None: + result = evaluate_rollout( + RolloutSnapshot( + comparisons=100, + mismatches=5, + errors=5, + ), + RolloutThresholds(), + ) + + self.assertFalse(result.ready) + self.assertEqual(len(result.reasons), 3) + self.assertIn("comparisons", result.reasons[0]) diff --git a/mreg/tests/test_treetop.py b/mreg/tests/test_treetop.py new file mode 100644 index 00000000..82f5de7b --- /dev/null +++ b/mreg/tests/test_treetop.py @@ -0,0 +1,329 @@ +"""Tests for synchronous endpoint policy stacks.""" + +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from hostpolicy.api.permissions import IsSuperOrHostPolicyAdminOrReadOnly +from mreg.api.permissions import IsGrantedNetGroupRegexPermission +from mreg.api.treetop import ( + PolicyAll, + PolicyAny, + PolicyCheck, + PolicyLeaf, + PolicyResource, + _SynchronousCircuitBreaker, + _build_resource_attrs, + authorize_policy_stack, + close_policy_client, + disable_policy_parity, + policy_all, + policy_any, + policy_leaf, + policy_shadow_enabled, +) +from mreg.policy.config import PolicyMode + + +class SynchronousPolicyStackTests(SimpleTestCase): + def setUp(self) -> None: + self.factory = APIRequestFactory() + self.user = SimpleNamespace(username="alice", group_list=("users",)) + + def _request(self): + return self.factory.get( + "/api/v1/hosts/", + HTTP_X_CORRELATION_ID="test-correlation", + ) + + @staticmethod + def _leaf(name: str = "host.example.org") -> PolicyLeaf: + return policy_leaf( + action="host_read", + resource_kind="Host", + resource_id=name, + resource_attrs={"kind": "host", "name": name}, + ) + + @staticmethod + def _result(allowed: bool, index: int): + result = Mock() + result.index = index + result.id = f"mreg-{index}" + result.is_success.return_value = True + result.is_allowed.return_value = allowed + return result + + def _client(self, *decisions: bool): + client = Mock() + client.authorize.return_value = SimpleNamespace(results=[self._result(decision, index) for index, decision in enumerate(decisions)]) + return client + + def test_policy_contracts_reject_empty_values(self) -> None: + with self.assertRaisesRegex(ValueError, "kind"): + PolicyResource("", "id", {"kind": "host"}) + with self.assertRaisesRegex(ValueError, "ID"): + PolicyResource("Host", "", {"kind": "host"}) + with self.assertRaisesRegex(ValueError, "attributes"): + PolicyResource("Host", "id", {}) + with self.assertRaisesRegex(ValueError, "action"): + PolicyCheck("", PolicyResource("Host", "id", {"kind": "host"})) + with self.assertRaisesRegex(ValueError, "at least one"): + PolicyAll(()) + with self.assertRaisesRegex(ValueError, "at least one"): + PolicyAny(()) + + def test_resource_attributes_follow_contract_types(self) -> None: + attrs = _build_resource_attrs({"selfAccess": "true", "ip": "192.0.2.1", "hostname": "true"}) + self.assertEqual(attrs["selfAccess"].type.value, "Bool") + self.assertEqual(attrs["ip"].type.value, "Ip") + self.assertEqual(attrs["hostname"].type.value, "String") + with self.assertRaisesRegex(ValueError, "boolean"): + _build_resource_attrs({"selfAccess": "yes"}) + + def test_netgroup_targets_send_only_raw_hostname_and_ip(self) -> None: + root = IsGrantedNetGroupRegexPermission()._target_policy_node( + hostname="old.example.org", + policy_name="new.example.org", + ips=("192.0.2.10", "2001:db8::10"), + action="host_update", + resource_kind="Host", + resource_id="old.example.org", + ) + + self.assertIsInstance(root, PolicyAny) + self.assertEqual( + [dict(child.check.resource.attrs) for child in root.children], + [ + {"hostname": "new.example.org", "ip": "192.0.2.10"}, + {"hostname": "new.example.org", "ip": "2001:db8::10"}, + ], + ) + + @patch("hostpolicy.api.permissions.authorize_policy_stack", return_value=True) + @patch("hostpolicy.api.permissions.Host.objects") + def test_hostpolicy_membership_sends_exact_role_and_raw_host(self, host_objects, authorize) -> None: + host_objects.filter.return_value.exclude.return_value.values_list.return_value = ["192.0.2.10"] + request = self.factory.post("/api/v1/hostpolicy/roles/web/hosts/") + view = SimpleNamespace(kwargs={"name": "web", "host": "web-1.example.org"}) + + self.assertTrue( + IsSuperOrHostPolicyAdminOrReadOnly()._authorize_role_host_membership( + request=request, + view=view, + legacy=False, + ) + ) + + root = authorize.call_args.kwargs["root"] + self.assertIsInstance(root, PolicyAny) + self.assertEqual(len(root.children), 1) + leaf = root.children[0] + self.assertEqual(leaf.check.resource.kind, "HostPolicyRole") + self.assertEqual(leaf.check.resource.id, "web") + self.assertEqual( + dict(leaf.check.resource.attrs), + {"hostname": "web-1.example.org", "ip": "192.0.2.10"}, + ) + + @patch("mreg.api.treetop.MregUser.from_request") + @patch("mreg.api.treetop._get_treetop_client") + def test_nested_stack_uses_one_batched_authorize_call(self, get_client, from_request) -> None: + from_request.return_value = self.user + client = self._client(True, False, True) + get_client.return_value = client + root = policy_all( + policy_any(self._leaf("one.example.org"), self._leaf("two.example.org")), + self._leaf("three.example.org"), + ) + + with ( + patch("mreg.api.treetop.POLICY_MODE", PolicyMode.ENFORCE), + patch("mreg.api.treetop.POLICY_BASE_URL", "http://policy"), + ): + self.assertTrue(authorize_policy_stack(False, request=self._request(), root=root)) + + client.authorize.assert_called_once() + requests = client.authorize.call_args.args[0] + self.assertEqual(len(requests), 3) + self.assertEqual([request.id for request in requests], ["mreg-0", "mreg-1", "mreg-2"]) + + @patch("mreg.api.treetop.MregUser.from_request") + @patch("mreg.api.treetop._get_treetop_client") + def test_identical_stack_is_cached_inside_request(self, get_client, from_request) -> None: + from_request.return_value = self.user + client = self._client(True) + get_client.return_value = client + root = self._leaf() + request = self._request() + with ( + patch("mreg.api.treetop.POLICY_MODE", PolicyMode.ENFORCE), + patch("mreg.api.treetop.POLICY_BASE_URL", "http://policy"), + ): + self.assertTrue(authorize_policy_stack(False, request=request, root=root)) + self.assertTrue(authorize_policy_stack(False, request=request, root=root)) + client.authorize.assert_called_once() + + @patch("mreg.api.treetop.MregUser.from_request") + @patch("mreg.api.treetop._get_treetop_client") + def test_second_different_stack_denies_without_second_call(self, get_client, from_request) -> None: + from_request.return_value = self.user + client = self._client(True) + get_client.return_value = client + request = self._request() + with ( + patch("mreg.api.treetop.POLICY_MODE", PolicyMode.ENFORCE), + patch("mreg.api.treetop.POLICY_BASE_URL", "http://policy"), + ): + self.assertTrue(authorize_policy_stack(False, request=request, root=self._leaf("one"))) + self.assertFalse(authorize_policy_stack(True, request=request, root=self._leaf("two"))) + client.authorize.assert_called_once() + + @patch("mreg.api.treetop._get_treetop_client") + def test_off_and_unconfigured_shadow_do_not_call_treetop(self, get_client) -> None: + for mode in (PolicyMode.OFF, PolicyMode.SHADOW): + with ( + patch("mreg.api.treetop.POLICY_MODE", mode), + patch("mreg.api.treetop.POLICY_BASE_URL", ""), + ): + self.assertTrue(authorize_policy_stack(True, request=self._request(), root=self._leaf())) + get_client.assert_not_called() + + def test_shadow_enabled_reflects_configuration_and_disable_scope(self) -> None: + with ( + patch("mreg.api.treetop.POLICY_MODE", PolicyMode.SHADOW), + patch("mreg.api.treetop.POLICY_PARITY_ENABLED", True), + patch("mreg.api.treetop.POLICY_BASE_URL", "http://policy"), + ): + self.assertTrue(policy_shadow_enabled()) + with disable_policy_parity(): + self.assertFalse(policy_shadow_enabled()) + + with patch("mreg.api.treetop.POLICY_MODE", PolicyMode.ENFORCE): + self.assertFalse(policy_shadow_enabled()) + + @patch("mreg.api.treetop.MregUser.from_request") + @patch("mreg.api.treetop._get_treetop_client") + def test_shadow_calls_synchronously_but_returns_legacy(self, get_client, from_request) -> None: + from_request.return_value = self.user + get_client.return_value = self._client(True) + with ( + patch("mreg.api.treetop.POLICY_MODE", PolicyMode.SHADOW), + patch("mreg.api.treetop.POLICY_PARITY_ENABLED", True), + patch("mreg.api.treetop.POLICY_BASE_URL", "http://policy"), + ): + self.assertFalse(authorize_policy_stack(False, request=self._request(), root=self._leaf())) + get_client.return_value.authorize.assert_called_once() + + @patch("mreg.api.treetop.MregUser.from_request") + @patch("mreg.api.treetop._get_treetop_client") + def test_enforce_returns_allow_and_deny(self, get_client, from_request) -> None: + from_request.return_value = self.user + with ( + patch("mreg.api.treetop.POLICY_MODE", PolicyMode.ENFORCE), + patch("mreg.api.treetop.POLICY_BASE_URL", "http://policy"), + ): + get_client.return_value = self._client(True) + self.assertTrue(authorize_policy_stack(False, request=self._request(), root=self._leaf())) + get_client.return_value = self._client(False) + self.assertFalse(authorize_policy_stack(True, request=self._request(), root=self._leaf())) + + @patch("mreg.api.treetop.MregUser.from_request") + @patch("mreg.api.treetop._get_treetop_client") + def test_results_are_composed_by_response_index(self, get_client, from_request) -> None: + from_request.return_value = self.user + client = self._client(True, False) + client.authorize.return_value.results.reverse() + get_client.return_value = client + with ( + patch("mreg.api.treetop.POLICY_MODE", PolicyMode.ENFORCE), + patch("mreg.api.treetop.POLICY_BASE_URL", "http://policy"), + ): + self.assertFalse( + authorize_policy_stack( + True, + request=self._request(), + root=policy_all(self._leaf("one"), self._leaf("two")), + ) + ) + + @patch("mreg.api.treetop.MregUser.from_request") + @patch("mreg.api.treetop._get_treetop_client") + def test_invalid_result_is_a_circuit_failure(self, get_client, from_request) -> None: + from_request.return_value = self.user + client = self._client(True) + client.authorize.return_value.results[0].id = "wrong" + get_client.return_value = client + circuit = _SynchronousCircuitBreaker(2, 30) + with ( + patch("mreg.api.treetop.POLICY_MODE", PolicyMode.ENFORCE), + patch("mreg.api.treetop.POLICY_BASE_URL", "http://policy"), + patch("mreg.api.treetop._circuit", circuit), + ): + self.assertFalse(authorize_policy_stack(True, request=self._request(), root=self._leaf())) + self.assertEqual(circuit._failures, 1) + + @patch("mreg.api.treetop.MregUser.from_request") + @patch("mreg.api.treetop._get_treetop_client") + def test_enforce_errors_deny_and_shadow_errors_use_legacy(self, get_client, from_request) -> None: + from_request.return_value = self.user + get_client.return_value.authorize.side_effect = RuntimeError("offline") + with ( + patch("mreg.api.treetop.POLICY_BASE_URL", "http://policy"), + patch("mreg.api.treetop._circuit", _SynchronousCircuitBreaker(5, 30)), + ): + with patch("mreg.api.treetop.POLICY_MODE", PolicyMode.ENFORCE): + self.assertFalse(authorize_policy_stack(True, request=self._request(), root=self._leaf())) + with ( + patch("mreg.api.treetop.POLICY_MODE", PolicyMode.SHADOW), + patch("mreg.api.treetop.POLICY_PARITY_ENABLED", True), + ): + self.assertTrue(authorize_policy_stack(True, request=self._request(), root=self._leaf())) + + @patch("mreg.api.treetop._get_treetop_client") + def test_disable_helper_only_disables_shadow(self, get_client) -> None: + with ( + patch("mreg.api.treetop.POLICY_MODE", PolicyMode.SHADOW), + patch("mreg.api.treetop.POLICY_PARITY_ENABLED", True), + patch("mreg.api.treetop.POLICY_BASE_URL", "http://policy"), + disable_policy_parity(), + ): + self.assertTrue(authorize_policy_stack(True, request=self._request(), root=self._leaf())) + get_client.assert_not_called() + + @patch("mreg.api.treetop.MregUser.from_request") + @patch("mreg.api.treetop._get_treetop_client") + def test_disable_helper_cannot_bypass_enforcement(self, get_client, from_request) -> None: + from_request.return_value = self.user + get_client.return_value = self._client(False) + with ( + patch("mreg.api.treetop.POLICY_MODE", PolicyMode.ENFORCE), + patch("mreg.api.treetop.POLICY_BASE_URL", "http://policy"), + disable_policy_parity(), + ): + self.assertFalse(authorize_policy_stack(True, request=self._request(), root=self._leaf())) + get_client.return_value.authorize.assert_called_once() + + def test_circuit_opens_and_allows_one_half_open_probe(self) -> None: + circuit = _SynchronousCircuitBreaker(2, 30) + self.assertTrue(circuit.allow_call()) + circuit.failure() + self.assertTrue(circuit.allow_call()) + circuit.failure() + self.assertFalse(circuit.allow_call()) + + circuit._open_until = 0.1 + with patch("mreg.api.treetop.monotonic", return_value=1.0): + self.assertTrue(circuit.allow_call()) + self.assertFalse(circuit.allow_call()) + circuit.success() + self.assertTrue(circuit.allow_call()) + + @patch("mreg.api.treetop._client_lock") + def test_close_policy_client_is_safe_without_client(self, client_lock) -> None: + client_lock.__enter__ = Mock() + client_lock.__exit__ = Mock(return_value=False) + with patch("mreg.api.treetop._client", None): + close_policy_client() diff --git a/mreg/tests/test_treetop_policy_generator.py b/mreg/tests/test_treetop_policy_generator.py new file mode 100644 index 00000000..11498217 --- /dev/null +++ b/mreg/tests/test_treetop_policy_generator.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tempfile +from unittest import TestCase +from unittest.mock import patch + +from mreg.policy import treetop_generator as generator + + +class JsonResponse: + def __init__(self, url: str, payload: object) -> None: + self.url = url + self.payload = payload + + def __enter__(self) -> JsonResponse: + return self + + def __exit__(self, *args: object) -> None: + return None + + def geturl(self) -> str: + return self.url + + def read(self) -> bytes: + return json.dumps(self.payload).encode() + + +class RawResponse(JsonResponse): + def read(self) -> bytes: + assert isinstance(self.payload, bytes) + return self.payload + + +class TreeTopPolicyGeneratorTests(TestCase): + snapshot_payload = { + "schema_version": generator.SNAPSHOT_SCHEMA_VERSION, + "permissions": [ + { + "range": "10.0.0.0/24", + "group": "group two", + "regex": r"^web [0-9]+\.example$", + "labels": ["Shared", "Unused"], + }, + { + "range": "10.0.0.1/32", + "group": "group two", + "regex": r"^web [0-9]+\.example$", + "labels": ["Shared"], + }, + { + "range": "2001:db8::1/128", + "group": "ipv6", + "regex": r".*\.example$", + "labels": [], + }, + ], + "roles": [ + {"name": "role1", "labels": ["Shared"]}, + {"name": "role2", "labels": ["Missing"]}, + ], + } + + def snapshot(self, payload: object | None = None) -> str: + return json.dumps(self.snapshot_payload if payload is None else payload) + + def test_snapshot_parser_preserves_spaces_and_normalizes_values(self) -> None: + permissions, roles = generator.parse_snapshot(self.snapshot()) + + self.assertEqual(permissions[0].group, "group two") + self.assertEqual(permissions[0].regex, r"^web [0-9]+\.example$") + self.assertEqual(permissions[0].labels, ("Shared", "Unused")) + self.assertEqual(permissions[-1].network, "2001:db8::1/128") + self.assertEqual(roles[0].name, "role1") + self.assertEqual(roles[0].labels, ("Shared",)) + + def test_generation_uses_derived_labels_and_exact_roles(self) -> None: + permissions, roles = generator.parse_snapshot(self.snapshot()) + result = generator.generate_policy(permissions, roles) + report = json.loads(result.report) + + derived_label = report["derived_labels"][r"^web [0-9]+\.example$"] + self.assertIn(f'resource.nameLabels.contains("{derived_label}")', result.cedar) + self.assertIn('resource == MREG::HostPolicyRole::"role1"', result.cedar) + self.assertNotIn("Shared", result.cedar) + self.assertNotIn("Unused", result.labels) + self.assertEqual(report["generated_role_rules"], 1) + self.assertEqual(report["unused_permission_labels"], ["Unused"]) + self.assertEqual(report["unmatched_role_labels"], ["Missing"]) + + def test_duplicate_permission_rows_are_deduplicated_and_output_is_stable(self) -> None: + payload = json.loads(self.snapshot()) + payload["permissions"].append(dict(payload["permissions"][0])) + permissions, roles = generator.parse_snapshot(self.snapshot(payload)) + + self.assertEqual(len(permissions), 3) + first = generator.generate_policy(permissions, roles) + second = generator.generate_policy(tuple(reversed(permissions)), tuple(reversed(roles))) + self.assertEqual(first, second) + + def test_rejects_malformed_snapshot(self) -> None: + with self.assertRaisesRegex(generator.ConversionError, "valid JSON"): + generator.parse_snapshot("{") + + payload = json.loads(self.snapshot()) + payload["schema_version"] = 99 + with self.assertRaisesRegex(generator.ConversionError, "schema_version"): + generator.parse_snapshot(self.snapshot(payload)) + + payload = json.loads(self.snapshot()) + payload["permissions"][0]["range"] = "10.0.0.1/24" + with self.assertRaisesRegex(generator.ConversionError, "Invalid permission range"): + generator.parse_snapshot(self.snapshot(payload)) + + payload = json.loads(self.snapshot()) + payload["roles"].append(dict(payload["roles"][0])) + with self.assertRaisesRegex(generator.ConversionError, "Duplicate host-policy role"): + generator.parse_snapshot(self.snapshot(payload)) + + invalid_payloads = [ + ([], "snapshot must be a JSON object"), + ({"schema_version": 1, "permissions": None, "roles": []}, "permissions must be a JSON array"), + ( + { + "schema_version": 1, + "permissions": [{"range": "10.0.0.0/24", "regex": ".*", "labels": []}], + "roles": [{"name": "role1", "labels": []}], + }, + "group must be a non-empty string", + ), + ( + { + "schema_version": 1, + "permissions": [{"range": "10.0.0.0/24", "group": "group", "regex": "[", "labels": []}], + "roles": [{"name": "role1", "labels": []}], + }, + "Invalid permission regex", + ), + ( + { + "schema_version": 1, + "permissions": [{"range": "10.0.0.0/24", "group": "group", "regex": ".*", "labels": [1]}], + "roles": [{"name": "role1", "labels": []}], + }, + "must contain only non-empty strings", + ), + ({"schema_version": 1, "permissions": [], "roles": []}, "permissions contains no data rows"), + ( + { + "schema_version": 1, + "permissions": [{"range": "10.0.0.0/24", "group": "group", "regex": ".*", "labels": []}], + "roles": [], + }, + "roles contains no data rows", + ), + ] + for invalid_payload, error in invalid_payloads: + with self.subTest(error=error), self.assertRaisesRegex(generator.ConversionError, error): + generator.parse_snapshot(self.snapshot(invalid_payload)) + + def test_endpoint_rows_resolve_label_ids_to_names(self) -> None: + snapshot = generator.snapshot_from_endpoint_rows( + permission_rows=[ + { + "range": "10.0.0.0/24", + "group": "group two", + "regex": r".*\.example$", + "labels": [2, 1], + } + ], + role_rows=[{"name": "role1", "labels": [1]}], + label_rows=[{"id": 1, "name": "Shared"}, {"id": 2, "name": "Unused"}], + ) + + permissions, roles = generator.parse_snapshot(snapshot) + self.assertEqual(permissions[0].labels, ("Shared", "Unused")) + self.assertEqual(roles[0].labels, ("Shared",)) + + with self.assertRaisesRegex(generator.ConversionError, "unknown label id 3"): + generator.snapshot_from_endpoint_rows( + permission_rows=[ + { + "range": "10.0.0.0/24", + "group": "group two", + "regex": ".*", + "labels": [3], + } + ], + role_rows=[{"name": "role1", "labels": []}], + label_rows=[{"id": 1, "name": "Shared"}], + ) + + label_errors = [ + ([{"id": True, "name": "Shared"}], "id must be an integer"), + ([{"id": 1, "name": "Shared"}, {"id": 1, "name": "Other"}], "Duplicate label id"), + ([{"id": 1, "name": "Shared"}, {"id": 2, "name": "Shared"}], "Duplicate label name"), + ] + for labels, error in label_errors: + with self.subTest(error=error), self.assertRaisesRegex(generator.ConversionError, error): + generator.snapshot_from_endpoint_rows([], [], labels) + + with self.assertRaisesRegex(generator.ConversionError, "integer label ids"): + generator.snapshot_from_endpoint_rows( + permission_rows=[ + {"range": "10.0.0.0/24", "group": "group", "regex": ".*", "labels": ["Shared"]} + ], + role_rows=[], + label_rows=[{"id": 1, "name": "Shared"}], + ) + + def test_fetches_all_endpoint_pages_with_token_authentication(self) -> None: + calls: list[tuple[str, str | None, float]] = [] + + def fake_urlopen(request, timeout: float) -> JsonResponse: + calls.append((request.full_url, request.get_header("Authorization"), timeout)) + if "/labels/" in request.full_url and "page=2" not in request.full_url: + return JsonResponse( + request.full_url, + { + "next": "/api/v1/labels/?ordering=name&page=2&page_size=1000", + "results": [{"id": 1, "name": "Shared"}], + }, + ) + if "/labels/" in request.full_url: + return JsonResponse( + request.full_url, + {"next": None, "results": [{"id": 2, "name": "Unused"}]}, + ) + if "/permissions/netgroupregex/" in request.full_url: + return JsonResponse( + request.full_url, + { + "next": None, + "results": [ + { + "range": "10.0.0.0/24", + "group": "group two", + "regex": r".*\.example$", + "labels": [2, 1], + } + ], + }, + ) + return JsonResponse( + request.full_url, + {"next": None, "results": [{"name": "role1", "labels": [1]}]}, + ) + + with patch.object(generator, "urlopen", side_effect=fake_urlopen): + snapshot = generator.fetch_policy_snapshot("https://mreg.example/", "secret", 3.5) + + permissions, roles = generator.parse_snapshot(snapshot) + self.assertEqual(permissions[0].labels, ("Shared", "Unused")) + self.assertEqual(roles[0].labels, ("Shared",)) + self.assertEqual(len(calls), 4) + self.assertTrue(all(auth == "Token secret" for _url, auth, _timeout in calls)) + self.assertTrue(all(timeout == 3.5 for _url, _auth, timeout in calls)) + self.assertTrue(all(url.startswith("https://mreg.example/") for url, _auth, _timeout in calls)) + self.assertIn("page_size=1000", calls[0][0]) + + def test_rejects_invalid_endpoint_configuration_and_responses(self) -> None: + invalid_configurations = [ + (("mreg.example", "secret", 1), "absolute HTTP"), + (("https://user:password@mreg.example", "secret", 1), "must not contain credentials"), + (("https://mreg.example", "", 1), "must be a non-empty HTTP header"), + (("https://mreg.example", "value\nInjected: header", 1), "must be a non-empty HTTP header"), + (("https://mreg.example", "secret", 0), "greater than zero"), + (("https://mreg.example", "secret", float("nan")), "finite number"), + ] + for arguments, error in invalid_configurations: + with self.subTest(error=error), self.assertRaisesRegex(generator.ConversionError, error): + generator.fetch_policy_snapshot(*arguments) + + def fetch_one(response: object) -> None: + with patch.object(generator, "urlopen", return_value=response): + generator._fetch_paginated_rows( + base_url="https://mreg.example", + path="/api/v1/labels/", + token="secret", + timeout=1, + ordering="name", + ) + + invalid_responses = [ + (JsonResponse("https://other.example/api/v1/labels/", {"next": None, "results": []}), "response changed origin"), + (RawResponse("https://mreg.example/api/v1/labels/", b"not json"), "returned invalid JSON"), + (JsonResponse("https://mreg.example/api/v1/labels/", []), "must be a JSON object"), + (JsonResponse("https://mreg.example/api/v1/labels/", {"next": None, "results": {}}), "results must be a JSON array"), + (JsonResponse("https://mreg.example/api/v1/labels/", {"next": None, "results": [1]}), "must be a JSON object"), + (JsonResponse("https://mreg.example/api/v1/labels/", {"next": 1, "results": []}), "next must be a URL or null"), + ] + for response, error in invalid_responses: + with self.subTest(error=error), self.assertRaisesRegex(generator.ConversionError, error): + fetch_one(response) + + initial_url = "https://mreg.example/api/v1/labels/?ordering=name&page_size=1000" + pagination_errors = [ + ({"next": initial_url, "results": []}, "pagination loop"), + ({"next": "https://other.example/api/v1/labels/", "results": []}, "pagination URL changed origin"), + ] + for payload, error in pagination_errors: + with self.subTest(error=error), self.assertRaisesRegex(generator.ConversionError, error): + fetch_one(JsonResponse(initial_url, payload)) + + http_error = generator.HTTPError(initial_url, 401, "Unauthorized", {}, None) + transport_errors = [ + (http_error, "returned HTTP 401"), + (generator.URLError("connection refused"), "Unable to reach MREG API"), + ] + for error_response, error in transport_errors: + with self.subTest(error=error), patch.object(generator, "urlopen", side_effect=error_response): + with self.assertRaisesRegex(generator.ConversionError, error): + generator._fetch_paginated_rows( + base_url="https://mreg.example", + path="/api/v1/labels/", + token="secret", + timeout=1, + ordering="name", + ) + + def test_cli_fetches_and_persists_current_api_snapshot(self) -> None: + with tempfile.TemporaryDirectory() as directory: + temp_dir = Path(directory) + snapshot_path = temp_dir / "fixtures" / "policy-source.json" + output_dir = temp_dir / "output" + snapshot = generator.serialize_snapshot(*generator.parse_snapshot(self.snapshot())) + arguments = [ + "--api-base-url", + "https://mreg.example", + "--snapshot", + str(snapshot_path), + "--output-dir", + str(output_dir), + ] + + with ( + patch.dict(generator.os.environ, {"MREG_API_TOKEN": "secret"}), + patch.object(generator, "fetch_policy_snapshot", return_value=snapshot) as fetch, + ): + self.assertEqual(generator.main(arguments), 0) + self.assertEqual(generator.main([*arguments, "--check"]), 0) + + fetch.assert_called_with("https://mreg.example", "secret", 20.0) + self.assertEqual(snapshot_path.read_text(), snapshot) + + snapshot_path.write_text("stale\n") + with ( + patch.dict(generator.os.environ, {"MREG_API_TOKEN": "secret"}), + patch.object(generator, "fetch_policy_snapshot", return_value=snapshot), + ): + self.assertEqual(generator.main([*arguments, "--check"]), 1) + + with patch.dict(generator.os.environ, {}, clear=True): + self.assertEqual(generator.main(arguments), 2) + + def test_cli_check_detects_stale_output(self) -> None: + with tempfile.TemporaryDirectory() as directory: + temp_dir = Path(directory) + snapshot_path = temp_dir / "policy-source.json" + output_dir = temp_dir / "output" + snapshot_path.write_text(self.snapshot()) + arguments = [ + "--api-base-url", + "", + "--snapshot", + str(snapshot_path), + "--output-dir", + str(output_dir), + ] + + self.assertEqual(generator.main(arguments), 0) + self.assertEqual(generator.main([*arguments, "--check"]), 0) + + (output_dir / "netgroup.cedar").write_text("stale\n") + self.assertEqual(generator.main([*arguments, "--check"]), 1) diff --git a/mregsite/settings.py b/mregsite/settings.py index 0cde7a98..de375510 100644 --- a/mregsite/settings.py +++ b/mregsite/settings.py @@ -16,10 +16,16 @@ import sys from typing import Literal, TypeVar +from django.core.exceptions import ImproperlyConfigured import structlog import mreg.log_processors import mreg.__about__ +from mreg.policy.config import ( + PolicyMode, + resolve_policy_mode, + validate_policy_configuration, +) DefaultT = TypeVar("DefaultT", str, int, float, bool) @@ -81,6 +87,34 @@ def parse_protected_attrs(raw: str) -> list[dict[str, str]]: SECRET_KEY = ")e#67040xjxar=zl^y#@#b*zilv2dxtraj582$^(e6!wf++_n#" LOG_LEVEL = envvar("MREG_LOG_LEVEL", "CRITICAL").upper() +POLICY_PARITY_LOG_LEVEL = envvar("MREG_POLICY_PARITY_LOG_LEVEL", "WARNING").upper() +POLICY_BASE_URL = envvar("MREG_POLICY_BASE_URL", "").strip() +_legacy_policy_parity_enabled = envvar("MREG_POLICY_PARITY_ENABLED", True) +_raw_policy_mode = envvar("MREG_POLICY_MODE", "") +_policy_mode_was_explicit = bool((_raw_policy_mode or "").strip()) +try: + _policy_mode = resolve_policy_mode( + _raw_policy_mode, + legacy_parity_enabled=_legacy_policy_parity_enabled, + ) + validate_policy_configuration(_policy_mode, POLICY_BASE_URL) +except ValueError as exc: + raise ImproperlyConfigured(str(exc)) from exc +POLICY_MODE = _policy_mode.value +# Compatibility for local settings and integrations that still inspect the old +# boolean. Explicit MREG_POLICY_MODE takes precedence over the deprecated flag. +POLICY_PARITY_ENABLED = _policy_mode == PolicyMode.SHADOW +raw = (envvar("MREG_POLICY_NAMESPACE", "MREG") or "").strip() +# Accept both Cedar-style `org::MREG` and comma-separated `org,MREG`. +raw = raw.replace("::", ",") +POLICY_NAMESPACE = [ns.strip() for ns in raw.split(",") if ns.strip()] or ["MREG"] +POLICY_PARITY_LOG_DETAILS = envvar("MREG_POLICY_PARITY_LOG_DETAILS", False) +POLICY_TIMEOUT_SECONDS = envvar("MREG_POLICY_TIMEOUT_SECONDS", 5.0) +POLICY_CIRCUIT_FAILURES = envvar("MREG_POLICY_CIRCUIT_FAILURES", 5) +POLICY_CIRCUIT_RESET_SECONDS = envvar("MREG_POLICY_CIRCUIT_RESET_SECONDS", 30.0) +POLICY_ROLLOUT_MIN_COMPARISONS = envvar("MREG_POLICY_ROLLOUT_MIN_COMPARISONS", 10_000) +POLICY_ROLLOUT_MAX_MISMATCH_RATE = envvar("MREG_POLICY_ROLLOUT_MAX_MISMATCH_RATE", 0.001) +POLICY_ROLLOUT_MAX_ERROR_RATE = envvar("MREG_POLICY_ROLLOUT_MAX_ERROR_RATE", 0.001) REQUESTS_THRESHOLD_SLOW = envvar("MREG_REQUESTS_THRESHOLD_SLOW", 1000) REQUESTS_LOG_LEVEL_SLOW = envvar("MREG_REQUESTS_LOG_LEVEL_SLOW", "WARNING") @@ -399,6 +433,19 @@ def parse_protected_attrs(raw: str) -> list[dict[str, str]]: "filename": LOG_FILE_NAME, "formatter": "plain", }, + "policy_parity_default": { + "level": POLICY_PARITY_LOG_LEVEL, + "class": "logging.StreamHandler", + "formatter": "colored", + }, + "policy_parity_file": { + "level": POLICY_PARITY_LOG_LEVEL, + "class": "logging.handlers.RotatingFileHandler", + "maxBytes": LOG_FILE_SIZE, + "backupCount": LOG_FILE_COUNT, + "filename": LOG_FILE_NAME, + "formatter": "plain", + }, }, "loggers": { "": { @@ -406,6 +453,11 @@ def parse_protected_attrs(raw: str) -> list[dict[str, str]]: "level": "DEBUG", "propagate": True, }, + "mreg.policy.parity": { + "handlers": ["policy_parity_default", "policy_parity_file"], + "level": POLICY_PARITY_LOG_LEVEL, + "propagate": False, + }, }, } ) @@ -445,7 +497,7 @@ def parse_protected_attrs(raw: str) -> list[dict[str, str]]: MREG_PROFILING_ENABLED = envvar("MREG_PROFILING_ENABLED", False) # Use cProfile for profiling of the selected views. -# If this is disabled, silk will only collect request/response data and timings, +# If this is disabled, silk will only collect request/response data and timings, # but not detailed profiling information. SILKY_PYTHON_PROFILER = envvar("MREG_SILKY_PYTHON_PROFILER", True) @@ -462,6 +514,26 @@ def parse_protected_attrs(raw: str) -> list[dict[str, str]]: except ImportError: pass +# Validate policy values again because local_settings.py may override the +# environment-derived configuration above. +try: + _post_local_policy_mode = POLICY_MODE + if ( + not _policy_mode_was_explicit + and _post_local_policy_mode == PolicyMode.SHADOW.value + and not POLICY_PARITY_ENABLED + ): + _post_local_policy_mode = "" + _policy_mode = resolve_policy_mode( + _post_local_policy_mode, + legacy_parity_enabled=POLICY_PARITY_ENABLED, + ) + validate_policy_configuration(_policy_mode, POLICY_BASE_URL) +except ValueError as exc: + raise ImproperlyConfigured(str(exc)) from exc +POLICY_MODE = _policy_mode.value +POLICY_PARITY_ENABLED = _policy_mode == PolicyMode.SHADOW + if TESTING or "CI" in os.environ: SUPERUSER_GROUP = "default-super-group" ADMINUSER_GROUP = "default-admin-group" @@ -493,7 +565,7 @@ def get_pool_settings() -> dict[str, int] | Literal[False]: "NAME": MREG_DB_NAME, "USER": MREG_DB_USER, "PASSWORD": MREG_DB_PASSWORD, - "HOST": MREG_DB_HOST, + "HOST": MREG_DB_HOST, "PORT": MREG_DB_PORT, "CONN_MAX_AGE": 0, # Let the pool manage connection lifecycle "OPTIONS": { @@ -515,10 +587,10 @@ def get_pool_settings() -> dict[str, int] | Literal[False]: "Install silk with `uv sync --(only-)group profile` or disable profiling.", ) sys.exit(1) - + # NOTE: logging happens twice here on startup for some reason... logger.warning("Profiling is enabled. All requests will be profiled with Silk. This will impact performance.") - + # Define views to enable Silk profiling for # (Can be overridden by setting SILKY_DYNAMIC_PROFILING in local_settings.py) if "SILKY_DYNAMIC_PROFILING" not in globals(): @@ -589,7 +661,7 @@ def get_pool_settings() -> dict[str, int] | Literal[False]: 'name': 'Get Role Hosts', }, ] - + # Ensure the profiler result path exists and is writable before enabling Silk if SILKY_PYTHON_PROFILER_RESULT_PATH: p = Path(SILKY_PYTHON_PROFILER_RESULT_PATH) diff --git a/pyproject.toml b/pyproject.toml index 96f248dc..0df83bdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "mreg" -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = [ "Django>=5.2", "djangorestframework>=3.16.0,<3.17", - "django-auth-ldap>=5.2.0", + "django-auth-ldap>=5.3.0", "django-logging-json>=1.15", "django-netfields>=1.3.2", "django-filter>=25", @@ -24,9 +24,10 @@ dependencies = [ # For OpenAPI schema generation # Pinned to prevent breaking changes: https://drf-spectacular.readthedocs.io/en/latest/readme.html#release-management "drf-spectacular[sidecar]==0.29.0", - # For testing inside Docker image + "treetop-client>=0.0.12", + "prometheus-client>=0.24", + # The production image also carries the Django test entrypoint. "unittest-parametrize", - "prometheus-client>=0.20", ] dynamic = ["version"] @@ -35,9 +36,8 @@ dynamic = ["version"] dev = [ "tox-uv>=1.29", "coverage[toml]", - "pytest", - "pytest-django", - "uv>=0.9", + "uv>=0.10", + "tblib>=3", {include-group = "profile"}, ] ci = [ diff --git a/scripts/build-treetop-bundle.sh b/scripts/build-treetop-bundle.sh new file mode 100755 index 00000000..c5e2165f --- /dev/null +++ b/scripts/build-treetop-bundle.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +set -eu + +bundle_bin=${TREETOP_BUNDLE_BIN:-treetop-bundle} +manifest=${TREETOP_BUNDLE_MANIFEST:-treetop/data/treetop-bundle.toml} +archive=${TREETOP_BUNDLE_ARCHIVE:-treetop/data/mreg-bundle.tar.gz} + +if ! command -v "$bundle_bin" >/dev/null 2>&1; then + echo "treetop-bundle executable not found: $bundle_bin" >&2 + exit 127 +fi + +tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/mreg-treetop-bundle-build.XXXXXX") +trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM +generated_archive="$tmpdir/mreg-bundle.tar.gz" + +"$bundle_bin" check bundle "$manifest" --format human +"$bundle_bin" build \ + --manifest "$manifest" \ + --output "$generated_archive" \ + --format human +"$bundle_bin" check archive "$generated_archive" \ + --signature-policy allow-unsigned \ + --format human +mv "$generated_archive" "$archive" + +echo "Wrote reproducible unsigned TreeTop bundle to $archive" diff --git a/scripts/check-treetop-bundle.sh b/scripts/check-treetop-bundle.sh new file mode 100755 index 00000000..29e603f0 --- /dev/null +++ b/scripts/check-treetop-bundle.sh @@ -0,0 +1,32 @@ +#!/bin/sh + +set -eu + +bundle_bin=${TREETOP_BUNDLE_BIN:-treetop-bundle} +manifest=${TREETOP_BUNDLE_MANIFEST:-treetop/data/treetop-bundle.toml} +archive=${TREETOP_BUNDLE_ARCHIVE:-treetop/data/mreg-bundle.tar.gz} + +if ! command -v "$bundle_bin" >/dev/null 2>&1; then + echo "treetop-bundle executable not found: $bundle_bin" >&2 + exit 127 +fi + +tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/mreg-treetop-bundle.XXXXXX") +trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM +generated_archive="$tmpdir/mreg-bundle.tar.gz" + +"$bundle_bin" check bundle "$manifest" --format human +"$bundle_bin" build \ + --manifest "$manifest" \ + --output "$generated_archive" \ + --format human +"$bundle_bin" check archive "$generated_archive" \ + --signature-policy allow-unsigned \ + --format human + +if ! cmp -s "$generated_archive" "$archive"; then + echo "Committed TreeTop bundle is stale. Rebuild it with treetop-bundle." >&2 + exit 1 +fi + +echo "TreeTop bundle is valid, unsigned, and reproducible." diff --git a/scripts/generate-treetop-policy.py b/scripts/generate-treetop-policy.py new file mode 100644 index 00000000..f6bb3b84 --- /dev/null +++ b/scripts/generate-treetop-policy.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Generate TreeTop policy data from existing MREG API endpoints.""" + +from pathlib import Path +from runpy import run_path + + +ROOT = Path(__file__).resolve().parents[1] +main = run_path(str(ROOT / "mreg/policy/treetop_generator.py"))["main"] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate-treetop-schema.py b/scripts/generate-treetop-schema.py new file mode 100644 index 00000000..62b5a77c --- /dev/null +++ b/scripts/generate-treetop-schema.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Generate or verify the Cedar schema from MREG's policy contracts.""" + +from __future__ import annotations + +import argparse +import importlib.util +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_PATH = Path("treetop/data/mreg.cedarschema") +CONTRACTS_PATH = ROOT / "mreg/policy/contracts.py" + + +def _render_cedar_schema() -> str: + """Load the dependency-free contracts without importing the MREG package.""" + spec = importlib.util.spec_from_file_location("_mreg_policy_contracts", CONTRACTS_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load policy contracts from {CONTRACTS_PATH}") + module = importlib.util.module_from_spec(spec) + # dataclasses resolves annotations through the defining module while the + # class decorators execute, so the standalone module must be registered. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module.render_cedar_schema() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true", help="fail if the committed schema is stale") + args = parser.parse_args() + rendered = _render_cedar_schema() + if args.check: + if not SCHEMA_PATH.exists() or SCHEMA_PATH.read_text() != rendered: + print(f"{SCHEMA_PATH} is stale; run {sys.argv[0]}", file=sys.stderr) + return 1 + print(f"{SCHEMA_PATH} matches the Python policy contracts") + return 0 + SCHEMA_PATH.write_text(rendered) + print(f"wrote {SCHEMA_PATH}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tox.ini b/tox.ini index 7ac914d3..701091a9 100644 --- a/tox.ini +++ b/tox.ini @@ -4,14 +4,13 @@ skip_missing_interpreters = true envlist = lint coverage - python{311,312,313}-django52 + python{312,313}-django52 python{312,313,314}-django60 toxworkdir = {env:TOX_WORKDIR:.tox} [gh-actions] python = - 3.11: python311 3.12: python312 3.13: python313 3.14: python314 @@ -28,11 +27,10 @@ setenv = CI=True passenv = MREG_*, GITHUB_* basepython = - python311: python3.11 python312: python3.12 python313: python3.13 python314: python3.14 - python3 + python3.12 commands = python --version django52: python -c "import django; assert django.VERSION[:2] == (5, 2), django.get_version(); print(django.get_version())" diff --git a/treetop/data/global.cedar b/treetop/data/global.cedar new file mode 100644 index 00000000..03792d6a --- /dev/null +++ b/treetop/data/global.cedar @@ -0,0 +1,15 @@ +// Global policy kept in its own bundle module because it intentionally applies +// across every namespace and action. +@id("global.super_admin_allow_all_policy") +permit ( + principal == MREG::User::"super", + action, + resource +); + +@id("global.mreg_superadmin") +permit ( + principal in MREG::Group::"default-super-group", + action, + resource +); diff --git a/treetop/data/labels.json b/treetop/data/labels.json new file mode 100644 index 00000000..babf094d --- /dev/null +++ b/treetop/data/labels.json @@ -0,0 +1,368 @@ +[ + { + "kind": "MREG::Host", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::Ipaddress", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::Cname", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::Hinfo", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::Loc", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::Mx", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::Naptr", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::NameServer", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::PtrOverride", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::Sshfp", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::Srv", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::Txt", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::BACnetID", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "dns_wildcard", + "regex": "\\*" + }, + { + "name": "dns_wildcard_valid_depth", + "regex": "^(?:[^.]+\\.){3,}[^.]+$" + }, + { + "name": "dns_underscore", + "regex": "_" + }, + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + }, + { + "kind": "MREG::HostPolicyRole", + "field": "hostname", + "output": "nameLabels", + "patterns": [ + { + "name": "netgroup_5ffe5b162fe5", + "regex": ".*\\.example\\.org$" + }, + { + "name": "netgroup_6eb5380aba41", + "regex": "^web-\\d+" + } + ] + } +] diff --git a/treetop/data/mreg-bundle.tar.gz b/treetop/data/mreg-bundle.tar.gz new file mode 100644 index 00000000..62e700e0 Binary files /dev/null and b/treetop/data/mreg-bundle.tar.gz differ diff --git a/treetop/data/mreg.cedar b/treetop/data/mreg.cedar new file mode 100644 index 00000000..a92fd2ba --- /dev/null +++ b/treetop/data/mreg.cedar @@ -0,0 +1,365 @@ +// MREG endpoint authorization. Each protected endpoint sends one request stack; +// TreeTop evaluates every leaf in one authorize call and MREG composes the result. + +@id("MREG.authenticated_access") +permit ( + principal, + action == MREG::Action::"authenticated_access", + resource +); + +@id("MREG.read_all") +permit ( + principal, + action in + [MREG::Action::"bacnet_id_read", + MREG::Action::"cname_read", + MREG::Action::"community_read", + MREG::Action::"forward_zone_delegation_read", + MREG::Action::"forward_zone_read", + MREG::Action::"hinfo_read", + MREG::Action::"host_community_mapping_read", + MREG::Action::"host_contacts_read", + MREG::Action::"host_group_read", + MREG::Action::"host_policy_atom_read", + MREG::Action::"host_policy_role_read", + MREG::Action::"host_read", + MREG::Action::"ipaddress_read", + MREG::Action::"label_read", + MREG::Action::"loc_read", + MREG::Action::"mx_read", + MREG::Action::"name_server_read", + MREG::Action::"naptr_read", + MREG::Action::"net_group_regex_permission_read", + MREG::Action::"network_excluded_range_read", + MREG::Action::"network_policy_attribute_read", + MREG::Action::"network_policy_attribute_value_read", + MREG::Action::"network_policy_read", + MREG::Action::"network_read", + MREG::Action::"ptr_override_read", + MREG::Action::"reverse_zone_delegation_read", + MREG::Action::"reverse_zone_read", + MREG::Action::"srv_read", + MREG::Action::"sshfp_read", + MREG::Action::"txt_read"], + resource +); + +@id("MREG.user_info_self") +permit ( + principal, + action == MREG::Action::"user_info_read", + resource is MREG::Generic +) +when { + resource has selfAccess && resource.selfAccess +}; + +@id("MREG.user_info_admin") +permit ( + principal in MREG::Group::"default-admin-group", + action == MREG::Action::"user_info_read", + resource is MREG::Generic +); + +@id("MREG.user_info_hostgroup_admin") +permit ( + principal in MREG::Group::"default-groupadmin-group", + action == MREG::Action::"user_info_read", + resource is MREG::Generic +); + +// Resources managed by the ordinary MREG administrator role. +@id("MREG.admin_crud") +permit ( + principal in MREG::Group::"default-admin-group", + action in + [MREG::Action::"host_create", + MREG::Action::"host_update", + MREG::Action::"host_delete", + MREG::Action::"host_contacts_create", + MREG::Action::"host_contacts_delete", + MREG::Action::"ipaddress_create", + MREG::Action::"ipaddress_update", + MREG::Action::"ipaddress_delete", + MREG::Action::"cname_create", + MREG::Action::"cname_update", + MREG::Action::"cname_delete", + MREG::Action::"hinfo_create", + MREG::Action::"hinfo_update", + MREG::Action::"hinfo_delete", + MREG::Action::"loc_create", + MREG::Action::"loc_update", + MREG::Action::"loc_delete", + MREG::Action::"mx_create", + MREG::Action::"mx_update", + MREG::Action::"mx_delete", + MREG::Action::"naptr_create", + MREG::Action::"naptr_update", + MREG::Action::"naptr_delete", + MREG::Action::"name_server_create", + MREG::Action::"name_server_update", + MREG::Action::"name_server_delete", + MREG::Action::"ptr_override_create", + MREG::Action::"ptr_override_update", + MREG::Action::"ptr_override_delete", + MREG::Action::"sshfp_create", + MREG::Action::"sshfp_update", + MREG::Action::"sshfp_delete", + MREG::Action::"srv_create", + MREG::Action::"srv_update", + MREG::Action::"srv_delete", + MREG::Action::"txt_create", + MREG::Action::"txt_update", + MREG::Action::"txt_delete", + MREG::Action::"bacnet_id_create", + MREG::Action::"bacnet_id_update", + MREG::Action::"bacnet_id_delete", + MREG::Action::"community_create", + MREG::Action::"community_update", + MREG::Action::"community_delete", + MREG::Action::"host_community_mapping_create", + MREG::Action::"host_community_mapping_update", + MREG::Action::"host_community_mapping_delete", + MREG::Action::"label_create", + MREG::Action::"label_update", + MREG::Action::"label_delete", + MREG::Action::"net_group_regex_permission_create", + MREG::Action::"net_group_regex_permission_update", + MREG::Action::"net_group_regex_permission_delete"], + resource +); + +@id("MREG.network_admin_crud") +permit ( + principal in MREG::Group::"default-networkadmin-group", + action in + [MREG::Action::"network_create", + MREG::Action::"network_update", + MREG::Action::"network_delete", + MREG::Action::"network_excluded_range_create", + MREG::Action::"network_excluded_range_update", + MREG::Action::"network_excluded_range_delete", + MREG::Action::"network_policy_create", + MREG::Action::"network_policy_update", + MREG::Action::"network_policy_delete", + MREG::Action::"network_policy_attribute_create", + MREG::Action::"network_policy_attribute_update", + MREG::Action::"network_policy_attribute_delete", + MREG::Action::"network_policy_attribute_value_create", + MREG::Action::"network_policy_attribute_value_update", + MREG::Action::"network_policy_attribute_value_delete", + MREG::Action::"community_create", + MREG::Action::"community_update", + MREG::Action::"community_delete", + MREG::Action::"host_create", + MREG::Action::"host_delete"], + resource +); + +@id("MREG.hostgroup_admin_crud") +permit ( + principal in MREG::Group::"default-groupadmin-group", + action in + [MREG::Action::"host_group_create", + MREG::Action::"host_group_update", + MREG::Action::"host_group_delete", + MREG::Action::"hostgroup_membership_update"], + resource is MREG::HostGroup +); + +@id("MREG.hostgroup_owner_update") +permit ( + principal, + action == MREG::Action::"host_group_update", + resource is MREG::HostGroup +) +when { + resource has requesterIsOwner && resource.requesterIsOwner && + resource has descriptionUpdate && resource.descriptionUpdate +}; + +@id("MREG.hostgroup_owner_membership") +permit ( + principal, + action == MREG::Action::"hostgroup_membership_update", + resource is MREG::HostGroup +) +when { + resource has requesterIsOwner && resource.requesterIsOwner && + resource has ownerMutation && !resource.ownerMutation +}; + +@id("MREG.hostpolicy_admin_crud") +permit ( + principal in MREG::Group::"default-hostpolicyadmin-group", + action in + [MREG::Action::"host_policy_atom_create", + MREG::Action::"host_policy_atom_update", + MREG::Action::"host_policy_atom_delete", + MREG::Action::"host_policy_role_create", + MREG::Action::"host_policy_role_update", + MREG::Action::"host_policy_role_delete", + MREG::Action::"hostpolicy_role_atom_membership_update", + MREG::Action::"hostpolicy_role_host_membership_update"], + resource +); + +// These rules replace local post-policy denials in authoritative mode. +@id("MREG.invalid_or_unprivileged_dns_wildcard") +forbid ( + principal, + action in + [MREG::Action::"host_create", + MREG::Action::"host_update", + MREG::Action::"cname_create", + MREG::Action::"cname_update", + MREG::Action::"hinfo_create", + MREG::Action::"hinfo_update", + MREG::Action::"loc_create", + MREG::Action::"loc_update", + MREG::Action::"mx_create", + MREG::Action::"mx_update", + MREG::Action::"naptr_create", + MREG::Action::"naptr_update", + MREG::Action::"name_server_create", + MREG::Action::"name_server_update", + MREG::Action::"ptr_override_create", + MREG::Action::"ptr_override_update", + MREG::Action::"sshfp_create", + MREG::Action::"sshfp_update", + MREG::Action::"srv_create", + MREG::Action::"srv_update", + MREG::Action::"txt_create", + MREG::Action::"txt_update"], + resource +) +when { + resource has nameLabels && + resource.nameLabels.contains("dns_wildcard") && + principal != MREG::User::"super" && + !(principal in MREG::Group::"default-super-group") && + (!resource.nameLabels.contains("dns_wildcard_valid_depth") || + !(principal in MREG::Group::"default-dns-wildcard-group")) +}; + +@id("MREG.unprivileged_dns_underscore") +forbid ( + principal, + action in + [MREG::Action::"host_create", + MREG::Action::"host_update", + MREG::Action::"cname_create", + MREG::Action::"cname_update", + MREG::Action::"hinfo_create", + MREG::Action::"hinfo_update", + MREG::Action::"loc_create", + MREG::Action::"loc_update", + MREG::Action::"mx_create", + MREG::Action::"mx_update", + MREG::Action::"naptr_create", + MREG::Action::"naptr_update", + MREG::Action::"name_server_create", + MREG::Action::"name_server_update", + MREG::Action::"ptr_override_create", + MREG::Action::"ptr_override_update", + MREG::Action::"sshfp_create", + MREG::Action::"sshfp_update", + MREG::Action::"txt_create", + MREG::Action::"txt_update"], + resource +) +when { + resource has nameLabels && + resource.nameLabels.contains("dns_underscore") && + principal != MREG::User::"super" && + !(principal in MREG::Group::"default-super-group") && + !(principal in MREG::Group::"default-dns-underscore-group") +}; + +@id("MREG.unprivileged_restricted_ip") +forbid ( + principal, + action in + [MREG::Action::"host_create", + MREG::Action::"host_update", + MREG::Action::"ipaddress_create", + MREG::Action::"ipaddress_update", + MREG::Action::"ptr_override_create", + MREG::Action::"ptr_override_update"], + resource +) +when { + resource has ip && + (resource.ip.isInRange(ip("10.0.0.0/32")) || + resource.ip.isInRange(ip("10.0.0.1/32")) || + resource.ip.isInRange(ip("10.0.0.2/32")) || + resource.ip.isInRange(ip("10.0.0.3/32")) || + resource.ip.isInRange(ip("10.0.0.255/32")) || + resource.ip.isInRange(ip("10.1.0.0/32")) || + resource.ip.isInRange(ip("10.1.0.1/32")) || + resource.ip.isInRange(ip("10.1.0.2/32")) || + resource.ip.isInRange(ip("10.1.0.3/32")) || + resource.ip.isInRange(ip("10.1.0.127/32")) || + resource.ip.isInRange(ip("192.168.1.0/32")) || + resource.ip.isInRange(ip("192.168.1.1/32")) || + resource.ip.isInRange(ip("192.168.1.2/32")) || + resource.ip.isInRange(ip("192.168.1.3/32")) || + resource.ip.isInRange(ip("192.168.1.255/32")) || + resource.ip.isInRange(ip("2001:db8::/128")) || + resource.ip.isInRange(ip("2001:db8::1/128")) || + resource.ip.isInRange(ip("2001:db8::2/128")) || + resource.ip.isInRange(ip("2001:db8::3/128"))) && + principal != MREG::User::"super" && + !(principal in MREG::Group::"default-super-group") && + !(principal in MREG::Group::"default-networkadmin-group") +}; + +// Explicit membership actions retained for custom/admin endpoints. +@id("MREG.admin_membership") +permit ( + principal in MREG::Group::"default-admin-group", + action == MREG::Action::"admin_access", + resource +); + +@id("MREG.network_admin_membership") +permit ( + principal in MREG::Group::"default-networkadmin-group", + action in + [MREG::Action::"network_admin_access", + MREG::Action::"ip_network_management", + MREG::Action::"ip_reserved_management", + MREG::Action::"ip_restricted_management", + MREG::Action::"ip_gw_management", + MREG::Action::"ip_broadcast_management"], + resource +); + +@id("MREG.hostgroup_admin_membership") +permit ( + principal in MREG::Group::"default-groupadmin-group", + action == MREG::Action::"hostgroup_admin_access", + resource +); + +@id("MREG.hostpolicy_admin_membership") +permit ( + principal in MREG::Group::"default-hostpolicyadmin-group", + action == MREG::Action::"hostpolicy_admin_access", + resource +); + +@id("MREG.dns_wildcard_admin_membership") +permit ( + principal in MREG::Group::"default-dns-wildcard-group", + action == MREG::Action::"dns_wildcard_admin_access", + resource +); + +@id("MREG.dns_underscore_admin_membership") +permit ( + principal in MREG::Group::"default-dns-underscore-group", + action == MREG::Action::"dns_underscore_admin_access", + resource +); diff --git a/treetop/data/mreg.cedarschema b/treetop/data/mreg.cedarschema new file mode 100644 index 00000000..adfb0628 --- /dev/null +++ b/treetop/data/mreg.cedarschema @@ -0,0 +1,618 @@ +namespace MREG { + entity Group; + entity User in [Group]; + + entity Generic = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Host = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity HostContact = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Ipaddress = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Cname = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Hinfo = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Loc = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Mx = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Naptr = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity NameServer = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity PtrOverride = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Sshfp = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Srv = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Txt = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity BACnetID = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Community = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity HostCommunityMapping = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Label = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity Network = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity NetworkPolicy = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity NetworkPolicyAttribute = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity NetworkPolicyAttributeValue = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity HostGroup = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity NetworkExcludedRange = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity ForwardZone = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity ForwardZoneDelegation = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity ReverseZone = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity ReverseZoneDelegation = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity HostPolicyAtom = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity HostPolicyRole = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + entity NetGroupRegexPermission = { + kind?: String, + id?: String, + name?: String, + path?: String, + hostname?: String, + ip?: ipaddr, + nameLabels?: Set, + selfAccess?: Bool, + requesterIsOwner?: Bool, + ownerMutation?: Bool, + descriptionUpdate?: Bool, + network?: String, + }; + + action + "admin_access", + "authenticated_access", + "bacnet_id_create", + "bacnet_id_delete", + "bacnet_id_read", + "bacnet_id_update", + "cname_create", + "cname_delete", + "cname_read", + "cname_update", + "community_create", + "community_delete", + "community_read", + "community_update", + "create_label", + "delete_label", + "dns_underscore_admin_access", + "dns_wildcard_admin_access", + "edit_label", + "forward_zone_create", + "forward_zone_delegation_create", + "forward_zone_delegation_delete", + "forward_zone_delegation_read", + "forward_zone_delegation_update", + "forward_zone_delete", + "forward_zone_read", + "forward_zone_update", + "hinfo_create", + "hinfo_delete", + "hinfo_read", + "hinfo_update", + "host_community_mapping_create", + "host_community_mapping_delete", + "host_community_mapping_read", + "host_community_mapping_update", + "host_contacts_create", + "host_contacts_delete", + "host_contacts_read", + "host_create", + "host_delete", + "host_group_create", + "host_group_delete", + "host_group_read", + "host_group_update", + "host_policy_atom_create", + "host_policy_atom_delete", + "host_policy_atom_read", + "host_policy_atom_update", + "host_policy_role_create", + "host_policy_role_delete", + "host_policy_role_read", + "host_policy_role_update", + "host_read", + "host_update", + "hostgroup_admin_access", + "hostgroup_membership_update", + "hostpolicy_admin_access", + "hostpolicy_role_atom_membership_update", + "hostpolicy_role_host_membership_update", + "ip_broadcast_management", + "ip_gw_management", + "ip_network_management", + "ip_reserved_management", + "ip_restricted_management", + "ipaddress_create", + "ipaddress_delete", + "ipaddress_read", + "ipaddress_update", + "is_superuser", + "label_create", + "label_delete", + "label_read", + "label_update", + "loc_create", + "loc_delete", + "loc_read", + "loc_update", + "mx_create", + "mx_delete", + "mx_read", + "mx_update", + "name_server_create", + "name_server_delete", + "name_server_read", + "name_server_update", + "naptr_create", + "naptr_delete", + "naptr_read", + "naptr_update", + "net_group_regex_permission_create", + "net_group_regex_permission_delete", + "net_group_regex_permission_read", + "net_group_regex_permission_update", + "network_admin_access", + "network_create", + "network_delete", + "network_excluded_range_create", + "network_excluded_range_delete", + "network_excluded_range_read", + "network_excluded_range_update", + "network_policy_attribute_create", + "network_policy_attribute_delete", + "network_policy_attribute_read", + "network_policy_attribute_update", + "network_policy_attribute_value_create", + "network_policy_attribute_value_delete", + "network_policy_attribute_value_read", + "network_policy_attribute_value_update", + "network_policy_create", + "network_policy_delete", + "network_policy_read", + "network_policy_update", + "network_read", + "network_update", + "ptr_override_create", + "ptr_override_delete", + "ptr_override_read", + "ptr_override_update", + "reverse_zone_create", + "reverse_zone_delegation_create", + "reverse_zone_delegation_delete", + "reverse_zone_delegation_read", + "reverse_zone_delegation_update", + "reverse_zone_delete", + "reverse_zone_read", + "reverse_zone_update", + "srv_create", + "srv_delete", + "srv_read", + "srv_update", + "sshfp_create", + "sshfp_delete", + "sshfp_read", + "sshfp_update", + "superuser_access", + "txt_create", + "txt_delete", + "txt_read", + "txt_update", + "user_info_read", + "view_label" + appliesTo { + principal: User, + resource: [ + Generic, + Host, + HostContact, + Ipaddress, + Cname, + Hinfo, + Loc, + Mx, + Naptr, + NameServer, + PtrOverride, + Sshfp, + Srv, + Txt, + BACnetID, + Community, + HostCommunityMapping, + Label, + Network, + NetworkPolicy, + NetworkPolicyAttribute, + NetworkPolicyAttributeValue, + HostGroup, + NetworkExcludedRange, + ForwardZone, + ForwardZoneDelegation, + ReverseZone, + ReverseZoneDelegation, + HostPolicyAtom, + HostPolicyRole, + NetGroupRegexPermission + ] + }; +} diff --git a/treetop/data/netgroup-conversion-report.json b/treetop/data/netgroup-conversion-report.json new file mode 100644 index 00000000..bf96113b --- /dev/null +++ b/treetop/data/netgroup-conversion-report.json @@ -0,0 +1,27 @@ +{ + "derived_labels": { + ".*\\.example\\.org$": "netgroup_5ffe5b162fe5", + "^web-\\d+": "netgroup_6eb5380aba41" + }, + "generated_role_rules": 3, + "generated_rules": 12, + "permission_rows": 8, + "policy_ids": [ + "hostpolicy_role_2b9b9645d997", + "hostpolicy_role_9856da2c249d", + "hostpolicy_role_fec5919a9dae", + "netgroup_hostname_06d46be4843b", + "netgroup_hostname_39b7f3522b17", + "netgroup_hostname_72fdb3c3e854", + "netgroup_ip_06d46be4843b", + "netgroup_ip_39b7f3522b17", + "netgroup_ip_72fdb3c3e854", + "netgroup_network_781caa6738a6", + "netgroup_network_828db597e176", + "netgroup_network_b04d124aeecc" + ], + "role_rows": 2, + "unique_regexes": 2, + "unmatched_role_labels": [], + "unused_permission_labels": [] +} diff --git a/treetop/data/netgroup.cedar b/treetop/data/netgroup.cedar new file mode 100644 index 00000000..4663e096 --- /dev/null +++ b/treetop/data/netgroup.cedar @@ -0,0 +1,298 @@ +// Generated from the normalized MREG API policy snapshot. Do not edit by hand. + +@id("MREG.generated.netgroup_ip_72fdb3c3e854") +permit ( + principal in MREG::Group::"dummygroup", + action in + [MREG::Action::"host_create", + MREG::Action::"host_update", + MREG::Action::"host_delete", + MREG::Action::"host_contacts_create", + MREG::Action::"host_contacts_delete", + MREG::Action::"ipaddress_create", + MREG::Action::"ipaddress_update", + MREG::Action::"ipaddress_delete", + MREG::Action::"hinfo_create", + MREG::Action::"hinfo_update", + MREG::Action::"hinfo_delete", + MREG::Action::"loc_create", + MREG::Action::"loc_update", + MREG::Action::"loc_delete", + MREG::Action::"mx_create", + MREG::Action::"mx_update", + MREG::Action::"mx_delete", + MREG::Action::"naptr_create", + MREG::Action::"naptr_update", + MREG::Action::"naptr_delete", + MREG::Action::"name_server_create", + MREG::Action::"name_server_update", + MREG::Action::"name_server_delete", + MREG::Action::"ptr_override_create", + MREG::Action::"ptr_override_update", + MREG::Action::"ptr_override_delete", + MREG::Action::"sshfp_create", + MREG::Action::"sshfp_update", + MREG::Action::"sshfp_delete", + MREG::Action::"srv_create", + MREG::Action::"srv_update", + MREG::Action::"srv_delete", + MREG::Action::"txt_create", + MREG::Action::"txt_update", + MREG::Action::"txt_delete", + MREG::Action::"bacnet_id_create", + MREG::Action::"bacnet_id_update", + MREG::Action::"bacnet_id_delete"], + resource +) +when { + resource has nameLabels && + resource.nameLabels.contains("netgroup_5ffe5b162fe5") && + resource has ip && + (resource.ip.isInRange(ip("11.22.33.0/24"))) +}; + +@id("MREG.generated.netgroup_hostname_72fdb3c3e854") +permit ( + principal in MREG::Group::"dummygroup", + action in + [MREG::Action::"cname_create", + MREG::Action::"cname_update", + MREG::Action::"cname_delete"], + resource is MREG::Cname +) +when { + resource has nameLabels && + resource.nameLabels.contains("netgroup_5ffe5b162fe5") +}; + +@id("MREG.generated.netgroup_ip_06d46be4843b") +permit ( + principal in MREG::Group::"testgroup", + action in + [MREG::Action::"host_create", + MREG::Action::"host_update", + MREG::Action::"host_delete", + MREG::Action::"host_contacts_create", + MREG::Action::"host_contacts_delete", + MREG::Action::"ipaddress_create", + MREG::Action::"ipaddress_update", + MREG::Action::"ipaddress_delete", + MREG::Action::"hinfo_create", + MREG::Action::"hinfo_update", + MREG::Action::"hinfo_delete", + MREG::Action::"loc_create", + MREG::Action::"loc_update", + MREG::Action::"loc_delete", + MREG::Action::"mx_create", + MREG::Action::"mx_update", + MREG::Action::"mx_delete", + MREG::Action::"naptr_create", + MREG::Action::"naptr_update", + MREG::Action::"naptr_delete", + MREG::Action::"name_server_create", + MREG::Action::"name_server_update", + MREG::Action::"name_server_delete", + MREG::Action::"ptr_override_create", + MREG::Action::"ptr_override_update", + MREG::Action::"ptr_override_delete", + MREG::Action::"sshfp_create", + MREG::Action::"sshfp_update", + MREG::Action::"sshfp_delete", + MREG::Action::"srv_create", + MREG::Action::"srv_update", + MREG::Action::"srv_delete", + MREG::Action::"txt_create", + MREG::Action::"txt_update", + MREG::Action::"txt_delete", + MREG::Action::"bacnet_id_create", + MREG::Action::"bacnet_id_update", + MREG::Action::"bacnet_id_delete"], + resource +) +when { + resource has nameLabels && + resource.nameLabels.contains("netgroup_5ffe5b162fe5") && + resource has ip && + (resource.ip.isInRange(ip("10.0.0.0/24")) || + resource.ip.isInRange(ip("10.1.0.0/25")) || + resource.ip.isInRange(ip("192.168.1.0/24")) || + resource.ip.isInRange(ip("192.168.2.1/32")) || + resource.ip.isInRange(ip("2001:db8::/64")) || + resource.ip.isInRange(ip("2002:db9::/64"))) +}; + +@id("MREG.generated.netgroup_hostname_06d46be4843b") +permit ( + principal in MREG::Group::"testgroup", + action in + [MREG::Action::"cname_create", + MREG::Action::"cname_update", + MREG::Action::"cname_delete"], + resource is MREG::Cname +) +when { + resource has nameLabels && + resource.nameLabels.contains("netgroup_5ffe5b162fe5") +}; + +@id("MREG.generated.netgroup_ip_39b7f3522b17") +permit ( + principal in MREG::Group::"webadmins", + action in + [MREG::Action::"host_create", + MREG::Action::"host_update", + MREG::Action::"host_delete", + MREG::Action::"host_contacts_create", + MREG::Action::"host_contacts_delete", + MREG::Action::"ipaddress_create", + MREG::Action::"ipaddress_update", + MREG::Action::"ipaddress_delete", + MREG::Action::"hinfo_create", + MREG::Action::"hinfo_update", + MREG::Action::"hinfo_delete", + MREG::Action::"loc_create", + MREG::Action::"loc_update", + MREG::Action::"loc_delete", + MREG::Action::"mx_create", + MREG::Action::"mx_update", + MREG::Action::"mx_delete", + MREG::Action::"naptr_create", + MREG::Action::"naptr_update", + MREG::Action::"naptr_delete", + MREG::Action::"name_server_create", + MREG::Action::"name_server_update", + MREG::Action::"name_server_delete", + MREG::Action::"ptr_override_create", + MREG::Action::"ptr_override_update", + MREG::Action::"ptr_override_delete", + MREG::Action::"sshfp_create", + MREG::Action::"sshfp_update", + MREG::Action::"sshfp_delete", + MREG::Action::"srv_create", + MREG::Action::"srv_update", + MREG::Action::"srv_delete", + MREG::Action::"txt_create", + MREG::Action::"txt_update", + MREG::Action::"txt_delete", + MREG::Action::"bacnet_id_create", + MREG::Action::"bacnet_id_update", + MREG::Action::"bacnet_id_delete"], + resource +) +when { + resource has nameLabels && + resource.nameLabels.contains("netgroup_6eb5380aba41") && + resource has ip && + (resource.ip.isInRange(ip("192.168.1.0/24"))) +}; + +@id("MREG.generated.netgroup_hostname_39b7f3522b17") +permit ( + principal in MREG::Group::"webadmins", + action in + [MREG::Action::"cname_create", + MREG::Action::"cname_update", + MREG::Action::"cname_delete"], + resource is MREG::Cname +) +when { + resource has nameLabels && + resource.nameLabels.contains("netgroup_6eb5380aba41") +}; + +@id("MREG.generated.netgroup_network_828db597e176") +permit ( + principal in MREG::Group::"dummygroup", + action in + [MREG::Action::"community_create", + MREG::Action::"community_update", + MREG::Action::"community_delete", + MREG::Action::"host_create", + MREG::Action::"host_delete"], + resource +) +when { + resource has network && + (resource.network == "11.22.33.0/24") +}; + +@id("MREG.generated.netgroup_network_781caa6738a6") +permit ( + principal in MREG::Group::"testgroup", + action in + [MREG::Action::"community_create", + MREG::Action::"community_update", + MREG::Action::"community_delete", + MREG::Action::"host_create", + MREG::Action::"host_delete"], + resource +) +when { + resource has network && + (resource.network == "10.0.0.0/24" || + resource.network == "10.1.0.0/25" || + resource.network == "192.168.1.0/24" || + resource.network == "192.168.2.1/32" || + resource.network == "2001:db8::/64" || + resource.network == "2002:db9::/64") +}; + +@id("MREG.generated.netgroup_network_b04d124aeecc") +permit ( + principal in MREG::Group::"webadmins", + action in + [MREG::Action::"community_create", + MREG::Action::"community_update", + MREG::Action::"community_delete", + MREG::Action::"host_create", + MREG::Action::"host_delete"], + resource +) +when { + resource has network && + (resource.network == "192.168.1.0/24") +}; + +@id("MREG.generated.hostpolicy_role_9856da2c249d") +permit ( + principal in MREG::Group::"dummygroup", + action == MREG::Action::"hostpolicy_role_host_membership_update", + resource == MREG::HostPolicyRole::"role1" +) +when { + resource has nameLabels && + resource.nameLabels.contains("netgroup_5ffe5b162fe5") && + resource has ip && + (resource.ip.isInRange(ip("11.22.33.0/24"))) +}; + +@id("MREG.generated.hostpolicy_role_2b9b9645d997") +permit ( + principal in MREG::Group::"testgroup", + action == MREG::Action::"hostpolicy_role_host_membership_update", + resource == MREG::HostPolicyRole::"role1" +) +when { + resource has nameLabels && + resource.nameLabels.contains("netgroup_5ffe5b162fe5") && + resource has ip && + (resource.ip.isInRange(ip("10.0.0.0/24")) || + resource.ip.isInRange(ip("10.1.0.0/25")) || + resource.ip.isInRange(ip("192.168.1.0/24")) || + resource.ip.isInRange(ip("192.168.2.1/32")) || + resource.ip.isInRange(ip("2001:db8::/64")) || + resource.ip.isInRange(ip("2002:db9::/64"))) +}; + +@id("MREG.generated.hostpolicy_role_fec5919a9dae") +permit ( + principal in MREG::Group::"webadmins", + action == MREG::Action::"hostpolicy_role_host_membership_update", + resource == MREG::HostPolicyRole::"web" +) +when { + resource has nameLabels && + resource.nameLabels.contains("netgroup_6eb5380aba41") && + resource has ip && + (resource.ip.isInRange(ip("192.168.1.0/24"))) +}; diff --git a/treetop/data/treetop-bundle.toml b/treetop/data/treetop-bundle.toml new file mode 100644 index 00000000..4519a43f --- /dev/null +++ b/treetop/data/treetop-bundle.toml @@ -0,0 +1,10 @@ +format_version = 1 +name = "mreg" + +[[modules]] +manifest = "treetop-mreg-module.toml" +role = "ordinary" + +[[modules]] +manifest = "treetop-global-module.toml" +role = "global" diff --git a/treetop/data/treetop-global-module.toml b/treetop/data/treetop-global-module.toml new file mode 100644 index 00000000..4d77d8f4 --- /dev/null +++ b/treetop/data/treetop-global-module.toml @@ -0,0 +1,4 @@ +format_version = 1 +name = "global" +namespace = "global" +policies = ["global.cedar"] diff --git a/treetop/data/treetop-mreg-module.toml b/treetop/data/treetop-mreg-module.toml new file mode 100644 index 00000000..e34f5b03 --- /dev/null +++ b/treetop/data/treetop-mreg-module.toml @@ -0,0 +1,6 @@ +format_version = 1 +name = "MREG" +namespace = "MREG" +policies = ["mreg.cedar", "netgroup.cedar"] +schemas = ["mreg.cedarschema"] +labels = ["labels.json"] diff --git a/treetop/docker-compose.yml b/treetop/docker-compose.yml new file mode 100644 index 00000000..f0ac2caa --- /dev/null +++ b/treetop/docker-compose.yml @@ -0,0 +1,27 @@ +# This docker file allows you to set up a TreeTop policy server for tests. +services: + bundle-server: + image: svenstaro/miniserve + ports: + - "8080:8080" + volumes: + - ./data:/data:ro + command: ["/data", "--port", "8080"] + + treetop-server: + image: ghcr.io/treetop-policy-engine/treetop-rest:v0.0.14 + pull_policy: "always" + depends_on: + - bundle-server + ports: + - "9999:9999" + environment: + - TREETOP_BUNDLE_URL=http://bundle-server:8080/mreg-bundle.tar.gz + - TREETOP_BUNDLE_UPDATE_FREQUENCY=5 + - TREETOP_BUNDLE_SIGNATURE_POLICY=allow-unsigned + - TREETOP_PORT=9999 + - TREETOP_CLIENT_ALLOWLIST=* + - TREETOP_LISTEN=0.0.0.0 + - RUST_LOG=mio=warn,actix_server=warn,actix_http=warn,hyper_util=warn,reqwest=warn,info + healthcheck: + test: ["NONE"] diff --git a/treetop/fixtures/policy-source.json b/treetop/fixtures/policy-source.json new file mode 100644 index 00000000..580a1fe0 --- /dev/null +++ b/treetop/fixtures/policy-source.json @@ -0,0 +1,83 @@ +{ + "permissions": [ + { + "group": "testgroup", + "labels": [ + "Safelabel" + ], + "range": "10.0.0.0/24", + "regex": ".*\\.example\\.org$" + }, + { + "group": "testgroup", + "labels": [ + "Safelabel" + ], + "range": "10.1.0.0/25", + "regex": ".*\\.example\\.org$" + }, + { + "group": "dummygroup", + "labels": [ + "Safelabel" + ], + "range": "11.22.33.0/24", + "regex": ".*\\.example\\.org$" + }, + { + "group": "testgroup", + "labels": [ + "Safelabel" + ], + "range": "192.168.1.0/24", + "regex": ".*\\.example\\.org$" + }, + { + "group": "webadmins", + "labels": [ + "Webserver" + ], + "range": "192.168.1.0/24", + "regex": "^web-\\d+" + }, + { + "group": "testgroup", + "labels": [ + "Safelabel" + ], + "range": "192.168.2.1/32", + "regex": ".*\\.example\\.org$" + }, + { + "group": "testgroup", + "labels": [ + "Safelabel" + ], + "range": "2001:db8::/64", + "regex": ".*\\.example\\.org$" + }, + { + "group": "testgroup", + "labels": [ + "Safelabel" + ], + "range": "2002:db9::/64", + "regex": ".*\\.example\\.org$" + } + ], + "roles": [ + { + "labels": [ + "Safelabel" + ], + "name": "role1" + }, + { + "labels": [ + "Webserver" + ], + "name": "web" + } + ], + "schema_version": 1 +} diff --git a/uv.lock b/uv.lock index f96c4b2a..5cf7e36f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,16 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10" -resolution-markers = [ - "python_full_version >= '3.12' and extra != 'group-4-mreg-django52' and extra == 'group-4-mreg-django60'", - "python_full_version == '3.11.*' and extra != 'group-4-mreg-django52' and extra == 'group-4-mreg-django60'", - "python_full_version < '3.11' and extra != 'group-4-mreg-django52' and extra == 'group-4-mreg-django60'", - "python_full_version >= '3.11' and extra == 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", - "python_full_version < '3.11' and extra == 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", - "python_full_version >= '3.12' and extra != 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", - "python_full_version == '3.11.*' and extra != 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", - "python_full_version < '3.11' and extra != 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", -] +requires-python = ">=3.12" conflicts = [[ { package = "mreg", group = "django52" }, { package = "mreg", group = "django60" }, @@ -30,7 +20,6 @@ name = "anyio" version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, ] @@ -43,9 +32,6 @@ wheels = [ name = "asgiref" version = "3.12.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, -] sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, @@ -66,7 +52,6 @@ version = "2.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycodestyle" }, - { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/d8/30873d2b7b57dee9263e53d142da044c4600a46f2d28374b3e38b023df16/autopep8-2.3.2.tar.gz", hash = "sha256:89440a4f969197b69a995e4ce0661b031f455a9f776d2c5ba3dbd83466931758", size = 92210, upload-time = "2025-01-14T14:46:18.454Z" } wheels = [ @@ -75,11 +60,11 @@ wheels = [ [[package]] name = "cachetools" -version = "7.1.6" +version = "7.1.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/af/861ebc2e318a5c3300e3eb63bc4d30f3d70a46d13b360093728ac0705eed/cachetools-7.1.6.tar.gz", hash = "sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1", size = 40572, upload-time = "2026-07-23T22:47:53.737Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/d2/47e8bc06fe2a06d3f5bdf20f1126ab66c4e99dc48d940e7ba873f7ac7131/cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50", size = 40680, upload-time = "2026-08-01T21:20:40.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/f2/2086ba18a925a73586c4d4e61d25f4a6058e56fd00d77ce8f1d361ab4c9b/cachetools-7.1.6-py3-none-any.whl", hash = "sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096", size = 16954, upload-time = "2026-07-23T22:47:52.397Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z" }, ] [[package]] @@ -93,89 +78,133 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, - { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, - { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, - { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, - { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, - { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, - { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, - { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, - { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, - { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, - { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, - { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, - { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, - { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, - { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, - { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, - { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, - { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, - { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, - { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, - { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, - { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, - { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, - { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, - { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, - { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, - { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, - { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, - { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, - { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, - { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, - { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, - { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, - { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, - { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, ] [[package]] @@ -189,105 +218,101 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, - { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, - { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, - { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, - { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, - { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, - { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, - { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, - { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, - { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, - { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, - { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, - { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, - { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, - { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, - { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, - { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, - { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, - { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, - { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, - { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, - { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, - { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, - { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, - { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, - { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, - { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, - { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, - { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, - { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, - { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, - { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, - { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, - { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, - { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, - { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, - { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, - { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, - { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, - { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, - { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, - { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, ] [[package]] @@ -295,7 +320,7 @@ name = "coveralls" version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "requests" }, { name = "typer" }, ] @@ -315,41 +340,30 @@ wheels = [ [[package]] name = "django" -version = "5.2.16" +version = "5.2.17" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.11.*' and extra != 'group-4-mreg-django52' and extra == 'group-4-mreg-django60'", - "python_full_version < '3.11' and extra != 'group-4-mreg-django52' and extra == 'group-4-mreg-django60'", - "python_full_version >= '3.11' and extra == 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", - "python_full_version < '3.11' and extra == 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", - "python_full_version == '3.11.*' and extra != 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", - "python_full_version < '3.11' and extra != 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", -] dependencies = [ - { name = "asgiref", marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "sqlparse", marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "tzdata", marker = "(python_full_version < '3.12' and sys_platform == 'win32') or (sys_platform == 'win32' and extra == 'group-4-mreg-django52') or (sys_platform != 'win32' and extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "asgiref", marker = "extra == 'group-4-mreg-django52'" }, + { name = "sqlparse", marker = "extra == 'group-4-mreg-django52'" }, + { name = "tzdata", marker = "(sys_platform == 'win32' and extra == 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/d8/43e9d000519adceb189620b6869ff88031e046df91c2e9da72f8f6918399/django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f", size = 10889740, upload-time = "2026-08-04T15:04:03.173Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/13/1e5e3e4c15dcecb04281b3cb2a46a4670e1cef131068e202f6040df19224/django-5.2.16-py3-none-any.whl", hash = "sha256:04f354bf9d807a86ad1a8392fe3808d362358a8eafc322848e0e43e59b24371d", size = 8311943, upload-time = "2026-07-07T13:52:11.223Z" }, + { url = "https://files.pythonhosted.org/packages/df/f8/ce120525ca78f12b07daf65786679c5d0b54a75285a8958d3ae55e39da35/django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db", size = 8315563, upload-time = "2026-08-04T15:03:59.1Z" }, ] [[package]] name = "django" -version = "6.0.7" +version = "6.0.8" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", -] dependencies = [ - { name = "asgiref", marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, - { name = "sqlparse", marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, - { name = "tzdata", marker = "(python_full_version >= '3.12' and sys_platform == 'win32' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "(sys_platform == 'win32' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/28/5fee292e588dbdb782582436d1eb62ac8809ec55b98ef27f5b9676a00e9a/django-6.0.8.tar.gz", hash = "sha256:cb0bd962d27fc866f3c514b20aae6a7df56ec80b488f9899da46d675cd051526", size = 10924565, upload-time = "2026-08-04T15:03:39.469Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/ec/1ce5334b6a2c52ce619c23a0be8d366a57a0e080ebb2d88266e5c849157c/django-6.0.7-py3-none-any.whl", hash = "sha256:a037427c2288443a8c02a1b02295a31c239663aa682bc50b1976afb7cf6a769e", size = 8373344, upload-time = "2026-07-07T13:51:20.007Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b4/23e74d261eebfa9c8baed5e8810f09858b9afd613d1037f42ff7aa95c87f/django-6.0.8-py3-none-any.whl", hash = "sha256:9b98b7e1902e0e575ea4f42c175fc9512784f7f2580898286aee3388b322219d", size = 8376956, upload-time = "2026-08-04T15:03:35.138Z" }, ] [[package]] @@ -357,8 +371,8 @@ name = "django-auth-ldap" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django52'" }, + { name = "django", version = "6.0.8", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django60' or extra != 'group-4-mreg-django52'" }, { 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" } @@ -371,8 +385,8 @@ name = "django-filter" version = "26.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django52'" }, + { name = "django", version = "6.0.8", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django60' or extra != 'group-4-mreg-django52'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/563965173d4cbb5fc308087e7b3d11a115b7b67273d093622480b1e31f78/django_filter-26.1.tar.gz", hash = "sha256:66ea04031b068c77c86e1ac26ced7a3f8f13ce797f5795751707e3deefc58054", size = 144299, upload-time = "2026-07-11T09:27:02.767Z" } wheels = [ @@ -385,8 +399,8 @@ version = "1.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, - { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django52'" }, + { name = "django", version = "6.0.8", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django60' or extra != 'group-4-mreg-django52'" }, { name = "elasticsearch" }, { name = "six" }, ] @@ -400,8 +414,8 @@ name = "django-netfields" version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django52'" }, + { name = "django", version = "6.0.8", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django60' or extra != 'group-4-mreg-django52'" }, { name = "netaddr" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/4b/a34f990c67826a097c770fb6868a84e1b85c4e07e66a49727110c732bb84/django_netfields-1.4.1.tar.gz", hash = "sha256:a9bcde955fabb92a26663108bbf81992a979287561c938ce3d9cd79c7fcad7f6", size = 36894, upload-time = "2026-03-05T16:38:49.147Z" } @@ -411,17 +425,17 @@ wheels = [ [[package]] name = "django-silk" -version = "5.5.0" +version = "5.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django52'" }, + { name = "django", version = "6.0.8", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django60' or extra != 'group-4-mreg-django52'" }, { name = "gprof2dot" }, { name = "sqlparse" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/3e/db3bda7cf1327aa3800d32dca80f0d077954b52f61dc32cf76f605a28767/django_silk-5.5.0.tar.gz", hash = "sha256:41fcabe65d59d31ccdb69daeb3c3e6d30879ab2c00b0875b111fda0f69aec065", size = 4495794, upload-time = "2026-03-08T05:00:57.663Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/f4/e5f50497db9a3e59121237e3f74d6b2a2e5fc09ae0d242a659f3e0ca33c6/django_silk-5.5.2.tar.gz", hash = "sha256:c488ee1eab763a6f3d0db09a969e7a91b637743f04159cd8d10141386157ec0d", size = 4498731, upload-time = "2026-08-13T04:25:18.482Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/54/c56950e8b9279d2c6156d46571a64c1a808c07bddde571819e2bdbb8d5e3/django_silk-5.5.0-py3-none-any.whl", hash = "sha256:82b5a690d623935be916dd145e2f605a4ac9454d54e03df6a7ffcf38333c8444", size = 1944887, upload-time = "2026-03-08T05:01:13.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/da/48607c4a756df4a3d37752006824004fe6ecb5e0a2b34864c97a9903ea08/django_silk-5.5.2-py3-none-any.whl", hash = "sha256:6f11ae724699192bdda247513aa40160fd5e7e3de3083ec307b7151d801d1d51", size = 1945106, upload-time = "2026-08-13T04:25:29.316Z" }, ] [package.optional-dependencies] @@ -434,8 +448,8 @@ name = "djangorestframework" version = "3.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django52'" }, + { name = "django", version = "6.0.8", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django60' or extra != 'group-4-mreg-django52'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } wheels = [ @@ -447,8 +461,8 @@ name = "drf-spectacular" version = "0.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django52'" }, + { name = "django", version = "6.0.8", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django60' or extra != 'group-4-mreg-django52'" }, { name = "djangorestframework" }, { name = "inflection" }, { name = "jsonschema" }, @@ -467,15 +481,15 @@ sidecar = [ [[package]] name = "drf-spectacular-sidecar" -version = "2026.7.1" +version = "2026.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django52'" }, + { name = "django", version = "6.0.8", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django60' or extra != 'group-4-mreg-django52'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/51/9e038d14bf51a0bd051e8bcb690287349c908fa7ba69021d9f3e5d5ac51f/drf_spectacular_sidecar-2026.7.1.tar.gz", hash = "sha256:40113c4066c7bc3ef15a7ce1c40cda227a907a9986748024a813a9e0595eba25", size = 2593211, upload-time = "2026-07-01T13:39:06.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/82/eb18e2108b8ceaa50a01d2cdb35ab5ce198c18f062448b2d29d6e89aabeb/drf_spectacular_sidecar-2026.8.1.tar.gz", hash = "sha256:9d9ee678e5ec70cdda2c7ed95cafcdb6141664dc3e11def8a8599c8ad36cf6f5", size = 2603635, upload-time = "2026-08-01T12:09:03.088Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/d08f5c79f7643dbff4512605c28b75481966ed6c8cc9b397c9dd2ee91cd1/drf_spectacular_sidecar-2026.7.1-py3-none-any.whl", hash = "sha256:bc6d50c9b64660e45e09296d39553b3e759eedd825fc41d631ee5b3f88e0c5de", size = 2617384, upload-time = "2026-07-01T13:39:03.787Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5b/5ea443b8c24b4a4815a6467a158c4511dfe8bed83f8861a27bdb29c4fc47/drf_spectacular_sidecar-2026.8.1-py3-none-any.whl", hash = "sha256:6b8784d3e8c6bd9aa740b14e874640697ab1bc67f5232d74fdd9ef84eaea6bc3", size = 2626283, upload-time = "2026-08-01T12:09:01.705Z" }, ] [[package]] @@ -483,8 +497,8 @@ name = "drf-standardized-errors" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django52'" }, + { name = "django", version = "6.0.8", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django60' or extra != 'group-4-mreg-django52'" }, { name = "djangorestframework" }, ] sdist = { url = "https://files.pythonhosted.org/packages/99/3f/1a465bde6583355309559dfcfb4e7fdf1b3687f73894d7de8a959c67b50c/drf_standardized_errors-0.16.0.tar.gz", hash = "sha256:77b367955af7ed246db12d299a5a75a71eccef54dae4ef69ead29f5fb01be192", size = 62112, upload-time = "2026-04-29T09:38:41.613Z" } @@ -508,7 +522,7 @@ wheels = [ [[package]] name = "elasticsearch" -version = "9.4.1" +version = "9.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -517,30 +531,18 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/4b/9b753f0a8f56ae508dced2f7ac87bef7a27ce8f890e349e16812e9f7f4fa/elasticsearch-9.4.1.tar.gz", hash = "sha256:1d78fdfba97a903ec35a5eb5808a74e33392b7c620bd5f742d465a3a26c27d75", size = 908138, upload-time = "2026-05-26T16:28:40.132Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/f6/23e897503cbd9c22bb21cdba480160987909d2137efeeb41582b776898d4/elasticsearch-9.5.0.tar.gz", hash = "sha256:c37576ba04d04200a05012db99bc99024000ae15b9ebdd683966aa8a0e6cd1c4", size = 923577, upload-time = "2026-08-04T17:55:58.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/8e/2c93805e93e724a90156004a9212572ec86473974deede4605a33b8b169a/elasticsearch-9.4.1-py3-none-any.whl", hash = "sha256:71ab71c3d1b20fd88c2922fb82c3277cce7ea03c160686e7b9368b265c2b4cac", size = 993647, upload-time = "2026-05-26T16:28:36.556Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12' or (python_full_version == '3.12.*' and extra == 'group-4-mreg-django52') or (python_full_version >= '3.13' and extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/d84fe1f2a6f60ce1fbc682a2e98776575d7b73e60680ac5aa90f53052466/elasticsearch-9.5.0-py3-none-any.whl", hash = "sha256:010e04f44fd161428f0ab7f94b93a533d3dcc3285d02c9df509b4e0127916420", size = 1011512, upload-time = "2026-08-04T17:55:54.792Z" }, ] [[package]] name = "filelock" -version = "3.32.0" +version = "3.32.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, ] [[package]] @@ -554,41 +556,66 @@ wheels = [ [[package]] name = "gunicorn" -version = "26.0.0" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b8/ec4ba3f6cace4091c34e27478b576bb80f2f06fab80fd42c0ecc785b308f/gunicorn-26.1.0.tar.gz", hash = "sha256:1413d777bf99d31ebeb08acd354b01f1ecc44db0aa7b811ae7b86c669232e4f7", size = 755923, upload-time = "2026-08-18T11:49:39.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/dc/7a55fc605543fd5cb11c003fbbb21a1911d5e88a582cce6c5e063bf5c176/gunicorn-26.1.0-py3-none-any.whl", hash = "sha256:9f45bcddec5e9dc7a25a3bdccb0c6832f11fd5d4739b1ee36c8d2fec25f1dc86", size = 216237, upload-time = "2026-08-18T11:49:38.001Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, + { name = "certifi" }, + { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] -name = "idna" -version = "3.18" +name = "httpx" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] -name = "inflection" -version = "0.5.1" +name = "idna" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] -name = "iniconfig" -version = "2.3.0" +name = "inflection" +version = "0.5.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/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, ] [[package]] @@ -599,8 +626,7 @@ dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "rpds-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -644,8 +670,8 @@ wheels = [ name = "mreg" source = { editable = "." } dependencies = [ - { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or extra == 'group-4-mreg-django52'" }, - { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'group-4-mreg-django52') or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django52'" }, + { name = "django", version = "6.0.8", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-4-mreg-django60' or extra != 'group-4-mreg-django52'" }, { name = "django-auth-ldap" }, { name = "django-filter" }, { name = "django-logging-json" }, @@ -661,34 +687,33 @@ dependencies = [ { name = "rich" }, { name = "sentry-sdk" }, { name = "structlog" }, + { name = "treetop-client" }, { name = "tzdata" }, { name = "unittest-parametrize" }, ] [package.dev-dependencies] ci = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "coveralls" }, { name = "django-silk", extra = ["formatting"] }, - { name = "pytest" }, - { name = "pytest-django" }, + { name = "tblib" }, { name = "tox-gh-actions" }, { name = "tox-uv" }, { name = "uv" }, ] dev = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "django-silk", extra = ["formatting"] }, - { name = "pytest" }, - { name = "pytest-django" }, + { name = "tblib" }, { name = "tox-uv" }, { name = "uv" }, ] django52 = [ - { name = "django", version = "5.2.16", source = { registry = "https://pypi.org/simple" } }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" } }, ] django60 = [ - { name = "django", version = "6.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "django", version = "6.0.8", source = { registry = "https://pypi.org/simple" } }, ] profile = [ { name = "django-silk", extra = ["formatting"] }, @@ -697,7 +722,7 @@ profile = [ [package.metadata] requires-dist = [ { name = "django", specifier = ">=5.2" }, - { name = "django-auth-ldap", specifier = ">=5.2.0" }, + { name = "django-auth-ldap", specifier = ">=5.3.0" }, { name = "django-filter", specifier = ">=25" }, { name = "django-logging-json", specifier = ">=1.15" }, { name = "django-netfields", specifier = ">=1.3.2" }, @@ -707,11 +732,12 @@ requires-dist = [ { name = "gunicorn", specifier = ">=23.0.0" }, { name = "idna", specifier = ">=3.11" }, { name = "pika", specifier = ">=1.3.2" }, - { name = "prometheus-client", specifier = ">=0.20" }, + { name = "prometheus-client", specifier = ">=0.24" }, { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3" }, { name = "rich", specifier = ">=14" }, { name = "sentry-sdk", specifier = ">=2.48.0" }, { name = "structlog", specifier = ">=25" }, + { name = "treetop-client", specifier = ">=0.0.12" }, { name = "tzdata", specifier = ">=2025.3" }, { name = "unittest-parametrize" }, ] @@ -721,19 +747,17 @@ ci = [ { name = "coverage", extras = ["toml"] }, { name = "coveralls" }, { name = "django-silk", extras = ["formatting"], specifier = ">=5.5.0" }, - { name = "pytest" }, - { name = "pytest-django" }, + { name = "tblib", specifier = ">=3" }, { name = "tox-gh-actions" }, { name = "tox-uv", specifier = ">=1.29" }, - { name = "uv", specifier = ">=0.9" }, + { name = "uv", specifier = ">=0.10" }, ] dev = [ { name = "coverage", extras = ["toml"] }, { name = "django-silk", extras = ["formatting"], specifier = ">=5.5.0" }, - { name = "pytest" }, - { name = "pytest-django" }, + { name = "tblib", specifier = ">=3" }, { name = "tox-uv", specifier = ">=1.29" }, - { name = "uv", specifier = ">=0.9" }, + { name = "uv", specifier = ">=0.10" }, ] django52 = [{ name = "django", specifier = ">=5.2,<5.3" }] django60 = [{ name = "django", marker = "python_full_version >= '3.12'", specifier = ">=6.0,<6.1" }] @@ -750,29 +774,29 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +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/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { 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 = "pika" -version = "1.4.2" +version = "1.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/06/a5e4589eccb22be5790bf1327e20c5387b64327daa40c4d857cf9265ec3c/pika-1.4.2.tar.gz", hash = "sha256:48d1f50297e76be4fc798fd5232d4d532d7a4758e51f7c0ae6c4004b9808a26b", size = 154365, upload-time = "2026-07-23T02:11:26.003Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/26/54e0b98a7f60b474cb0a6c05ecf048d4bc8c866e10ab1c82bc83865e7421/pika-1.4.4.tar.gz", hash = "sha256:8cfc8b33a5cb16e733bd60cffca9732c0d1d761ecd80a89f34ed7df2cd38d6d6", size = 154713, upload-time = "2026-08-06T21:33:39.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/82/3b047c707700e08539cde489475eb5eaca13550599baaa17a8da038003e7/pika-1.4.2-py3-none-any.whl", hash = "sha256:b1df7389cdffaa45856bd01ade4e81bd488e0e199c443faf0c7bea89015f373f", size = 165087, upload-time = "2026-07-23T02:11:24.712Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f3/921170b78779ac3f8b405cb28fce88acca1562ac114072cbf0a115e19bb9/pika-1.4.4-py3-none-any.whl", hash = "sha256:48de960c97a93b55db06b8be4c53eb977c9c8a2754c57cdae9097abcbd70ce04", size = 165275, upload-time = "2026-08-06T21:33:38.449Z" }, ] [[package]] name = "platformdirs" -version = "4.11.0" +version = "4.11.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, ] [[package]] @@ -819,28 +843,6 @@ name = "psycopg-binary" version = "3.3.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" }, - { url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" }, - { url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" }, - { url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" }, - { url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" }, - { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, - { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, - { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, - { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, - { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, - { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, - { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, - { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, @@ -920,11 +922,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +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/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { 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]] @@ -933,43 +935,12 @@ version = "1.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, - { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/bd/2f985c12bf33fdd8637e3a8f9418d6806f177601dee7c4924d0b2bb28650/pyproject_api-1.11.0.tar.gz", hash = "sha256:b8807d85a293e6c9f133e6575946fed45f1d42b22d58c780b33aa2421a799549", size = 23787, upload-time = "2026-07-21T13:09:33.744Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a3/6a/2822ee12bafe87af8f6cc620f7d1f126e77f82ce56ba888b94a9af5a979c/pyproject_api-1.11.0-py3-none-any.whl", hash = "sha256:860060c8832dce983b5eec6f41c4c43eb3ec06ff7332387a63acdf5ca27b68d8", size = 13275, upload-time = "2026-07-21T13:09:32.559Z" }, ] -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "pytest-django" -version = "4.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -984,15 +955,14 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.5.0" +version = "1.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, - { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/51/276f964496a5714ab9f320896195639086881c2b39c03b5ad13de84acbb8/python_discovery-1.5.0.tar.gz", hash = "sha256:3e014c6327154d3dda27939a9a0dc9c5c000439f1906d3f303b48f984bd2ecef", size = 72483, upload-time = "2026-07-21T13:14:14.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b7/ac44da2cf0e53ada0e419033c2d058219c95dc1403126f163304c9e814b1/python_discovery-1.5.2.tar.gz", hash = "sha256:45fd4f20a4e3f9b7bf2e0817870bc8e3b320a19658da177af800768c82dbf354", size = 82350, upload-time = "2026-08-12T14:05:26.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/7b/14882602ddee241d7984a742fcb423cb4a30fb0d6efc546ac3129fba475a/python_discovery-1.5.0-py3-none-any.whl", hash = "sha256:70c4fc61b4e7404e44f01d6fc44a715c4d685ca6cea83d295922f05891877c98", size = 34205, upload-time = "2026-07-21T13:14:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl", hash = "sha256:3e338c2d0f15dfaeea57493f4c2c6caebe0e998ea815c30ae8bf8ee21f1112d3", size = 38350, upload-time = "2026-08-12T14:05:25.113Z" }, ] [[package]] @@ -1011,24 +981,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, @@ -1075,8 +1027,7 @@ version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } @@ -1112,159 +1063,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, - { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, - { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, - { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, - { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, -] - [[package]] name = "rpds-py" version = "2026.6.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12' and extra != 'group-4-mreg-django52' and extra == 'group-4-mreg-django60'", - "python_full_version == '3.11.*' and extra != 'group-4-mreg-django52' and extra == 'group-4-mreg-django60'", - "python_full_version >= '3.11' and extra == 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", - "python_full_version >= '3.12' and extra != 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", - "python_full_version == '3.11.*' and extra != 'group-4-mreg-django52' and extra != 'group-4-mreg-django60'", -] sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, - { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, - { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, - { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, - { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, - { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, @@ -1353,31 +1157,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, - { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, - { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, - { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, - { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, - { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, - { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, ] [[package]] name = "sentry-sdk" -version = "2.66.1" +version = "2.68.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543, upload-time = "2026-07-22T12:26:54.553Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/94/23b7dd072acb9628907bd3f4fbf61794a7b12a9db8f33c1276f70ae5ac92/sentry_sdk-2.68.0.tar.gz", hash = "sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3", size = 1008854, upload-time = "2026-08-13T09:06:21.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/7d/9b/e2421d08956d0bc4691d995393d835e563886bff499d8fb10fdefae85a8d/sentry_sdk-2.68.0-py3-none-any.whl", hash = "sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4", size = 518670, upload-time = "2026-08-13T09:06:19.735Z" }, ] [[package]] @@ -1409,77 +1201,29 @@ wheels = [ [[package]] name = "sqlparse" -version = "0.5.5" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, + { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, ] [[package]] name = "structlog" version = "26.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, -] sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, ] [[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +name = "tblib" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8a/14c15ae154895cc131174f858c707790d416c444fc69f93918adfd8c4c0b/tblib-3.2.2.tar.gz", hash = "sha256:e9a652692d91bf4f743d4a15bc174c0b76afc750fe8c7b6d195cc1c1d6d2ccec", size = 35046, upload-time = "2025-11-12T12:21:16.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl", hash = "sha256:26bdccf339bcce6a88b2b5432c988b266ebbe63a4e593f6b578b1d2e723d2b76", size = 12893, upload-time = "2025-11-12T12:21:14.407Z" }, ] [[package]] @@ -1493,7 +1237,7 @@ wheels = [ [[package]] name = "tox" -version = "4.58.0" +version = "4.60.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, @@ -1504,14 +1248,13 @@ dependencies = [ { name = "pluggy" }, { name = "pyproject-api" }, { name = "python-discovery" }, - { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, { name = "tomli-w" }, - { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/8e/4d2b1b2a81f4de1cd4e54fa40df1ab5f9bb88fe2e37461fc44aba8f9d302/tox-4.58.0.tar.gz", hash = "sha256:ab0b126a04dd56bc18e6d216386db09335247f2289b54cf534deb5c4ae3a8d2e", size = 296926, upload-time = "2026-07-21T13:10:36.622Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/1f/3acba68301b0081cec87b1769ba4c3a4a0a30f20879a05420673c7e4671d/tox-4.60.0.tar.gz", hash = "sha256:6f93bb580d35e00fc69cfec1116eecbe21155f747753ca5019374f7c69b2eac9", size = 301598, upload-time = "2026-08-13T23:14:26.944Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/3d/7ba55871e9d794d40b6c8424f2e5d1b267ea5d9a4bd2175e08b57960ba13/tox-4.58.0-py3-none-any.whl", hash = "sha256:dcae21f5f015f3a67658e35644cce0d1aa0dedcd06f3927f95d84e1717f6cea5", size = 223298, upload-time = "2026-07-21T13:10:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e6/3a1042add765c0e1e67f76a3b0f2036bfe6b0229ace83c6ff9638a5e391b/tox-4.60.0-py3-none-any.whl", hash = "sha256:175abbc4cdef615d66874c0843be4f44c353c14aab6d89939bb22246f84122bd", size = 226332, upload-time = "2026-08-13T23:14:25.324Z" }, ] [[package]] @@ -1544,7 +1287,6 @@ version = "1.36.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, - { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, { name = "tox" }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/df/9f90a59de8c87cece6e691eee53dc1cb0a824d73aea8f380ae2f6ecb71de/tox_uv_bare-1.36.0.tar.gz", hash = "sha256:d9b0a2fd0f74fa65d9597108f8a0ef7abb08651c9196a998a6073b781b45cfd0", size = 32548, upload-time = "2026-07-21T13:09:56.435Z" } @@ -1552,9 +1294,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/0a/6dc462e4fb543305283a6157c80f43e3d12ca4702da6ae6521d541c6b55c/tox_uv_bare-1.36.0-py3-none-any.whl", hash = "sha256:ba397dd0396df95a75744d4e42a50ee27207c0ffcf277b62ffba9c3de455a939", size = 22489, upload-time = "2026-07-21T13:09:55.389Z" }, ] +[[package]] +name = "treetop-client" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/58/e36d2bf847b29a9cc37f4fa7849124c170ffdc8fde98072cb5d519936066/treetop_client-0.0.12.tar.gz", hash = "sha256:e43e4d182e2840440fbe50707a819f7e9e9c93b9fe837e8728e50b8b9b38eb15", size = 17074, upload-time = "2026-08-14T23:03:08.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/3b/75abc17eabf28fea4c4730f29ac1e6e168381d4e27425f0ca158051d46e8/treetop_client-0.0.12-py3-none-any.whl", hash = "sha256:df9cee9c261a2128e241611103d7dac116194e517f9f73169260dd9ae735b173", size = 15883, upload-time = "2026-08-14T23:03:07.558Z" }, +] + [[package]] name = "typer" -version = "0.27.0" +version = "0.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -1562,9 +1316,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, ] [[package]] @@ -1614,42 +1368,41 @@ wheels = [ [[package]] name = "uv" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/5a94b658b08c46142cf7bf1d0c432cc7d04375b80f42765633414e7541bd/uv-0.12.0.tar.gz", hash = "sha256:80ba22cae467c6f47d2157ec2b840c032cac709b85ab1300ac4dcfeb29986462", size = 5827380, upload-time = "2026-07-28T18:57:12.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/e9/5663af6b4d90827c008005cfe7926a747688bd408226913d249b9de8492b/uv-0.12.0-py3-none-linux_armv6l.whl", hash = "sha256:11cc7ef5386fe54536cc8921676728a0e5c348cf522c8ee1fa0b81cbafc20cbc", size = 21499556, upload-time = "2026-07-28T18:56:27.552Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8e/b88ae4a3b704f60f8e9dcdef78047c3749077b2f1e884bd387c8e41fe378/uv-0.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:074e693e9b2df99f621166b44760abe0d53cd9b0ae96fcbfec5809497925da87", size = 19751720, upload-time = "2026-07-28T18:56:30.623Z" }, - { url = "https://files.pythonhosted.org/packages/a5/7b/15d6865264120bd30c738b4bf63ddff66d087087cadeb2a6b88c6284a446/uv-0.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009758d8fde2da2b90900f5fe863c71d0e1b8b28bbdba59863ceb967973a3735", size = 18117978, upload-time = "2026-07-28T18:56:32.904Z" }, - { url = "https://files.pythonhosted.org/packages/0e/bc/2066cc63e6930e3d5e27c73a9c439418164eafb4f1c24845f17caf63eaea/uv-0.12.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:effc2de9f044e880306f3c52b048bf24ee4fe63429c82dd6509c9a0f3d1b8f0b", size = 20833318, upload-time = "2026-07-28T18:56:35.567Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d6/49fef7e4e3c401540113115846e47094aff7cda86f54ba79477636758e38/uv-0.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:e9e660171873f905a6782bf2a5e7515aba1a8e8a5cfce0add68fbe7a22ead8b0", size = 21056599, upload-time = "2026-07-28T18:56:38.117Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b9/cc32f406b5429cbb0f0849938d12a24a33c3bd28b710c8ccd0955c588131/uv-0.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53c5c07fafcf620d23faa8f339742806d57cb82122c97544d0f3750f55e2fe36", size = 21100563, upload-time = "2026-07-28T18:56:40.305Z" }, - { url = "https://files.pythonhosted.org/packages/42/ff/36eef4c1624ed371d8367cf96207f35ba81b42b8308688d1acad835432cc/uv-0.12.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b80a1a89aad16c6d84dd96b0c795b44f3824f0765e815af2f93fd05cb4a894cd", size = 21763617, upload-time = "2026-07-28T18:56:42.59Z" }, - { url = "https://files.pythonhosted.org/packages/05/23/b82dbd945c5b8a88ed5dc8c2c001619677ad5aea246318716c773711aef9/uv-0.12.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1e84987c4b4d832796b779ad614e91c1b44ac1ade5163c00654b70881ef53cb", size = 22917937, upload-time = "2026-07-28T18:56:45.546Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/44f5f753fda99820b972251c3be9ca9e56d98f4ced752cea623f19479fa8/uv-0.12.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbb9d9c40e91b6bf5e124230277fe5579ecf685e6de47e61a0eed8af5ffa0cdb", size = 22555435, upload-time = "2026-07-28T18:56:47.882Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ba/bc14d74741b0292edd8e61e87a4bd96f79447a1b9d27e85cda2e8539039b/uv-0.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbff74f884846d794713670faf8abe10db3bd70c43b01e63223f74eb7d958689", size = 21986958, upload-time = "2026-07-28T18:56:50.271Z" }, - { url = "https://files.pythonhosted.org/packages/1d/52/e14f0a91be4b426f18107f63b1b87e99ec671e8907689cf45144a79c4f76/uv-0.12.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c818bb6aead39652e2ad644583fa418ac8d92baf50b4c6f685738bb2598e33bd", size = 20965849, upload-time = "2026-07-28T18:56:52.628Z" }, - { url = "https://files.pythonhosted.org/packages/0d/a6/ef7b436f9983c467b88bacb5ce58620398c7fe7fa86ee67906bfce343201/uv-0.12.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:5fe6cdc82cacc630827f2ec779b91b0d13ff57ff476e41bdcace05cd61261951", size = 21671684, upload-time = "2026-07-28T18:56:54.923Z" }, - { url = "https://files.pythonhosted.org/packages/9d/c8/19086d68078b514be4c266081e11d5530b07d099cb05d011e1fa6a216e10/uv-0.12.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fcf4b6d0807f8f05a7dd8c090f080674e8526db27a0764af2a5a54ab5096c3eb", size = 21798247, upload-time = "2026-07-28T18:56:57.226Z" }, - { url = "https://files.pythonhosted.org/packages/4d/87/571847075bbe2205ec7ae108c17d01742a1251aea9fe5f9cd5da1496922e/uv-0.12.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4be9870fca2952143f33a02347c8da603bbe645283e3e989f038ef7b306b3ecb", size = 20977006, upload-time = "2026-07-28T18:56:59.544Z" }, - { url = "https://files.pythonhosted.org/packages/be/df/d391bc0f5901ff8a0d6285eb433222cacb972b5e5817a420e084ee698894/uv-0.12.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ed4053e07048ab3561de95c3b686b7983f997cd19d53a265a238103b5dbf258a", size = 22186132, upload-time = "2026-07-28T18:57:02.014Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fe/d440d50811ef913cb035e4c5f346799d9353fd5a8aa8479e57d7efc34692/uv-0.12.0-py3-none-win32.whl", hash = "sha256:bef14df9bec1ee7577fdc5b37d02ad8128574a2eebc130c525255edac051b9a4", size = 19210613, upload-time = "2026-07-28T18:57:04.493Z" }, - { url = "https://files.pythonhosted.org/packages/cb/27/c3da5b9136925ea2bc9209f7cabbfae12fd191f778456ead0f2d6de446a7/uv-0.12.0-py3-none-win_amd64.whl", hash = "sha256:ffdfed09a23e67ef6facf1d4db978a3cd73a886674644131a11a933fd746904a", size = 20005960, upload-time = "2026-07-28T18:57:07.332Z" }, - { url = "https://files.pythonhosted.org/packages/9f/bc/d04df3b6c36be124cb99e7eab59db514ec528f2b5c5ac2ed9fec41fbdc71/uv-0.12.0-py3-none-win_arm64.whl", hash = "sha256:e3d748f526739110dd9e267ecca30604b64a5fe3344f903d348b5a3af1f0a90a", size = 18981523, upload-time = "2026-07-28T18:57:09.743Z" }, +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b0/3085b844fe59aa319a3f94a5cca9938fffecc82705aa9c2762a749f7095c/uv-0.12.5.tar.gz", hash = "sha256:442a21d181faae21742aaaf6d2091a0d27755d3eac344061a9a00c90169b7524", size = 7101936, upload-time = "2026-08-14T19:56:57.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/4c/6412d4a618230db699118b362ec41c54795f93992b43c53e225bd0213501/uv-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:2bd62134e56af35b9cf017aaf8ae41a605d6501dd49afc35b70b544a45dd8354", size = 23310055, upload-time = "2026-08-14T19:55:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/d76387b388fa21620088b89b9c67f2596a707add585104e0cb5e8abf55f2/uv-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1a06c8bc4d43b5f6c1e3f2ae3d0f6455b07515f762516f95e52e6c0cbccedf15", size = 21401335, upload-time = "2026-08-14T19:55:55.371Z" }, + { url = "https://files.pythonhosted.org/packages/6d/bc/81ab953b7261ae6be40874b1f283a10873871e02eb353d354614dd8da96b/uv-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d87156bc174d94fae890bb7a261e2867140abb9fe1e9de81a5295e582fb9d0f5", size = 19290641, upload-time = "2026-08-14T19:55:58.998Z" }, + { url = "https://files.pythonhosted.org/packages/7d/13/07585043c10e648820bf826474dac46864ce6691da5dc52fee43c5c7523a/uv-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d65b7b3bc3fd28678f62aa7fb5d90f106ad9782c1354af60b6cecdf9ea9ecd9", size = 22245569, upload-time = "2026-08-14T19:56:02.729Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6d/310f8f56f8d001b4000112a09d7b7de80fb2024a90208fabb9ddc457c123/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:712624b62e25c84e5a10fc6aa144d8a81b685fdc067a54a7ca4367d75d2cf791", size = 22745152, upload-time = "2026-08-14T19:56:06.426Z" }, + { url = "https://files.pythonhosted.org/packages/92/da/7922b67eec5ee03e94333c5841b682c335033ee80acac17c3417bd752656/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9656ac7a00fd4314980fb0f790df1c1f3fa9cbcf9af9c6f611b19448b9da687", size = 22787947, upload-time = "2026-08-14T19:56:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/5dbaed832a4b36809ef8a07c8e56e9fee0dedb0aa0454f6d232b6e468f2c/uv-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:568485b44e848eb3693f85d6b00299ccd8fc4d26902030dbf24f549c276db9ca", size = 23367616, upload-time = "2026-08-14T19:56:13.768Z" }, + { url = "https://files.pythonhosted.org/packages/11/77/baf761d12bb66efb01706e3bbb5926ed0d13cb0a40539a661fcfffd46de4/uv-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd08c82831b0033330f8eeeb0d90f938a4d999f25569bee68a975c736142d795", size = 24586263, upload-time = "2026-08-14T19:56:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a8/76c1031c4834c959bb8a8059c9feabeaa77488ce8b6a3529d6d929ae81cf/uv-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd9ff6154b891146a342c143cd29b330ad97ac6a4b20ff4a99a20a4da84ceca", size = 24160655, upload-time = "2026-08-14T19:56:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/dacc9a0bc8604187a1ba954a3aef8329e4104eb0af772d2c3c634893bd9b/uv-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e195ccf1ed60c8bb24a6447ce306441a4181d54b602407e09bc56e963911c15", size = 23657089, upload-time = "2026-08-14T19:56:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/e8f9c071622f2cb4072d8b587d27b27d23cf0d3ebf8b3687f5af6030f587/uv-0.12.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:58abfb0f658b39a834307a11223bc170294ea214263b4c99ecc7663720d43544", size = 22379954, upload-time = "2026-08-14T19:56:28.789Z" }, + { url = "https://files.pythonhosted.org/packages/73/95/4c3f060e95f7cbe9177b4ab361f0cbfc4ae22e5a49b22e73eee9f0d0a6ca/uv-0.12.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6ad2c455f1fe4d2962f6fd7ccb3b1f61c61856681c9d99f40e170b2074353fa3", size = 23318163, upload-time = "2026-08-14T19:56:32.504Z" }, + { url = "https://files.pythonhosted.org/packages/a0/96/ca0497ef8912ef48dbbc9982a8b4212260c34d56bfd0d45fe67b31942121/uv-0.12.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a05b497c2a948c8600f4c831a89852b4d2514b7f561074225cc9edd0cc4811e2", size = 23470437, upload-time = "2026-08-14T19:56:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/60/e7/8bdc37669a6cd2b46a2ec08ccbb58c61395ec84a073e199f5a4a64bb998f/uv-0.12.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7817f8e957960f9ddc452ea353f283c0d6393e2e31b400276485adced5b1f371", size = 22545803, upload-time = "2026-08-14T19:56:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/37/cc/01e39e1dbeb838a6b3c26bf97c867d6f366459b22a38bea691af8c6c94c0/uv-0.12.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:dc14e4f81a99b585a891350c60d1ff4557d54cb3c3c81fa45fd4e0dd512ba752", size = 23874113, upload-time = "2026-08-14T19:56:44.193Z" }, + { url = "https://files.pythonhosted.org/packages/0a/38/9053599a73a351d1cd34195c7a48c1db4d4d51b57b543607fad7ecf9354c/uv-0.12.5-py3-none-win32.whl", hash = "sha256:39bb102766c95571781a7b4c611675ea213e08df5c680f3936279b3c0d1f6c3c", size = 20744641, upload-time = "2026-08-14T19:56:47.689Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f6/a9af9311c7f5640ca2bfcfdedb7aca37fa6d1d9f5c981fb50c5be02b7477/uv-0.12.5-py3-none-win_amd64.whl", hash = "sha256:455c3e57602e2141e66e2f0bf685898c9c5e5a70377d14c9a71554a3baf3ddbf", size = 21621812, upload-time = "2026-08-14T19:56:51.126Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/e1266399f755f97a0783de379f2fed6dae0a2a240db32fe5a2eb976fec8a/uv-0.12.5-py3-none-win_arm64.whl", hash = "sha256:bea86f27a027e0e3af908db4bdd4f1ceef3ca2bd47673b5ccca7f550e325b1b4", size = 20381876, upload-time = "2026-08-14T19:56:54.883Z" }, ] [[package]] name = "virtualenv" -version = "21.7.0" +version = "21.7.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, - { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-4-mreg-django52' and extra == 'group-4-mreg-django60')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/25/e367a7229b0914772ca8d81b41fde012d9feda68523b52644a571bb21ce8/virtualenv-21.7.0.tar.gz", hash = "sha256:7f9519b9432ff11b6e1a3e94061664efc2ff99ea21780e3cf4f6bd0a5da8b37c", size = 5527510, upload-time = "2026-07-21T13:12:14.109Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/dc/a6eb1ddfa7f1e390fa599b078453c97edb3f6f846b34fb4eac3e8ea16401/virtualenv-21.7.4.tar.gz", hash = "sha256:c9d960c95fa458171e58222a5ccab7465298e4b6559977865e627c4719f1e825", size = 5345511, upload-time = "2026-08-10T22:54:33.316Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/7a/ae29312b1e88a22e81f5d21fc11526d2a114089776c2550d2b205b6c2a47/virtualenv-21.7.0-py3-none-any.whl", hash = "sha256:a8370c1c5530fbabf955e40b8fbbc68a431648b10f9433faa587db30a06e51dd", size = 5507078, upload-time = "2026-07-21T13:12:12.136Z" }, + { url = "https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl", hash = "sha256:376ec93cd6aab3044fa395d7db226db38043b7b5748948044b2a87168525e843", size = 5324444, upload-time = "2026-08-10T22:54:31.515Z" }, ]