diff --git a/.github/act/act-pytest.sh b/.github/act/act-pytest.sh
new file mode 100755
index 00000000..e257d40b
--- /dev/null
+++ b/.github/act/act-pytest.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT"
+
+# act bind-mounts the repo; earlier root steps may leave root-owned __pycache__ dirs.
+find "$ROOT" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
+
+# Prefer ~/.local/bin/act over an older ~/bin/act install.
+export PATH="${HOME}/.local/bin:${PATH}"
+hash -r 2>/dev/null || true
+
+DOCKER_GID="$(stat -c '%g' /var/run/docker.sock)"
+GITHUB_TOKEN="${GITHUB_TOKEN:-${ghpat:-$(gh auth token 2>/dev/null || true)}}"
+
+if [[ -z "${GITHUB_TOKEN}" ]]; then
+ echo "Set GITHUB_TOKEN or ghpat before running." >&2
+ exit 1
+fi
+
+ACT_VERSION="$(act --version 2>/dev/null | awk '{print $3}')"
+MIN_ACT_VERSION="0.2.81"
+if [[ -n "${ACT_VERSION}" ]] \
+ && [[ "$(printf '%s\n' "${MIN_ACT_VERSION}" "${ACT_VERSION}" | sort -V | head -1)" != "${MIN_ACT_VERSION}" ]]; then
+ echo "act ${ACT_VERSION} is too old for node24 actions; install act >= ${MIN_ACT_VERSION}." >&2
+ echo " curl -sL https://github.com/nektos/act/releases/download/v0.2.89/act_Linux_x86_64.tar.gz | tar -xz -C ~/.local/bin act" >&2
+ exit 1
+fi
+
+# act-latest has no passwordless sudo for ubuntu; run the job as root for apt/sudo steps.
+# install.sh re-execs bench setup as ubuntu. Map test_site via docker --add-host below.
+ACT_RUNNER_IMAGE="${ACT_RUNNER_IMAGE:-catthehacker/ubuntu:act-latest}"
+ACT_PULL_ARGS=(--pull=false)
+if ! docker image inspect "${ACT_RUNNER_IMAGE}" >/dev/null 2>&1; then
+ echo "Runner image ${ACT_RUNNER_IMAGE} not found locally; pulling once..." >&2
+ ACT_PULL_ARGS=(--pull=true)
+fi
+
+# ACT job container shares the service Docker network; use the service hostname.
+# GHA runs steps on the VM host and uses 127.0.0.1:1631 from the workflow env instead.
+exec act pull_request \
+ -W .github/workflows/pytest.yaml \
+ -j tests \
+ -e .github/act/pull_request.json \
+ -s "GITHUB_TOKEN=${GITHUB_TOKEN}" \
+ --actor "$(gh api user -q .login 2>/dev/null || echo nektos)" \
+ -P "ubuntu-latest=${ACT_RUNNER_IMAGE}" \
+ "${ACT_PULL_ARGS[@]}" \
+ --container-architecture linux/amd64 \
+ --env BENCH_ROOT=/home/ubuntu/frappe-bench \
+ --env BEAM_TEST_TELEMETRY=true \
+ --env BEAM_TEST_STACK_DUMP_SECONDS=60 \
+ --env BEAM_CUPS_HOST=cups \
+ --env BEAM_CUPS_PORT=631 \
+ --container-options "--group-add ${DOCKER_GID} --add-host test_site:127.0.0.1" \
+ "$@"
diff --git a/.github/act/pull_request.json b/.github/act/pull_request.json
new file mode 100644
index 00000000..d2a5ad27
--- /dev/null
+++ b/.github/act/pull_request.json
@@ -0,0 +1,10 @@
+{
+ "pull_request": {
+ "base": {
+ "ref": "version-15"
+ },
+ "head": {
+ "ref": "add_printer_wizard"
+ }
+ }
+}
diff --git a/.github/helper/common_site_config.json b/.github/helper/common_site_config.json
new file mode 100644
index 00000000..bb67c142
--- /dev/null
+++ b/.github/helper/common_site_config.json
@@ -0,0 +1,5 @@
+{
+ "redis_cache": "redis://127.0.0.1:6379",
+ "redis_queue": "redis://127.0.0.1:6379",
+ "redis_socketio": "redis://127.0.0.1:6379"
+}
diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index ec90d40d..b61df946 100644
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -4,8 +4,24 @@ export PIP_ROOT_USER_ACTION=ignore
set -e
+# act runs workflow steps as root; bench refuses root. Re-run this script as ubuntu.
+if [[ "${ACT:-}" == "true" && "$(id -u)" -eq 0 ]]; then
+ mkdir -p /home/ubuntu
+ chown ubuntu:ubuntu /home/ubuntu
+ exec runuser -u ubuntu -- env \
+ HOME=/home/ubuntu \
+ ACT=true \
+ BRANCH_NAME="${BRANCH_NAME:-}" \
+ GITHUB_WORKSPACE="${GITHUB_WORKSPACE}" \
+ PIP_ROOT_USER_ACTION=ignore \
+ PATH="${PATH}" \
+ bash "$0"
+fi
+
# Check for merge conflicts before proceeding
-python -m compileall -f "${GITHUB_WORKSPACE}"
+if [[ "${ACT:-}" != "true" ]]; then
+ python -m compileall -f "${GITHUB_WORKSPACE}"
+fi
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
then echo "Found merge conflicts"
exit 1
@@ -30,6 +46,8 @@ echo BRANCH_NAME: "${BRANCH_NAME}"
git clone https://github.com/frappe/frappe --branch ${BRANCH_NAME}
bench init frappe-bench --frappe-path ~/frappe --python "$(which python)" --skip-assets --ignore-exist
+cp "${GITHUB_WORKSPACE}/.github/helper/common_site_config.json" ~/frappe-bench/sites/common_site_config.json
+
mkdir ~/frappe-bench/sites/test_site
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config.json" ~/frappe-bench/sites/test_site/
@@ -39,6 +57,20 @@ sed -i 's/watch:/# watch:/g' Procfile
sed -i 's/schedule:/# schedule:/g' Procfile
sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
+sed -i 's/^redis_cache:/# redis_cache:/g' Procfile
+sed -i 's/^redis_queue:/# redis_queue:/g' Procfile
+
+wait_for_redis() {
+ local port="${REDIS_PORT:-6379}"
+ for _ in $(seq 1 60); do
+ if (echo > /dev/tcp/127.0.0.1/"$port") 2>/dev/null; then
+ return 0
+ fi
+ sleep 1
+ done
+ echo "Redis service did not become reachable on port ${port}" >&2
+ return 1
+}
bench get-app erpnext https://github.com/frappe/erpnext --branch ${BRANCH_NAME} --resolve-deps --skip-assets
bench get-app beam "${GITHUB_WORKSPACE}" --skip-assets
@@ -47,8 +79,7 @@ printf '%s\n' 'frappe' 'erpnext' 'beam' > ~/frappe-bench/sites/apps.txt
bench setup requirements --python
bench use test_site
-bench start &> bench_run_logs.txt &
-CI=Yes &
+wait_for_redis
bench --site test_site reinstall --yes --admin-password admin
bench setup requirements --dev
@@ -58,6 +89,5 @@ bench version
echo "SITE LIST-APPS:"
bench list-apps
-bench start &> bench_run_logs.txt &
-CI=Yes &
+wait_for_redis
bench execute 'beam.tests.setup.before_test'
diff --git a/.github/helper/install_dependencies.sh b/.github/helper/install_dependencies.sh
index ef7ba146..513a30ac 100644
--- a/.github/helper/install_dependencies.sh
+++ b/.github/helper/install_dependencies.sh
@@ -1,10 +1,13 @@
#!/bin/bash
# Check for merge conflicts before proceeding
-python -m compileall -f $GITHUB_WORKSPACE
-if grep -lr --exclude-dir=node_modules "^<<<<<<< " $GITHUB_WORKSPACE
+if [[ "${ACT:-}" != "true" ]]; then
+ python -m compileall -f "${GITHUB_WORKSPACE}"
+fi
+if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
then echo "Found merge conflicts"
exit 1
fi
-sudo apt update -y && sudo apt install redis-server libcups2-dev mariadb-client -y
+# redis-server binary is required for `bench init` version detection; runtime Redis is the workflow service.
+sudo apt update -y && sudo apt install redis-server cups libcups2-dev mariadb-client cron -y
diff --git a/.github/workflows/publish-cups.yml b/.github/workflows/publish-cups.yml
new file mode 100644
index 00000000..e1a4b11c
--- /dev/null
+++ b/.github/workflows/publish-cups.yml
@@ -0,0 +1,61 @@
+name: Publish CUPS container
+
+on:
+ push:
+ branches:
+ - version-14
+ - version-15
+ workflow_dispatch:
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: agritheory/beam-cups
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Resolve CUPS admin credentials
+ id: creds
+ env:
+ CUPS_ADMIN_USER_SECRET: ${{ secrets.CUPS_ADMIN_USER }}
+ CUPS_ADMIN_PASSWORD_SECRET: ${{ secrets.CUPS_ADMIN_PASSWORD }}
+ run: |
+ echo "user=${CUPS_ADMIN_USER_SECRET:-admin}" >> "$GITHUB_OUTPUT"
+ echo "password=${CUPS_ADMIN_PASSWORD_SECRET:-root}" >> "$GITHUB_OUTPUT"
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=raw,value=latest,enable=${{ github.ref_name == 'version-15' }}
+ type=ref,event=branch
+ type=sha,prefix=sha-
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: cups/cups
+ file: cups/cups/Containerfile
+ push: true
+ build-args: |
+ CUPS_ADMIN_USER=${{ steps.creds.outputs.user }}
+ CUPS_ADMIN_PASSWORD=${{ steps.creds.outputs.password }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml
index 2df05b9d..6727787e 100644
--- a/.github/workflows/pytest.yaml
+++ b/.github/workflows/pytest.yaml
@@ -17,13 +17,69 @@ env:
# cancel-in-progress: true
jobs:
+ build-cups:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ outputs:
+ image: ${{ steps.image.outputs.image }}
+ cups_admin_user: ${{ steps.creds.outputs.user }}
+ cups_admin_password: ${{ steps.creds.outputs.password }}
+ steps:
+ - name: Clone
+ uses: actions/checkout@v4
+
+ - name: Resolve CUPS admin credentials
+ id: creds
+ env:
+ CUPS_ADMIN_USER_SECRET: ${{ secrets.CUPS_ADMIN_USER }}
+ CUPS_ADMIN_PASSWORD_SECRET: ${{ secrets.CUPS_ADMIN_PASSWORD }}
+ run: |
+ echo "user=${CUPS_ADMIN_USER_SECRET:-admin}" >> "$GITHUB_OUTPUT"
+ echo "password=${CUPS_ADMIN_PASSWORD_SECRET:-root}" >> "$GITHUB_OUTPUT"
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push CUPS image
+ id: build
+ uses: docker/build-push-action@v6
+ with:
+ context: cups/cups
+ file: cups/cups/Containerfile
+ push: true
+ build-args: |
+ CUPS_ADMIN_USER=${{ steps.creds.outputs.user }}
+ CUPS_ADMIN_PASSWORD=${{ steps.creds.outputs.password }}
+ tags: ghcr.io/agritheory/beam-cups:sha-${{ github.sha }}
+
+ - name: Export image reference
+ id: image
+ run: echo "image=ghcr.io/agritheory/beam-cups:sha-${{ github.sha }}" >> "$GITHUB_OUTPUT"
+
tests:
+ needs: build-cups
runs-on: ${{ matrix.os }}
+ permissions:
+ contents: read
+ packages: read
+ pull-requests: write
strategy:
matrix:
os: [ubuntu-latest]
fail-fast: false
name: Server
+ env:
+ CUPS_ADMIN_USER: ${{ needs.build-cups.outputs.cups_admin_user }}
+ CUPS_ADMIN_PASSWORD: ${{ needs.build-cups.outputs.cups_admin_password }}
+ BEAM_CUPS_HOST: 127.0.0.1
+ BEAM_CUPS_PORT: "1631"
+ BENCH_ROOT: /home/runner/frappe-bench
services:
mariadb:
@@ -33,7 +89,27 @@ jobs:
MYSQL_ROOT_PASSWORD: 'admin'
ports:
- 3306:3306
- options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
+ options: --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -padmin" --health-interval=5s --health-timeout=2s --health-retries=3
+
+ redis:
+ image: redis:7
+ ports:
+ - 6379:6379
+ options: --health-cmd="redis-cli ping" --health-interval=5s --health-timeout=2s --health-retries=3
+
+ cups:
+ image: ${{ needs.build-cups.outputs.image }}
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ ports:
+ - 1631:631
+ options: >-
+ --add-host=host.docker.internal:host-gateway
+ --health-cmd="bash -c 'echo > /dev/tcp/127.0.0.1/631'"
+ --health-interval=5s
+ --health-timeout=3s
+ --health-retries=12
steps:
- name: Clone
@@ -52,6 +128,7 @@ jobs:
cache: 'yarn' # Replaces `Get yarn cache directory path` and `yarn-cache` steps
- name: Add to Hosts
+ if: ${{ !env.ACT }}
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
@@ -81,15 +158,24 @@ jobs:
bash ${{ github.workspace }}/.github/helper/install.sh
- name: Run Tests
- working-directory: /home/runner/frappe-bench
+ working-directory: ${{ env.BENCH_ROOT }}
+ env:
+ POETRY_VIRTUALENVS_CREATE: "false"
+ BEAM_TEST_TELEMETRY: "true"
+ BEAM_TEST_STACK_DUMP_SECONDS: "60"
run: |
+ set -o pipefail
source env/bin/activate
cd apps/beam
poetry install
- pytest --cov=beam --cov-report=xml --disable-warnings -s | tee pytest-coverage.txt
+ pytest --cov=beam --cov-report=xml --cov-report=term-missing:skip-covered --disable-warnings -s | tee pytest-coverage.txt
- name: Pytest coverage comment
+ if: ${{ !env.ACT }}
uses: MishaKav/pytest-coverage-comment@main
with:
- pytest-coverage-path: /home/runner/frappe-bench/apps/beam/pytest-coverage.txt
- pytest-xml-coverage-path: /home/runner/frappe-bench/apps/beam/coverage.xml
+ pytest-coverage-path: ${{ env.BENCH_ROOT }}/apps/beam/pytest-coverage.txt
+ pytest-xml-coverage-path: ${{ env.BENCH_ROOT }}/apps/beam/coverage.xml
+ default-branch: version-15
+ report-only-changed-files: true
+ xml-skip-covered: true
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b6548f25..b3111b61 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -53,7 +53,7 @@ repos:
additional_dependencies: ['flake8-bugbear']
- repo: https://github.com/agritheory/test_utils
- rev: v1.20.2
+ rev: v1.28.0
hooks:
- id: update_pre_commit_config
- id: validate_frappe_project
diff --git a/README.md b/README.md
index 1bb76b50..a886dc17 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,8 @@ mypy ./apps/beam/beam --ignore-missing-imports
pytest ./apps/beam/beam/tests -s --disable-warnings
```
+CUPS integration tests (`test_printer_cups_integration.py`) use the CUPS service container from the pytest workflow (built from [`cups/cups/Containerfile`](./cups/cups/Containerfile)). Locally, publish port 631 from the beam-cups image and set `BEAM_CUPS_HOST` / `BEAM_CUPS_PORT`.
+
### Printer Server setup
```shell
sudo apt-get install gcc cups python3-dev libcups2-dev -y
diff --git a/beam/beam/custom/network_printer_settings.json b/beam/beam/custom/network_printer_settings.json
index aa230540..8a7d50fb 100644
--- a/beam/beam/custom/network_printer_settings.json
+++ b/beam/beam/custom/network_printer_settings.json
@@ -1,98 +1,147 @@
{
- "custom_fields": [
- {
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "dt": "Network Printer Settings",
- "fetch_if_empty": 0,
- "fieldname": "printer_type",
- "fieldtype": "Select",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "printer_name",
- "is_system_generated": 0,
- "is_virtual": 0,
- "label": "Printer Type",
- "length": 0,
- "module": "BEAM",
- "name": "Network Printer Settings-printer_type",
- "no_copy": 0,
- "options": "\nGeneral Purpose\nLabel / RAW",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "dt": "Network Printer Settings",
- "fetch_if_empty": 0,
- "fieldname": "printer_location",
- "fieldtype": "Data",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "printer_type",
- "is_system_generated": 0,
- "is_virtual": 0,
- "label": "Printer Location",
- "length": 0,
- "module": "BEAM",
- "name": "Network Printer Settings-printer_location",
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "sort_options": 0,
- "translatable": 0,
- "unique": 0
- }
- ],
- "property_setters": [
- {
- "doc_type": "Network Printer Settings",
- "doctype_or_field": "DocField",
- "field_name": "printer_name",
- "idx": 0,
- "module": "BEAM",
- "name": "Network Printer Settings-printer_name-fieldtype",
- "property": "fieldtype",
- "property_type": "Data",
- "value": "Autocomplete"
- }
- ],
- "doctype": "Network Printer Settings",
- "sync_on_migrate": 1
-}
+ "custom_fields": [
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "dt": "Network Printer Settings",
+ "fetch_if_empty": 0,
+ "fieldname": "printer_type",
+ "fieldtype": "Select",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "printer_location",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Printer Type",
+ "length": 0,
+ "module": "BEAM",
+ "name": "Network Printer Settings-printer_type",
+ "no_copy": 0,
+ "options": "\nGeneral Purpose\nLabel / RAW",
+ "permlevel": 0,
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "dt": "Network Printer Settings",
+ "fetch_if_empty": 0,
+ "fieldname": "printer_location",
+ "fieldtype": "Data",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_preview": 0,
+ "in_standard_filter": 1,
+ "insert_after": "printer_name",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Printer Location",
+ "length": 0,
+ "module": "BEAM",
+ "name": "Network Printer Settings-printer_location",
+ "no_copy": 0,
+ "permlevel": 0,
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "dt": "Network Printer Settings",
+ "fetch_if_empty": 0,
+ "fieldname": "device_uri",
+ "fieldtype": "Data",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "printer_type",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Device URI",
+ "length": 0,
+ "module": "BEAM",
+ "name": "Network Printer Settings-device_uri",
+ "no_copy": 0,
+ "permlevel": 0,
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 1,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0
+ }
+ ],
+ "doctype": "Network Printer Settings",
+ "property_setters": [
+ {
+ "doc_type": "Network Printer Settings",
+ "doctype_or_field": "DocField",
+ "field_name": "printer_name",
+ "idx": 0,
+ "module": "BEAM",
+ "name": "Network Printer Settings-printer_name-fieldtype",
+ "property": "fieldtype",
+ "property_type": "Data",
+ "value": "Autocomplete"
+ },
+ {
+ "doc_type": "Network Printer Settings",
+ "doctype_or_field": "DocType",
+ "idx": 0,
+ "module": "BEAM",
+ "name": "Network Printer Settings-quick_entry",
+ "property": "quick_entry",
+ "property_type": "Check",
+ "value": "1"
+ }
+ ],
+ "sync_on_migrate": 1
+}
\ No newline at end of file
diff --git a/beam/beam/overrides/network_printer_settings.py b/beam/beam/overrides/network_printer_settings.py
index 0579e59e..3e5fcde0 100644
--- a/beam/beam/overrides/network_printer_settings.py
+++ b/beam/beam/overrides/network_printer_settings.py
@@ -1,36 +1,872 @@
# Copyright (c) 2025, AgriTheory and contributors
# For license information, please see license.txt
+import re
+import shutil
+import socket
+import subprocess
+from contextlib import closing
+from pathlib import Path
+from urllib.parse import urlparse
+
import frappe
from frappe import _
from frappe.printing.doctype.network_printer_settings.network_printer_settings import (
NetworkPrinterSettings,
)
+NPS_DOCTYPE = "Network Printer Settings"
+
+RAW_QUEUE_PPD = "raw"
+GENERAL_PDF_PPD = "everywhere"
+PRINTER_STATE_IDLE = 3
+PRINTER_STATE_PROCESSING = 4
+PRINTER_STATE_STOPPED = 5
+COMMON_PRINTER_OPTIONS = (
+ "media",
+ "MediaSize",
+ "PageSize",
+ "sides",
+ "Sides",
+ "ColorModel",
+ "print-quality",
+ "PrintQuality",
+)
+
+
+def check_network_printer_settings_read_permission():
+ frappe.has_permission(NPS_DOCTYPE, "read", throw=True)
+
+
+def check_network_printer_settings_write_permission():
+ frappe.has_permission(NPS_DOCTYPE, "write", throw=True)
+
+
+def check_network_printer_settings_create_permission():
+ frappe.has_permission(NPS_DOCTYPE, "create", throw=True)
+
+
+def require_cups():
+ try:
+ import cups
+ except ImportError:
+ frappe.throw(
+ _(
+ """This feature can not be used as dependencies are missing.
+ Please contact your system manager to enable this by installing pycups!"""
+ )
+ )
+ return cups
+
+
+def cups_connection(server_ip, port):
+ cups = require_cups()
+ if is_local_cups_server(server_ip, port):
+ # HTTP to localhost:631 requires Basic auth; the Unix socket uses peer credentials.
+ cups.setServer("")
+ cups.setPort(631)
+ return cups.Connection()
+
+ cups.setServer(server_ip)
+ cups.setPort(int(port))
+ return cups.Connection()
+
+
+def is_local_cups_server(server_ip, port):
+ if int(port or 631) != 631:
+ return False
+ normalized = (server_ip or "").strip().lower()
+ return normalized in ("localhost", "127.0.0.1", "::1")
+
+
+def get_nps_by_printer_name(server_ip, port):
+ rows = frappe.get_all(
+ "Network Printer Settings",
+ filters={"server_ip": server_ip, "port": int(port)},
+ fields=["name", "printer_name"],
+ )
+ return {row.printer_name: row.name for row in rows if row.printer_name}
+
+
+def default_ppd_for_type(printer_type):
+ if printer_type == "Label / RAW":
+ return RAW_QUEUE_PPD
+ return GENERAL_PDF_PPD
+
+
+def reject_usb_uri(device_uri):
+ if device_uri and device_uri.startswith("usb://"):
+ frappe.throw(_("USB printers are not supported."))
+
+
+def raise_cups_admin_error(exc):
+ cups = require_cups()
+ if not isinstance(exc, cups.IPPError):
+ raise exc
+
+ status = exc.args[0] if exc.args else None
+ reason = exc.args[1] if len(exc.args) > 1 else str(exc)
+ if status != 4096 and reason != "Unauthorized":
+ frappe.throw(_("CUPS error: {0}").format(reason or exc))
+
+ import getpass
+
+ user = getpass.getuser()
+ frappe.throw(
+ _(
+ "CUPS denied this change (Unauthorized). For a local print server, use Server IP localhost. For remote servers, the bench user ({0}) needs lpadmin membership and CUPS must accept the connection: sudo usermod -aG lpadmin {0}"
+ ).format(user),
+ title=_("CUPS Permission Denied"),
+ )
+
+
+def run_cups_admin(action_label, func):
+ try:
+ return func()
+ except Exception as exc:
+ cups = require_cups()
+ if isinstance(exc, cups.IPPError):
+ raise_cups_admin_error(exc)
+ frappe.throw(_("Failed to {0}: {1}").format(action_label, exc))
+
+
+def parse_device_uri_host(device_uri):
+ if not device_uri:
+ return None
+ try:
+ parsed = urlparse(device_uri)
+ if parsed.hostname:
+ return parsed.hostname
+ except Exception:
+ pass
+ match = re.search(r"://([^:/]+)", device_uri)
+ if not match:
+ return None
+ host = match.group(1)
+ return host
+
+
+def parse_device_uri_port(device_uri):
+ if not device_uri:
+ return None
+ parsed = urlparse(device_uri)
+ if parsed.port:
+ return parsed.port
+ scheme = (parsed.scheme or "").lower()
+ if scheme == "socket":
+ return 9100
+ if scheme in ("ipp", "ipps"):
+ return 631
+ if scheme == "http":
+ return 80
+ if scheme == "https":
+ return 443
+ return None
+
+
+def scheme_needs_socket_check(device_uri):
+ if not device_uri:
+ return False
+ scheme = (urlparse(device_uri).scheme or "").lower()
+ return scheme in ("socket", "ipp", "ipps", "http", "https", "lpd")
+
+
+def check_socket_reachable(host, port, timeout=3):
+ if not host or not port:
+ return False
+ try:
+ with closing(socket.create_connection((host, int(port)), timeout=timeout)):
+ return True
+ except OSError:
+ return False
+
+
+def host_for_reachability_check(host):
+ """Map Docker bridge hostnames to localhost for checks run on the bench host."""
+ normalized = (host or "").strip().lower()
+ if normalized == "host.docker.internal":
+ return "127.0.0.1"
+ return host
+
+
+def validate_device_uri(device_uri):
+ warnings = []
+ host = parse_device_uri_host(device_uri)
+ port = parse_device_uri_port(device_uri)
+ ping_result = (
+ ping_host(host)
+ if host
+ else {"reachable": False, "host": None, "message": _("No host address found in device URI.")}
+ )
+
+ socket_reachable = None
+ if device_uri and scheme_needs_socket_check(device_uri) and host and port:
+ socket_reachable = check_socket_reachable(host_for_reachability_check(host), port)
+
+ if device_uri and device_uri.startswith("socket://") and port and port != 9100:
+ warnings.append(_("Port {0} is unusual for raw socket printing; expected 9100.").format(port))
+
+ if socket_reachable is False:
+ message = _("Cannot connect to {0}:{1}").format(host, port)
+ elif socket_reachable is True:
+ message = _("Connected to {0}:{1}").format(host, port)
+ else:
+ message = ping_result.get("message", "")
+
+ reachable = socket_reachable if socket_reachable is not None else ping_result.get("reachable")
+
+ return {
+ "host": host,
+ "port": port,
+ "ping_result": ping_result,
+ "socket_reachable": socket_reachable,
+ "reachable": reachable,
+ "message": message,
+ "warnings": warnings,
+ "device_uri": device_uri,
+ }
+
+
+def enforce_device_uri_reachable(device_uri):
+ result = validate_device_uri(device_uri)
+ if device_uri and device_uri.startswith("socket://") and result.get("socket_reachable") is False:
+ frappe.throw(result.get("message"))
+ return result
+
+
+@frappe.whitelist()
+def test_device_uri(device_uri):
+ check_network_printer_settings_read_permission()
+ reject_usb_uri(device_uri)
+ return validate_device_uri(device_uri)
+
+
+def normalize_state_reasons(state_reasons):
+ if not state_reasons:
+ return ""
+ if isinstance(state_reasons, (list, tuple)):
+ return ", ".join(str(reason) for reason in state_reasons if reason)
+ return str(state_reasons)
+
+
+def map_printer_state_to_indicator(printer_state, state_reasons=None):
+ reasons = [
+ reason.strip() for reason in normalize_state_reasons(state_reasons).split(",") if reason.strip()
+ ]
+ reason_text = ", ".join(reasons)
+
+ if printer_state == PRINTER_STATE_PROCESSING:
+ return {"color": "blue", "label": _("Processing"), "reasons": reason_text}
+ if printer_state == PRINTER_STATE_STOPPED:
+ label = _("Stopped")
+ if any("offline" in reason.lower() for reason in reasons):
+ label = _("Offline")
+ return {"color": "red", "label": label, "reasons": reason_text}
+ return {"color": "green", "label": _("Idle"), "reasons": reason_text}
+
+
+def get_cups_printer_status_from_attrs(printer):
+ if not printer:
+ return None
+
+ state = printer.get("printer-state")
+ reasons = printer.get("printer-state-reasons", "")
+ indicator = map_printer_state_to_indicator(state, reasons)
+ accepting = printer.get("printer-is-accepting-jobs")
+ if accepting is None:
+ accepting = True
+ elif isinstance(accepting, str):
+ accepting = accepting.lower() in ("true", "1")
+ else:
+ accepting = bool(accepting)
+
+ return {
+ "printer_location": printer.get("printer-location", ""),
+ "device_uri": printer.get("device-uri", ""),
+ "make_model": printer.get("printer-make-and-model", ""),
+ "description": printer.get("printer-info", ""),
+ "printer_state": state,
+ "printer_state_reasons": reasons,
+ "is_accepting_jobs": bool(accepting),
+ "is_enabled": state != PRINTER_STATE_STOPPED,
+ "indicator_color": indicator["color"],
+ "indicator_label": indicator["label"],
+ "state_reasons_display": indicator["reasons"],
+ }
+
+
+def build_test_zpl(queue_name, nps_name):
+ timestamp = frappe.utils.now_datetime().strftime("%Y-%m-%d %H:%M")
+ safe_queue = (queue_name or "").replace("^", "")
+ safe_name = (nps_name or "").replace("^", "")
+ return (
+ f"^XA\n"
+ f"^FO50,50^A0N,40,40^FDTest Print^FS\n"
+ f"^FO50,100^A0N,30,30^FD{safe_queue}^FS\n"
+ f"^FO50,150^A0N,30,30^FD{safe_name}^FS\n"
+ f"^FO50,200^A0N,25,25^FD{timestamp}^FS\n"
+ f"^XZ\n"
+ )
+
+
+def build_test_text(queue_name, nps_name):
+ timestamp = frappe.utils.now_datetime().strftime("%Y-%m-%d %H:%M")
+ return _("Test Print\nQueue: {0}\nPrinter: {1}\n{2}").format(queue_name, nps_name, timestamp)
+
+
+def format_printer_options(conn, printer_name):
+ try:
+ options = conn.getOptions(printer_name)
+ except Exception:
+ return {}
+
+ printer_attrs = conn.getPrinters().get(printer_name, {})
+ formatted = {}
+ for name, choices in options.items():
+ if not choices:
+ continue
+ choice_list = list(choices) if isinstance(choices, (list, tuple)) else [choices]
+ current = printer_attrs.get(name, choice_list[0] if choice_list else "")
+ formatted[name] = {"current": current, "choices": choice_list}
+ return formatted
+
+
+def classify_fleet_row(cups_queue, cups_attrs, nps_doc=None):
+ cups_location = cups_attrs.get("printer-location", "")
+ cups_uri = cups_attrs.get("device-uri", "")
+
+ if not nps_doc:
+ return {
+ "status": _("Orphan Queue"),
+ "indicator_color": "orange",
+ "cups_queue": cups_queue,
+ "nps_name": "",
+ "cups_location": cups_location,
+ "erpnext_location": "",
+ "cups_device_uri": cups_uri,
+ "erpnext_device_uri": "",
+ }
+
+ nps_location = nps_doc.get("printer_location") or ""
+ nps_uri = nps_doc.get("device_uri") or ""
+ status = _("Synced")
+ indicator_color = "green"
+
+ if nps_location.strip() != cups_location.strip() or nps_uri.strip() != cups_uri.strip():
+ status = _("Mismatch")
+ indicator_color = "red"
+
+ status_attrs = get_cups_printer_status_from_attrs(cups_attrs) or {}
+
+ return {
+ "status": status,
+ "indicator_color": indicator_color,
+ "cups_queue": cups_queue,
+ "nps_name": nps_doc.get("name"),
+ "cups_location": cups_location,
+ "erpnext_location": nps_location,
+ "cups_device_uri": cups_uri,
+ "erpnext_device_uri": nps_uri,
+ "accepting_jobs": status_attrs.get("is_accepting_jobs"),
+ "printer_state": status_attrs.get("indicator_label"),
+ }
+
+
+def get_cups_printer_attrs(conn, printer_name):
+ printer = conn.getPrinters().get(printer_name)
+ if not printer:
+ return None
+
+ try:
+ attrs = conn.getPrinterAttributes(printer_name)
+ if attrs:
+ printer = {**printer, **attrs}
+ except Exception:
+ pass
+
+ return get_cups_printer_status_from_attrs(printer)
+
+
+def format_printer_description(make_model, location=""):
+ if location and make_model:
+ return f"{location} — {make_model}"
+ if location:
+ return location
+ return make_model or ""
+
+
+def ping_host(host, count=3, timeout=2):
+ if not host:
+ return {
+ "reachable": False,
+ "host": None,
+ "message": _("No host address found in device URI."),
+ }
+
+ if host in ("localhost", "127.0.0.1", "::1"):
+ return {
+ "reachable": True,
+ "host": host,
+ "message": _("Print server is local."),
+ }
+
+ ping_bin = shutil.which("ping")
+ if not ping_bin:
+ return {
+ "reachable": None,
+ "host": host,
+ "message": _("ping is not available on this server."),
+ }
+
+ try:
+ result = subprocess.run(
+ [ping_bin, "-c", str(count), "-W", str(timeout), host],
+ capture_output=True,
+ text=True,
+ timeout=(count * timeout) + 5,
+ check=False,
+ )
+ reachable = result.returncode == 0
+ message = (
+ _("{0} is reachable").format(host)
+ if reachable
+ else _("{0} did not respond to ping").format(host)
+ )
+ return {
+ "reachable": reachable,
+ "host": host,
+ "message": message,
+ "output": (result.stdout or result.stderr or "")[-500:],
+ }
+ except subprocess.TimeoutExpired:
+ return {
+ "reachable": False,
+ "host": host,
+ "message": _("Ping timed out for {0}").format(host),
+ }
+ except Exception as exc:
+ return {
+ "reachable": False,
+ "host": host,
+ "message": str(exc),
+ }
+
+
+def build_status_indicator(configured, nps_name=None):
+ if configured:
+ color = "green"
+ if nps_name:
+ title = _("Configured as {0}").format(nps_name)
+ else:
+ title = _("Configured in ERPNext")
+ else:
+ color = "orange"
+ title = _("Not configured in ERPNext")
+ safe_title = frappe.utils.escape_html(title)
+ return f' '
+
+
+def build_status_description(base_description, configured, nps_name=None):
+ indicator = build_status_indicator(configured, nps_name)
+ if base_description:
+ safe_description = frappe.utils.escape_html(base_description)
+ return f"{indicator} {safe_description}"
+ return indicator
+
+
+def sort_wizard_entries(entries):
+ entries.sort(key=lambda entry: (entry.get("configured"), (entry.get("label") or "").lower()))
+ return entries
+
+
+def safe_get_devices(conn):
+ cups = require_cups()
+ try:
+ try:
+ return conn.getDevices(include_schemes=["socket", "ipp", "lpd", "dnssd", "http", "https"])
+ except TypeError:
+ return conn.getDevices()
+ except cups.IPPError:
+ return {}
+ except Exception:
+ return {}
+
+
+@frappe.whitelist()
+def get_wizard_devices(server_ip, port):
+ check_network_printer_settings_read_permission()
+ conn = cups_connection(server_ip, port)
+ nps_lookup = get_nps_by_printer_name(server_ip, port)
+ entries = []
+
+ for device_uri, attrs in safe_get_devices(conn).items():
+ if device_uri.startswith("usb://"):
+ continue
+ make_model = attrs.get("device-make-and-model") or attrs.get("device-info") or device_uri
+ label = make_model if make_model != device_uri else device_uri.split("/")[-1] or device_uri
+ entries.append(
+ {
+ "value": device_uri,
+ "label": label,
+ "description": build_status_description(make_model, False),
+ "kind": "device",
+ "device_uri": device_uri,
+ "host": parse_device_uri_host(device_uri),
+ "configured": False,
+ "nps_name": None,
+ }
+ )
+
+ printers = conn.getPrinters()
+ for printer_id, printer in printers.items():
+ make_model = printer.get("printer-make-and-model", "")
+ location = printer.get("printer-location", "")
+ base_description = format_printer_description(make_model, location)
+ configured = printer_id in nps_lookup
+ nps_name = nps_lookup.get(printer_id)
+ entries.append(
+ {
+ "value": printer_id,
+ "label": printer_id,
+ "description": build_status_description(base_description, configured, nps_name),
+ "kind": "queue",
+ "device_uri": printer.get("device-uri", ""),
+ "location": location,
+ "host": parse_device_uri_host(printer.get("device-uri", "")),
+ "configured": configured,
+ "nps_name": nps_name,
+ }
+ )
+
+ return sort_wizard_entries(entries)
+
+
+def safe_get_ppds(conn, kwargs=None):
+ cups = require_cups()
+ kwargs = kwargs or {}
+ try:
+ return conn.getPPDs(**kwargs) if kwargs else conn.getPPDs()
+ except cups.IPPError:
+ return {}
+ except Exception:
+ return {}
+
+
+@frappe.whitelist()
+def get_ppds(server_ip, port, make=None, query=None):
+ check_network_printer_settings_read_permission()
+ conn = cups_connection(server_ip, port)
+ kwargs = {}
+ if make:
+ kwargs["ppd_make"] = make
+ ppds = safe_get_ppds(conn, kwargs)
+ results = []
+ query_lower = (query or "").lower()
+
+ for ppd_name, attrs in ppds.items():
+ make_and_model = attrs.get("ppd-make-and-model") or attrs.get("ppd-make") or ppd_name
+ if (
+ query_lower
+ and query_lower not in make_and_model.lower()
+ and query_lower not in ppd_name.lower()
+ ):
+ continue
+ results.append(
+ {
+ "value": ppd_name,
+ "label": make_and_model,
+ "description": ppd_name,
+ }
+ )
+
+ if not results:
+ for ppd_name, label in (
+ (RAW_QUEUE_PPD, "Raw Queue"),
+ (GENERAL_PDF_PPD, "Generic IPP Everywhere"),
+ ):
+ if query_lower and query_lower not in label.lower() and query_lower not in ppd_name.lower():
+ continue
+ results.append({"value": ppd_name, "label": label, "description": ppd_name})
+
+ results.sort(key=lambda row: row["label"].lower())
+ return results
+
+
+@frappe.whitelist()
+def validate_wizard_selection(
+ server_ip,
+ port,
+ action,
+ printer_name=None,
+ device_uri=None,
+ kind=None,
+):
+ check_network_printer_settings_read_permission()
+ reject_usb_uri(device_uri)
+ nps_lookup = get_nps_by_printer_name(server_ip, port)
+ conn = cups_connection(server_ip, port)
+ cups_printers = conn.getPrinters()
+
+ if action == "link":
+ if kind != "queue":
+ return {
+ "allowed": False,
+ "action": "block",
+ "message": _("Select an existing CUPS queue to link."),
+ }
+ if not printer_name or printer_name not in cups_printers:
+ return {
+ "allowed": False,
+ "action": "block",
+ "message": _("Printer not found on the print server."),
+ }
+ if printer_name in nps_lookup:
+ return {
+ "allowed": False,
+ "action": "block",
+ "message": _("Already configured as {0}").format(nps_lookup[printer_name]),
+ }
+ return {"allowed": True, "action": "link", "message": ""}
+
+ if action == "create":
+ if printer_name in cups_printers:
+ return {
+ "allowed": False,
+ "action": "block",
+ "message": _("{0} already exists on the print server").format(printer_name),
+ }
+ if printer_name in nps_lookup:
+ return {
+ "allowed": False,
+ "action": "block",
+ "message": _("Already configured as {0}").format(nps_lookup[printer_name]),
+ }
+ if not device_uri:
+ return {"allowed": False, "action": "block", "message": _("A device URI is required.")}
+ uri_result = validate_device_uri(device_uri)
+ if device_uri.startswith("socket://") and uri_result.get("socket_reachable") is False:
+ return {
+ "allowed": False,
+ "action": "block",
+ "message": uri_result.get("message"),
+ "warnings": uri_result.get("warnings", []),
+ }
+ return {
+ "allowed": True,
+ "action": "create",
+ "message": "",
+ "warnings": uri_result.get("warnings", []),
+ }
+
+ return {"allowed": False, "action": "block", "message": _("Invalid action.")}
+
+
+@frappe.whitelist()
+def create_printer_queue(
+ name,
+ server_ip,
+ port,
+ printer_name,
+ device_uri,
+ ppdname=None,
+ printer_location="",
+ printer_type="",
+):
+ check_network_printer_settings_create_permission()
+ reject_usb_uri(device_uri)
+ validation = validate_wizard_selection(
+ server_ip, port, "create", printer_name=printer_name, device_uri=device_uri, kind="device"
+ )
+ if not validation.get("allowed"):
+ frappe.throw(validation.get("message"))
+
+ enforce_device_uri_reachable(device_uri)
+
+ if not ppdname:
+ ppdname = default_ppd_for_type(printer_type)
+
+ conn = cups_connection(server_ip, port)
+ try:
+ conn.addPrinter(
+ printer_name,
+ ppdname=ppdname,
+ info=name,
+ location=printer_location or "",
+ device=device_uri,
+ )
+ conn.enablePrinter(printer_name)
+ conn.acceptJobs(printer_name)
+ except Exception as exc:
+ cups = require_cups()
+ if isinstance(exc, cups.IPPError):
+ raise_cups_admin_error(exc)
+ frappe.throw(_("Failed to create printer on print server: {0}").format(exc))
+
+ try:
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": name,
+ "server_ip": server_ip,
+ "port": int(port),
+ "printer_name": printer_name,
+ "printer_location": printer_location,
+ "printer_type": printer_type,
+ "device_uri": device_uri,
+ }
+ )
+ doc.insert()
+ except Exception as exc:
+ try:
+ conn.deletePrinter(printer_name)
+ except Exception:
+ frappe.log_error(
+ title="CUPS rollback failed",
+ message=f"Could not delete orphaned queue {printer_name} after NPS insert failure",
+ )
+ frappe.throw(
+ _("Printer was created on the print server but ERPNext record failed: {0}").format(exc)
+ )
+
+ return doc.as_dict()
+
+
+@frappe.whitelist()
+def link_existing_printer(
+ name,
+ server_ip,
+ port,
+ printer_name,
+ printer_location="",
+ printer_type="",
+):
+ check_network_printer_settings_create_permission()
+ validation = validate_wizard_selection(
+ server_ip, port, "link", printer_name=printer_name, kind="queue"
+ )
+ if not validation.get("allowed"):
+ frappe.throw(validation.get("message"))
+
+ conn = cups_connection(server_ip, port)
+ printer = conn.getPrinters().get(printer_name, {})
+ device_uri = printer.get("device-uri", "")
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": name,
+ "server_ip": server_ip,
+ "port": int(port),
+ "printer_name": printer_name,
+ "printer_location": printer_location or printer.get("printer-location", ""),
+ "printer_type": printer_type,
+ "device_uri": device_uri,
+ }
+ )
+ doc.insert()
+ return doc.as_dict()
+
+
+@frappe.whitelist()
+def get_fleet_status(server_ip=None):
+ check_network_printer_settings_read_permission()
+ servers = frappe.get_all(
+ "Network Printer Settings",
+ fields=["server_ip", "port"],
+ distinct=True,
+ )
+ if server_ip:
+ servers = [row for row in servers if row.server_ip == server_ip]
+
+ rows = []
+ seen_servers = set()
+
+ for server in servers:
+ key = (server.server_ip, int(server.port))
+ if key in seen_servers:
+ continue
+ seen_servers.add(key)
+
+ nps_rows = frappe.get_all(
+ "Network Printer Settings",
+ filters={"server_ip": server.server_ip, "port": server.port},
+ fields=["name", "printer_name", "printer_location", "device_uri"],
+ )
+ nps_by_queue = {row.printer_name: row for row in nps_rows if row.printer_name}
+
+ try:
+ conn = cups_connection(server.server_ip, server.port)
+ cups_printers = conn.getPrinters()
+ except Exception as exc:
+ rows.append(
+ {
+ "status": _("Unavailable"),
+ "indicator_color": "red",
+ "server_ip": server.server_ip,
+ "port": server.port,
+ "cups_queue": "",
+ "nps_name": "",
+ "cups_location": "",
+ "erpnext_location": "",
+ "cups_device_uri": "",
+ "erpnext_device_uri": "",
+ "accepting_jobs": "",
+ "printer_state": str(exc),
+ }
+ )
+ continue
+
+ matched_queues = set()
+ for cups_queue, cups_attrs in cups_printers.items():
+ matched_queues.add(cups_queue)
+ nps_doc = nps_by_queue.get(cups_queue)
+ row = classify_fleet_row(cups_queue, cups_attrs, nps_doc)
+ row.update({"server_ip": server.server_ip, "port": server.port})
+ rows.append(row)
+
+ for nps_doc in nps_rows:
+ if nps_doc.printer_name in matched_queues:
+ continue
+ rows.append(
+ {
+ "status": _("Missing Queue"),
+ "indicator_color": "red",
+ "server_ip": server.server_ip,
+ "port": server.port,
+ "cups_queue": nps_doc.printer_name,
+ "nps_name": nps_doc.name,
+ "cups_location": "",
+ "erpnext_location": nps_doc.printer_location or "",
+ "cups_device_uri": "",
+ "erpnext_device_uri": nps_doc.device_uri or "",
+ "accepting_jobs": "",
+ "printer_state": "",
+ }
+ )
+
+ rows.sort(
+ key=lambda row: (
+ row.get("server_ip") or "",
+ row.get("status") or "",
+ row.get("cups_queue") or "",
+ )
+ )
+ return rows
+
class BEAMNetworkPrinterSettings(NetworkPrinterSettings):
@frappe.whitelist()
- def get_printers_list(self, ip="127.0.0.1", port=631):
+ def get_printers_list(self, ip=None, port=None):
+ server_ip = ip if ip is not None else self.server_ip
+ server_port = int(port if port is not None else (self.port or 631))
printer_list = []
try:
- import cups
- except ImportError:
- frappe.throw(
- _(
- """This feature can not be used as dependencies are missing.
- Please contact your system manager to enable this by installing pycups!"""
- )
- )
- return
- try:
- cups.setServer(self.server_ip)
- cups.setPort(self.port)
- conn = cups.Connection()
+ conn = cups_connection(server_ip, server_port)
printers = conn.getPrinters()
for printer_id, printer in printers.items():
make_model = printer["printer-make-and-model"]
location = printer.get("printer-location", "")
- description = f"{make_model}, {location}" if location else make_model
+ description = format_printer_description(make_model, location)
printer_list.append(
{
"value": printer_id,
@@ -45,6 +881,211 @@ def get_printers_list(self, ip="127.0.0.1", port=631):
frappe.throw(_("Failed to connect to server"))
return printer_list
+ @frappe.whitelist()
+ def configure_printer_queue(
+ self,
+ device_uri=None,
+ ppdname=None,
+ printer_location=None,
+ enabled=None,
+ accept_jobs=None,
+ ):
+ if not self.printer_name:
+ frappe.throw(_("Printer Name is required."))
+
+ reject_usb_uri(device_uri)
+ if device_uri:
+ enforce_device_uri_reachable(device_uri)
+ conn = cups_connection(self.server_ip, self.port)
+
+ def apply_changes():
+ if device_uri:
+ conn.setPrinterDevice(self.printer_name, device_uri)
+ self.device_uri = device_uri
+
+ if ppdname:
+ conn.addPrinter(
+ self.printer_name,
+ ppdname=ppdname,
+ info=self.name,
+ location=printer_location if printer_location is not None else (self.printer_location or ""),
+ device=device_uri
+ or self.device_uri
+ or conn.getPrinters().get(self.printer_name, {}).get("device-uri", ""),
+ )
+
+ if printer_location is not None:
+ conn.setPrinterLocation(self.printer_name, printer_location or "")
+ self.printer_location = printer_location
+
+ if enabled is not None:
+ if frappe.utils.cint(enabled):
+ conn.enablePrinter(self.printer_name)
+ else:
+ conn.disablePrinter(self.printer_name)
+
+ if accept_jobs is not None:
+ if frappe.utils.cint(accept_jobs):
+ conn.acceptJobs(self.printer_name)
+ else:
+ conn.rejectJobs(self.printer_name)
+ elif enabled is not None and frappe.utils.cint(enabled):
+ conn.acceptJobs(self.printer_name)
+
+ run_cups_admin(_("configure printer"), apply_changes)
+ self.save()
+ return self.as_dict()
+
+ @frappe.whitelist()
+ def get_cups_printer_status(self):
+ if not self.printer_name:
+ frappe.throw(_("Printer Name is required."))
+
+ conn = cups_connection(self.server_ip, self.port)
+ status = get_cups_printer_attrs(conn, self.printer_name)
+ if not status:
+ frappe.throw(_("Printer not found on the print server."))
+
+ return status
+
+ @frappe.whitelist()
+ def get_cups_printer_info(self):
+ return self.get_cups_printer_status()
+
+ @frappe.whitelist()
+ def sync_from_cups(self):
+ info = self.get_cups_printer_info()
+ self.printer_location = info.get("printer_location") or ""
+ if info.get("device_uri"):
+ self.device_uri = info["device_uri"]
+ self.save()
+ return self.as_dict()
+
+ @frappe.whitelist()
+ def print_test_page(self):
+ if not self.printer_name:
+ frappe.throw(_("Printer Name is required."))
+
+ conn = cups_connection(self.server_ip, self.port)
+ if self.printer_name not in conn.getPrinters():
+ frappe.throw(_("Printer not found on the print server."))
+
+ if self.printer_type == "Label / RAW":
+ zpl = build_test_zpl(self.printer_name, self.name)
+ file_path = Path(f"/tmp/frappe-zpl-test-{frappe.generate_hash()}.txt")
+ file_path.write_text(zpl)
+ try:
+ job_id = conn.printFile(
+ self.printer_name,
+ str(file_path),
+ _("Test Print"),
+ {"document-format": "application/vnd.cups-raw"},
+ )
+ except Exception as exc:
+ frappe.throw(_("Failed to print test label: {0}").format(exc))
+ finally:
+ file_path.unlink(missing_ok=True)
+ return {
+ "job_id": job_id,
+ "test_type": "zpl",
+ "message": _("Test label submitted to {0}").format(self.printer_name),
+ }
+
+ try:
+ job_id = conn.printTestPage(self.printer_name)
+ return {
+ "job_id": job_id,
+ "test_type": "testpage",
+ "message": _("Test page submitted to {0}").format(self.printer_name),
+ }
+ except Exception:
+ text = build_test_text(self.printer_name, self.name)
+ file_path = Path(f"/tmp/frappe-text-test-{frappe.generate_hash()}.txt")
+ file_path.write_text(text)
+ try:
+ job_id = conn.printFile(
+ self.printer_name,
+ str(file_path),
+ _("Test Print"),
+ {"document-format": "text/plain"},
+ )
+ except Exception as exc:
+ frappe.throw(_("Failed to print test page: {0}").format(exc))
+ finally:
+ file_path.unlink(missing_ok=True)
+ return {
+ "job_id": job_id,
+ "test_type": "text",
+ "message": _("Test page submitted to {0}").format(self.printer_name),
+ }
+
+ @frappe.whitelist()
+ def get_printer_options(self):
+ if not self.printer_name:
+ frappe.throw(_("Printer Name is required."))
+ if self.printer_type == "Label / RAW":
+ return {"options": {}, "message": _("Raw queues have no driver options.")}
+
+ conn = cups_connection(self.server_ip, self.port)
+ if self.printer_name not in conn.getPrinters():
+ frappe.throw(_("Printer not found on the print server."))
+
+ return {"options": format_printer_options(conn, self.printer_name)}
+
+ @frappe.whitelist()
+ def set_printer_options(self, options=None):
+ if not self.printer_name:
+ frappe.throw(_("Printer Name is required."))
+ if self.printer_type == "Label / RAW":
+ frappe.throw(_("Raw queues have no driver options."))
+
+ if isinstance(options, str):
+ options = frappe.parse_json(options)
+ options = options or {}
+
+ conn = cups_connection(self.server_ip, self.port)
+
+ def apply_options():
+ conn.setPrinterOptions(self.printer_name, options)
+
+ run_cups_admin(_("update printer options"), apply_options)
+ return self.get_printer_options()
+
+ @frappe.whitelist()
+ def ping_printer(self):
+ if not self.printer_name:
+ frappe.throw(_("Printer Name is required."))
+
+ conn = cups_connection(self.server_ip, self.port)
+ attrs = get_cups_printer_attrs(conn, self.printer_name)
+ if not attrs:
+ frappe.throw(_("Printer not found on the print server."))
+
+ device_uri = self.device_uri or attrs.get("device_uri") or ""
+ host = parse_device_uri_host(device_uri)
+ result = ping_host(host)
+ result["device_uri"] = device_uri
+ result["cups_location"] = attrs.get("printer_location") or self.printer_location or ""
+ result["make_model"] = attrs.get("make_model") or ""
+ return result
+
+ @frappe.whitelist()
+ def delete_printer_queue(self):
+ if not self.printer_name:
+ frappe.throw(_("Printer Name is required."))
+
+ conn = cups_connection(self.server_ip, self.port)
+ try:
+ conn.deletePrinter(self.printer_name)
+ except Exception as exc:
+ cups = require_cups()
+ if isinstance(exc, cups.IPPError):
+ raise_cups_admin_error(exc)
+ frappe.throw(_("Failed to delete printer from print server: {0}").format(exc))
+
+ frappe.delete_doc("Network Printer Settings", self.name)
+ return {"deleted": self.name}
+
def validate(self):
self.push_location_to_cups()
@@ -52,11 +1093,10 @@ def push_location_to_cups(self):
if not self.printer_name:
return
try:
- import cups
-
- cups.setServer(self.server_ip)
- cups.setPort(self.port)
- conn = cups.Connection()
+ conn = cups_connection(self.server_ip, self.port)
conn.setPrinterLocation(self.printer_name, self.printer_location or "")
- except Exception:
- pass
+ except Exception as exc:
+ frappe.log_error(
+ title="CUPS location sync failed",
+ message=f"Network Printer Settings {self.name}: {exc}",
+ )
diff --git a/beam/beam/page/__init__.py b/beam/beam/page/__init__.py
new file mode 100644
index 00000000..b1279b72
--- /dev/null
+++ b/beam/beam/page/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
diff --git a/beam/beam/page/printer_queue/__init__.py b/beam/beam/page/printer_queue/__init__.py
new file mode 100644
index 00000000..b1279b72
--- /dev/null
+++ b/beam/beam/page/printer_queue/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
diff --git a/beam/beam/page/printer_queue/printer_queue.js b/beam/beam/page/printer_queue/printer_queue.js
new file mode 100644
index 00000000..f8562fb9
--- /dev/null
+++ b/beam/beam/page/printer_queue/printer_queue.js
@@ -0,0 +1,385 @@
+// Copyright (c) 2025, AgriTheory and contributors
+// For license information, please see license.txt
+
+frappe.pages['printer-queue'].on_page_load = function (wrapper) {
+ wrapper.printer_queue = new beam.PrinterQueueController(wrapper)
+}
+
+frappe.pages['printer-queue'].refresh = function (wrapper) {
+ wrapper.printer_queue?.on_page_show()
+}
+
+frappe.provide('beam')
+
+beam.PRINTER_QUEUE_POLL_INTERVAL_MS = 4000
+
+beam.PrinterQueueController = class PrinterQueueController {
+ constructor(wrapper) {
+ this.wrapper = wrapper
+ this.page = frappe.ui.make_app_page({
+ parent: wrapper,
+ title: __('Printer Queue'),
+ single_column: true,
+ })
+ this.task_id = null
+ this.jobs = []
+ this.errors = []
+ this.completed_since = null
+ this.poll_timer = null
+ this.starting_session = false
+ this.polling = false
+ this.can_cancel = frappe.model.can_write('Network Printer Settings')
+ this.make_toolbar()
+ this.make_table()
+ this.bind_route_guard()
+ this.on_page_show()
+ }
+
+ make_toolbar() {
+ this.page.add_inner_button(__('Network Printer Settings'), () => {
+ frappe.set_route('List', 'Network Printer Settings')
+ })
+
+ this.search_field = this.page.add_field({
+ fieldname: 'search',
+ label: __('Search'),
+ fieldtype: 'Data',
+ change: () => this.render_table(),
+ })
+ }
+
+ make_table() {
+ this.$main = $(`
`).appendTo(this.page.main)
+ this.$errors = $(`
`).appendTo(this.$main)
+ this.$table_wrap = $(`
`).appendTo(this.$main)
+ this.$table = $(`
+
+ `).appendTo(this.$table_wrap)
+ }
+
+ bind_route_guard() {
+ if (beam.printer_queue_route_bound) {
+ return
+ }
+ beam.printer_queue_route_bound = true
+ frappe.router.on('change', () => {
+ if (!beam.is_printer_queue_route()) {
+ beam.PrinterQueueController.stop_active_session()
+ }
+ })
+ }
+
+ static active_controller = null
+
+ static stop_active_session() {
+ beam.PrinterQueueController.active_controller?.stop_session()
+ }
+
+ on_page_show() {
+ if (!beam.is_printer_queue_route()) {
+ return
+ }
+ beam.PrinterQueueController.active_controller = this
+ this.start_session()
+ }
+
+ start_session() {
+ if (this.task_id || this.starting_session) {
+ return
+ }
+ this.starting_session = true
+ frappe.call({
+ method: 'beam.beam.printer_queue.start_printer_queue_watcher',
+ freeze: true,
+ freeze_message: __('Connecting to print server...'),
+ callback: ({ message }) => {
+ this.starting_session = false
+ if (!message?.task_id) {
+ return
+ }
+ this.task_id = message.task_id
+ this.apply_snapshot(message)
+ this.start_polling()
+ },
+ error: () => {
+ this.starting_session = false
+ },
+ })
+ }
+
+ stop_session() {
+ if (this.poll_timer) {
+ clearInterval(this.poll_timer)
+ this.poll_timer = null
+ }
+ if (this.task_id) {
+ const task_id = this.task_id
+ this.task_id = null
+ this.completed_since = null
+ frappe.call({
+ method: 'beam.beam.printer_queue.stop_printer_queue_watcher',
+ args: { task_id },
+ })
+ }
+ if (beam.PrinterQueueController.active_controller === this) {
+ beam.PrinterQueueController.active_controller = null
+ }
+ }
+
+ start_polling() {
+ if (this.poll_timer) {
+ clearInterval(this.poll_timer)
+ }
+ this.poll_timer = setInterval(() => {
+ this.poll_queue()
+ }, beam.PRINTER_QUEUE_POLL_INTERVAL_MS)
+ }
+
+ poll_queue() {
+ if (!this.task_id || this.polling) {
+ return
+ }
+ this.polling = true
+ frappe.call({
+ method: 'beam.beam.printer_queue.poll_printer_queue',
+ args: { task_id: this.task_id },
+ callback: ({ message }) => {
+ this.polling = false
+ if (!message) {
+ return
+ }
+ if (message.expired) {
+ this.stop_session()
+ this.start_session()
+ return
+ }
+ this.apply_snapshot(message)
+ },
+ error: () => {
+ this.polling = false
+ },
+ })
+ }
+
+ apply_snapshot(data) {
+ if (data.completed_since != null) {
+ this.completed_since = data.completed_since
+ }
+ const incoming = data.jobs || []
+ const merged = new Map()
+
+ for (const job of incoming) {
+ merged.set(this.job_row_key(job), job)
+ }
+
+ for (const job of this.jobs) {
+ const key = this.job_row_key(job)
+ if (merged.has(key)) {
+ continue
+ }
+ if (this.is_terminal_job(job) && this.job_in_session_window(job)) {
+ merged.set(key, job)
+ }
+ }
+
+ this.jobs = this.sort_jobs(Array.from(merged.values()))
+ this.errors = data.errors || []
+ this.render_errors()
+ this.render_table()
+ }
+
+ job_in_session_window(job) {
+ if (this.completed_since == null) {
+ return false
+ }
+ const finished = job.time_at_completed || job.time_at_creation || 0
+ return finished >= this.completed_since
+ }
+
+ job_row_key(job) {
+ return `${job.server_ip}:${job.port}:${job.job_id}`
+ }
+
+ is_terminal_job(job) {
+ const status = job.display_status || job.job_state
+ return ['printed', 'completed', 'cancelled', 'aborted'].includes(status)
+ }
+
+ sort_jobs(jobs) {
+ const active_states = new Set(['pending', 'held', 'processing', 'stopped'])
+ return jobs.sort((left, right) => {
+ const left_active = active_states.has(left.job_state)
+ const right_active = active_states.has(right.job_state)
+ if (left_active !== right_active) {
+ return left_active ? -1 : 1
+ }
+ const left_finished = left.time_at_completed || left.time_at_creation || 0
+ const right_finished = right.time_at_completed || right.time_at_creation || 0
+ if (left_finished !== right_finished) {
+ return right_finished - left_finished
+ }
+ return right.job_id - left.job_id
+ })
+ }
+
+ render_errors() {
+ this.$errors.empty()
+ if (!this.errors.length) {
+ return
+ }
+ for (const error of this.errors) {
+ this.$errors.append(
+ `${frappe.utils.escape_html(error.server_label)}: ${frappe.utils.escape_html(
+ error.message
+ )}
`
+ )
+ }
+ }
+
+ get_filtered_jobs() {
+ const query = (this.search_field?.get_value() || '').trim().toLowerCase()
+ if (!query) {
+ return this.jobs
+ }
+ return this.jobs.filter(job => {
+ const haystack = [
+ job.job_id,
+ job.job_name,
+ job.user,
+ job.printer,
+ job.server_label,
+ job.job_state,
+ job.display_status,
+ ]
+ .join(' ')
+ .toLowerCase()
+ return haystack.includes(query)
+ })
+ }
+
+ render_table() {
+ const jobs = this.get_filtered_jobs()
+ const columns = [
+ { id: 'job_id', label: __('Job ID') },
+ { id: 'job_name', label: __('Document') },
+ { id: 'user', label: __('User') },
+ { id: 'job_state', label: __('Status') },
+ { id: 'printer', label: __('Printer') },
+ { id: 'server_label', label: __('Server') },
+ { id: 'submitted', label: __('Submitted') },
+ { id: 'finished', label: __('Finished') },
+ { id: 'pages', label: __('Pages') },
+ ]
+ if (this.can_cancel) {
+ columns.push({ id: 'actions', label: '' })
+ }
+
+ const $thead = this.$table.find('thead').empty()
+ const $head_row = $(' ').appendTo($thead)
+ for (const column of columns) {
+ $head_row.append(`${column.label} `)
+ }
+
+ const $tbody = this.$table.find('tbody').empty()
+ if (!jobs.length) {
+ const colspan = columns.length
+ $tbody.append(`${__('No print jobs.')} `)
+ return
+ }
+
+ for (const job of jobs) {
+ const $row = $(' ').appendTo($tbody)
+ $row.append(`${frappe.utils.escape_html(String(job.job_id))} `)
+ $row.append(`${frappe.utils.escape_html(job.job_name || '')} `)
+ $row.append(`${frappe.utils.escape_html(job.user || '')} `)
+ $row.append(`${this.render_status_badge(job)} `)
+ $row.append(`${frappe.utils.escape_html(job.printer || '')} `)
+ $row.append(`${frappe.utils.escape_html(job.server_label || '')} `)
+ $row.append(`${this.format_timestamp(job.time_at_creation)} `)
+ $row.append(`${this.format_timestamp(job.time_at_completed)} `)
+ $row.append(`${frappe.utils.escape_html(this.format_pages(job))} `)
+ if (this.can_cancel) {
+ const $actions = $(' ').appendTo($row)
+ if (this.can_cancel_job(job)) {
+ $actions.append(
+ `${__(
+ 'Cancel'
+ )} `
+ )
+ }
+ }
+ }
+
+ this.$table.find('button[data-job-id]').on('click', event => {
+ const $btn = $(event.currentTarget)
+ this.cancel_job($btn.data('job-id'), $btn.data('server-ip'), $btn.data('port'))
+ })
+ }
+
+ can_cancel_job(job) {
+ return ['pending', 'held', 'processing', 'stopped'].includes(job.job_state)
+ }
+
+ render_status_badge(job) {
+ const state = job.display_status || job.job_state || 'unknown'
+ const color_map = {
+ pending: 'orange',
+ held: 'yellow',
+ processing: 'blue',
+ stopped: 'red',
+ cancelled: 'gray',
+ aborted: 'red',
+ completed: 'green',
+ printed: 'green',
+ }
+ const color = color_map[state] || 'gray'
+ const label = __(state)
+ return `${frappe.utils.escape_html(label)} `
+ }
+
+ format_timestamp(unix_time) {
+ if (!unix_time) {
+ return ''
+ }
+ return frappe.datetime.str_to_user(
+ frappe.datetime.convert_to_user_tz(moment.unix(unix_time).format('YYYY-MM-DD HH:mm:ss'))
+ )
+ }
+
+ format_pages(job) {
+ if (job.pages == null) {
+ return ''
+ }
+ if (job.pages_completed != null) {
+ return `${job.pages_completed}/${job.pages}`
+ }
+ return String(job.pages)
+ }
+
+ cancel_job(job_id, server_ip, port) {
+ frappe.confirm(__('Cancel print job {0}?', [job_id]), () => {
+ frappe.call({
+ method: 'beam.beam.printer_queue.cancel_printer_job',
+ args: { server_ip, port, job_id },
+ freeze: true,
+ callback: () => {
+ frappe.show_alert({
+ message: __('Print job cancelled'),
+ indicator: 'green',
+ })
+ this.poll_queue()
+ },
+ })
+ })
+ }
+}
+
+beam.is_printer_queue_route = function () {
+ const route = frappe.get_route()
+ return route[0] === 'printer-queue'
+}
diff --git a/beam/beam/page/printer_queue/printer_queue.json b/beam/beam/page/printer_queue/printer_queue.json
new file mode 100644
index 00000000..06cd88c9
--- /dev/null
+++ b/beam/beam/page/printer_queue/printer_queue.json
@@ -0,0 +1,25 @@
+{
+ "content": null,
+ "creation": "2026-06-17 00:00:00.000000",
+ "docstatus": 0,
+ "doctype": "Page",
+ "idx": 0,
+ "modified": "2026-06-17 00:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "BEAM",
+ "name": "printer-queue",
+ "owner": "Administrator",
+ "page_name": "printer-queue",
+ "roles": [
+ {
+ "role": "System Manager"
+ },
+ {
+ "role": "Desk User"
+ }
+ ],
+ "script": null,
+ "standard": "Yes",
+ "style": null,
+ "title": "Printer Queue"
+}
diff --git a/beam/beam/printer_queue.py b/beam/beam/printer_queue.py
new file mode 100644
index 00000000..412ed000
--- /dev/null
+++ b/beam/beam/printer_queue.py
@@ -0,0 +1,454 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+import time
+
+import frappe
+from frappe import _
+
+from beam.beam.overrides.network_printer_settings import (
+ check_network_printer_settings_read_permission,
+ check_network_printer_settings_write_permission,
+ cups_connection,
+ is_local_cups_server,
+)
+
+SESSION_CACHE_PREFIX = "printer_queue_session:"
+SESSION_LEASE_SECONDS = 120
+SESSION_LOOKBACK_SECONDS = 120
+COMPLETED_JOBS_FETCH_LIMIT = 100
+
+TERMINAL_JOB_STATES = {"completed", "cancelled", "aborted"}
+ACTIVE_JOB_STATES = {"pending", "held", "processing", "stopped"}
+
+JOB_STATE_LABELS = {
+ 3: "pending",
+ 4: "held",
+ 5: "processing",
+ 6: "stopped",
+ 7: "cancelled",
+ 8: "aborted",
+ 9: "completed",
+}
+
+JOB_ATTRIBUTES = [
+ "job-id",
+ "job-name",
+ "job-state",
+ "job-state-reasons",
+ "job-originating-user-name",
+ "job-printer-uri",
+ "time-at-creation",
+ "time-at-processing",
+ "time-at-completed",
+ "job-media-sheets",
+ "job-media-sheets-completed",
+]
+
+
+def check_printer_queue_read_permission():
+ check_network_printer_settings_read_permission()
+
+
+def check_printer_queue_write_permission():
+ check_network_printer_settings_write_permission()
+
+
+def session_cache_key(task_id):
+ return f"{SESSION_CACHE_PREFIX}{task_id}"
+
+
+def watcher_cache_key(task_id):
+ return session_cache_key(task_id)
+
+
+def list_print_servers():
+ rows = frappe.get_all(
+ "Network Printer Settings",
+ fields=["server_ip", "port"],
+ order_by="server_ip asc, port asc",
+ )
+ servers = []
+ seen = set()
+ for row in rows:
+ server_ip = row.server_ip or "localhost"
+ port = int(row.port or 631)
+ key = (server_ip, port)
+ if key in seen:
+ continue
+ seen.add(key)
+ servers.append({"server_ip": server_ip, "port": port})
+ return servers
+
+
+def get_print_servers():
+ check_printer_queue_read_permission()
+ return list_print_servers()
+
+
+def print_server_label(server_ip, port):
+ if is_local_cups_server(server_ip, port):
+ return _("Local Print Server")
+ return f"{server_ip}:{port}"
+
+
+def printer_name_from_uri(printer_uri):
+ if not printer_uri:
+ return ""
+ if printer_uri.endswith("/"):
+ printer_uri = printer_uri[:-1]
+ return printer_uri.rsplit("/", 1)[-1]
+
+
+def normalize_job_state(state_value):
+ if state_value is None:
+ return "unknown"
+ try:
+ state_int = int(state_value)
+ except (TypeError, ValueError):
+ return str(state_value).lower()
+ return JOB_STATE_LABELS.get(state_int, str(state_int))
+
+
+def display_status_for_state(job_state):
+ if job_state == "completed":
+ return "printed"
+ return job_state
+
+
+def coerce_timestamp(value):
+ if value is None:
+ return None
+ if isinstance(value, (list, tuple)):
+ value = value[0] if value else None
+ if value is None:
+ return None
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def normalize_job(job_id, attrs, server_ip, port):
+ creation = coerce_timestamp(attrs.get("time-at-creation"))
+ job_state = normalize_job_state(attrs.get("job-state"))
+ return {
+ "job_id": int(job_id),
+ "job_name": attrs.get("job-name") or attrs.get("job-name-orig") or "",
+ "job_state": job_state,
+ "display_status": display_status_for_state(job_state),
+ "job_state_reasons": attrs.get("job-state-reasons") or [],
+ "user": attrs.get("job-originating-user-name") or "",
+ "printer": printer_name_from_uri(attrs.get("job-printer-uri") or ""),
+ "printer_uri": attrs.get("job-printer-uri") or "",
+ "server_ip": server_ip,
+ "port": int(port),
+ "server_label": print_server_label(server_ip, port),
+ "time_at_creation": creation,
+ "time_at_processing": coerce_timestamp(attrs.get("time-at-processing")),
+ "time_at_completed": coerce_timestamp(attrs.get("time-at-completed")),
+ "pages": attrs.get("job-media-sheets"),
+ "pages_completed": attrs.get("job-media-sheets-completed"),
+ }
+
+
+def merge_job_rows(rows):
+ merged = {}
+ for row in rows:
+ key = (row["server_ip"], row["port"], row["job_id"])
+ existing = merged.get(key)
+ if not existing or row.get("time_at_creation", 0) >= existing.get("time_at_creation", 0):
+ merged[key] = row
+ return list(merged.values())
+
+
+def sort_timestamp(value):
+ return value if value is not None else 0
+
+
+def sort_job_rows(rows):
+ def sort_key(row):
+ is_active = row.get("job_state") in ACTIVE_JOB_STATES
+ completed_at = sort_timestamp(row.get("time_at_completed"))
+ if row.get("time_at_completed") is None:
+ completed_at = sort_timestamp(row.get("time_at_creation"))
+ created_at = sort_timestamp(row.get("time_at_creation"))
+ return (is_active, completed_at, created_at, row["job_id"])
+
+ rows.sort(key=sort_key, reverse=True)
+ return rows
+
+
+def job_row_cache_key(row):
+ return f"{row['server_ip']}:{row['port']}:{row['job_id']}"
+
+
+def job_finished_at(row):
+ if row.get("time_at_completed") is not None:
+ return row["time_at_completed"]
+ if row.get("time_at_creation") is not None:
+ return row["time_at_creation"]
+ return None
+
+
+def job_in_session_window(row, completed_since, allow_unknown_terminal=False):
+ if row.get("job_state") not in TERMINAL_JOB_STATES:
+ return True
+ finished = job_finished_at(row)
+ if finished is not None and finished >= completed_since:
+ return True
+ return allow_unknown_terminal and finished is None
+
+
+def fetch_job_row(server_ip, port, job_id):
+ conn = cups_connection(server_ip, port)
+ attrs = conn.getJobAttributes(int(job_id), requested_attributes=JOB_ATTRIBUTES)
+ if not attrs:
+ return None
+ return normalize_job(job_id, attrs, server_ip, port)
+
+
+def fetch_jobs_for_server(
+ server_ip,
+ port,
+ completed_since=None,
+ include_completed=False,
+ completed_limit=COMPLETED_JOBS_FETCH_LIMIT,
+):
+ conn = cups_connection(server_ip, port)
+ active_jobs = conn.getJobs(
+ which_jobs="not-completed",
+ requested_attributes=JOB_ATTRIBUTES,
+ )
+ rows = []
+ for job_id, attrs in (active_jobs or {}).items():
+ rows.append(normalize_job(job_id, attrs, server_ip, port))
+
+ if include_completed and completed_since is not None:
+ completed_jobs = conn.getJobs(
+ which_jobs="completed",
+ limit=completed_limit,
+ requested_attributes=JOB_ATTRIBUTES,
+ )
+ for job_id, attrs in (completed_jobs or {}).items():
+ row = normalize_job(job_id, attrs, server_ip, port)
+ if job_in_session_window(row, completed_since, allow_unknown_terminal=True):
+ rows.append(row)
+
+ return sort_job_rows(merge_job_rows(rows))
+
+
+def get_session_state(task_id):
+ return frappe.cache.get_value(session_cache_key(task_id), expires=True) or {}
+
+
+def get_watcher_state(task_id):
+ return get_session_state(task_id)
+
+
+def merge_session_jobs(jobs, session_jobs):
+ if not session_jobs:
+ return jobs
+ merged = {(row["server_ip"], row["port"], row["job_id"]): row for row in jobs}
+ for row in session_jobs.values():
+ key = (row["server_ip"], row["port"], row["job_id"])
+ if key in merged:
+ continue
+ merged[key] = row
+ return list(merged.values())
+
+
+def apply_session_state(task_id, current_jobs):
+ state = get_session_state(task_id)
+ if not state:
+ return current_jobs
+
+ completed_since = state.get("completed_since")
+ session_jobs = dict(state.get("session_jobs") or {})
+ seen_active = dict(state.get("seen_active") or {})
+ current_by_key = {job_row_cache_key(row): row for row in current_jobs}
+ current_active_keys = {
+ key for key, row in current_by_key.items() if row.get("job_state") in ACTIVE_JOB_STATES
+ }
+
+ for key in current_active_keys:
+ seen_active[key] = current_by_key[key]
+
+ for key, prev_row in list(seen_active.items()):
+ if key in current_active_keys:
+ continue
+ if key in session_jobs:
+ del seen_active[key]
+ continue
+ if key in current_by_key:
+ row = current_by_key[key]
+ if row.get("job_state") in TERMINAL_JOB_STATES:
+ session_jobs[key] = row
+ del seen_active[key]
+ continue
+ try:
+ row = fetch_job_row(prev_row["server_ip"], prev_row["port"], prev_row["job_id"])
+ except Exception:
+ row = None
+ if row and row.get("job_state") in TERMINAL_JOB_STATES:
+ session_jobs[key] = row
+ del seen_active[key]
+ elif row:
+ seen_active[key] = row
+ else:
+ session_jobs[key] = {
+ **prev_row,
+ "job_state": "completed",
+ "display_status": "printed",
+ "time_at_completed": int(time.time()),
+ }
+ del seen_active[key]
+
+ for key, row in current_by_key.items():
+ if row.get("job_state") in TERMINAL_JOB_STATES:
+ session_jobs[key] = row
+
+ state["session_jobs"] = session_jobs
+ state["seen_active"] = seen_active
+ frappe.cache.set_value(
+ session_cache_key(task_id),
+ state,
+ expires_in_sec=SESSION_LEASE_SECONDS,
+ )
+ return merge_session_jobs(current_jobs, session_jobs)
+
+
+def collect_printer_jobs_snapshot(task_id=None):
+ completed_since = None
+ include_completed = False
+ if task_id:
+ state = get_session_state(task_id)
+ completed_since = state.get("completed_since")
+ include_completed = bool(state.get("fetch_completed_once"))
+
+ jobs = []
+ errors = []
+ for server in list_print_servers():
+ try:
+ jobs.extend(
+ fetch_jobs_for_server(
+ server["server_ip"],
+ server["port"],
+ completed_since,
+ include_completed=include_completed,
+ )
+ )
+ except Exception as exc:
+ errors.append(
+ {
+ "server_ip": server["server_ip"],
+ "port": server["port"],
+ "server_label": print_server_label(server["server_ip"], server["port"]),
+ "message": str(exc),
+ }
+ )
+
+ result_jobs = sort_job_rows(merge_job_rows(jobs))
+ if task_id:
+ if include_completed:
+ state = get_session_state(task_id)
+ if state and state.get("fetch_completed_once"):
+ state["fetch_completed_once"] = False
+ frappe.cache.set_value(
+ session_cache_key(task_id),
+ state,
+ expires_in_sec=SESSION_LEASE_SECONDS,
+ )
+ result_jobs = apply_session_state(task_id, result_jobs)
+ return {
+ "jobs": sort_job_rows(merge_job_rows(result_jobs)),
+ "errors": errors,
+ "completed_since": completed_since,
+ }
+
+
+def get_printer_jobs_snapshot():
+ check_printer_queue_read_permission()
+ return collect_printer_jobs_snapshot()
+
+
+def set_session_lease(task_id, user, session_started_at=None, completed_since=None):
+ existing = get_session_state(task_id)
+ state = {
+ "user": user,
+ "task_id": task_id,
+ "session_started_at": session_started_at or existing.get("session_started_at"),
+ "completed_since": (
+ completed_since if completed_since is not None else existing.get("completed_since")
+ ),
+ "fetch_completed_once": (
+ True if session_started_at is not None else existing.get("fetch_completed_once", False)
+ ),
+ "session_jobs": existing.get("session_jobs") or {},
+ "seen_active": existing.get("seen_active") or {},
+ }
+ frappe.cache.set_value(
+ session_cache_key(task_id),
+ state,
+ expires_in_sec=SESSION_LEASE_SECONDS,
+ )
+ return state
+
+
+def set_watcher_lease(task_id, user, session_started_at=None, completed_since=None):
+ return set_session_lease(task_id, user, session_started_at, completed_since)
+
+
+def session_lease_active(task_id):
+ return bool(frappe.cache.get_value(session_cache_key(task_id), expires=True))
+
+
+def watcher_lease_active(task_id):
+ return session_lease_active(task_id)
+
+
+@frappe.whitelist()
+def start_printer_queue_watcher():
+ check_printer_queue_read_permission()
+ task_id = frappe.generate_hash(length=12)
+ user = frappe.session.user
+ session_started_at = int(time.time())
+ completed_since = session_started_at - SESSION_LOOKBACK_SECONDS
+ set_session_lease(
+ task_id,
+ user,
+ session_started_at=session_started_at,
+ completed_since=completed_since,
+ )
+ snapshot = collect_printer_jobs_snapshot(task_id=task_id)
+ return {"task_id": task_id, **snapshot}
+
+
+@frappe.whitelist()
+def poll_printer_queue(task_id):
+ check_printer_queue_read_permission()
+ if not task_id or not session_lease_active(task_id):
+ return {"expired": True, "task_id": task_id}
+ set_session_lease(task_id, frappe.session.user)
+ snapshot = collect_printer_jobs_snapshot(task_id=task_id)
+ return {"expired": False, "task_id": task_id, **snapshot}
+
+
+@frappe.whitelist()
+def stop_printer_queue_watcher(task_id):
+ check_printer_queue_read_permission()
+ if not task_id:
+ return {"stopped": False}
+ frappe.cache.delete_value(session_cache_key(task_id))
+ return {"stopped": True, "task_id": task_id}
+
+
+@frappe.whitelist()
+def cancel_printer_job(server_ip, port, job_id):
+ check_printer_queue_write_permission()
+ conn = cups_connection(server_ip, int(port))
+ try:
+ conn.cancelJob(int(job_id))
+ except Exception as exc:
+ frappe.throw(_("Could not cancel print job: {0}").format(exc))
+ return {"cancelled": True, "job_id": int(job_id)}
diff --git a/beam/beam/printing.py b/beam/beam/printing.py
index c78ece1b..7481c989 100644
--- a/beam/beam/printing.py
+++ b/beam/beam/printing.py
@@ -13,10 +13,13 @@
from jinja2 import DebugUndefined, Environment
from pypdf import PdfWriter
+from beam.beam.overrides.network_printer_settings import cups_connection, require_cups
+
try:
- import cups
-except Exception as e:
- frappe.log_error(e, "CUPS is not installed on this server")
+ cups = require_cups()
+except Exception:
+ cups = None
+ frappe.log_error("CUPS is not installed on this server", "CUPS Import Error")
@frappe.whitelist()
@@ -40,10 +43,10 @@ def print_by_server(
if not print_format:
print_format = "Standard"
print_format = frappe.get_doc("Print Format", print_format)
+ if not cups:
+ frappe.throw(frappe._("CUPS is not installed on this server"))
try:
- cups.setServer(print_settings.server_ip)
- cups.setPort(print_settings.port)
- conn = cups.Connection()
+ conn = cups_connection(print_settings.server_ip, print_settings.port)
if print_format.raw_printing == 1:
output = ""
# using a custom jinja environment so we don't have to use frappe's formatting
diff --git a/beam/beam/report/printer_fleet_status/__init__.py b/beam/beam/report/printer_fleet_status/__init__.py
new file mode 100644
index 00000000..ea2ab3f3
--- /dev/null
+++ b/beam/beam/report/printer_fleet_status/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026, AgriTheory and contributors
+# For license information, please see license.txt
diff --git a/beam/beam/report/printer_fleet_status/printer_fleet_status.js b/beam/beam/report/printer_fleet_status/printer_fleet_status.js
new file mode 100644
index 00000000..ca126361
--- /dev/null
+++ b/beam/beam/report/printer_fleet_status/printer_fleet_status.js
@@ -0,0 +1,12 @@
+// Copyright (c) 2025, AgriTheory and contributors
+// For license information, please see license.txt
+
+frappe.query_reports['Printer Fleet Status'] = {
+ filters: [
+ {
+ fieldname: 'server_ip',
+ label: __('Server IP'),
+ fieldtype: 'Data',
+ },
+ ],
+}
diff --git a/beam/beam/report/printer_fleet_status/printer_fleet_status.json b/beam/beam/report/printer_fleet_status/printer_fleet_status.json
new file mode 100644
index 00000000..f74fc75a
--- /dev/null
+++ b/beam/beam/report/printer_fleet_status/printer_fleet_status.json
@@ -0,0 +1,29 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2026-06-17 12:00:00.000000",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "modified": "2026-06-17 12:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "BEAM",
+ "name": "Printer Fleet Status",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Network Printer Settings",
+ "report_name": "Printer Fleet Status",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "System Manager"
+ },
+ {
+ "role": "Stock Manager"
+ }
+ ]
+}
diff --git a/beam/beam/report/printer_fleet_status/printer_fleet_status.py b/beam/beam/report/printer_fleet_status/printer_fleet_status.py
new file mode 100644
index 00000000..8ecf6596
--- /dev/null
+++ b/beam/beam/report/printer_fleet_status/printer_fleet_status.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+
+from beam.beam.overrides.network_printer_settings import get_fleet_status
+
+
+def execute(filters=None):
+ filters = frappe._dict(filters or {})
+ columns = get_columns()
+ data = get_fleet_status(filters.get("server_ip"))
+ return columns, data
+
+
+def get_columns():
+ return [
+ {"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 140},
+ {"label": _("Server IP"), "fieldname": "server_ip", "fieldtype": "Data", "width": 120},
+ {"label": _("Port"), "fieldname": "port", "fieldtype": "Int", "width": 70},
+ {"label": _("CUPS Queue"), "fieldname": "cups_queue", "fieldtype": "Data", "width": 140},
+ {
+ "label": _("Network Printer Settings"),
+ "fieldname": "nps_name",
+ "fieldtype": "Link",
+ "options": "Network Printer Settings",
+ "width": 180,
+ },
+ {"label": _("CUPS Location"), "fieldname": "cups_location", "fieldtype": "Data", "width": 160},
+ {
+ "label": _("ERPNext Location"),
+ "fieldname": "erpnext_location",
+ "fieldtype": "Data",
+ "width": 160,
+ },
+ {
+ "label": _("CUPS Device URI"),
+ "fieldname": "cups_device_uri",
+ "fieldtype": "Data",
+ "width": 220,
+ },
+ {
+ "label": _("ERPNext Device URI"),
+ "fieldname": "erpnext_device_uri",
+ "fieldtype": "Data",
+ "width": 220,
+ },
+ {"label": _("Accepting Jobs"), "fieldname": "accepting_jobs", "fieldtype": "Data", "width": 110},
+ {"label": _("Printer State"), "fieldname": "printer_state", "fieldtype": "Data", "width": 120},
+ ]
diff --git a/beam/beam/scan/__init__.py b/beam/beam/scan/__init__.py
index ad382736..7ee59668 100644
--- a/beam/beam/scan/__init__.py
+++ b/beam/beam/scan/__init__.py
@@ -217,6 +217,13 @@ def get_list_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di
return actions
+def set_item_stock_uom(target: frappe._dict, item_code: str) -> None:
+ stock_uom = frappe.get_cached_value("Item", item_code, "stock_uom")
+ if stock_uom:
+ target.uom = stock_uom
+ target.stock_uom = stock_uom
+
+
def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[dict[str, Any]]:
target = None
beam_override = frappe.get_hooks("beam_frm")
@@ -237,7 +244,7 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di
}
)
elif has_frm_override:
- # A beam_frm override handles this form - skip get_item_details() which would
+ # A beam_frm override handles this form — skip get_item_details() which would
# fail for forms without a standard "{doctype} Item" child table.
target = frappe._dict(
{
@@ -245,6 +252,7 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di
"item_code": hu_details.item_code,
}
)
+ set_item_stock_uom(target, hu_details.item_code)
else:
target = get_item_details(
{
@@ -285,6 +293,7 @@ def get_form_action(barcode_doc: frappe._dict, context: frappe._dict) -> list[di
"item_code": barcode_doc.doc.name,
}
)
+ set_item_stock_uom(target, barcode_doc.doc.name)
else:
target = get_item_details(
{
diff --git a/beam/docs/print_server.md b/beam/docs/print_server.md
index ad0cdb0d..0d1bfe9f 100644
--- a/beam/docs/print_server.md
+++ b/beam/docs/print_server.md
@@ -4,7 +4,7 @@ For license information, please see license.txt-->
# Print Server
- Tyler Matteson 2026-02-23
+ Rohan Bansal, Heather Kusmierz, and Tyler Matteson 2026-06-23
@@ -14,13 +14,43 @@ There are several steps to get a print server connected in ERPNext.
- [OpenPrinting CUPS installation and configuration instructions](https://github.com/OpenPrinting/cups/blob/master/INSTALL.md)
- [PyCUPS dependencies, compiling, and installation information](https://github.com/OpenPrinting/pycups)
-2. Next, the user must add their Zebra printer to the CUPS server. This can be done by following the official CUPS documentation, which can be found [here](https://supportcommunity.zebra.com/s/article/Adding-a-Zebra-Printer-in-a-CUPS-Printing-System).
+2. Add the bench user to the `lpadmin` group when administering a local CUPS server: `sudo usermod -aG lpadmin {username}`. Use **Server IP** `localhost` (not `127.0.0.1`) so BEAM uses the CUPS Unix socket and admin operations work without HTTP authentication errors.
-3. The user must also create a new `Network Printer Settings` document and fill in the relevant information.
+3. Add a network printer using the **Network Printer Settings** wizard (**New** opens a guided setup). The **Stock Manager** at **Ambrosia Pie Company** can register a ZD621 on the Chelsea receiving dock when **Chelsea Fruit Co** deliveries arrive — without opening the CUPS web UI. **Administrator** can repeat the wizard during go-live or link a queue that already exists on CUPS (for example `vRAW`).
+
+4. The wizard walks through print server connection, discovery, queue details, and driver selection. Discovery shows network printers and existing CUPS queues, sorted with unconfigured entries first. Use **Test Connection** on the discovery and details steps to ping the host and verify the device URI before creating the queue. USB printers are not supported.

-The **Printer Name** field is an autocomplete that queries the configured CUPS server and displays available printers by their CUPS identifier, with the make/model and location shown as secondary text. Selecting a printer automatically fills in the **Printer Location** field from CUPS. The location can be edited freely — saving the record pushes the updated value back to CUPS, keeping the two in sync. The **Printer Type** field (`General Purpose` or `Label / RAW`) can be used to distinguish IPP or PDF printers from ZPL/raw label printers.
+The friendly **Network Printer Settings** name (for example **Chelsea Receiving Labels**) is separate from the CUPS queue name (for example `ZD621`). The **Printer Name** field on the full form is an autocomplete that queries the configured CUPS server and displays available printers by their CUPS identifier, with the physical **Printer Location** and make/model shown as secondary text (location first when set). Selecting a printer automatically fills in **Printer Location** from CUPS. The location can be edited freely — saving the record pushes the updated value back to CUPS, keeping the two in sync. **Printer Location** also appears in the list view for fleet management. The **Printer Type** field (`General Purpose` or `Label / RAW`) distinguishes PDF printers from ZPL/raw label printers.
+
+Saved records show a live **CUPS status** panel (idle/processing/offline indicator, make/model, CUPS description, accepting jobs).
+
+On a saved record, **Administrator** can use:
+
+| Action | Purpose |
+|--------|---------|
+| **Configure Printer** | Change device URI, driver (PPD), location, enabled state, and accepting jobs |
+| **Sync from CUPS** | Pull the latest location and device URI from the print server |
+| **Print Test** | Submit a ZPL test label for **Label / RAW** queues, or a CUPS/text test for **General Purpose** |
+| **Printer Options** | View and set CUPS driver defaults (General Purpose only) |
+| **Ping Printer** | Ping the host extracted from the device URI |
+| **Delete from CUPS** | Remove the queue and ERPNext record when decommissioning hardware |
+
+Use the **Printer Fleet Status** report to compare CUPS queues against ERPNext records (synced, orphan, missing, mismatch) across print servers.
+
+## Testing (CUPS container)
+
+Integration tests use the CUPS **service container** from the pytest workflow (no testcontainers):
+
+```bash
+pytest beam/beam/tests/test_printer_logic.py
+pytest beam/beam/tests/test_printer_cups_integration.py
+```
+
+CI builds and runs `ghcr.io/agritheory/beam-cups:sha-` as a workflow service on port 631. Locally, run the image with `-p 631:631` and set `BEAM_CUPS_HOST` / `BEAM_CUPS_PORT`. Optional env vars: `CUPS_ADMIN_USER`, `CUPS_ADMIN_PASSWORD` (defaults match `cups/.env.example`).
+
+Pure logic tests run without CUPS. Integration tests use `test_utils.printers` mock servers (TCP raw + IPP) plus real pycups/CUPS queue creation against the container on a mapped HTTP port.
---
diff --git a/beam/hooks.py b/beam/hooks.py
index e369589a..137bce13 100644
--- a/beam/hooks.py
+++ b/beam/hooks.py
@@ -29,14 +29,18 @@
# webform_include_css = {"doctype": "public/css/doctype.css"}
# include js in page
-# page_js = {"page" : "public/js/file.js"}
+page_js = {
+ "printer-queue": "beam/page/printer_queue/printer_queue.js",
+}
# include js in doctype views
doctype_js = {
"Network Printer Settings": "public/js/network_printer_settings_custom.js",
"Stock Entry": "public/js/stock_entry_custom.js",
}
-# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
+doctype_list_js = {
+ "Network Printer Settings": "public/js/network_printer_settings_list.js",
+}
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
diff --git a/beam/public/js/beam.bundle.js b/beam/public/js/beam.bundle.js
index 70eaa466..17e3d347 100644
--- a/beam/public/js/beam.bundle.js
+++ b/beam/public/js/beam.bundle.js
@@ -3,4 +3,5 @@
import './scan/scan.js'
import './print/print.js'
+import './network_printer_settings_quick_entry.js'
// import './example_custom_callback.js'
diff --git a/beam/public/js/network_printer_settings_custom.js b/beam/public/js/network_printer_settings_custom.js
index e98acf69..a35113bf 100644
--- a/beam/public/js/network_printer_settings_custom.js
+++ b/beam/public/js/network_printer_settings_custom.js
@@ -9,12 +9,340 @@ function set_location_from_cache(frm, printer_name) {
frm.set_value('printer_location', match ? match.location || '' : '')
}
+function load_ppds_for_dialog(server_ip, port, field) {
+ return frappe
+ .call({
+ method: 'beam.beam.overrides.network_printer_settings.get_ppds',
+ args: { server_ip, port },
+ })
+ .then(r => {
+ field.set_data(r.message || [])
+ return r.message || []
+ })
+}
+
+function render_cups_status_dashboard(frm, status) {
+ if (!status) {
+ return
+ }
+
+ const color = status.indicator_color || 'green'
+ const label = frappe.utils.escape_html(status.indicator_label || __('Unknown'))
+ const make_model = frappe.utils.escape_html(status.make_model || '')
+ const description = frappe.utils.escape_html(status.description || '')
+ const accepting = status.is_accepting_jobs ? __('Accepting jobs') : __('Rejecting jobs')
+ const reasons =
+ status.state_reasons_display && status.state_reasons_display !== 'none'
+ ? frappe.utils.escape_html(status.state_reasons_display)
+ : ''
+
+ const lines = [` ${label}`, make_model, description, accepting]
+ if (reasons) {
+ lines.push(reasons)
+ }
+
+ const html = `${lines.filter(Boolean).join(' ')}
`
+
+ if (!frm.cups_status_wrapper || !$.contains(document, frm.cups_status_wrapper[0])) {
+ frm.cups_status_wrapper = $(
+ `
`
+ ).appendTo(frm.layout.wrapper.find('.form-page'))
+ }
+ frm.cups_status_wrapper.html(html)
+}
+
+function refresh_cups_status_dashboard(frm) {
+ if (frm.is_new() || !frm.doc.printer_name) {
+ return
+ }
+ frappe.call({
+ doc: frm.doc,
+ method: 'get_cups_printer_status',
+ callback(r) {
+ render_cups_status_dashboard(frm, r.message || {})
+ },
+ })
+}
+
+function show_configure_dialog(frm) {
+ frappe.call({
+ doc: frm.doc,
+ method: 'get_cups_printer_status',
+ callback(r) {
+ const status = r.message || {}
+ const dialog = new frappe.ui.Dialog({
+ title: __('Configure Printer'),
+ fields: [
+ {
+ fieldname: 'device_uri',
+ fieldtype: 'Data',
+ label: __('Device URI'),
+ default: frm.doc.device_uri,
+ },
+ {
+ fieldname: 'ppdname',
+ fieldtype: 'Autocomplete',
+ label: __('Driver (PPD)'),
+ },
+ {
+ fieldname: 'printer_location',
+ fieldtype: 'Data',
+ label: __('Printer Location'),
+ default: frm.doc.printer_location,
+ description: __('Physical location shown in CUPS and ERPNext, e.g. Chelsea Receiving Dock'),
+ },
+ {
+ fieldname: 'enabled',
+ fieldtype: 'Check',
+ label: __('Enabled'),
+ default: status.is_enabled ? 1 : 0,
+ },
+ {
+ fieldname: 'accept_jobs',
+ fieldtype: 'Check',
+ label: __('Accepting Jobs'),
+ default: status.is_accepting_jobs ? 1 : 0,
+ },
+ ],
+ primary_action_label: __('Save'),
+ primary_action(values) {
+ const ppd_options = dialog.fields_dict.ppdname._data || []
+ const ppd_match = ppd_options.find(
+ option => option.label === values.ppdname || option.value === values.ppdname
+ )
+ frappe.call({
+ doc: frm.doc,
+ method: 'configure_printer_queue',
+ args: {
+ device_uri: values.device_uri,
+ ppdname: ppd_match ? ppd_match.value : values.ppdname,
+ printer_location: values.printer_location,
+ enabled: values.enabled ? 1 : 0,
+ accept_jobs: values.accept_jobs ? 1 : 0,
+ },
+ freeze: true,
+ callback() {
+ dialog.hide()
+ frm.reload_doc()
+ },
+ })
+ },
+ })
+ dialog.show()
+ load_ppds_for_dialog(frm.doc.server_ip, frm.doc.port, dialog.fields_dict.ppdname)
+ },
+ })
+}
+
+function show_printer_options_dialog(frm) {
+ frappe.call({
+ doc: frm.doc,
+ method: 'get_printer_options',
+ callback(r) {
+ const payload = r.message || {}
+ const options = payload.options || {}
+ if (!Object.keys(options).length) {
+ frappe.msgprint(payload.message || __('No printer options available for this queue.'))
+ return
+ }
+
+ const common_names = [
+ 'media',
+ 'MediaSize',
+ 'PageSize',
+ 'sides',
+ 'Sides',
+ 'ColorModel',
+ 'print-quality',
+ 'PrintQuality',
+ ]
+ const fields = []
+ const seen = new Set()
+
+ common_names.forEach(name => {
+ if (!options[name]) {
+ return
+ }
+ seen.add(name)
+ fields.push({
+ fieldname: name,
+ fieldtype: 'Select',
+ label: frappe.model.unscrub(name),
+ options: options[name].choices.join('\n'),
+ default: options[name].current,
+ })
+ })
+
+ Object.keys(options)
+ .sort()
+ .forEach(name => {
+ if (seen.has(name)) {
+ return
+ }
+ fields.push({
+ fieldname: name,
+ fieldtype: 'Select',
+ label: frappe.model.unscrub(name),
+ options: options[name].choices.join('\n'),
+ default: options[name].current,
+ })
+ })
+
+ const dialog = new frappe.ui.Dialog({
+ title: __('Printer Options'),
+ fields,
+ size: 'large',
+ primary_action_label: __('Save'),
+ primary_action(values) {
+ const selected = {}
+ Object.keys(options).forEach(name => {
+ if (values[name]) {
+ selected[name] = values[name]
+ }
+ })
+ frappe.call({
+ doc: frm.doc,
+ method: 'set_printer_options',
+ args: { options: selected },
+ freeze: true,
+ callback() {
+ dialog.hide()
+ frappe.show_alert({
+ message: __('Printer options updated'),
+ indicator: 'green',
+ })
+ },
+ })
+ },
+ })
+ dialog.show()
+ },
+ })
+}
+
+function delete_printer_from_cups(frm) {
+ frappe.confirm(__('Delete {0} from CUPS and remove this record?', [frm.doc.printer_name]), () => {
+ frappe.call({
+ doc: frm.doc,
+ method: 'delete_printer_queue',
+ freeze: true,
+ callback() {
+ frappe.show_alert({
+ message: __('Printer deleted'),
+ indicator: 'green',
+ })
+ frappe.set_route('List', 'Network Printer Settings')
+ },
+ })
+ })
+}
+
+function sync_from_cups(frm) {
+ frappe.call({
+ doc: frm.doc,
+ method: 'sync_from_cups',
+ freeze: true,
+ callback() {
+ frappe.show_alert({
+ message: __('Synced printer details from CUPS'),
+ indicator: 'green',
+ })
+ frm.reload_doc()
+ },
+ })
+}
+
+function print_test(frm) {
+ frappe.call({
+ doc: frm.doc,
+ method: 'print_test_page',
+ freeze: true,
+ callback(r) {
+ frappe.show_alert({
+ message: r.message?.message || __('Test submitted'),
+ indicator: 'green',
+ })
+ },
+ })
+}
+
+function ping_printer(frm) {
+ frappe.call({
+ doc: frm.doc,
+ method: 'ping_printer',
+ freeze: true,
+ callback(r) {
+ const result = r.message || {}
+ let indicator = 'orange'
+ if (result.reachable === true) {
+ indicator = 'green'
+ } else if (result.reachable === false) {
+ indicator = 'red'
+ }
+
+ const lines = [result.message]
+ if (result.host) {
+ lines.push(`${__('Host')}: ${frappe.utils.escape_html(result.host)}`)
+ }
+ if (result.device_uri) {
+ lines.push(`${__('Device URI')}: ${frappe.utils.escape_html(result.device_uri)}`)
+ }
+ if (result.cups_location) {
+ lines.push(`${__('CUPS Location')}: ${frappe.utils.escape_html(result.cups_location)}`)
+ }
+ if (result.make_model) {
+ lines.push(`${__('Make / Model')}: ${frappe.utils.escape_html(result.make_model)}`)
+ }
+
+ frappe.msgprint({
+ title: __('Printer Connectivity'),
+ message: lines.join(' '),
+ indicator,
+ })
+ },
+ })
+}
+
+function pull_cups_printer_info(frm) {
+ if (frm.is_new() || !frm.doc.printer_name) {
+ return
+ }
+ frappe.call({
+ doc: frm.doc,
+ method: 'get_cups_printer_info',
+ callback(r) {
+ const info = r.message || {}
+ if (!frm.doc.printer_location && info.printer_location) {
+ frm.set_value('printer_location', info.printer_location)
+ }
+ if (!frm.doc.device_uri && info.device_uri) {
+ frm.set_value('device_uri', info.device_uri)
+ }
+ },
+ })
+}
+
frappe.ui.form.on('Network Printer Settings', {
+ refresh(frm) {
+ if (!frm.is_new() && frappe.user.has_role('System Manager')) {
+ frm.add_custom_button(__('Configure Printer'), () => show_configure_dialog(frm), __('Actions'))
+ frm.add_custom_button(__('Sync from CUPS'), () => sync_from_cups(frm), __('Actions'))
+ frm.add_custom_button(__('Print Test'), () => print_test(frm), __('Actions'))
+ frm.add_custom_button(__('Ping Printer'), () => ping_printer(frm), __('Actions'))
+ if (frm.doc.printer_type === 'General Purpose') {
+ frm.add_custom_button(__('Printer Options'), () => show_printer_options_dialog(frm), __('Actions'))
+ }
+ frm.add_custom_button(__('Delete from CUPS'), () => delete_printer_from_cups(frm), __('Actions'))
+ }
+ if (!frm.is_new() && frm.doc.printer_name) {
+ pull_cups_printer_info(frm)
+ refresh_cups_status_dashboard(frm)
+ }
+ },
after_save(frm) {
- // Refresh cache from CUPS so subsequent printer_name changes
- // reflect the just-saved location, not stale pre-load data.
printers_cache = []
frm.trigger('connect_print_server')
+ refresh_cups_status_dashboard(frm)
},
connect_print_server(frm) {
if (frm.doc.server_ip && frm.doc.port) {
@@ -27,8 +355,9 @@ frappe.ui.form.on('Network Printer Settings', {
},
callback(data) {
printers_cache = data.message || []
- frm.fields_dict.printer_name.set_data(printers_cache)
- // Resolve any pending printer_name lookup that fired before cache was ready
+ if (frm.fields_dict.printer_name?.set_data) {
+ frm.fields_dict.printer_name.set_data(printers_cache)
+ }
if (pending_printer_name) {
set_location_from_cache(frm, pending_printer_name)
pending_printer_name = null
@@ -42,7 +371,6 @@ frappe.ui.form.on('Network Printer Settings', {
return
}
if (!printers_cache.length) {
- // Cache not populated yet — queue the lookup and trigger a fetch
pending_printer_name = frm.doc.printer_name
frm.trigger('connect_print_server')
return
diff --git a/beam/public/js/network_printer_settings_list.js b/beam/public/js/network_printer_settings_list.js
new file mode 100644
index 00000000..d44345cb
--- /dev/null
+++ b/beam/public/js/network_printer_settings_list.js
@@ -0,0 +1,50 @@
+// Copyright (c) 2025, AgriTheory and contributors
+// For license information, please see license.txt
+
+frappe.provide('beam')
+
+const HIDDEN_NPS_VIEWS = new Set(['Dashboard', 'Report', 'Gantt', 'Kanban', 'Calendar', 'Image', 'Tree', 'Map'])
+
+const NPS_DOCTYPE = 'Network Printer Settings'
+
+function redirect_nps_dashboard_to_printer_queue() {
+ const route = frappe.get_route()
+ if (route[0] === 'List' && route[1] === NPS_DOCTYPE && (route[2] || '').toLowerCase() === 'dashboard') {
+ frappe.set_route('printer-queue')
+ }
+}
+
+if (!frappe.views.ListViewSelect.prototype.add_view_to_menu_patched_for_nps) {
+ const original_add_view_to_menu = frappe.views.ListViewSelect.prototype.add_view_to_menu
+ frappe.views.ListViewSelect.prototype.add_view_to_menu = function add_view_to_menu(view, action) {
+ if (this.doctype === NPS_DOCTYPE && HIDDEN_NPS_VIEWS.has(view)) {
+ return
+ }
+ return original_add_view_to_menu.call(this, view, action)
+ }
+ frappe.views.ListViewSelect.prototype.add_view_to_menu_patched_for_nps = true
+}
+
+if (!frappe.views.BaseList.prototype.setup_view_menu_patched_for_nps) {
+ const original_setup_view_menu = frappe.views.BaseList.prototype.setup_view_menu
+ frappe.views.BaseList.prototype.setup_view_menu = function setup_view_menu() {
+ original_setup_view_menu.apply(this, arguments)
+ if (this.doctype !== NPS_DOCTYPE || !this.views_menu) {
+ return
+ }
+ this.page.add_custom_menu_item(
+ this.views_menu,
+ __('Printer Queue'),
+ () => frappe.set_route('printer-queue'),
+ true,
+ null,
+ 'printer'
+ )
+ }
+ frappe.views.BaseList.prototype.setup_view_menu_patched_for_nps = true
+}
+
+if (!beam.network_printer_settings_route_bound) {
+ beam.network_printer_settings_route_bound = true
+ frappe.router.on('change', redirect_nps_dashboard_to_printer_queue)
+}
diff --git a/beam/public/js/network_printer_settings_quick_entry.js b/beam/public/js/network_printer_settings_quick_entry.js
new file mode 100644
index 00000000..ae0968c2
--- /dev/null
+++ b/beam/public/js/network_printer_settings_quick_entry.js
@@ -0,0 +1,477 @@
+// Copyright (c) 2025, AgriTheory and contributors
+// For license information, please see license.txt
+
+frappe.provide('frappe.ui.form')
+
+frappe.ui.form.NetworkPrinterSettingsQuickEntryForm = class NetworkPrinterSettingsQuickEntryForm extends (
+ frappe.ui.form.QuickEntryForm
+) {
+ constructor(doctype, after_insert, init_callback, doc, force) {
+ super(doctype, after_insert, init_callback, doc, force)
+ this.skip_redirect_on_error = true
+ this.current_step = 0
+ this.wizard_state = {
+ server_ip: 'localhost',
+ port: 631,
+ action: 'create',
+ kind: 'device',
+ selection: null,
+ device_uri: '',
+ ppdname: '',
+ use_manual_uri: false,
+ }
+ this.discovery_options = []
+ this.ppd_options = []
+ }
+
+ render_dialog() {
+ const me = this
+ this.dialog = new frappe.ui.Dialog({
+ title: __('Add Network Printer'),
+ fields: this.get_all_fields(),
+ size: 'large',
+ })
+
+ this.dialog.fields_dict.discovery.$input.on('awesomplete-selectcomplete', () => {
+ me.on_discovery_select()
+ })
+
+ this.dialog.fields_dict.printer_type.$input.on('change', () => {
+ me.set_default_ppd()
+ })
+
+ this.dialog.onhide = () => {
+ me.dialog.fields_dict.discovery.$input.off('awesomplete-selectcomplete')
+ me.dialog.fields_dict.printer_type.$input.off('change')
+ }
+
+ this.register_wizard_actions()
+ this.dialog.show()
+ this.show_step(0)
+ }
+
+ get_step_values(step) {
+ const fieldnames = [
+ ['server_ip', 'port'],
+ ['discovery', 'use_manual_uri', 'device_uri'],
+ ['__newname', 'printer_name', 'printer_location', 'printer_type'],
+ ['ppdname'],
+ ][step]
+ const values = {}
+ ;(fieldnames || []).forEach(fieldname => {
+ values[fieldname] = this.dialog.get_value(fieldname)
+ })
+ return values
+ }
+
+ get_all_fields() {
+ return [
+ {
+ fieldtype: 'Section Break',
+ label: __('Print Server'),
+ fieldname: 'step_server_section',
+ },
+ {
+ fieldname: 'server_ip',
+ fieldtype: 'Data',
+ label: __('Server IP'),
+ reqd: 1,
+ default: 'localhost',
+ },
+ {
+ fieldname: 'port',
+ fieldtype: 'Int',
+ label: __('Port'),
+ reqd: 1,
+ default: 631,
+ },
+ {
+ fieldtype: 'Section Break',
+ label: __('Discovery'),
+ fieldname: 'step_discovery_section',
+ },
+ {
+ fieldname: 'discovery',
+ fieldtype: 'Autocomplete',
+ label: __('Discovered Printer or Queue'),
+ },
+ {
+ fieldname: 'use_manual_uri',
+ fieldtype: 'Check',
+ label: __('Enter device URI manually'),
+ },
+ {
+ fieldname: 'device_uri',
+ fieldtype: 'Data',
+ label: __('Device URI'),
+ depends_on: 'eval:doc.use_manual_uri',
+ },
+ {
+ fieldtype: 'Section Break',
+ label: __('Printer Details'),
+ fieldname: 'step_details_section',
+ },
+ {
+ fieldname: '__newname',
+ fieldtype: 'Data',
+ label: __('Network Printer Settings Name'),
+ },
+ {
+ fieldname: 'printer_name',
+ fieldtype: 'Data',
+ label: __('CUPS Queue Name'),
+ },
+ {
+ fieldname: 'printer_location',
+ fieldtype: 'Data',
+ label: __('Printer Location'),
+ description: __('Physical location shown in CUPS, e.g. Chelsea Receiving Dock'),
+ },
+ {
+ fieldname: 'printer_type',
+ fieldtype: 'Select',
+ label: __('Printer Type'),
+ options: '\nGeneral Purpose\nLabel / RAW',
+ default: 'Label / RAW',
+ },
+ {
+ fieldtype: 'Section Break',
+ label: __('Driver'),
+ fieldname: 'step_driver_section',
+ },
+ {
+ fieldname: 'ppdname',
+ fieldtype: 'Autocomplete',
+ label: __('Driver (PPD)'),
+ },
+ ]
+ }
+
+ register_wizard_actions() {
+ const me = this
+ this.dialog.set_primary_action(__('Next'), () => me.next_step())
+ this.dialog.set_secondary_action_label(__('Back'))
+ this.dialog.set_secondary_action(() => me.prev_step())
+ this.dialog.add_custom_action(__('Test Connection'), () => me.test_connection(), 'btn-test-connection')
+ this.test_connection_btn = this.dialog.custom_actions.find('.btn-test-connection')
+ this.test_connection_btn.hide()
+ }
+
+ show_test_connection_button(step) {
+ if (!this.test_connection_btn) {
+ return
+ }
+ if (step === 1 || step === 2) {
+ this.test_connection_btn.show()
+ } else {
+ this.test_connection_btn.hide()
+ }
+ }
+
+ get_current_device_uri() {
+ const values = this.get_step_values(1)
+ if (values.use_manual_uri) {
+ return values.device_uri
+ }
+ if (this.wizard_state.device_uri) {
+ return this.wizard_state.device_uri
+ }
+ if (this.wizard_state.selection) {
+ return this.wizard_state.selection.device_uri || this.wizard_state.selection.value
+ }
+ return values.device_uri
+ }
+
+ test_connection() {
+ const device_uri = this.get_current_device_uri()
+ if (!device_uri) {
+ frappe.msgprint(__('Select a discovered printer or enter a device URI first.'))
+ return
+ }
+ frappe
+ .call({
+ method: 'beam.beam.overrides.network_printer_settings.test_device_uri',
+ args: { device_uri },
+ freeze: true,
+ })
+ .then(r => {
+ const result = r.message || {}
+ let indicator = 'orange'
+ if (result.reachable === true) {
+ indicator = 'green'
+ } else if (result.reachable === false) {
+ indicator = 'red'
+ }
+ const lines = [result.message]
+ if (result.host) {
+ lines.push(`${__('Host')}: ${frappe.utils.escape_html(result.host)}`)
+ }
+ if (result.port) {
+ lines.push(`${__('Port')}: ${result.port}`)
+ }
+ ;(result.warnings || []).forEach(warning => {
+ lines.push(frappe.utils.escape_html(warning))
+ })
+ frappe.msgprint({
+ title: __('Printer Connectivity'),
+ message: lines.join(' '),
+ indicator,
+ })
+ })
+ }
+
+ show_step(step) {
+ this.current_step = step
+ const sections = [
+ ['server_ip', 'port'],
+ ['discovery', 'use_manual_uri', 'device_uri'],
+ ['__newname', 'printer_name', 'printer_location', 'printer_type'],
+ ['ppdname'],
+ ]
+ const all_fields = sections.flat()
+ all_fields.forEach(fieldname => {
+ this.dialog.get_field(fieldname)?.$wrapper?.hide()
+ })
+ ;(sections[step] || []).forEach(fieldname => {
+ this.dialog.get_field(fieldname)?.$wrapper?.show()
+ })
+
+ if (step === 3 && this.wizard_state.action === 'link') {
+ this.finish_wizard()
+ return
+ }
+
+ if (step === 0) {
+ this.dialog.get_primary_btn().text(__('Next'))
+ } else if (step === 3) {
+ this.dialog.get_primary_btn().text(__('Create Printer'))
+ } else if (step === 2 && this.wizard_state.action === 'link') {
+ this.dialog.get_primary_btn().text(__('Link Printer'))
+ } else {
+ this.dialog.get_primary_btn().text(__('Next'))
+ }
+ this.show_test_connection_button(step)
+ }
+
+ next_step() {
+ if (this.current_step === 0) {
+ const values = this.get_step_values(0)
+ if (!values.server_ip || !values.port) {
+ frappe.msgprint(__('Server IP and Port are required.'))
+ return
+ }
+ this.wizard_state.server_ip = values.server_ip
+ this.wizard_state.port = values.port
+ this.load_discovery_options().then(() => this.show_step(1))
+ return
+ }
+ if (this.current_step === 1) {
+ if (!this.prepare_discovery_step(this.get_step_values(1))) {
+ return
+ }
+ this.show_step(2)
+ return
+ }
+ if (this.current_step === 2) {
+ const values = this.get_step_values(2)
+ if (!values.__newname || !values.printer_name) {
+ frappe.msgprint(__('Network Printer Settings Name and CUPS Queue Name are required.'))
+ return
+ }
+ if (this.wizard_state.action === 'link') {
+ this.finish_wizard()
+ return
+ }
+ this.load_ppd_options().then(() => {
+ this.set_default_ppd()
+ this.show_step(3)
+ })
+ return
+ }
+ if (this.current_step === 3) {
+ this.finish_wizard()
+ }
+ }
+
+ prev_step() {
+ if (this.current_step === 0) {
+ return
+ }
+ if (this.current_step === 2 && this.wizard_state.action === 'link') {
+ this.show_step(1)
+ return
+ }
+ this.show_step(this.current_step - 1)
+ }
+
+ load_discovery_options() {
+ const me = this
+ const server_ip = this.dialog.get_value('server_ip') || this.wizard_state.server_ip
+ const port = this.dialog.get_value('port') || this.wizard_state.port
+ return frappe
+ .call({
+ method: 'beam.beam.overrides.network_printer_settings.get_wizard_devices',
+ args: { server_ip, port },
+ freeze: true,
+ })
+ .then(r => {
+ me.discovery_options = r.message || []
+ me.dialog.fields_dict.discovery.set_data(me.discovery_options)
+ })
+ }
+
+ load_ppd_options() {
+ const me = this
+ return frappe
+ .call({
+ method: 'beam.beam.overrides.network_printer_settings.get_ppds',
+ args: {
+ server_ip: this.wizard_state.server_ip,
+ port: this.wizard_state.port,
+ },
+ freeze: true,
+ })
+ .then(r => {
+ me.ppd_options = r.message || []
+ me.dialog.fields_dict.ppdname.set_data(me.ppd_options)
+ })
+ }
+
+ on_discovery_select() {
+ const label = this.dialog.get_value('discovery')
+ const match = this.discovery_options.find(option => option.label === label || option.value === label)
+ if (!match) {
+ return
+ }
+ this.wizard_state.selection = match
+ this.wizard_state.kind = match.kind
+ if (match.kind === 'queue') {
+ this.wizard_state.action = match.configured ? 'block' : 'link'
+ this.dialog.set_value('printer_name', match.value)
+ this.dialog.set_value('printer_location', match.location || '')
+ this.dialog.set_value('device_uri', match.device_uri || '')
+ } else {
+ this.wizard_state.action = 'create'
+ this.dialog.set_value('device_uri', match.device_uri || match.value)
+ const suggested = (match.device_uri || match.value).split('/').pop() || ''
+ if (suggested && !this.dialog.get_value('printer_name')) {
+ this.dialog.set_value('printer_name', suggested.replace(/[^a-zA-Z0-9_-]/g, '_'))
+ }
+ }
+ }
+
+ prepare_discovery_step(values) {
+ if (values.use_manual_uri) {
+ if (!values.device_uri) {
+ frappe.msgprint(__('Device URI is required.'))
+ return false
+ }
+ if (values.device_uri.startsWith('usb://')) {
+ frappe.msgprint(__('USB printers are not supported.'))
+ return false
+ }
+ this.wizard_state.action = 'create'
+ this.wizard_state.kind = 'device'
+ this.wizard_state.device_uri = values.device_uri
+ this.wizard_state.selection = null
+ return true
+ }
+
+ if (!this.wizard_state.selection) {
+ this.on_discovery_select()
+ }
+ if (!this.wizard_state.selection) {
+ frappe.msgprint(__('Select a discovered printer or queue, or enter a URI manually.'))
+ return false
+ }
+ if (this.wizard_state.selection.configured) {
+ frappe.msgprint(
+ __('This printer is already configured as {0}.', [
+ this.wizard_state.selection.nps_name || this.wizard_state.selection.label,
+ ])
+ )
+ return false
+ }
+ if (this.wizard_state.kind === 'queue') {
+ this.wizard_state.action = 'link'
+ this.wizard_state.device_uri = this.wizard_state.selection.device_uri || ''
+ } else {
+ this.wizard_state.action = 'create'
+ this.wizard_state.device_uri = this.wizard_state.selection.device_uri || this.wizard_state.selection.value
+ }
+ return true
+ }
+
+ set_default_ppd() {
+ const printer_type = this.dialog.get_value('printer_type')
+ const default_ppd = printer_type === 'General Purpose' ? 'everywhere' : 'raw'
+ const match = this.ppd_options.find(option => option.value === default_ppd)
+ if (match) {
+ this.dialog.set_value('ppdname', match.label)
+ this.wizard_state.ppdname = match.value
+ }
+ }
+
+ get_ppd_value() {
+ const label = this.dialog.get_value('ppdname')
+ const match = this.ppd_options.find(option => option.label === label || option.value === label)
+ return match ? match.value : label
+ }
+
+ finish_wizard() {
+ const details = this.get_step_values(2)
+ const driver = this.get_step_values(3)
+ if (!details.__newname || !details.printer_name) {
+ frappe.msgprint(__('Network Printer Settings Name and CUPS Queue Name are required.'))
+ return
+ }
+ const common_args = {
+ name: details.__newname,
+ server_ip: this.wizard_state.server_ip,
+ port: this.wizard_state.port,
+ printer_name: details.printer_name,
+ printer_location: details.printer_location || '',
+ printer_type: details.printer_type || '',
+ }
+
+ if (this.wizard_state.action === 'link') {
+ frappe
+ .call({
+ method: 'beam.beam.overrides.network_printer_settings.link_existing_printer',
+ args: common_args,
+ freeze: true,
+ })
+ .then(r => this.on_wizard_complete(r.message))
+ return
+ }
+
+ frappe
+ .call({
+ method: 'beam.beam.overrides.network_printer_settings.create_printer_queue',
+ args: {
+ ...common_args,
+ device_uri: this.wizard_state.device_uri || this.dialog.get_value('device_uri'),
+ ppdname: this.get_ppd_value() || driver.ppdname,
+ },
+ freeze: true,
+ })
+ .then(r => this.on_wizard_complete(r.message))
+ }
+
+ on_wizard_complete(doc) {
+ this.dialog.hide()
+ frappe.show_alert({
+ message: __('Printer {0} configured', [doc.name]),
+ indicator: 'green',
+ })
+ if (this.after_insert) {
+ this.after_insert(doc)
+ } else {
+ frappe.set_route('Form', 'Network Printer Settings', doc.name)
+ }
+ }
+
+ insert() {
+ return Promise.resolve()
+ }
+}
diff --git a/beam/tests/conftest.py b/beam/tests/conftest.py
index f3f7866a..f8bf9a43 100644
--- a/beam/tests/conftest.py
+++ b/beam/tests/conftest.py
@@ -1,7 +1,77 @@
# Copyright (c) 2025, AgriTheory and contributors
# For license information, please see license.txt
+# Business story (Ambrosia Pie Company)
+#
+# Order | Chapter
+# ------|---------
+# 10 | BEAM Settings — handling units enabled
+# 20–28 | Barcodes — Code128 generation, labels, print format
+# 40–48 | Scanning — list/form scan actions, delivery note hooks
+# 60–70 | Receiving & production — purchase receipt through manufacture
+# 71 | Serialized finished goods — serial number scan
+# 72–92 | Outbound & corrections — delivery, invoice, transfer, cancel paths
+# 100–106 | Document printing — print_by_server format routing
+# 110–132 | Print server logic — URI, CUPS state, fleet rows, ZPL, notifications
+# 140–162 | Printer setup — wizard, configure, test print, decommission
+# 166–168 | Printer setup — wizard API permissions
+# 170–200 | Print queue panel — job snapshots, client polling, permissions
+# 210–220 | Live CUPS — ZD621 at Chelsea dock (workflow CUPS service container)
+#
+# Manual testing helpers
+# ----------------------
+# Prerequisites (local print server):
+# sudo apt-get install -y gcc cups python3-dev libcups2-dev printer-driver-cups-pdf
+# bench pip install pycups
+# sudo usermod -aG lpadmin $USER # re-login or newgrp lpadmin
+# Use Server IP "localhost" (not 127.0.0.1) in Network Printer Settings — BEAM uses the
+# Unix socket and avoids CUPS HTTP auth errors.
+#
+# General Purpose / PDF testing (no physical printer):
+# sudo apt-get install -y printer-driver-cups-pdf
+# lpadmin -p PDF -E -v cups-pdf:/ -m everywhere -L "Office PDF"
+# cupsenable PDF && cupsaccept PDF
+# PDF output lands under /var/spool/cups-pdf// (Debian/Ubuntu).
+# In ERPNext: wizard or Network Printer Settings with Printer Type "General Purpose",
+# queue name PDF, PPD "everywhere". Use Print Test or print_by_server from a document.
+#
+# Label / RAW testing without hardware (mock-printer CLI or sync wrapper):
+# pip install "git+https://github.com/agritheory/test_utils.git@v1.28.0"
+# mock-printer raw --save-dir /tmp/prints
+# mock-printer ipp --save-dir /tmp/prints
+# mock-printer both --save-dir /tmp/prints
+#
+# from test_utils.printers import raw_printer_sync, ipp_printer_sync
+# from beam.tests.cups_test_utils import temporary_cups_queue, delete_nps_if_exists
+# with raw_printer_sync(save_dir="/tmp/prints") as printer:
+# print(printer.uri) # e.g. socket://127.0.0.1:54321
+# with temporary_cups_queue("BEAM_TEST_ZD621", printer.uri):
+# ... create Network Printer Settings, print_test_page(), etc.
+# print(printer.last_text()) # captured ZPL payload
+#
+# Quick checks from bench console:
+# bench --site pinyon console
+# >>> from beam.beam.overrides import network_printer_settings as nps
+# >>> nps.cups_connection("localhost", 631).getPrinters()
+# >>> nps.get_wizard_devices("localhost", 631)
+# >>> from beam.beam import printer_queue as pq
+# >>> pq.get_printer_jobs_snapshot()
+#
+# Desk UI:
+# /app/printer-queue — live job panel (polls every 4s, no background worker)
+# /app/query-report/Printer Fleet Status
+# http://localhost:631 — CUPS admin (remote admin needs extra cupsd.conf rules)
+#
+# Automated tests (pytest from bench root):
+# pytest apps/beam/beam/tests/test_printer_logic.py -v # no CUPS required
+# pytest apps/beam/beam/tests/test_printer_wizard.py -v # mocked CUPS
+# pytest apps/beam/beam/tests/test_printer_queue.py -v # mocked CUPS
+# pytest apps/beam/beam/tests/test_printer_cups_integration.py -v # CUPS workflow service
+# bench --site pinyon set-config allow_tests true # if bench run-tests is used
+
import json
+import os
+import sys
from pathlib import Path
from unittest.mock import MagicMock
@@ -9,6 +79,20 @@
import pytest
from frappe.utils import get_bench_path
+from cups_test_utils import (
+ configure_pycups_credentials,
+ mock_host_for_cups_container,
+ wait_for_cups_http,
+)
+from test_telemetry import emit as telemetry
+
+TESTS_DIR = Path(__file__).resolve().parent
+if str(TESTS_DIR) not in sys.path:
+ sys.path.insert(0, str(TESTS_DIR))
+
+DEFAULT_CUPS_ADMIN_USER = os.environ.get("CUPS_ADMIN_USER", "admin")
+DEFAULT_CUPS_ADMIN_PASSWORD = os.environ.get("CUPS_ADMIN_PASSWORD", "root")
+
def _get_logger(*args, **kwargs):
from frappe.utils.logger import get_logger
@@ -43,3 +127,36 @@ def db_instance():
frappe.connect()
frappe.db.commit = MagicMock()
yield frappe.db
+
+
+@pytest.fixture(scope="module")
+def cups_server():
+ """Connect to the CUPS service container started by the pytest workflow."""
+ host = os.environ.get("BEAM_CUPS_HOST", "127.0.0.1")
+ port = int(os.environ.get("BEAM_CUPS_PORT", "631"))
+ server = {
+ "host": host,
+ "port": port,
+ "user": DEFAULT_CUPS_ADMIN_USER,
+ "password": DEFAULT_CUPS_ADMIN_PASSWORD,
+ "cups_host_from_container": mock_host_for_cups_container(),
+ }
+
+ telemetry(
+ f"cups_server connect host={host} port={port} " f"mock_host={server['cups_host_from_container']}"
+ )
+ try:
+ wait_for_cups_http(server)
+ configure_pycups_credentials(server)
+ except Exception as exc:
+ telemetry(f"cups_server failed: {exc}")
+ raise RuntimeError(
+ "CUPS integration tests require the CUPS workflow service on "
+ f"{host}:{port}. In CI this is provided by the pytest workflow; locally run "
+ "the beam-cups image with port 631 published and set BEAM_CUPS_HOST/PORT. "
+ f"Original error: {exc}"
+ ) from exc
+
+ telemetry("cups_server ready")
+ yield server
+ telemetry("cups_server module complete")
diff --git a/beam/tests/cups_test_utils.py b/beam/tests/cups_test_utils.py
new file mode 100644
index 00000000..e2c833e4
--- /dev/null
+++ b/beam/tests/cups_test_utils.py
@@ -0,0 +1,152 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+import os
+import time
+from contextlib import contextmanager
+from urllib.parse import urlparse, urlunparse
+
+import frappe
+import httpx
+from test_utils.printers.ipp.codec import IppEncoder, IppOperation, IppTag
+
+from beam.beam.overrides.network_printer_settings import (
+ default_ppd_for_type,
+ require_cups,
+ run_cups_admin,
+)
+
+CUPS_HOST_FROM_CONTAINER = "host.docker.internal"
+# Mock printers must listen on all interfaces so the CUPS container can reach them
+# via host.docker.internal / host-gateway (127.0.0.1-only listeners reject those connections).
+MOCK_PRINTER_BIND_HOST = "0.0.0.0"
+
+
+def mock_host_for_cups_container():
+ """Host that the CUPS service container uses to reach mock printers in the test runner."""
+ explicit = os.environ.get("BEAM_CUPS_MOCK_HOST", "").strip()
+ if explicit:
+ return explicit
+ if os.environ.get("CI") == "true" or os.environ.get("ACT") == "true":
+ import subprocess
+
+ try:
+ ip = subprocess.check_output(["hostname", "-I"], text=True).split()[0]
+ if ip:
+ return ip
+ except (IndexError, subprocess.CalledProcessError, FileNotFoundError):
+ pass
+ return CUPS_HOST_FROM_CONTAINER
+
+
+def cups_test_connection(server):
+ cups = require_cups()
+ password = server["password"]
+ cups.setServer(server["host"])
+ cups.setPort(int(server["port"]))
+ cups.setUser(server["user"])
+ cups.setPasswordCB(lambda prompt: password)
+ return cups.Connection()
+
+
+def cups_available(server):
+ try:
+ conn = cups_test_connection(server)
+ conn.getPrinters()
+ except Exception:
+ return False
+ return True
+
+
+def device_uri_for_cups(printer, server):
+ uri = printer.uri
+ replacement_host = server.get("cups_host_from_container", CUPS_HOST_FROM_CONTAINER)
+ parsed = urlparse(uri)
+ if parsed.hostname not in ("127.0.0.1", "localhost", "::1", "0.0.0.0"):
+ return uri
+
+ hostname = replacement_host
+ if parsed.hostname == "::1":
+ hostname = f"[{replacement_host}]"
+
+ netloc = hostname
+ if parsed.port:
+ netloc = f"{hostname}:{parsed.port}"
+ if parsed.username:
+ userinfo = parsed.username
+ if parsed.password:
+ userinfo = f"{userinfo}:{parsed.password}"
+ netloc = f"{userinfo}@{netloc}"
+
+ return urlunparse(parsed._replace(netloc=netloc))
+
+
+@contextmanager
+def temporary_cups_queue(queue_name, device_uri, server, ppdname=None):
+ conn = cups_test_connection(server)
+ ppdname = ppdname or default_ppd_for_type("Label / RAW")
+
+ def create_queue():
+ if queue_name in conn.getPrinters():
+ conn.deletePrinter(queue_name)
+ conn.addPrinter(queue_name, ppdname=ppdname, device=device_uri, info=queue_name, location="")
+ conn.enablePrinter(queue_name)
+ conn.acceptJobs(queue_name)
+
+ def delete_queue():
+ if queue_name in conn.getPrinters():
+ conn.deletePrinter(queue_name)
+
+ run_cups_admin("create test queue", create_queue)
+ try:
+ yield conn
+ finally:
+ run_cups_admin("delete test queue", delete_queue)
+
+
+def delete_nps_if_exists(name):
+ if frappe.db.exists("Network Printer Settings", name):
+ frappe.delete_doc("Network Printer Settings", name)
+
+
+def submit_ipp_print_job(printer, document: bytes, job_name: str, mime_type: str = "text/plain"):
+ """Send a Print-Job to the mock IPP server at the printer's registered URI."""
+ enc = IppEncoder()
+ enc.write_header((1, 1), IppOperation.PRINT_JOB, 1)
+ enc.write_tag(IppTag.OPERATION)
+ enc.write_charset("attributes-charset", "utf-8")
+ enc.write_language("attributes-natural-language", "en")
+ enc.write_uri("printer-uri", printer.uri)
+ enc.write_name("job-name", job_name)
+ enc.write_mime_type("document-format", mime_type)
+ enc.write_tag(IppTag.END)
+ response = httpx.post(
+ f"http://{printer.address}{printer.printer_path}",
+ content=enc.get_bytes() + document,
+ headers={"Content-Type": "application/ipp"},
+ timeout=5.0,
+ )
+ response.raise_for_status()
+ return response
+
+
+def configure_pycups_credentials(server):
+ cups = require_cups()
+ password = server["password"]
+ cups.setUser(server["user"])
+ cups.setPasswordCB(lambda prompt: password)
+
+
+def wait_for_cups_http(server, timeout=120.0):
+ url = f"http://{server['host']}:{server['port']}/"
+ auth = (server["user"], server["password"])
+ with httpx.Client(timeout=5.0) as client:
+ for _ in range(int(timeout / 2)):
+ try:
+ response = client.get(url, auth=auth)
+ if response.status_code < 500:
+ return
+ except httpx.HTTPError:
+ pass
+ time.sleep(2)
+ raise RuntimeError(f"CUPS did not become ready at {url} within {timeout}s")
diff --git a/beam/tests/test_barcode_auto_generate.py b/beam/tests/test_barcode_auto_generate.py
index 1115a780..5f2c8661 100644
--- a/beam/tests/test_barcode_auto_generate.py
+++ b/beam/tests/test_barcode_auto_generate.py
@@ -8,6 +8,7 @@
from beam.beam.doctype.beam_settings.beam_settings import get_doctypes_with_item_barcodes
+@pytest.mark.order(20)
def test_get_doctypes_with_item_barcodes():
doctypes = get_doctypes_with_item_barcodes()
assert isinstance(doctypes, list)
@@ -42,6 +43,7 @@ def beam_settings():
settings.save()
+@pytest.mark.order(22)
def test_barcode_generated_when_doctype_allowed(beam_settings):
beam_settings.auto_barcode_doctypes = '["Item", "Warehouse"]'
beam_settings.save()
@@ -52,6 +54,7 @@ def test_barcode_generated_when_doctype_allowed(beam_settings):
assert any(b.barcode_type == "Code128" for b in item.barcodes)
+@pytest.mark.order(24)
def test_barcode_not_generated_when_doctype_not_allowed(beam_settings):
beam_settings.auto_barcode_doctypes = '["Warehouse"]'
beam_settings.save()
@@ -62,6 +65,7 @@ def test_barcode_not_generated_when_doctype_not_allowed(beam_settings):
assert not any(b.barcode_type == "Code128" for b in item.barcodes)
+@pytest.mark.order(26)
def test_barcode_not_duplicated_when_code128_exists(beam_settings):
beam_settings.auto_barcode_doctypes = '["Item", "Warehouse"]'
beam_settings.save()
diff --git a/beam/tests/test_handling_unit.py b/beam/tests/test_handling_unit.py
index d1374dfc..76b5d9b0 100644
--- a/beam/tests/test_handling_unit.py
+++ b/beam/tests/test_handling_unit.py
@@ -22,6 +22,7 @@ def submit_all_purchase_receipts():
pr.submit()
+@pytest.mark.order(10)
def test_enable_handling_units_setting():
"""Test that enable_handling_units setting controls whether handling units are assigned to SLEs"""
company = frappe.defaults.get_defaults().get("company")
@@ -100,7 +101,7 @@ def test_enable_handling_units_setting():
beam_settings.save()
-@pytest.mark.order(1)
+@pytest.mark.order(60)
def test_purchase_receipt_handling_unit_generation():
for pr in frappe.get_all("Purchase Receipt"):
pr = frappe.get_doc("Purchase Receipt", pr)
@@ -115,7 +116,7 @@ def test_purchase_receipt_handling_unit_generation():
assert hu.stock_qty == row.stock_qty
-@pytest.mark.order(2)
+@pytest.mark.order(62)
def test_purchase_invoice():
for pi in frappe.get_all("Purchase Invoice"):
pi = frappe.get_doc("Purchase Invoice", pi)
@@ -132,7 +133,7 @@ def test_purchase_invoice():
assert row.handling_unit == None
-@pytest.mark.order(3)
+@pytest.mark.order(64)
def test_stock_entry_material_receipt():
submit_all_purchase_receipts()
se = frappe.new_doc("Stock Entry")
@@ -171,7 +172,7 @@ def test_stock_entry_material_receipt():
assert row.handling_unit == sle.handling_unit
-@pytest.mark.order(4)
+@pytest.mark.order(66)
def test_stock_entry_repack():
submit_all_purchase_receipts()
pr_hu = frappe.get_value(
@@ -227,7 +228,7 @@ def test_stock_entry_repack():
assert hu.stock_qty == 100
-@pytest.mark.order(4)
+@pytest.mark.order(68)
def test_stock_entry_material_transfer_for_manufacture():
submit_all_purchase_receipts()
wo = frappe.get_value("Work Order", {"production_item": "Kaduka Key Lime Pie Filling"})
@@ -278,7 +279,7 @@ def test_stock_entry_material_transfer_for_manufacture():
assert row.handling_unit != row.to_handling_unit
-@pytest.mark.order(6)
+@pytest.mark.order(70)
def test_stock_entry_for_manufacture():
submit_all_purchase_receipts()
wo = frappe.get_value("Work Order", {"production_item": "Kaduka Key Lime Pie Filling"})
@@ -341,7 +342,7 @@ def test_stock_entry_for_manufacture():
assert row.t_warehouse == sle.warehouse # target warehouse
-@pytest.mark.order(7)
+@pytest.mark.order(72)
def test_delivery_note():
se = frappe.new_doc("Stock Entry")
se.stock_entry_type = se.purpose = "Material Receipt"
@@ -384,7 +385,7 @@ def test_delivery_note():
assert hu.item_code == dn.items[0].item_code
-@pytest.mark.order(8)
+@pytest.mark.order(74)
def test_sales_invoice():
se = frappe.new_doc("Stock Entry")
se.stock_entry_type = se.purpose = "Material Receipt"
@@ -428,7 +429,7 @@ def test_sales_invoice():
assert hu.item_code == si.items[0].item_code
-@pytest.mark.order(9)
+@pytest.mark.order(76)
def test_packing_slip():
se = frappe.new_doc("Stock Entry")
se.stock_entry_type = se.purpose = "Material Receipt"
@@ -485,7 +486,7 @@ def test_packing_slip():
assert hu.stock_qty == 0
-@pytest.mark.order(10)
+@pytest.mark.order(78)
def test_stock_entry_material_transfer():
# create clean material receipt to avoid conflicts with Repack test
semr = frappe.new_doc("Stock Entry")
@@ -578,7 +579,7 @@ def test_stock_entry_material_transfer():
assert row.t_warehouse == tsle.warehouse # target warehouse
-@pytest.mark.order(11)
+@pytest.mark.order(80)
def test_stock_entry_for_send_to_subcontractor():
submit_all_purchase_receipts()
se = frappe.new_doc("Stock Entry")
@@ -639,7 +640,7 @@ def test_stock_entry_for_send_to_subcontractor():
assert hu.qty > 0
-@pytest.mark.order(12)
+@pytest.mark.order(82)
def test_subcontracting_receipt():
for row in frappe.get_all("Subcontracting Order", pluck="name"):
if not frappe.db.exists(
@@ -661,9 +662,10 @@ def test_subcontracting_receipt():
assert hu.stock_qty == row.returned_qty
-@pytest.mark.order(13)
-@pytest.mark.skip() # Remove when validate_handling_unit_overconsumption is uncommented in hooks.py doc_events
+@pytest.mark.order(84)
def test_handling_units_overconsumption_in_material_transfer_stock_entry():
+ # validate_handling_unit_overconsumption is not wired in hooks.py yet.
+ pytest.skip("Handling unit overconsumption validation is disabled pending feature completion")
# Tests validate_handling_unit_overconsumption Stock Entry incoming code block
with pytest.raises(NegativeStockError) as exc_info:
se = frappe.new_doc("Stock Entry")
@@ -717,9 +719,10 @@ def test_handling_units_overconsumption_in_material_transfer_stock_entry():
)
-@pytest.mark.order(14)
-@pytest.mark.skip() # Remove when validate_handling_unit_overconsumption is uncommented in hooks.py doc_events
+@pytest.mark.order(86)
def test_handling_units_overconsumption_in_delivery_note():
+ # validate_handling_unit_overconsumption is not wired in hooks.py yet.
+ pytest.skip("Handling unit overconsumption validation is disabled pending feature completion")
# Tests validate_handling_unit_overconsumption Delivery Note code block
with pytest.raises(NegativeStockError) as exc_info:
se = frappe.new_doc("Stock Entry")
@@ -765,7 +768,7 @@ def test_handling_units_overconsumption_in_delivery_note():
)
-@pytest.mark.order(15)
+@pytest.mark.order(88)
def test_repack_cancel_without_recombine():
"""Test cancelling a Repack Stock Entry without recombining handling units"""
# Create a material receipt with a known handling unit
@@ -840,7 +843,7 @@ def test_repack_cancel_without_recombine():
assert target_hu_doc.stock_qty == 100 # produced
-@pytest.mark.order(16)
+@pytest.mark.order(90)
def test_repack_cancel_with_recombine():
"""Test cancelling a Repack Stock Entry WITH recombining handling units"""
# Create a material receipt with a known handling unit
@@ -916,7 +919,7 @@ def test_repack_cancel_with_recombine():
assert target_hu_doc is None or target_hu_doc.stock_qty == 0
-@pytest.mark.order(17)
+@pytest.mark.order(92)
def test_material_transfer_cancel_without_recombine():
"""Test cancelling a Material Transfer Stock Entry without recombining handling units"""
# Create a material receipt
diff --git a/beam/tests/test_hooks_override.py b/beam/tests/test_hooks_override.py
index 7149a91f..6873e0a7 100644
--- a/beam/tests/test_hooks_override.py
+++ b/beam/tests/test_hooks_override.py
@@ -43,6 +43,7 @@ def patched_hooks(*args, **kwargs):
monkeymodule.setattr("frappe.get_hooks", patched_hooks)
+@pytest.mark.order(46)
def test_beam_frm_hooks_override(patch_frappe_get_hooks):
item_barcode = frappe.get_value("Item Barcode", {"parent": "Kaduka Key Lime Pie"}, "barcode")
dn = frappe.new_doc("Delivery Note")
@@ -67,6 +68,7 @@ def test_beam_frm_hooks_override(patch_frappe_get_hooks):
assert scan[1].get("target") == "Nos"
+@pytest.mark.order(48)
def test_beam_listview_hooks_override(patch_frappe_get_hooks):
item_barcode = frappe.get_value("Item Barcode", {"parent": "Kaduka Key Lime Pie"}, "barcode")
scan = frappe.call(
diff --git a/beam/tests/test_item_barcode_print_format.py b/beam/tests/test_item_barcode_print_format.py
index 46ce6a9e..54cfc405 100644
--- a/beam/tests/test_item_barcode_print_format.py
+++ b/beam/tests/test_item_barcode_print_format.py
@@ -6,6 +6,7 @@
from beam.beam.barcodes import barcode128
+@pytest.mark.order(28)
@pytest.mark.parametrize("barcode_text", ["123456789012", "ITEM-00001", "987654321098"])
def test_item_barcode_print_format(barcode_text):
# Generate barcode image in print format
diff --git a/beam/tests/test_printer_cups_integration.py b/beam/tests/test_printer_cups_integration.py
new file mode 100644
index 00000000..14709cd0
--- /dev/null
+++ b/beam/tests/test_printer_cups_integration.py
@@ -0,0 +1,210 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+import frappe
+import pytest
+from test_utils.printers import ipp_printer_sync, raw_printer_sync
+
+from beam.beam.overrides import network_printer_settings as nps
+from beam.beam.report.printer_fleet_status.printer_fleet_status import execute
+from cups_test_utils import (
+ MOCK_PRINTER_BIND_HOST,
+ cups_test_connection,
+ delete_nps_if_exists,
+ device_uri_for_cups,
+ submit_ipp_print_job,
+ temporary_cups_queue,
+)
+
+
+@pytest.mark.order(210)
+def test_create_queue_prints_zpl_test_label_to_fake_printer(cups_server):
+ """Chelsea Receiving Labels sends a ZPL test label to the fake ZD621 raw printer."""
+ nps_name = "Chelsea Receiving Labels Integration"
+ queue_name = "BEAM_TEST_ZD621"
+ delete_nps_if_exists(nps_name)
+
+ with raw_printer_sync(host=MOCK_PRINTER_BIND_HOST) as printer:
+ device_uri = device_uri_for_cups(printer, cups_server)
+ with temporary_cups_queue(queue_name, device_uri, cups_server) as conn:
+ assert queue_name in conn.getPrinters()
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": nps_name,
+ "server_ip": cups_server["host"],
+ "port": cups_server["port"],
+ "printer_name": queue_name,
+ "printer_type": "Label / RAW",
+ "device_uri": device_uri,
+ }
+ ).insert()
+
+ result = doc.print_test_page()
+ assert result["test_type"] == "zpl"
+ payload = printer.wait_for_payload()
+ text = payload.decode("utf-8", errors="replace")
+ assert "^XA" in text
+ assert queue_name in text
+
+ delete_nps_if_exists(nps_name)
+
+
+@pytest.mark.order(212)
+def test_configure_printer_updates_device_uri_on_cups(cups_server):
+ """Administrator retargets Chelsea Receiving Labels to a new fake printer URI."""
+ nps_name = "Chelsea Receiving Labels Configure Integration"
+ queue_name = "BEAM_TEST_CONFIGURE"
+ delete_nps_if_exists(nps_name)
+
+ with (
+ raw_printer_sync(host=MOCK_PRINTER_BIND_HOST) as first_printer,
+ raw_printer_sync(host=MOCK_PRINTER_BIND_HOST) as second_printer,
+ ):
+ first_device_uri = device_uri_for_cups(first_printer, cups_server)
+ second_device_uri = device_uri_for_cups(second_printer, cups_server)
+ with temporary_cups_queue(queue_name, first_device_uri, cups_server):
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": nps_name,
+ "server_ip": cups_server["host"],
+ "port": cups_server["port"],
+ "printer_name": queue_name,
+ "printer_type": "Label / RAW",
+ "device_uri": first_device_uri,
+ }
+ ).insert()
+
+ updated = doc.configure_printer_queue(device_uri=second_device_uri, accept_jobs=1)
+ assert updated["device_uri"] == second_device_uri
+
+ conn = cups_test_connection(cups_server)
+ cups_uri = conn.getPrinters()[queue_name]["device-uri"]
+ assert cups_uri == second_device_uri
+
+ delete_nps_if_exists(nps_name)
+
+
+@pytest.mark.order(214)
+def test_configure_printer_reject_jobs(cups_server):
+ """Chelsea dock ZD621 stops accepting jobs without deleting the queue."""
+ nps_name = "Chelsea Receiving Labels Reject Integration"
+ queue_name = "BEAM_TEST_REJECT"
+ delete_nps_if_exists(nps_name)
+
+ with raw_printer_sync(host=MOCK_PRINTER_BIND_HOST) as printer:
+ device_uri = device_uri_for_cups(printer, cups_server)
+ with temporary_cups_queue(queue_name, device_uri, cups_server):
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": nps_name,
+ "server_ip": cups_server["host"],
+ "port": cups_server["port"],
+ "printer_name": queue_name,
+ "printer_type": "Label / RAW",
+ "device_uri": device_uri,
+ }
+ ).insert()
+
+ doc.configure_printer_queue(accept_jobs=0)
+ status = doc.get_cups_printer_status()
+ assert status["is_accepting_jobs"] is False
+
+ delete_nps_if_exists(nps_name)
+
+
+@pytest.mark.order(216)
+def test_fleet_report_lists_orphan_vraw_queue(cups_server):
+ """Legacy vRAW on CUPS appears as an orphan queue in the fleet report."""
+ queue_name = "BEAM_TEST_ORPHAN_VRAW"
+ probe_name = "Chelsea Print Server Fleet Probe"
+ delete_nps_if_exists(probe_name)
+
+ frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": probe_name,
+ "server_ip": cups_server["host"],
+ "port": cups_server["port"],
+ "printer_name": "BEAM_TEST_PROBE",
+ "printer_type": "Label / RAW",
+ }
+ ).insert()
+
+ with raw_printer_sync(host=MOCK_PRINTER_BIND_HOST) as printer:
+ device_uri = device_uri_for_cups(printer, cups_server)
+ with temporary_cups_queue(queue_name, device_uri, cups_server):
+ columns, rows = execute({"server_ip": cups_server["host"]})
+ orphan_rows = [row for row in rows if row.get("cups_queue") == queue_name]
+ assert orphan_rows
+ assert orphan_rows[0]["status"] == "Orphan Queue"
+
+ delete_nps_if_exists(probe_name)
+
+
+@pytest.mark.order(218)
+def test_create_queue_fails_when_device_unreachable(cups_server):
+ """Stock Manager cannot create Chelsea Receiving Labels against a dead socket URI."""
+ nps_name = "Chelsea Receiving Labels Dead Socket"
+ queue_name = "BEAM_TEST_DEAD"
+ delete_nps_if_exists(nps_name)
+ existing = set(frappe.get_all("Network Printer Settings", pluck="name"))
+
+ with pytest.raises(frappe.ValidationError):
+ nps.create_printer_queue(
+ nps_name,
+ cups_server["host"],
+ cups_server["port"],
+ queue_name,
+ "socket://127.0.0.1:1",
+ ppdname="raw",
+ printer_location="Chelsea Receiving",
+ printer_type="Label / RAW",
+ )
+
+ created = set(frappe.get_all("Network Printer Settings", pluck="name")) - existing
+ assert nps_name not in created
+ conn = cups_test_connection(cups_server)
+ assert queue_name not in conn.getPrinters()
+
+
+@pytest.mark.order(220)
+def test_create_queue_registers_ipp_device_and_delivers_print_job(cups_server, tmp_path):
+ """Office PDF queue registers mock IPP on CUPS; Print-Job at that URI lands in save_dir."""
+ nps_name = "Office PDF Mock IPP Integration"
+ queue_name = "BEAM_TEST_IPP"
+ delete_nps_if_exists(nps_name)
+
+ with ipp_printer_sync(save_dir=tmp_path, name="PDF", host=MOCK_PRINTER_BIND_HOST) as printer:
+ device_uri = device_uri_for_cups(printer, cups_server)
+ # Mock IPP does not satisfy IPP Everywhere probes; raw PPD still registers the ipp:// device URI.
+ with temporary_cups_queue(queue_name, device_uri, cups_server, ppdname="raw") as conn:
+ assert conn.getPrinters()[queue_name]["device-uri"] == device_uri
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": nps_name,
+ "server_ip": cups_server["host"],
+ "port": cups_server["port"],
+ "printer_name": queue_name,
+ "printer_type": "General Purpose",
+ "device_uri": device_uri,
+ }
+ ).insert()
+
+ result = doc.print_test_page()
+ assert result["test_type"] in ("testpage", "text")
+ assert result["job_id"]
+
+ submit_ipp_print_job(
+ printer,
+ b"Office PDF integration test\n",
+ job_name=queue_name,
+ )
+ assert printer.job_count >= 1
+ assert list(tmp_path.glob("job_*"))
+
+ delete_nps_if_exists(nps_name)
diff --git a/beam/tests/test_printer_logic.py b/beam/tests/test_printer_logic.py
new file mode 100644
index 00000000..1fef8ca5
--- /dev/null
+++ b/beam/tests/test_printer_logic.py
@@ -0,0 +1,85 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+import pytest
+
+from beam.beam.overrides import network_printer_settings as nps
+
+
+@pytest.mark.order(110)
+def test_parse_device_uri_port_defaults_socket_to_9100():
+ assert nps.parse_device_uri_port("socket://192.168.1.50:9100") == 9100
+ assert nps.parse_device_uri_port("socket://192.168.1.50") == 9100
+
+
+@pytest.mark.order(112)
+def test_validate_device_uri_warns_on_port_63():
+ result = nps.validate_device_uri("socket://192.168.1.50:63")
+ assert any("9100" in warning for warning in result["warnings"])
+
+
+@pytest.mark.order(114)
+def test_map_printer_state_to_indicator_stopped():
+ indicator = nps.map_printer_state_to_indicator(
+ nps.PRINTER_STATE_STOPPED,
+ "offline-report",
+ )
+ assert indicator["color"] == "red"
+ assert indicator["label"] == "Offline"
+
+
+@pytest.mark.order(116)
+def test_map_printer_state_to_indicator_accepts_list_reasons():
+ indicator = nps.map_printer_state_to_indicator(
+ nps.PRINTER_STATE_STOPPED,
+ ["offline-report", "paused"],
+ )
+ assert indicator["color"] == "red"
+ assert indicator["label"] == "Offline"
+
+
+@pytest.mark.order(118)
+def test_map_printer_state_to_indicator_processing():
+ indicator = nps.map_printer_state_to_indicator(nps.PRINTER_STATE_PROCESSING)
+ assert indicator["color"] == "blue"
+ assert indicator["label"] == "Processing"
+
+
+@pytest.mark.order(120)
+def test_classify_fleet_row_orphan_queue():
+ row = nps.classify_fleet_row(
+ "vRAW",
+ {
+ "printer-location": "Chelsea Receiving",
+ "device-uri": "socket://192.168.1.51:9100",
+ "printer-state": nps.PRINTER_STATE_IDLE,
+ },
+ )
+ assert row["status"] == "Orphan Queue"
+ assert row["cups_queue"] == "vRAW"
+
+
+@pytest.mark.order(122)
+def test_classify_fleet_row_mismatch():
+ row = nps.classify_fleet_row(
+ "ZD621",
+ {
+ "printer-location": "Chelsea Receiving Dock",
+ "device-uri": "socket://192.168.1.50:9100",
+ "printer-state": nps.PRINTER_STATE_IDLE,
+ },
+ {
+ "name": "Chelsea Receiving Labels",
+ "printer_location": "Chelsea Receiving",
+ "device_uri": "socket://192.168.1.55:9100",
+ },
+ )
+ assert row["status"] == "Mismatch"
+
+
+@pytest.mark.order(124)
+def test_build_test_zpl_contains_queue_and_printer_name():
+ zpl = nps.build_test_zpl("ZD621", "Chelsea Receiving Labels")
+ assert "^XA" in zpl
+ assert "ZD621" in zpl
+ assert "Chelsea Receiving Labels" in zpl
diff --git a/beam/tests/test_printer_queue.py b/beam/tests/test_printer_queue.py
new file mode 100644
index 00000000..8aa42b18
--- /dev/null
+++ b/beam/tests/test_printer_queue.py
@@ -0,0 +1,357 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+import time
+from unittest.mock import Mock, patch
+
+import frappe
+import pytest
+
+from beam.beam import printer_queue as pq
+
+
+TEST_PRINT_SERVER = {"server_ip": "localhost", "port": 631}
+
+
+def mock_cups_connection(get_jobs=None):
+ mock_conn = Mock()
+ mock_conn.getJobs.return_value = get_jobs or {}
+ mock_conn.cancelJob = Mock()
+ return mock_conn
+
+
+@pytest.mark.order(180)
+def test_get_printer_jobs_snapshot_returns_active_jobs_only():
+ doc_name = "Printer Queue Snapshot Test"
+ if frappe.db.exists("Network Printer Settings", doc_name):
+ frappe.delete_doc("Network Printer Settings", doc_name)
+
+ frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": doc_name,
+ "server_ip": "localhost",
+ "port": 631,
+ "printer_name": "ZD621",
+ }
+ ).insert()
+
+ def get_jobs(which_jobs="not-completed", **kwargs):
+ if which_jobs == "not-completed":
+ return {
+ 42: {
+ "job-id": 42,
+ "job-name": "Invoice-001",
+ "job-state": 3,
+ "job-originating-user-name": "Administrator",
+ "job-printer-uri": "ipp://localhost/printers/ZD621",
+ "time-at-creation": 1_700_000_000,
+ "job-media-sheets": 2,
+ }
+ }
+ if which_jobs == "completed":
+ return {
+ 41: {
+ "job-id": 41,
+ "job-name": "Label-001",
+ "job-state": 9,
+ "job-originating-user-name": "Administrator",
+ "job-printer-uri": "ipp://localhost/printers/ZD621",
+ "time-at-creation": 1_699_999_000,
+ "time-at-completed": 1_700_000_050,
+ }
+ }
+ return {}
+
+ mock_conn = mock_cups_connection()
+ mock_conn.getJobs.side_effect = get_jobs
+ with (
+ patch.object(pq, "cups_connection", return_value=mock_conn),
+ patch.object(pq, "list_print_servers", return_value=[TEST_PRINT_SERVER]),
+ ):
+ result = pq.get_printer_jobs_snapshot()
+
+ assert len(result["jobs"]) == 1
+ pending = result["jobs"][0]
+ assert pending["job_state"] == "pending"
+ assert pending["job_id"] == 42
+ assert pending["printer"] == "ZD621"
+ mock_conn.getJobs.assert_called_once_with(
+ which_jobs="not-completed",
+ requested_attributes=pq.JOB_ATTRIBUTES,
+ )
+
+
+@pytest.mark.order(182)
+def test_session_snapshot_includes_recent_completed_jobs():
+ doc_name = "Printer Queue Session Test"
+ if frappe.db.exists("Network Printer Settings", doc_name):
+ frappe.delete_doc("Network Printer Settings", doc_name)
+
+ frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": doc_name,
+ "server_ip": "localhost",
+ "port": 631,
+ "printer_name": "ZD621",
+ }
+ ).insert()
+
+ task_id = "testsnapshot01"
+ completed_since = 1_700_000_000
+ pq.set_watcher_lease(
+ task_id,
+ frappe.session.user,
+ session_started_at=completed_since + pq.SESSION_LOOKBACK_SECONDS,
+ completed_since=completed_since,
+ )
+
+ def get_jobs(which_jobs="not-completed", **kwargs):
+ if which_jobs == "not-completed":
+ return {
+ 42: {
+ "job-id": 42,
+ "job-name": "Invoice-001",
+ "job-state": 3,
+ "job-originating-user-name": "Administrator",
+ "job-printer-uri": "ipp://localhost/printers/ZD621",
+ "time-at-creation": 1_700_000_100,
+ }
+ }
+ if which_jobs == "completed":
+ return {
+ 41: {
+ "job-id": 41,
+ "job-name": "Recent Label",
+ "job-state": 9,
+ "job-originating-user-name": "Administrator",
+ "job-printer-uri": "ipp://localhost/printers/ZD621",
+ "time-at-creation": 1_699_999_900,
+ "time-at-completed": completed_since + 30,
+ },
+ 40: {
+ "job-id": 40,
+ "job-name": "Old Label",
+ "job-state": 9,
+ "job-originating-user-name": "Administrator",
+ "job-printer-uri": "ipp://localhost/printers/ZD621",
+ "time-at-creation": 1_699_000_000,
+ "time-at-completed": completed_since - 30,
+ },
+ }
+ return {}
+
+ mock_conn = mock_cups_connection()
+ mock_conn.getJobs.side_effect = get_jobs
+ with (
+ patch.object(pq, "cups_connection", return_value=mock_conn),
+ patch.object(pq, "list_print_servers", return_value=[TEST_PRINT_SERVER]),
+ ):
+ result = pq.collect_printer_jobs_snapshot(task_id=task_id)
+
+ assert len(result["jobs"]) == 2
+ job_ids = {job["job_id"] for job in result["jobs"]}
+ assert job_ids == {41, 42}
+ printed = next(job for job in result["jobs"] if job["job_id"] == 41)
+ assert printed["display_status"] == "printed"
+ assert result["completed_since"] == completed_since
+ completed_calls = [
+ call for call in mock_conn.getJobs.call_args_list if call.kwargs.get("which_jobs") == "completed"
+ ]
+ assert completed_calls
+ assert completed_calls[0].kwargs.get("limit") == pq.COMPLETED_JOBS_FETCH_LIMIT
+
+
+@pytest.mark.order(184)
+def test_session_tracks_jobs_that_finish_between_polls():
+ task_id = "testsnapshot02"
+ completed_since = 1_700_000_000
+ pq.set_watcher_lease(
+ task_id,
+ frappe.session.user,
+ session_started_at=completed_since + pq.SESSION_LOOKBACK_SECONDS,
+ completed_since=completed_since,
+ )
+ state = pq.get_watcher_state(task_id)
+ state["fetch_completed_once"] = False
+ frappe.cache.set_value(
+ pq.watcher_cache_key(task_id),
+ state,
+ expires_in_sec=pq.SESSION_LEASE_SECONDS,
+ )
+
+ active_row = {
+ "job_id": 55,
+ "job_name": "PDF Test",
+ "job_state": "processing",
+ "display_status": "processing",
+ "job_state_reasons": [],
+ "user": "Administrator",
+ "printer": "PDF",
+ "printer_uri": "ipp://localhost/printers/PDF",
+ "server_ip": "localhost",
+ "port": 631,
+ "server_label": "Local Print Server",
+ "time_at_creation": completed_since + 60,
+ "time_at_processing": completed_since + 61,
+ "time_at_completed": None,
+ "pages": 1,
+ "pages_completed": 0,
+ }
+ completed_row = {
+ **active_row,
+ "job_state": "completed",
+ "display_status": "printed",
+ "time_at_completed": completed_since + 62,
+ }
+
+ mock_conn = mock_cups_connection(get_jobs={})
+ with (
+ patch.object(pq, "cups_connection", return_value=mock_conn),
+ patch.object(pq, "list_print_servers", return_value=[{"server_ip": "localhost", "port": 631}]),
+ patch.object(pq, "fetch_job_row", return_value=completed_row),
+ ):
+ pq.apply_session_state(task_id, [active_row])
+ result = pq.collect_printer_jobs_snapshot(task_id=task_id)
+
+ assert any(job["job_id"] == 55 and job["display_status"] == "printed" for job in result["jobs"])
+ pq.stop_printer_queue_watcher(task_id)
+
+
+@pytest.mark.order(186)
+def test_start_printer_queue_session_does_not_enqueue():
+ mock_conn = mock_cups_connection()
+ with (
+ patch("frappe.enqueue") as mock_enqueue,
+ patch.object(pq, "cups_connection", return_value=mock_conn),
+ patch.object(pq, "list_print_servers", return_value=[TEST_PRINT_SERVER]),
+ ):
+ result = pq.start_printer_queue_watcher()
+
+ assert result["task_id"]
+ mock_enqueue.assert_not_called()
+ pq.stop_printer_queue_watcher(result["task_id"])
+
+
+@pytest.mark.order(188)
+def test_poll_printer_queue_refreshes_session():
+ task_id = "testpoll01"
+ pq.set_session_lease(task_id, frappe.session.user, session_started_at=int(time.time()))
+ assert pq.session_lease_active(task_id)
+
+ mock_conn = mock_cups_connection(
+ get_jobs={
+ 7: {
+ "job-id": 7,
+ "job-name": "Label-001",
+ "job-state": 5,
+ "job-originating-user-name": "Administrator",
+ "job-printer-uri": "ipp://localhost/printers/ZD621",
+ "time-at-creation": 1_700_000_100,
+ }
+ }
+ )
+ with (
+ patch.object(pq, "cups_connection", return_value=mock_conn),
+ patch.object(pq, "list_print_servers", return_value=[TEST_PRINT_SERVER]),
+ ):
+ result = pq.poll_printer_queue(task_id)
+
+ assert result["expired"] is False
+ assert result["task_id"] == task_id
+ assert result["jobs"][0]["job_id"] == 7
+ assert pq.session_lease_active(task_id)
+ pq.stop_printer_queue_watcher(task_id)
+
+
+@pytest.mark.order(190)
+def test_poll_printer_queue_expired_session():
+ result = pq.poll_printer_queue("missing-session")
+ assert result["expired"] is True
+
+
+@pytest.mark.order(192)
+def test_cancel_printer_job():
+ mock_conn = mock_cups_connection()
+ with patch.object(pq, "cups_connection", return_value=mock_conn):
+ result = pq.cancel_printer_job("localhost", 631, 99)
+
+ assert result["cancelled"] is True
+ mock_conn.cancelJob.assert_called_once_with(99)
+
+
+@pytest.mark.order(194)
+def test_start_and_stop_printer_queue_session_lease():
+ task_id = "testsession01"
+ pq.set_session_lease(task_id, frappe.session.user)
+ assert pq.session_lease_active(task_id)
+
+ pq.stop_printer_queue_watcher(task_id)
+ assert not pq.session_lease_active(task_id)
+
+
+@pytest.mark.order(196)
+def test_apply_session_state_keeps_still_active_job_in_seen_active():
+ """A job still processing on CUPS stays in seen_active instead of session history."""
+ task_id = "testsnapshot03"
+ completed_since = 1_700_000_000
+ pq.set_watcher_lease(
+ task_id,
+ frappe.session.user,
+ session_started_at=completed_since + pq.SESSION_LOOKBACK_SECONDS,
+ completed_since=completed_since,
+ )
+
+ active_row = {
+ "job_id": 56,
+ "job_name": "Kitchen Label",
+ "job_state": "processing",
+ "display_status": "processing",
+ "job_state_reasons": [],
+ "user": "Administrator",
+ "printer": "ZD621",
+ "printer_uri": "ipp://localhost/printers/ZD621",
+ "server_ip": "localhost",
+ "port": 631,
+ "server_label": "Local Print Server",
+ "time_at_creation": completed_since + 60,
+ "time_at_processing": completed_since + 61,
+ "time_at_completed": None,
+ "pages": 1,
+ "pages_completed": 0,
+ }
+ cache_key = pq.job_row_cache_key(active_row)
+
+ pq.apply_session_state(task_id, [active_row])
+ state = pq.get_watcher_state(task_id)
+ assert cache_key in state["seen_active"]
+
+ still_active_row = {**active_row, "job_state": "processing", "display_status": "processing"}
+ with patch.object(pq, "fetch_job_row", return_value=still_active_row):
+ pq.apply_session_state(task_id, [])
+
+ state = pq.get_watcher_state(task_id)
+ assert cache_key in state["seen_active"]
+ assert cache_key not in state["session_jobs"]
+ pq.stop_printer_queue_watcher(task_id)
+
+
+@pytest.mark.order(198)
+def test_job_in_session_window_treats_epoch_timestamp_as_recent():
+ row = {
+ "job_state": "completed",
+ "time_at_creation": 0,
+ "time_at_completed": 0,
+ }
+ assert pq.job_in_session_window(row, completed_since=0) is True
+
+
+@pytest.mark.order(200)
+def test_printer_queue_read_permission_required():
+ with pytest.raises(frappe.exceptions.PermissionError):
+ frappe.set_user("Guest")
+ try:
+ pq.get_printer_jobs_snapshot()
+ finally:
+ frappe.set_user("Administrator")
diff --git a/beam/tests/test_printer_wizard.py b/beam/tests/test_printer_wizard.py
new file mode 100644
index 00000000..3c289ba9
--- /dev/null
+++ b/beam/tests/test_printer_wizard.py
@@ -0,0 +1,505 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+from unittest.mock import Mock, patch
+
+import frappe
+import pytest
+
+from beam.beam.overrides import network_printer_settings as nps
+
+
+def mock_cups_connection(get_devices=None, get_printers=None, get_ppds=None):
+ printers = get_printers or {}
+ mock_conn = Mock()
+ mock_conn.getDevices.return_value = get_devices or {}
+ mock_conn.getPrinters.return_value = printers
+ mock_conn.getPPDs.return_value = get_ppds or {}
+ mock_conn.addPrinter = Mock()
+ mock_conn.enablePrinter = Mock()
+ mock_conn.acceptJobs = Mock()
+ mock_conn.deletePrinter = Mock()
+ mock_conn.setPrinterDevice = Mock()
+ mock_conn.setPrinterLocation = Mock()
+ mock_conn.disablePrinter = Mock()
+ mock_conn.rejectJobs = Mock()
+ mock_conn.printTestPage = Mock(return_value=1)
+ mock_conn.printFile = Mock(return_value=1)
+ mock_conn.getOptions = Mock(return_value={})
+ mock_conn.setPrinterOptions = Mock()
+
+ def get_printer_attributes(name):
+ attrs = dict(printers.get(name, {}))
+ attrs.setdefault("printer-is-accepting-jobs", True)
+ return attrs
+
+ mock_conn.getPrinterAttributes.side_effect = get_printer_attributes
+ return mock_conn
+
+
+@pytest.mark.order(140)
+def test_wizard_blocks_duplicate_cups_queue():
+ """Creating ZD621 fails when that queue already exists on the Chelsea print server."""
+ mock_conn = mock_cups_connection(
+ get_printers={
+ "ZD621": {
+ "printer-make-and-model": "Local Raw Printer",
+ "printer-location": "Chelsea Receiving",
+ "device-uri": "socket://192.168.1.50:9100",
+ }
+ }
+ )
+ with patch.object(nps, "cups_connection", return_value=mock_conn):
+ result = nps.validate_wizard_selection(
+ "localhost",
+ 631,
+ "create",
+ printer_name="ZD621",
+ device_uri="socket://192.168.1.50:9100",
+ kind="device",
+ )
+ assert result["allowed"] is False
+ assert "ZD621" in result["message"]
+
+
+@pytest.mark.order(142)
+def test_wizard_blocks_queue_already_linked():
+ """ZD621 cannot be linked again when Chelsea Receiving Labels already owns it."""
+ doc_name = "Chelsea Receiving Labels"
+ if not frappe.db.exists("Network Printer Settings", doc_name):
+ frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": doc_name,
+ "server_ip": "localhost",
+ "port": 631,
+ "printer_name": "ZD621",
+ }
+ ).insert()
+
+ mock_conn = mock_cups_connection(
+ get_printers={
+ "ZD621": {
+ "printer-make-and-model": "Local Raw Printer",
+ "printer-location": "Chelsea Receiving",
+ "device-uri": "socket://192.168.1.50:9100",
+ }
+ }
+ )
+ with patch.object(nps, "cups_connection", return_value=mock_conn):
+ result = nps.validate_wizard_selection(
+ "localhost",
+ 631,
+ "link",
+ printer_name="ZD621",
+ kind="queue",
+ )
+ assert result["allowed"] is False
+ assert doc_name in result["message"]
+
+
+@pytest.mark.order(144)
+def test_wizard_links_existing_unlinked_queue():
+ """Legacy vRAW on CUPS becomes Chelsea Raw Labels in ERPNext without addPrinter."""
+ queue_name = "vRAW"
+ nps_name = "Chelsea Raw Labels"
+ if frappe.db.exists("Network Printer Settings", nps_name):
+ frappe.delete_doc("Network Printer Settings", nps_name)
+ for linked_name in frappe.get_all(
+ "Network Printer Settings",
+ filters={"printer_name": queue_name},
+ pluck="name",
+ ):
+ frappe.delete_doc("Network Printer Settings", linked_name)
+
+ mock_conn = mock_cups_connection(
+ get_printers={
+ queue_name: {
+ "printer-make-and-model": "Local Raw Printer",
+ "printer-location": "Chelsea Receiving",
+ "device-uri": "socket://192.168.1.51:9100",
+ }
+ }
+ )
+ existing = set(frappe.get_all("Network Printer Settings", pluck="name"))
+ with patch.object(nps, "cups_connection", return_value=mock_conn):
+ doc = nps.link_existing_printer(
+ nps_name,
+ "localhost",
+ 631,
+ queue_name,
+ printer_location="Chelsea Receiving",
+ printer_type="Label / RAW",
+ )
+ assert doc["name"] == nps_name
+ assert doc["printer_name"] == queue_name
+ mock_conn.addPrinter.assert_not_called()
+ created = set(frappe.get_all("Network Printer Settings", pluck="name")) - existing
+ assert nps_name in created
+
+
+@pytest.mark.order(146)
+def test_get_wizard_devices_marks_configured_and_sorts_unconfigured_first():
+ """Receiving Printer is configured; ZD621 is not and appears first in discovery."""
+ receiving_name = "BEAM Wizard Sort Receiving"
+ receiving_queue = "BEAM_WIZARD_RECEIVING"
+ unconfigured_queue = "BEAM_WIZARD_ZD621"
+ for doc_name in (receiving_name, "BEAM Wizard Sort Unconfigured"):
+ if frappe.db.exists("Network Printer Settings", doc_name):
+ frappe.delete_doc("Network Printer Settings", doc_name)
+
+ frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": receiving_name,
+ "server_ip": "localhost",
+ "port": 631,
+ "printer_name": receiving_queue,
+ }
+ ).insert()
+
+ mock_conn = mock_cups_connection(
+ get_printers={
+ receiving_queue: {
+ "printer-make-and-model": "Office Printer",
+ "printer-location": "Kitchen",
+ "device-uri": "socket://192.168.1.10:9100",
+ },
+ unconfigured_queue: {
+ "printer-make-and-model": "Local Raw Printer",
+ "printer-location": "Chelsea Receiving",
+ "device-uri": "socket://192.168.1.50:9100",
+ },
+ }
+ )
+ with patch.object(nps, "cups_connection", return_value=mock_conn):
+ entries = nps.get_wizard_devices("localhost", 631)
+
+ queue_entries = [entry for entry in entries if entry["kind"] == "queue"]
+ unconfigured = next(entry for entry in queue_entries if entry["value"] == unconfigured_queue)
+ receiving = next(entry for entry in queue_entries if entry["value"] == receiving_queue)
+ assert unconfigured["configured"] is False
+ assert receiving["configured"] is True
+ assert receiving["nps_name"] == receiving_name
+ assert queue_entries.index(unconfigured) < queue_entries.index(receiving)
+
+
+@pytest.mark.order(148)
+def test_wizard_creates_queue_and_nps():
+ """Stock Manager adds Chelsea Receiving Labels as a new ZD621 raw queue."""
+ nps_name = "Chelsea Receiving Labels"
+ if frappe.db.exists("Network Printer Settings", nps_name):
+ frappe.delete_doc("Network Printer Settings", nps_name)
+
+ mock_conn = mock_cups_connection(get_printers={})
+ existing = set(frappe.get_all("Network Printer Settings", pluck="name"))
+ reachable_uri = {
+ "host": "192.168.1.50",
+ "port": 9100,
+ "ping_result": {"reachable": True, "host": "192.168.1.50", "message": "ok"},
+ "socket_reachable": True,
+ "message": "ok",
+ "warnings": [],
+ }
+ with patch.object(nps, "cups_connection", return_value=mock_conn), patch.object(
+ nps, "validate_device_uri", return_value=reachable_uri
+ ):
+ doc = nps.create_printer_queue(
+ nps_name,
+ "localhost",
+ 631,
+ "ZD621",
+ "socket://192.168.1.50:9100",
+ ppdname="raw",
+ printer_location="Chelsea Receiving",
+ printer_type="Label / RAW",
+ )
+
+ assert doc["printer_location"] == "Chelsea Receiving"
+ assert doc["printer_type"] == "Label / RAW"
+ mock_conn.addPrinter.assert_called_once()
+ created = set(frappe.get_all("Network Printer Settings", pluck="name")) - existing
+ assert nps_name in created
+
+
+@pytest.mark.order(150)
+def test_wizard_rejects_usb_uri():
+ """USB printers are rejected for Chelsea dock setup."""
+ with pytest.raises(frappe.ValidationError, match="USB"):
+ nps.reject_usb_uri("usb://Zebra/ZD621")
+
+
+@pytest.mark.order(152)
+def test_get_ppds_filters_by_query():
+ """PPD browser returns Zebra drivers when searching for ZD621."""
+ mock_conn = mock_cups_connection(
+ get_ppds={
+ "raw": {"ppd-make-and-model": "Raw Queue", "ppd-make": "Generic"},
+ "zebra.ppd": {"ppd-make-and-model": "Zebra ZD621", "ppd-make": "Zebra"},
+ "everywhere": {"ppd-make-and-model": "Generic IPP Everywhere", "ppd-make": "Generic"},
+ }
+ )
+ with patch.object(nps, "cups_connection", return_value=mock_conn):
+ results = nps.get_ppds("localhost", 631, query="zebra")
+ assert len(results) == 1
+ assert results[0]["value"] == "zebra.ppd"
+
+
+@pytest.mark.order(154)
+def test_configure_printer_updates_cups_queue():
+ """Administrator updates Chelsea Receiving Labels with a new socket URI."""
+ doc_name = "Chelsea Receiving Labels Configure"
+ if frappe.db.exists("Network Printer Settings", doc_name):
+ frappe.delete_doc("Network Printer Settings", doc_name)
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": doc_name,
+ "server_ip": "localhost",
+ "port": 631,
+ "printer_name": "ZD621",
+ "printer_location": "Chelsea Receiving",
+ "device_uri": "socket://192.168.1.50:9100",
+ }
+ ).insert()
+
+ mock_conn = mock_cups_connection(
+ get_printers={
+ "ZD621": {
+ "printer-make-and-model": "Local Raw Printer",
+ "printer-location": "Chelsea Receiving",
+ "device-uri": "socket://192.168.1.50:9100",
+ }
+ }
+ )
+ reachable_uri = {
+ "host": "192.168.1.55",
+ "port": 9100,
+ "ping_result": {"reachable": True, "host": "192.168.1.55", "message": "ok"},
+ "socket_reachable": True,
+ "message": "ok",
+ "warnings": [],
+ }
+ with patch.object(nps, "cups_connection", return_value=mock_conn), patch.object(
+ nps, "validate_device_uri", return_value=reachable_uri
+ ):
+ updated = doc.configure_printer_queue(
+ device_uri="socket://192.168.1.55:9100",
+ ppdname="raw",
+ printer_location="Chelsea Receiving Dock",
+ enabled=1,
+ )
+
+ assert updated["device_uri"] == "socket://192.168.1.55:9100"
+ assert updated["printer_location"] == "Chelsea Receiving Dock"
+ mock_conn.setPrinterDevice.assert_called_once()
+
+
+@pytest.mark.order(126)
+def test_is_local_cups_server():
+ assert nps.is_local_cups_server("localhost", 631) is True
+ assert nps.is_local_cups_server("127.0.0.1", 631) is True
+ assert nps.is_local_cups_server("10.1.10.1", 631) is False
+
+
+@pytest.mark.order(128)
+def test_parse_device_uri_host_extracts_socket_address():
+ assert nps.parse_device_uri_host("socket://192.168.1.50:9100") == "192.168.1.50"
+ assert nps.parse_device_uri_host("ipp://zebra.local/ipp/print") == "zebra.local"
+ assert nps.parse_device_uri_host("") is None
+
+
+@pytest.mark.order(130)
+def test_ping_host_reports_local_print_server():
+ result = nps.ping_host("localhost")
+ assert result["reachable"] is True
+ assert "local" in result["message"].lower()
+
+
+@pytest.mark.order(156)
+def test_get_cups_printer_info_returns_location_and_uri():
+ doc_name = "Chelsea Receiving Labels Info"
+ if frappe.db.exists("Network Printer Settings", doc_name):
+ frappe.delete_doc("Network Printer Settings", doc_name)
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": doc_name,
+ "server_ip": "localhost",
+ "port": 631,
+ "printer_name": "ZD621",
+ }
+ ).insert()
+
+ mock_conn = mock_cups_connection(
+ get_printers={
+ "ZD621": {
+ "printer-make-and-model": "Zebra ZD621",
+ "printer-location": "Chelsea Receiving Dock",
+ "device-uri": "socket://192.168.1.50:9100",
+ }
+ }
+ )
+ with patch.object(nps, "cups_connection", return_value=mock_conn):
+ info = doc.get_cups_printer_info()
+
+ assert info["printer_location"] == "Chelsea Receiving Dock"
+ assert info["device_uri"] == "socket://192.168.1.50:9100"
+
+
+@pytest.mark.order(158)
+def test_print_test_page_submits_cups_job():
+ doc_name = "Chelsea Receiving Labels Test Page"
+ if frappe.db.exists("Network Printer Settings", doc_name):
+ frappe.delete_doc("Network Printer Settings", doc_name)
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": doc_name,
+ "server_ip": "localhost",
+ "port": 631,
+ "printer_name": "ZD621",
+ }
+ ).insert()
+
+ mock_conn = mock_cups_connection(
+ get_printers={
+ "ZD621": {
+ "printer-make-and-model": "Zebra ZD621",
+ "device-uri": "socket://192.168.1.50:9100",
+ }
+ }
+ )
+ mock_conn.printTestPage = Mock(return_value=42)
+ with patch.object(nps, "cups_connection", return_value=mock_conn):
+ result = doc.print_test_page()
+
+ mock_conn.printTestPage.assert_called_once_with("ZD621")
+ assert result["job_id"] == 42
+
+
+@pytest.mark.order(160)
+def test_ping_printer_uses_device_uri_host():
+ doc_name = "Chelsea Receiving Labels Ping"
+ if frappe.db.exists("Network Printer Settings", doc_name):
+ frappe.delete_doc("Network Printer Settings", doc_name)
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": doc_name,
+ "server_ip": "localhost",
+ "port": 631,
+ "printer_name": "ZD621",
+ "device_uri": "socket://192.168.1.50:9100",
+ }
+ ).insert()
+
+ mock_conn = mock_cups_connection(
+ get_printers={
+ "ZD621": {
+ "printer-make-and-model": "Zebra ZD621",
+ "printer-location": "Chelsea Receiving",
+ "device-uri": "socket://192.168.1.50:9100",
+ }
+ }
+ )
+ with patch.object(nps, "cups_connection", return_value=mock_conn), patch.object(
+ nps, "ping_host", return_value={"reachable": True, "host": "192.168.1.50", "message": "ok"}
+ ) as mock_ping:
+ result = doc.ping_printer()
+
+ mock_ping.assert_called_once_with("192.168.1.50")
+ assert result["cups_location"] == "Chelsea Receiving"
+
+
+@pytest.mark.order(162)
+def test_delete_printer_queue_removes_cups_and_nps():
+ """Decommissioning removes ZD621 from CUPS and deletes the ERPNext record."""
+ doc_name = "Chelsea Receiving Labels Delete"
+ if frappe.db.exists("Network Printer Settings", doc_name):
+ frappe.delete_doc("Network Printer Settings", doc_name)
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": doc_name,
+ "server_ip": "localhost",
+ "port": 631,
+ "printer_name": "ZD621",
+ }
+ ).insert()
+
+ mock_conn = mock_cups_connection()
+ with patch.object(nps, "cups_connection", return_value=mock_conn):
+ doc.delete_printer_queue()
+
+ mock_conn.deletePrinter.assert_called_once_with("ZD621")
+ assert not frappe.db.exists("Network Printer Settings", doc_name)
+
+
+@pytest.mark.order(164)
+def test_push_location_to_cups_logs_sync_failure():
+ """Saving Chelsea Receiving Labels logs when CUPS rejects the location update."""
+ doc_name = "Chelsea Receiving Labels Location Sync"
+ if frappe.db.exists("Network Printer Settings", doc_name):
+ frappe.delete_doc("Network Printer Settings", doc_name)
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Network Printer Settings",
+ "name": doc_name,
+ "server_ip": "localhost",
+ "port": 631,
+ "printer_name": "ZD621",
+ "printer_location": "Chelsea Receiving",
+ }
+ ).insert()
+
+ mock_conn = mock_cups_connection()
+ mock_conn.setPrinterLocation.side_effect = RuntimeError("CUPS unavailable")
+ with (
+ patch.object(nps, "cups_connection", return_value=mock_conn),
+ patch("frappe.log_error") as mock_log_error,
+ ):
+ doc.printer_location = "Chelsea Receiving Dock"
+ doc.save()
+
+ mock_log_error.assert_called_once()
+ assert "CUPS location sync failed" in mock_log_error.call_args.kwargs["title"]
+ frappe.delete_doc("Network Printer Settings", doc_name)
+
+
+@pytest.mark.order(166)
+def test_wizard_read_permission_required():
+ with pytest.raises(frappe.exceptions.PermissionError):
+ frappe.set_user("Guest")
+ try:
+ nps.get_wizard_devices("localhost", 631)
+ finally:
+ frappe.set_user("Administrator")
+
+
+@pytest.mark.order(168)
+def test_wizard_create_permission_required():
+ mock_conn = mock_cups_connection()
+ with (
+ patch.object(nps, "cups_connection", return_value=mock_conn),
+ pytest.raises(frappe.exceptions.PermissionError),
+ ):
+ frappe.set_user("Guest")
+ try:
+ nps.create_printer_queue(
+ "Guest Printer",
+ "localhost",
+ 631,
+ "GuestQueue",
+ "socket://192.168.1.50:9100",
+ )
+ finally:
+ frappe.set_user("Administrator")
+
+ assert mock_conn.addPrinter.call_count == 0
diff --git a/beam/tests/test_printing.py b/beam/tests/test_printing.py
index e2f6f6a0..57f179a6 100644
--- a/beam/tests/test_printing.py
+++ b/beam/tests/test_printing.py
@@ -4,11 +4,13 @@
from unittest.mock import Mock, patch
import frappe
+import pytest
from frappe.exceptions import DoesNotExistError
from beam.beam.printing import print_by_server
+@pytest.mark.order(100)
def test_print_by_server_empty_string_uses_standard():
"""Empty print_format should default to Standard"""
mock_cups = Mock()
@@ -26,6 +28,7 @@ def test_print_by_server_empty_string_uses_standard():
assert "Standard" in str(e)
+@pytest.mark.order(102)
def test_print_by_server_none_uses_standard():
"""None print_format should default to Standard"""
mock_cups = Mock()
@@ -43,6 +46,7 @@ def test_print_by_server_none_uses_standard():
assert "Standard" in str(e)
+@pytest.mark.order(104)
def test_print_by_server_explicit_format():
"""Explicit print_format should be used"""
from beam.beam.printing import print_by_server
@@ -62,6 +66,7 @@ def test_print_by_server_explicit_format():
assert "Standard" not in str(e), "Should use explicit format, not Standard"
+@pytest.mark.order(106)
def test_print_by_server_with_serialized_doc():
"""Serialized doc should be properly deserialized as full document instance"""
# Get a real item doc and serialize it like the frontend would
diff --git a/beam/tests/test_scan.py b/beam/tests/test_scan.py
index 8d107b4e..75504005 100644
--- a/beam/tests/test_scan.py
+++ b/beam/tests/test_scan.py
@@ -2,6 +2,7 @@
# For license information, please see license.txt
import frappe
+import pytest
"""
1. Test that a scanned item code in a list view returns the correct values for filtering
@@ -12,6 +13,7 @@
"""
+@pytest.mark.order(40)
def test_item_scan_from_list_view_for_filter():
# purchase receipt listview
item_barcode = frappe.get_value("Item Barcode", {"parent": "Butter"}, "barcode")
@@ -25,6 +27,7 @@ def test_item_scan_from_list_view_for_filter():
assert scan[0].get("target") == "Butter"
+@pytest.mark.order(42)
def test_item_scan_from_list_view_for_route():
# item listview
item_barcode = frappe.get_value("Item Barcode", {"parent": "Butter"}, "barcode")
@@ -38,6 +41,7 @@ def test_item_scan_from_list_view_for_route():
assert scan[0].get("target") == "Butter"
+@pytest.mark.order(44)
def test_item_scan_from_form_view():
context = {
"frm": "Purchase Receipt",
diff --git a/beam/tests/test_serial_number.py b/beam/tests/test_serial_number.py
index a304a8ba..ccdea482 100644
--- a/beam/tests/test_serial_number.py
+++ b/beam/tests/test_serial_number.py
@@ -12,7 +12,7 @@ def _make_serials(series="WCC-.#####", qty=1):
return [make_autoname(series) for _ in range(qty)]
-@pytest.mark.order(20)
+@pytest.mark.order(71)
def test_serial_number_scan():
warehouse = "Storeroom - APC"
supplier = "Unity Bakery Supply"
diff --git a/beam/tests/test_telemetry.py b/beam/tests/test_telemetry.py
new file mode 100644
index 00000000..f92261ea
--- /dev/null
+++ b/beam/tests/test_telemetry.py
@@ -0,0 +1,107 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+"""Opt-in pytest telemetry for diagnosing hangs in CI and ACT.
+
+Enable with BEAM_TEST_TELEMETRY=1 (also auto-enabled when ACT=true).
+Periodic stack dumps: BEAM_TEST_STACK_DUMP_SECONDS (default 60).
+"""
+
+import faulthandler
+import os
+import sys
+import time
+
+import pytest
+
+ENABLED = (
+ os.environ.get("BEAM_TEST_TELEMETRY", "").lower() in ("1", "true", "yes")
+ or os.environ.get("ACT") == "true"
+)
+STACK_DUMP_SECONDS = int(os.environ.get("BEAM_TEST_STACK_DUMP_SECONDS", "60"))
+
+
+def emit(message):
+ if not ENABLED:
+ return
+ print(f"[beam-test-telemetry {time.strftime('%H:%M:%S')}] {message}", file=sys.stderr, flush=True)
+
+
+def dump_stacks(reason):
+ if not ENABLED:
+ return
+ emit(f"dumping all thread stacks: {reason}")
+ faulthandler.dump_traceback(file=sys.stderr, all_threads=True)
+
+
+def pytest_configure(config):
+ if not ENABLED:
+ return
+ faulthandler.enable(file=sys.stderr, all_threads=True)
+ if STACK_DUMP_SECONDS > 0:
+ faulthandler.dump_traceback_later(STACK_DUMP_SECONDS, repeat=True, file=sys.stderr)
+ emit(
+ "telemetry enabled; "
+ f"stderr stack dumps every {STACK_DUMP_SECONDS}s while the process is blocked"
+ )
+
+
+def pytest_runtest_logstart(nodeid, location):
+ emit(f"TEST logstart {nodeid} at {location[0]}:{location[1]}")
+
+
+def pytest_runtest_logfinish(nodeid, location):
+ emit(f"TEST logfinish {nodeid}")
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_runtest_setup(item):
+ emit(f"TEST setup begin {item.nodeid}")
+ started = time.monotonic()
+ yield
+ emit(f"TEST setup end {item.nodeid} elapsed={time.monotonic() - started:.3f}s")
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_runtest_call(item):
+ emit(f"TEST call begin {item.nodeid}")
+ started = time.monotonic()
+ yield
+ emit(f"TEST call end {item.nodeid} elapsed={time.monotonic() - started:.3f}s")
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_runtest_teardown(item):
+ emit(f"TEST teardown begin {item.nodeid}")
+ started = time.monotonic()
+ yield
+ emit(f"TEST teardown end {item.nodeid} elapsed={time.monotonic() - started:.3f}s")
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_fixture_setup(fixturedef, request):
+ name = fixturedef.argname
+ scope = fixturedef.scope
+ node = getattr(request, "node", None)
+ nodeid = getattr(node, "nodeid", "?")
+ emit(f"FIXTURE setup begin {name} scope={scope} node={nodeid}")
+ started = time.monotonic()
+ outcome = yield
+ status = "ok" if outcome.excinfo is None else "error"
+ emit(
+ f"FIXTURE setup end {name} scope={scope} "
+ f"elapsed={time.monotonic() - started:.3f}s status={status}"
+ )
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_fixture_post_finalizer(fixturedef):
+ name = fixturedef.argname
+ scope = fixturedef.scope
+ emit(f"FIXTURE teardown begin {name} scope={scope}")
+ started = time.monotonic()
+ yield
+ emit(f"FIXTURE teardown end {name} scope={scope} " f"elapsed={time.monotonic() - started:.3f}s")
+
+
+# pytest_plugins entry in conftest loads this module.
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 00000000..0e6e8d81
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2025, AgriTheory and contributors
+# For license information, please see license.txt
+
+pytest_plugins = ["test_telemetry"]
diff --git a/cups/README.md b/cups/README.md
index dcec66a0..28dfce41 100644
--- a/cups/README.md
+++ b/cups/README.md
@@ -6,6 +6,25 @@ For license information, please see license.txt-->
Printers service containerized.
Caddy (with TLS) + CUPS.
+## GHCR image
+
+The CUPS service image is published to GitHub Container Registry:
+
+```sh
+docker pull ghcr.io/agritheory/beam-cups:latest
+docker run --rm -p 631:631 \
+ --add-host=host.docker.internal:host-gateway \
+ ghcr.io/agritheory/beam-cups:latest
+```
+
+CI publishes on push to `version-14` / `version-15` via [`.github/workflows/publish-cups.yml`](../.github/workflows/publish-cups.yml). Optional repository secrets `CUPS_ADMIN_USER` and `CUPS_ADMIN_PASSWORD` override the default admin credentials baked in at build time (defaults match `.env.example`).
+
+To use the published image with compose instead of a local build, replace the `cups` service `build:` block with:
+
+```yaml
+# image: ghcr.io/agritheory/beam-cups:latest
+```
+
Podman
## Initial Setup
diff --git a/cups/cups/Containerfile b/cups/cups/Containerfile
index f2bb1e11..539c4110 100644
--- a/cups/cups/Containerfile
+++ b/cups/cups/Containerfile
@@ -1,6 +1,7 @@
FROM debian:testing-slim
# Install Packages (basic tools, cups, basic drivers, HP drivers)
+# printer-driver-all and printer-driver-gutenprint omitted (unavailable on Debian arm64)
RUN apt-get update \
&& apt-get install -y \
sudo \
@@ -11,7 +12,6 @@ RUN apt-get update \
cups-bsd \
cups-filters \
foomatic-db-compressed-ppds \
- printer-driver-all \
openprinting-ppds \
hpijs-ppds \
hp-ppd \
@@ -19,13 +19,12 @@ RUN apt-get update \
smbclient \
printer-driver-cups-pdf \
hplip \
- printer-driver-gutenprint \
avahi-daemon \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
-ARG CUPS_ADMIN_USER
-ARG CUPS_ADMIN_PASSWORD
+ARG CUPS_ADMIN_USER=admin
+ARG CUPS_ADMIN_PASSWORD=root
RUN useradd \
--create-home \
--groups sudo,lp,lpadmin \
@@ -39,8 +38,6 @@ COPY cupsd.conf /etc/cups/cupsd.conf
RUN sudo chmod -R a+rwx /var/spool \
&& sudo chmod -R a+rwx /etc/cups
-COPY avahi-daemon.conf /etc/avahi/avahi-daemon.conf
-
CMD [\
"/usr/sbin/cupsd", \
"-f" \
diff --git a/cups/docker-compose.yml b/cups/docker-compose.yml
index efc0fef5..d935765f 100644
--- a/cups/docker-compose.yml
+++ b/cups/docker-compose.yml
@@ -3,6 +3,7 @@ version: '3'
services:
cups:
container_name: ${CONTAINER_NAME_PREFIX}_cups
+ # image: ghcr.io/agritheory/beam-cups:latest
build:
context: cups
dockerfile: Containerfile
diff --git a/cups/podman-compose.yml b/cups/podman-compose.yml
index 4437164d..6cc31fc1 100644
--- a/cups/podman-compose.yml
+++ b/cups/podman-compose.yml
@@ -3,6 +3,7 @@ version: '3'
services:
cups:
container_name: ${CONTAINER_NAME_PREFIX}_cups
+ # image: ghcr.io/agritheory/beam-cups:latest
build:
context: ./cups
args:
diff --git a/poetry.lock b/poetry.lock
index 46bdf14e..a18c40cd 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,387 @@
-# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand.
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+description = "Reusable constraint types to use with typing.Annotated"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
+ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
+]
+
+[[package]]
+name = "anthropic"
+version = "0.99.0"
+description = "The official Python library for the anthropic API"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "anthropic-0.99.0-py3-none-any.whl", hash = "sha256:c44469b746ab2ef19a4c52dcbdb98e17bc95c60bebdd18ec40d76d2d23592b49"},
+ {file = "anthropic-0.99.0.tar.gz", hash = "sha256:16f41e00f215ed2d193b146be3dd567c4319c32ed3af6c8725d68ba875257c1c"},
+]
+
+[package.dependencies]
+anyio = ">=3.5.0,<5"
+distro = ">=1.7.0,<2"
+docstring-parser = ">=0.15,<1"
+httpx = ">=0.25.0,<1"
+jiter = ">=0.4.0,<1"
+pydantic = ">=1.9.0,<3"
+sniffio = "*"
+typing-extensions = ">=4.14,<5"
+
+[package.extras]
+aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
+aws = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"]
+bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"]
+mcp = ["mcp (>=1.0) ; python_version >= \"3.10\""]
+vertex = ["google-auth[requests] (>=2,<3)"]
+
+[[package]]
+name = "anyio"
+version = "4.14.0"
+description = "High-level concurrency and networking framework on top of asyncio or Trio"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9"},
+ {file = "anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89"},
+]
+
+[package.dependencies]
+exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
+idna = ">=2.8"
+typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
+
+[package.extras]
+trio = ["trio (>=0.32.0)"]
+
+[[package]]
+name = "astpath"
+version = "0.9.1"
+description = "A query language for Python abstract syntax trees"
+optional = false
+python-versions = "*"
+groups = ["dev"]
+files = [
+ {file = "astpath-0.9.1.tar.gz", hash = "sha256:935ad8b22e4c868d3e517bce8efab500a3a7574044510423978c8238b24ae1db"},
+]
+
+[package.dependencies]
+lxml = {version = ">=3.3.5", optional = true, markers = "extra == \"xpath\""}
+
+[package.extras]
+xpath = ["lxml (>=3.3.5)"]
+
+[[package]]
+name = "asttokens"
+version = "3.0.1"
+description = "Annotate AST trees with source code positions"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a"},
+ {file = "asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7"},
+]
+
+[package.extras]
+astroid = ["astroid (>=2,<5)"]
+test = ["astroid (>=2,<5)", "pytest (<9.0)", "pytest-cov", "pytest-xdist"]
+
+[[package]]
+name = "boto3"
+version = "1.43.36"
+description = "The AWS SDK for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "boto3-1.43.36-py3-none-any.whl", hash = "sha256:42942dde254673abcbc9e6e60017c88341a4f49d99d24e1f2e290fb38138c26f"},
+ {file = "boto3-1.43.36.tar.gz", hash = "sha256:587d7ee92a12e440ad12b0e7f11f3358f0c4d65b19f64726efc94aaf194aff28"},
+]
+
+[package.dependencies]
+botocore = ">=1.43.36,<1.44.0"
+jmespath = ">=0.7.1,<2.0.0"
+s3transfer = ">=0.19.0,<0.20.0"
+
+[package.extras]
+crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
+
+[[package]]
+name = "botocore"
+version = "1.43.36"
+description = "Low-level, data-driven core of boto 3."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "botocore-1.43.36-py3-none-any.whl", hash = "sha256:3c65fdc39ed01d8dfde1e961b34038aed03c459f8ddf80717a12ac006475e49d"},
+ {file = "botocore-1.43.36.tar.gz", hash = "sha256:4cae47d1b2d426316b85a0087d9e69e048f13bc003b5177d74639fe9dfd28205"},
+]
+
+[package.dependencies]
+jmespath = ">=0.7.1,<2.0.0"
+python-dateutil = ">=2.1,<3.0.0"
+urllib3 = ">=1.25.4,<2.2.0 || >2.2.0,<3"
+
+[package.extras]
+crt = ["awscrt (==0.32.2)"]
+
+[[package]]
+name = "certifi"
+version = "2026.6.17"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"},
+ {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"},
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+description = "Foreign Function Interface for Python calling C code."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+markers = "platform_python_implementation != \"PyPy\""
+files = [
+ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
+ {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"},
+ {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"},
+ {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"},
+ {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"},
+ {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"},
+ {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"},
+ {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"},
+ {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"},
+ {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"},
+ {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"},
+ {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"},
+ {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"},
+ {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"},
+ {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"},
+ {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"},
+ {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"},
+ {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"},
+ {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"},
+ {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"},
+ {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"},
+ {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"},
+ {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"},
+ {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"},
+ {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"},
+ {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"},
+ {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"},
+ {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"},
+ {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"},
+ {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"},
+ {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"},
+ {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"},
+ {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"},
+ {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"},
+ {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"},
+ {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"},
+ {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"},
+ {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"},
+ {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"},
+ {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"},
+ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
+ {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
+]
+
+[package.dependencies]
+pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
+ {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
+ {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
+]
[[package]]
name = "colorama"
@@ -7,7 +390,7 @@ description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
-markers = "sys_platform == \"win32\""
+markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -101,6 +484,98 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
+[[package]]
+name = "cryptography"
+version = "49.0.0"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+optional = false
+python-versions = "!=3.9.0,!=3.9.1,>=3.9"
+groups = ["dev"]
+files = [
+ {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"},
+ {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"},
+ {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"},
+ {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"},
+ {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"},
+ {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"},
+ {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"},
+ {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"},
+ {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"},
+ {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"},
+ {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"},
+ {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"},
+ {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"},
+]
+
+[package.dependencies]
+cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""}
+typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""}
+
+[package.extras]
+ssh = ["bcrypt (>=3.1.5)"]
+
+[[package]]
+name = "distro"
+version = "1.9.0"
+description = "Distro - an OS platform information API"
+optional = false
+python-versions = ">=3.6"
+groups = ["dev"]
+files = [
+ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"},
+ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"},
+]
+
+[[package]]
+name = "docstring-parser"
+version = "0.18.0"
+description = "Parse Python docstrings in reST, Google and Numpydoc format"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b"},
+ {file = "docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015"},
+]
+
+[package.extras]
+dev = ["pre-commit (>=2.16.0) ; python_version >= \"3.9\"", "pydoctor (>=25.4.0)", "pytest"]
+docs = ["pydoctor (>=25.4.0)"]
+test = ["pytest"]
+
[[package]]
name = "exceptiongroup"
version = "1.2.2"
@@ -117,6 +592,80 @@ files = [
[package.extras]
test = ["pytest (>=6)"]
+[[package]]
+name = "h11"
+version = "0.16.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
+ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+description = "A minimal low-level HTTP client."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
+ {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
+]
+
+[package.dependencies]
+certifi = "*"
+h11 = ">=0.16"
+
+[package.extras]
+asyncio = ["anyio (>=4.0,<5.0)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+trio = ["trio (>=0.22.0,<1.0)"]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+description = "The next generation HTTP client."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
+ {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
+]
+
+[package.dependencies]
+anyio = "*"
+certifi = "*"
+httpcore = "==1.*"
+idna = "*"
+
+[package.extras]
+brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
+cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "idna"
+version = "3.18"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
+ {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
+]
+
+[package.extras]
+all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+
[[package]]
name = "iniconfig"
version = "2.0.0"
@@ -129,6 +678,497 @@ files = [
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
+[[package]]
+name = "jiter"
+version = "0.15.0"
+description = "Fast iterable JSON parser."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "jiter-0.15.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:edebcf7d1f601199084bb6e844d7dc67e03e04f6ac786b0332d616635c4ff7a4"},
+ {file = "jiter-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f924585cdacf631cd382b657966847bb537bf9ed0a6f9b991da5f05a631480f"},
+ {file = "jiter-0.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abbf258599526ad0326fe51e252e24f2bd6f24f1852681b4b78feda3808f1d18"},
+ {file = "jiter-0.15.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c468136b8bd6bb18c8786e4236a1fa27362f24cb23450ba0cb204ab379b8e6f"},
+ {file = "jiter-0.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05906b93d72f03339e6bb7cf8dc10ebda64a0266126eed6beba79e20abcf5fd4"},
+ {file = "jiter-0.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30ce785d2adb8e32c3f7741442370a74834ec4c01f3c48f0750227a0b4ef27d6"},
+ {file = "jiter-0.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd73e3da91a0a722d67165e849ce2cdc10de0e0d48738c142be8c6c5f310f4c"},
+ {file = "jiter-0.15.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:ceb8fc27d38793f9c97149be8302720c5b22e5c195a37bf2c45dc36c4600a512"},
+ {file = "jiter-0.15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d726e3ceeb337191324b49de298142f27c3ad10886341555d1d5315b5f252c6a"},
+ {file = "jiter-0.15.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c8aea7781d2a372227871de4e1a1332aa96f5a89fd76c5e835dafdbad102887"},
+ {file = "jiter-0.15.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cf4bd113a69c0a740e27cb962ce10630c36d2b8f59d759a651b955ee9d18a823"},
+ {file = "jiter-0.15.0-cp310-cp310-win32.whl", hash = "sha256:d92a5cd21fdb083931d546c207aa29633787c5dc5b02daab2d32b843f88a2c53"},
+ {file = "jiter-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:e58585a58209d72691ce2d62a9147445f5a87beb0bde97fde284c96ae392a3d1"},
+ {file = "jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2"},
+ {file = "jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67"},
+ {file = "jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a"},
+ {file = "jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7"},
+ {file = "jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd"},
+ {file = "jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281"},
+ {file = "jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708"},
+ {file = "jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928"},
+ {file = "jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd"},
+ {file = "jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e"},
+ {file = "jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef"},
+ {file = "jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32"},
+ {file = "jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04"},
+ {file = "jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865"},
+ {file = "jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d"},
+ {file = "jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0"},
+ {file = "jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138"},
+ {file = "jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61"},
+ {file = "jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687"},
+ {file = "jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879"},
+ {file = "jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d"},
+ {file = "jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb"},
+ {file = "jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871"},
+ {file = "jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77"},
+ {file = "jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d"},
+ {file = "jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d"},
+ {file = "jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7"},
+ {file = "jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b"},
+ {file = "jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3"},
+ {file = "jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5"},
+ {file = "jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279"},
+ {file = "jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4"},
+ {file = "jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258"},
+ {file = "jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894"},
+ {file = "jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45"},
+ {file = "jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29"},
+ {file = "jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b"},
+ {file = "jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7"},
+ {file = "jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712"},
+ {file = "jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c"},
+ {file = "jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0"},
+ {file = "jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba"},
+ {file = "jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8"},
+ {file = "jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c"},
+ {file = "jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4"},
+ {file = "jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b"},
+ {file = "jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7"},
+ {file = "jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49"},
+ {file = "jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86"},
+ {file = "jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f"},
+ {file = "jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e"},
+ {file = "jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6"},
+ {file = "jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9"},
+ {file = "jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c"},
+ {file = "jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd"},
+ {file = "jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89"},
+ {file = "jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554"},
+ {file = "jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a"},
+ {file = "jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec"},
+ {file = "jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558"},
+ {file = "jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866"},
+ {file = "jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d"},
+ {file = "jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6"},
+ {file = "jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995"},
+ {file = "jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8"},
+ {file = "jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5"},
+ {file = "jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b"},
+ {file = "jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8"},
+ {file = "jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec"},
+ {file = "jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e"},
+ {file = "jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5"},
+ {file = "jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52"},
+ {file = "jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854"},
+ {file = "jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0"},
+ {file = "jiter-0.15.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:04b400bbf8c9efb03d9bdd976475c919c1d85593b04b9fff7ae234065daf87ae"},
+ {file = "jiter-0.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:25ffbe229aa8cd98c28879d8aa1a6e34ae77992ab984a65fba800859dab16269"},
+ {file = "jiter-0.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5607e6013ed7e6b0ec9661e467b7ffde0aa7ab36833a04850f26fcf88ed4845b"},
+ {file = "jiter-0.15.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50164d7610c00e7cd913a873fce30b6beeebf4b37e53983e33f22de4c900f6b8"},
+ {file = "jiter-0.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ab596fa3837e91e7e6a31b5f639988bfc6a35d1f915ac3932d946062219d588f"},
+ {file = "jiter-0.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d72d8af5c1013656a8870c866660627d1a75bc185814ee022c8533caa1de88ae"},
+ {file = "jiter-0.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c84c1b7be454b0c16f8499b4ebfbfd82ea5cca6527cceefcbbc06a7557b5ed2e"},
+ {file = "jiter-0.15.0-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:d636d5095155afd364247f65070fab7beda13498d7ff4de331046e704ab9657f"},
+ {file = "jiter-0.15.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d3d6683288c11cbab50e865f2e2f13950179aa45410e30b2cfbd3fb7b0177bf"},
+ {file = "jiter-0.15.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7ce8902f939970048b233087082e7bb829db29375811c7ad50687b8624c6fd08"},
+ {file = "jiter-0.15.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4363818355dbc70ae1a8e9eaba9de350d93ede4ff6992b8f8eb8cbb6e5122d42"},
+ {file = "jiter-0.15.0-cp39-cp39-win32.whl", hash = "sha256:8f7e9bc0f1135039b22ee6eab588d42df1ce55842b30740a352885eb267bd941"},
+ {file = "jiter-0.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:1c15024a3d892223b18f597c86d59387249dc396590844ce6b9f6131d1093bae"},
+ {file = "jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750"},
+ {file = "jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b"},
+ {file = "jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b"},
+ {file = "jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c"},
+ {file = "jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0"},
+ {file = "jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45"},
+ {file = "jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c"},
+ {file = "jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a"},
+ {file = "jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76"},
+]
+
+[[package]]
+name = "jmespath"
+version = "1.1.0"
+description = "JSON Matching Expressions"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"},
+ {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"},
+]
+
+[[package]]
+name = "librt"
+version = "0.11.0"
+description = "Mypyc runtime library"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+markers = "platform_python_implementation != \"PyPy\""
+files = [
+ {file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"},
+ {file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"},
+ {file = "librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c"},
+ {file = "librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33"},
+ {file = "librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884"},
+ {file = "librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280"},
+ {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c"},
+ {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb"},
+ {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783"},
+ {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0"},
+ {file = "librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89"},
+ {file = "librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4"},
+ {file = "librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29"},
+ {file = "librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9"},
+ {file = "librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5"},
+ {file = "librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b"},
+ {file = "librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89"},
+ {file = "librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc"},
+ {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5"},
+ {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7"},
+ {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d"},
+ {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412"},
+ {file = "librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d"},
+ {file = "librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73"},
+ {file = "librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c"},
+ {file = "librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46"},
+ {file = "librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3"},
+ {file = "librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67"},
+ {file = "librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a"},
+ {file = "librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a"},
+ {file = "librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f"},
+ {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b"},
+ {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766"},
+ {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d"},
+ {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8"},
+ {file = "librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a"},
+ {file = "librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9"},
+ {file = "librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c"},
+ {file = "librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894"},
+ {file = "librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c"},
+ {file = "librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea"},
+ {file = "librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230"},
+ {file = "librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2"},
+ {file = "librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3"},
+ {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21"},
+ {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930"},
+ {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be"},
+ {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e"},
+ {file = "librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e"},
+ {file = "librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47"},
+ {file = "librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44"},
+ {file = "librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd"},
+ {file = "librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4"},
+ {file = "librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8"},
+ {file = "librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b"},
+ {file = "librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175"},
+ {file = "librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03"},
+ {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c"},
+ {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3"},
+ {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96"},
+ {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe"},
+ {file = "librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f"},
+ {file = "librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7"},
+ {file = "librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1"},
+ {file = "librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72"},
+ {file = "librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa"},
+ {file = "librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548"},
+ {file = "librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2"},
+ {file = "librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f"},
+ {file = "librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51"},
+ {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2"},
+ {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085"},
+ {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3"},
+ {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd"},
+ {file = "librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8"},
+ {file = "librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c"},
+ {file = "librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253"},
+ {file = "librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f"},
+ {file = "librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677"},
+ {file = "librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab"},
+ {file = "librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0"},
+ {file = "librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1"},
+ {file = "librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e"},
+ {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa"},
+ {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1"},
+ {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5"},
+ {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192"},
+ {file = "librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f"},
+ {file = "librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3"},
+ {file = "librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1"},
+]
+
+[[package]]
+name = "lxml"
+version = "6.1.1"
+description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60"},
+ {file = "lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d"},
+ {file = "lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea"},
+ {file = "lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074"},
+ {file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30"},
+ {file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315"},
+ {file = "lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1"},
+ {file = "lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206"},
+ {file = "lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067"},
+ {file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a"},
+ {file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa"},
+ {file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383"},
+ {file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1"},
+ {file = "lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a"},
+ {file = "lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5"},
+ {file = "lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485"},
+ {file = "lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2"},
+ {file = "lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d"},
+ {file = "lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510"},
+ {file = "lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a"},
+ {file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d"},
+ {file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8"},
+ {file = "lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009"},
+ {file = "lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6"},
+ {file = "lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8"},
+ {file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83"},
+ {file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6"},
+ {file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c"},
+ {file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08"},
+ {file = "lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621"},
+ {file = "lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28"},
+ {file = "lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b"},
+ {file = "lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7"},
+ {file = "lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1"},
+ {file = "lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc"},
+ {file = "lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc"},
+ {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383"},
+ {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b"},
+ {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818"},
+ {file = "lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740"},
+ {file = "lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f"},
+ {file = "lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2"},
+ {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635"},
+ {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf"},
+ {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc"},
+ {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955"},
+ {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a"},
+ {file = "lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a"},
+ {file = "lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77"},
+ {file = "lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f"},
+ {file = "lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736"},
+ {file = "lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9"},
+ {file = "lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354"},
+ {file = "lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca"},
+ {file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099"},
+ {file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6"},
+ {file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085"},
+ {file = "lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e"},
+ {file = "lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f"},
+ {file = "lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c"},
+ {file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b"},
+ {file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2"},
+ {file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5"},
+ {file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785"},
+ {file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947"},
+ {file = "lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca"},
+ {file = "lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660"},
+ {file = "lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc"},
+ {file = "lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0"},
+ {file = "lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840"},
+ {file = "lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14"},
+ {file = "lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909"},
+ {file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00"},
+ {file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955"},
+ {file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13"},
+ {file = "lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7"},
+ {file = "lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245"},
+ {file = "lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5"},
+ {file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462"},
+ {file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465"},
+ {file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a"},
+ {file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590"},
+ {file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb"},
+ {file = "lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603"},
+ {file = "lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137"},
+ {file = "lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf"},
+ {file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee"},
+ {file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c"},
+ {file = "lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef"},
+ {file = "lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a"},
+ {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c"},
+ {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525"},
+ {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca"},
+ {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e"},
+ {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038"},
+ {file = "lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e"},
+ {file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072"},
+ {file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52"},
+ {file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b"},
+ {file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2"},
+ {file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e"},
+ {file = "lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1"},
+ {file = "lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e"},
+ {file = "lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c"},
+ {file = "lxml-6.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6689e828a94eee4f139408c337bb198e014724bb8a8c26d3cfac49d119ed69a6"},
+ {file = "lxml-6.1.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdebcc8a75d38c7598dfb2c9ed852d7a9eb4a10d6e2d0764b919b802bf32ac88"},
+ {file = "lxml-6.1.1-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8be8ad51249698103d24b0571df35a10990fbe93dd043b6c024172189485f5e3"},
+ {file = "lxml-6.1.1-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:76447f65250ed2501ead1a1552f5ce8edff159a86f308348e6a9c4acb5e1f1b4"},
+ {file = "lxml-6.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ffecec8eb889b58ba9be5b95fb1cc78e22ea8eedea38e8736a1568fe1979250e"},
+ {file = "lxml-6.1.1-cp38-cp38-win32.whl", hash = "sha256:c674693f055fa2495de12292cb45e9944199d8eaef5a2dec45175c7c61cb73e3"},
+ {file = "lxml-6.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:55b03549819867ea141c0202242c4816c82e52ec36e7e648db9d8da5a3dc3ed6"},
+ {file = "lxml-6.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9f79d5325907f13e1be0b3e4dacc1049d1dffc4aeee3c995284bea5fe0fab7d"},
+ {file = "lxml-6.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:83b6b30eb131da7a75b601f28c5d6971e6ed3e887919bf6b6a1ad3c2df289080"},
+ {file = "lxml-6.1.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:441dd227fa0690eb9fc81edabc63cdcefc212bba99b906dcf6e32cc1a9d3e533"},
+ {file = "lxml-6.1.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e07c65f443c887bbcf31cc1771d932ecc192a5273943589b3c7572b749f1ffb2"},
+ {file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bec7d03d78d853597d6107854c2310ce3f761fd218fe9fe91d5101fcf6c2efe"},
+ {file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f76acfb5f68ba982635a53fd985a8044be98a35b43232c2a1ee235ffab3e1dd"},
+ {file = "lxml-6.1.1-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:8d43ca737b20e106e4aebc42b2f3ae19f00ba63d7eb731698ee083d72d15646f"},
+ {file = "lxml-6.1.1-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:32ab449a5486f6c758e849bb86710d0e45edc24a04e250c01555f8f5653958f8"},
+ {file = "lxml-6.1.1-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53c909b62a0532183542fed00c5a7218258c56292d409bc789886fe1cb04c438"},
+ {file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:640f97d43d867bcb9c75b3af013b64850756b746cb6bce8ace83b70da3abba9d"},
+ {file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:469e3618338bd7ab5beb412d2439825479fcf0dab99e394ca563dbc4eaf6c834"},
+ {file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:aae97dfdb60715c164419ac2532a76d013c3918a665eb6cb7288098b5f349aaf"},
+ {file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c9a4b821dc7055bf9e05ff5719e18ec501f75c0f0bbfabd573b277559780833d"},
+ {file = "lxml-6.1.1-cp39-cp39-win32.whl", hash = "sha256:639f6c857d91d9be29bd7502348d6736dab168b54b5158cd899abf11684dc186"},
+ {file = "lxml-6.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:34c2d737beabfe35baada43941ed519251e9a12e779031496bcd5d539fcfd730"},
+ {file = "lxml-6.1.1-cp39-cp39-win_arm64.whl", hash = "sha256:07a4a68e286ee7a1ed7dfb8af83e615757c0ccfe9f18c6b4ea6771388d9ba8c9"},
+ {file = "lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e"},
+ {file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004"},
+ {file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e"},
+ {file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2"},
+ {file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf"},
+ {file = "lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84"},
+ {file = "lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40"},
+]
+
+[package.extras]
+cssselect = ["cssselect (>=0.7)"]
+html-clean = ["lxml_html_clean"]
+html5 = ["html5lib"]
+htmlsoup = ["BeautifulSoup4"]
+
+[[package]]
+name = "mypy"
+version = "1.20.2"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4"},
+ {file = "mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997"},
+ {file = "mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14"},
+ {file = "mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99"},
+ {file = "mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c"},
+ {file = "mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd"},
+ {file = "mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2"},
+ {file = "mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c"},
+ {file = "mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3"},
+ {file = "mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254"},
+ {file = "mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98"},
+ {file = "mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac"},
+ {file = "mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67"},
+ {file = "mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100"},
+ {file = "mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b"},
+ {file = "mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4"},
+ {file = "mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6"},
+ {file = "mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066"},
+ {file = "mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102"},
+ {file = "mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9"},
+ {file = "mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58"},
+ {file = "mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026"},
+ {file = "mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943"},
+ {file = "mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517"},
+ {file = "mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15"},
+ {file = "mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee"},
+ {file = "mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f"},
+ {file = "mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330"},
+ {file = "mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30"},
+ {file = "mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924"},
+ {file = "mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb"},
+ {file = "mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc"},
+ {file = "mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558"},
+ {file = "mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8"},
+ {file = "mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3"},
+ {file = "mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609"},
+ {file = "mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2"},
+ {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c"},
+ {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744"},
+ {file = "mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6"},
+ {file = "mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec"},
+ {file = "mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382"},
+ {file = "mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563"},
+ {file = "mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665"},
+]
+
+[package.dependencies]
+librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""}
+mypy_extensions = ">=1.0.0"
+pathspec = ">=1.0.0"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.15\""}
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+faster-cache = ["orjson"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+native-parser = ["ast-serialize (>=0.1.1,<1.0.0)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
+ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
+]
+
+[[package]]
+name = "openai"
+version = "2.43.0"
+description = "The official Python library for the openai API"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "openai-2.43.0-py3-none-any.whl", hash = "sha256:65a670b54fadf2268c9e1330133373c963eb779ee969e5cbad419ec2c21dce97"},
+ {file = "openai-2.43.0.tar.gz", hash = "sha256:e74d238200a26868977002190fb6631613480a93dfe0c9c982e77021ed60a017"},
+]
+
+[package.dependencies]
+anyio = ">=3.5.0,<5"
+distro = ">=1.7.0,<2"
+httpx = ">=0.23.0,<1"
+jiter = ">=0.10.0,<1"
+pydantic = ">=1.9.0,<3"
+sniffio = "*"
+tqdm = ">4"
+typing-extensions = ">=4.14,<5"
+
+[package.extras]
+aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
+datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
+realtime = ["websockets (>=13,<16)"]
+voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"]
+
[[package]]
name = "packaging"
version = "24.1"
@@ -141,6 +1181,23 @@ files = [
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
]
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"},
+ {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"},
+]
+
+[package.extras]
+hyperscan = ["hyperscan (>=0.7)"]
+optional = ["typing-extensions (>=4)"]
+re2 = ["google-re2 (>=1.1)"]
+
[[package]]
name = "pluggy"
version = "1.5.0"
@@ -157,6 +1214,265 @@ files = [
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
+[[package]]
+name = "pycparser"
+version = "3.0"
+description = "C parser in Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
+files = [
+ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
+ {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
+]
+
+[[package]]
+name = "pycups"
+version = "2.0.4"
+description = "Python bindings for libcups"
+optional = false
+python-versions = "*"
+groups = ["dev"]
+files = [
+ {file = "pycups-2.0.4.tar.gz", hash = "sha256:843e385c1dbf694996ca84ef02a7f30c28376035588f5fbeacd6bae005cf7c8d"},
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+description = "Data validation using Python type hints"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"},
+ {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"},
+]
+
+[package.dependencies]
+annotated-types = ">=0.6.0"
+pydantic-core = "2.46.4"
+typing-extensions = ">=4.14.1"
+typing-inspection = ">=0.4.2"
+
+[package.extras]
+email = ["email-validator (>=2.0.0)"]
+timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+description = "Core functionality for Pydantic validation and serialization"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"},
+ {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"},
+ {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"},
+ {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"},
+ {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"},
+ {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"},
+ {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"},
+ {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"},
+ {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"},
+ {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"},
+ {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"},
+ {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"},
+ {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"},
+ {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"},
+ {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"},
+ {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"},
+ {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"},
+ {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"},
+ {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"},
+ {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"},
+ {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"},
+ {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"},
+ {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"},
+ {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"},
+ {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.14.1"
+
+[[package]]
+name = "pygithub"
+version = "2.9.1"
+description = "Use the full Github API v3"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9"},
+ {file = "pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c"},
+]
+
+[package.dependencies]
+pyjwt = {version = ">=2.4.0", extras = ["crypto"]}
+pynacl = ">=1.4.0"
+requests = ">=2.14.0"
+typing-extensions = ">=4.5.0"
+urllib3 = ">=1.26.0"
+
+[[package]]
+name = "pyjwt"
+version = "2.13.0"
+description = "JSON Web Token implementation in Python"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"},
+ {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"},
+]
+
+[package.dependencies]
+cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""}
+typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+crypto = ["cryptography (>=3.4.0)"]
+
+[[package]]
+name = "pynacl"
+version = "1.6.2"
+description = "Python binding to the Networking and Cryptography (NaCl) library"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"},
+ {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"},
+ {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"},
+ {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"},
+ {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"},
+ {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"},
+ {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"},
+ {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"},
+ {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"},
+ {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"},
+ {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"},
+ {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"},
+ {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"},
+]
+
+[package.dependencies]
+cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""}
+
+[package.extras]
+docs = ["sphinx (<7)", "sphinx_rtd_theme"]
+tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
+
[[package]]
name = "pytest"
version = "8.3.2"
@@ -229,6 +1545,145 @@ files = [
[package.extras]
images = ["pillow"]
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["dev"]
+files = [
+ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
+ {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
+]
+
+[package.dependencies]
+certifi = ">=2023.5.7"
+charset_normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.26,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
+
+[[package]]
+name = "s3transfer"
+version = "0.19.0"
+description = "An Amazon S3 Transfer Manager"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "s3transfer-0.19.0-py3-none-any.whl", hash = "sha256:777cc2415536f1debadb5c2ef7779275d0fc0fe0e042411cdd6caebeb2685262"},
+ {file = "s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685"},
+]
+
+[package.dependencies]
+botocore = ">=1.37.4,<2.0a0"
+
+[package.extras]
+crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["dev"]
+files = [
+ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+description = "Sniff out which async library your code is running under"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
+ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
+]
+
+[[package]]
+name = "sqlglot"
+version = "25.34.1"
+description = "An easily customizable SQL parser and transpiler"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "sqlglot-25.34.1-py3-none-any.whl", hash = "sha256:15099f8af832e6f5593fb92211d8b3f0810744ac0dc443fb70010fa38dc2562b"},
+ {file = "sqlglot-25.34.1.tar.gz", hash = "sha256:6952c083c4a8b8de3c09c10b262a03c6853071bd397f05759c08f1e2f3c683cb"},
+]
+
+[package.extras]
+dev = ["duckdb (>=0.6)", "maturin (>=1.4,<2.0)", "mypy", "pandas", "pandas-stubs", "pdoc", "pre-commit", "python-dateutil", "pytz", "ruff (==0.7.2)", "types-python-dateutil", "types-pytz", "typing-extensions"]
+rs = ["sqlglotrs (==0.3.0)"]
+
+[[package]]
+name = "test-utils"
+version = "1.28.0"
+description = "AgriTheory Test Utilities and Fixtures"
+optional = false
+python-versions = ">=3.10,<3.15"
+groups = ["dev"]
+files = []
+develop = false
+
+[package.dependencies]
+anthropic = "^0.99.0"
+astpath = {version = "^0.9.1", extras = ["xpath"]}
+asttokens = ">=2.4.0"
+boto3 = "^1.43.4"
+mypy = "^1.15.0"
+openai = "^2.34.0"
+pygithub = "^2.9.1"
+requests = "^2.32.3"
+sqlglot = "^25.33.0"
+toml = "^0.10.2"
+tree-sitter = "^0.25.2"
+tree-sitter-javascript = "^0.25.0"
+tree-sitter-typescript = "^0.23.2"
+vulture = ">=2.0"
+
+[package.source]
+type = "git"
+url = "https://github.com/agritheory/test_utils.git"
+reference = "v1.28.0"
+resolved_reference = "e412232b6896637cb26645b96c24afa6b8472911"
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+groups = ["dev"]
+files = [
+ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+
[[package]]
name = "tomli"
version = "2.0.1"
@@ -242,6 +1697,180 @@ files = [
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
+[[package]]
+name = "tqdm"
+version = "4.68.3"
+description = "Fast, Extensible Progress Meter"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03"},
+ {file = "tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[package.extras]
+discord = ["envwrap", "requests"]
+notebook = ["ipywidgets (>=6)"]
+slack = ["envwrap", "slack-sdk"]
+telegram = ["envwrap", "requests"]
+
+[[package]]
+name = "tree-sitter"
+version = "0.25.2"
+description = "Python bindings to the Tree-sitter parsing library"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20"},
+ {file = "tree_sitter-0.25.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72a510931c3c25f134aac2daf4eb4feca99ffe37a35896d7150e50ac3eee06c7"},
+ {file = "tree_sitter-0.25.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44488e0e78146f87baaa009736886516779253d6d6bac3ef636ede72bc6a8234"},
+ {file = "tree_sitter-0.25.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2f8e7d6b2f8489d4a9885e3adcaef4bc5ff0a275acd990f120e29c4ab3395c5"},
+ {file = "tree_sitter-0.25.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b570690f87f1da424cd690e51cc56728d21d63f4abd4b326d382a30353acc7"},
+ {file = "tree_sitter-0.25.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a0ec41b895da717bc218a42a3a7a0bfcfe9a213d7afaa4255353901e0e21f696"},
+ {file = "tree_sitter-0.25.2-cp310-cp310-win_amd64.whl", hash = "sha256:7712335855b2307a21ae86efe949c76be36c6068d76df34faa27ce9ee40ff444"},
+ {file = "tree_sitter-0.25.2-cp310-cp310-win_arm64.whl", hash = "sha256:a925364eb7fbb9cdce55a9868f7525a1905af512a559303bd54ef468fd88cb37"},
+ {file = "tree_sitter-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ca72d841215b6573ed0655b3a5cd1133f9b69a6fa561aecad40dca9029d75b"},
+ {file = "tree_sitter-0.25.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc0351cfe5022cec5a77645f647f92a936b38850346ed3f6d6babfbeeeca4d26"},
+ {file = "tree_sitter-0.25.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1799609636c0193e16c38f366bda5af15b1ce476df79ddaae7dd274df9e44266"},
+ {file = "tree_sitter-0.25.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e65ae456ad0d210ee71a89ee112ac7e72e6c2e5aac1b95846ecc7afa68a194c"},
+ {file = "tree_sitter-0.25.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:49ee3c348caa459244ec437ccc7ff3831f35977d143f65311572b8ba0a5f265f"},
+ {file = "tree_sitter-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:56ac6602c7d09c2c507c55e58dc7026b8988e0475bd0002f8a386cce5e8e8adc"},
+ {file = "tree_sitter-0.25.2-cp311-cp311-win_arm64.whl", hash = "sha256:b3d11a3a3ac89bb8a2543d75597f905a9926f9c806f40fcca8242922d1cc6ad5"},
+ {file = "tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960"},
+ {file = "tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c"},
+ {file = "tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99"},
+ {file = "tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9"},
+ {file = "tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac"},
+ {file = "tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897"},
+ {file = "tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5"},
+ {file = "tree_sitter-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0628671f0de69bb279558ef6b640bcfc97864fe0026d840f872728a86cd6b6cd"},
+ {file = "tree_sitter-0.25.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f5ddcd3e291a749b62521f71fc953f66f5fd9743973fd6dd962b092773569601"},
+ {file = "tree_sitter-0.25.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd88fbb0f6c3a0f28f0a68d72df88e9755cf5215bae146f5a1bdc8362b772053"},
+ {file = "tree_sitter-0.25.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b878e296e63661c8e124177cc3084b041ba3f5936b43076d57c487822426f614"},
+ {file = "tree_sitter-0.25.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d77605e0d353ba3fe5627e5490f0fbfe44141bafa4478d88ef7954a61a848dae"},
+ {file = "tree_sitter-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:463c032bd02052d934daa5f45d183e0521ceb783c2548501cf034b0beba92c9b"},
+ {file = "tree_sitter-0.25.2-cp313-cp313-win_arm64.whl", hash = "sha256:b3f63a1796886249bd22c559a5944d64d05d43f2be72961624278eff0dcc5cb8"},
+ {file = "tree_sitter-0.25.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65d3c931013ea798b502782acab986bbf47ba2c452610ab0776cf4a8ef150fc0"},
+ {file = "tree_sitter-0.25.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bda059af9d621918efb813b22fb06b3fe00c3e94079c6143fcb2c565eb44cb87"},
+ {file = "tree_sitter-0.25.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eac4e8e4c7060c75f395feec46421eb61212cb73998dbe004b7384724f3682ab"},
+ {file = "tree_sitter-0.25.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:260586381b23be33b6191a07cea3d44ecbd6c01aa4c6b027a0439145fcbc3358"},
+ {file = "tree_sitter-0.25.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d2ee1acbacebe50ba0f85fff1bc05e65d877958f00880f49f9b2af38dce1af0"},
+ {file = "tree_sitter-0.25.2-cp314-cp314-win_amd64.whl", hash = "sha256:4973b718fcadfb04e59e746abfbb0288694159c6aeecd2add59320c03368c721"},
+ {file = "tree_sitter-0.25.2-cp314-cp314-win_arm64.whl", hash = "sha256:b8d4429954a3beb3e844e2872610d2a4800ba4eb42bb1990c6a4b1949b18459f"},
+]
+
+[package.extras]
+docs = ["sphinx (>=8.1,<9.0)", "sphinx-book-theme"]
+tests = ["tree-sitter-html (>=0.23.2)", "tree-sitter-javascript (>=0.23.1)", "tree-sitter-json (>=0.24.8)", "tree-sitter-python (>=0.23.6)", "tree-sitter-rust (>=0.23.2)"]
+
+[[package]]
+name = "tree-sitter-javascript"
+version = "0.25.0"
+description = "JavaScript grammar for tree-sitter"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "tree_sitter_javascript-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b70f887fb269d6e58c349d683f59fa647140c410cfe2bee44a883b20ec92e3dc"},
+ {file = "tree_sitter_javascript-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8264a996b8845cfce06965152a013b5d9cbb7d199bc3503e12b5682e62bb1de1"},
+ {file = "tree_sitter_javascript-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dc04ba91fc8583344e57c1f1ed5b2c97ecaaf47480011b92fbeab8dda96db75"},
+ {file = "tree_sitter_javascript-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:199d09985190852e0912da2b8d26c932159be314bc04952cf917ed0e4c633e6b"},
+ {file = "tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfcf789064c58dc13c0a4edb550acacfc6f0f280577f1e7a00de3e89fc7f8ddc"},
+ {file = "tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b852d3aee8a36186dbcc32c798b11b4869f9b5041743b63b65c2ef793db7a54"},
+ {file = "tree_sitter_javascript-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:e5ed840f5bd4a3f0272e441d19429b26eedc257abe5574c8546da6b556865e3c"},
+ {file = "tree_sitter_javascript-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:622a69d677aa7f6ee2931d8c77c981a33f0ebb6d275aa9d43d3397c879a9bb0b"},
+ {file = "tree_sitter_javascript-0.25.0.tar.gz", hash = "sha256:329b5414874f0588a98f1c291f1b28138286617aa907746ffe55adfdcf963f38"},
+]
+
+[package.extras]
+core = ["tree-sitter (>=0.24,<1.0)"]
+
+[[package]]
+name = "tree-sitter-typescript"
+version = "0.23.2"
+description = "TypeScript and TSX grammars for tree-sitter"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "tree_sitter_typescript-0.23.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3cd752d70d8e5371fdac6a9a4df9d8924b63b6998d268586f7d374c9fba2a478"},
+ {file = "tree_sitter_typescript-0.23.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c7cc1b0ff5d91bac863b0e38b1578d5505e718156c9db577c8baea2557f66de8"},
+ {file = "tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b1eed5b0b3a8134e86126b00b743d667ec27c63fc9de1b7bb23168803879e31"},
+ {file = "tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e96d36b85bcacdeb8ff5c2618d75593ef12ebaf1b4eace3477e2bdb2abb1752c"},
+ {file = "tree_sitter_typescript-0.23.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8d4f0f9bcb61ad7b7509d49a1565ff2cc363863644a234e1e0fe10960e55aea0"},
+ {file = "tree_sitter_typescript-0.23.2-cp39-abi3-win_amd64.whl", hash = "sha256:3f730b66396bc3e11811e4465c41ee45d9e9edd6de355a58bbbc49fa770da8f9"},
+ {file = "tree_sitter_typescript-0.23.2-cp39-abi3-win_arm64.whl", hash = "sha256:05db58f70b95ef0ea126db5560f3775692f609589ed6f8dd0af84b7f19f1cbb7"},
+ {file = "tree_sitter_typescript-0.23.2.tar.gz", hash = "sha256:7b167b5827c882261cb7a50dfa0fb567975f9b315e87ed87ad0a0a3aedb3834d"},
+]
+
+[package.extras]
+core = ["tree-sitter (>=0.23,<1.0)"]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+description = "Backported and Experimental Type Hints for Python 3.9+"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
+ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+description = "Runtime typing introspection tools"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
+ {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.12.0"
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
+ {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
+h2 = ["h2 (>=4,<5)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
+
+[[package]]
+name = "vulture"
+version = "2.16"
+description = "Find dead code"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee"},
+ {file = "vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717"},
+]
+
+[package.dependencies]
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+
[[package]]
name = "zebra-zpl"
version = "0.1.0"
@@ -260,5 +1889,5 @@ resolved_reference = "45ffc60638814df575d9fe11c7504b1a533e4ecb"
[metadata]
lock-version = "2.1"
-python-versions = ">=3.10"
-content-hash = "e35e922673042380da861bfeb9a93879cab1a8013af349af404febc89ee0255c"
+python-versions = ">=3.10,<3.15"
+content-hash = "c69661febdefcd14d5a01f31684a13a66b28ef20980ab2b3d2c9bb1b6e2f8b0a"
diff --git a/pyproject.toml b/pyproject.toml
index ab8c9db5..d589654f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,12 +5,12 @@ authors = [
{ name = "AgriTheory", email = "support@agritheory.dev" }
]
description = "Barcode Scanning for ERPNext"
-requires-python = ">=3.10"
+requires-python = ">=3.10,<3.15"
readme = "README.md"
dynamic = ["dependencies"]
[tool.poetry.dependencies]
-python = ">=3.10"
+python = ">=3.10,<3.15"
python-barcode = "^0.15.1"
zebra-zpl = {git = "https://github.com/mtking2/py-zebra-zpl.git"}
@@ -18,11 +18,14 @@ zebra-zpl = {git = "https://github.com/mtking2/py-zebra-zpl.git"}
pytest = "~=8.3.2"
pytest-cov = "~=5.0.0"
pytest-order = "~=1.2.1"
+pycups = "~=2.0.4"
[tool.poetry.group.dev.dependencies]
pytest = "^8.3.2"
pytest-order = "^1.2.1"
pytest-cov = "^5.0.0"
+pycups = "^2.0.4"
+test_utils = {git = "https://github.com/agritheory/test_utils.git", tag = "v1.28.0"}
[build-system]
requires = ["poetry-core"]
@@ -33,6 +36,8 @@ frappe = ">=15.0.0,<16.0.0"
erpnext = ">=15.0.0,<16.0.0"
[tool.pytest.ini_options]
+testpaths = ["beam/tests"]
+pythonpath = ["beam/tests"]
addopts = "--cov=beam --cov-report term-missing"
[tool.codespell]