[kde-linux/os-autoinst-distri-kdelinux] /: Port tooling to Python
Thomas Duckworth <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit 51cbb7dec0c950b0e76a022c990eaa7a10b99e47 by Thomas Duckworth.
Committed on 03/08/2026 at 07:56.
Pushed by tduck into branch 'master'.
Port tooling to Python
Replace the fragile and glued-together shell code with a strictly type
annotated Python equivalent, giving us a maintainable foundation to
iterate further and introduce new functionality.
Also cleans up some pre-existing code style and implementation issues
in test implementations.
M +5 -1 .gitignore
M +1 -2 .gitlab-ci.yml
A +1 -0 .python-version
M +44 -22 README.md
M +10 -0 REUSE.toml
D +0 -4 extensions/openqa/usr/lib/kde-linux-openqa/requirements.txt
M +1 -1 extensions/openqa/usr/lib/kde-linux-openqa/tests/basic_test.py
M +2 -2 extensions/openqa/usr/lib/kde-linux-openqa/tests/calamares_install.py
M +2 -11 extensions/openqa/usr/lib/kde-linux-openqa/tests/configure_automatic_login.py
M +1 -1 extensions/openqa/usr/lib/kde-linux-openqa/tests/default_applications.py
M +1 -3 extensions/openqa/usr/lib/kde-linux-openqa/tests/discover_install.py
M +1 -2 extensions/openqa/usr/lib/kde-linux-openqa/tests/discover_upgrade.py
M +3 -11 extensions/openqa/usr/lib/kde-linux-openqa/tests/drkonqi.py
M +1 -1 extensions/openqa/usr/lib/kde-linux-openqa/tests/firefox.py
M +1 -1 extensions/openqa/usr/lib/kde-linux-openqa/tests/plasma_setup.py
M +1 -3 extensions/openqa/usr/lib/kde-linux-openqa/tests/plasma_welcome.py
M +2 -1 extensions/openqa/usr/lib/kde-linux-openqa/tests/secret_service.py
M +3 -3 extensions/openqa/usr/lib/kde-linux-openqa/tests/system_development.py
A +0 -0 lib/common/__init__.py
A +106 -0 lib/common/log.py
R +13 -1 lib/common/paths.py [from: lib/paths.py - 054% similarity]
R +5 -0 lib/common/user_manager.py [from: lib/user_manager.py - 098% similarity]
R +0 -0 lib/sut/__init__.py [from: extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/__init__.py - 100% similarity]
R +0 -0 lib/sut/atspi.py [from: extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/atspi.py - 100% similarity]
R +0 -0 lib/sut/flatpak.py [from: extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/flatpak.py - 100% similarity]
R +1 -0 lib/sut/openqa_junit_xml.py [from: extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/openqa_junit_xml.py - 099% similarity]
R +1 -1 lib/sut/polkit.py [from: extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/polkit.py - 098% similarity]
R +0 -0 lib/sut/systemtray.py [from: extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/systemtray.py - 100% similarity]
R +0 -0 lib/test/__init__.py [from: lib/openqa/__init__.py - 100% similarity]
R +1 -0 lib/test/cli_session.py [from: lib/openqa/cli_session.py - 099% similarity]
R +24 -11 lib/test/cli_test.py [from: lib/openqa/cli_test.py - 080% similarity]
R +0 -0 lib/test/sessions/__init__.py [from: lib/openqa/sessions/__init__.py - 100% similarity]
R +0 -0 lib/test/sessions/base.py [from: lib/openqa/sessions/base.py - 100% similarity]
R +0 -0 lib/test/sessions/syscore/__init__.py [from: lib/openqa/sessions/syscore/__init__.py - 100% similarity]
R +1 -1 lib/test/sessions/syscore/plasma_desktop.py [from: lib/openqa/sessions/syscore/plasma_desktop.py - 093% similarity]
A +0 -0 lib/worker/__init__.py
A +92 -0 lib/worker/bootstrap.py
A +96 -0 lib/worker/download_image.py
A +461 -0 lib/worker/job.py
A +392 -0 lib/worker/job_flow.py
A +131 -0 lib/worker/sysext.py
A +88 -0 lib/worker/system_packages.py
M +14 -1 mocks/single-instance.yml
A +126 -0 mocks/start-single-instance
D +0 -47 mocks/start-single-instance.sh
M +8 -1 mocks/worker.yml
A +25 -0 pyproject.toml
A +164 -0 qa
A +17 -0 setup.sh
M +2 -2 tests/common/basic_test.py
M +1 -1 tests/common/bootup.py
M +2 -2 tests/common/network.py
M +2 -2 tests/common/reboot.py
M +2 -2 tests/common/shutdown.py
M +1 -1 tests/common/system_settings/disable_screen_lock.py
M +3 -3 tests/kdelinux-live/calamares_install.py
M +3 -3 tests/kdelinux/app/discover_install.py
M +3 -3 tests/kdelinux/app/discover_upgrade.py
M +2 -2 tests/kdelinux/app/dolphin.py
M +2 -2 tests/kdelinux/app/firefox.py
M +2 -2 tests/kdelinux/app/package_compatibility_helper.py
M +2 -2 tests/kdelinux/desktop/clipboard.py
M +2 -2 tests/kdelinux/desktop/create_file.py
M +3 -3 tests/kdelinux/desktop/drkonqi.py
M +2 -2 tests/kdelinux/desktop/krunner.py
M +2 -2 tests/kdelinux/desktop/panel.py
M +5 -5 tests/kdelinux/desktop/plasma_setup.py
M +3 -3 tests/kdelinux/desktop/plasma_welcome.py
M +2 -2 tests/kdelinux/desktop/secret_service.py
M +2 -2 tests/kdelinux/desktop/task_switcher.py
M +2 -2 tests/kdelinux/desktop/vaults.py
M +2 -2 tests/kdelinux/sddm/sddm_password_login.py
M +18 -3 tests/kdelinux/system/collect_logs.py
M +3 -3 tests/kdelinux/system/desktop_session_services.py
M +2 -2 tests/kdelinux/system/system_development.py
M +2 -2 tests/kdelinux/system/verify_upgrade.py
M +4 -4 tests/kdelinux/system_settings/configure_automatic_login.py
M +2 -2 tests/kdelinux/system_settings/default_applications.py
D +0 -29 utils/banner.sh
D +0 -47 utils/bootstrap-worker.sh
D +0 -73 utils/download_image.py
D +0 -199 utils/jobs.sh
D +0 -339 utils/run_job.sh
A +522 -0 uv.lock
D +0 -140 worker.sh
https://invent.kde.org/kde-linux/os-autoinst-distri-kdelinux/-/commit/51cbb7dec0c950b0e76a022c990eaa7a10b99e47
diff --git a/.gitignore b/.gitignore
index c90a326..e1d4c7b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,10 +7,14 @@ appium_artifact*
openqa-sysext.img
OVMF_VARS.4m.fd
*.pyc
+__pycache__
+.venv
+requirements.txt
+*.kate-swp
# Generated at build by worker.sh.
# Copied from top level ./lib.
-/extensions/openqa/usr/lib/kde-linux-openqa/lib/*.py
+/extensions/openqa/usr/lib/kde-linux-openqa/lib
# Generated for updates from a staging channel.
/extensions/openqa/usr/lib/sysupdate.d/*.transfer.d
/extensions/openqa/usr/lib/systemd/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 11d0c73..82870aa 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -50,7 +50,7 @@ reuse:
--env STAGING_CHANNEL_URL
--env SYSUPDATE_PUBKEY_B64
registry.opensuse.org/devel/openqa/containers16.0/openqa_worker:latest
- bash worker.sh $WORKER_ARGS
+ ./setup.sh ./qa worker $WORKER_ARGS
artifacts:
when: always
@@ -81,4 +81,3 @@ test-upgrade-encrypt:
stage: test-upgrade
variables:
WORKER_ARGS: --upgrade --encrypt
-
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..6324d40
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.14
diff --git a/README.md b/README.md
index 31d2b0c..f4aefa0 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,23 @@
## os-autoinst-distri-kdelinux
> End-to-end tests for KDE Linux using [openQA](https://open.qa/).
+### Development setup
+
+The Python tooling requires Python 3.14 or newer and
+[`uv`](https://docs.astral.sh/uv/). Install `uv`, then create and activate the
+environment from the repository root:
+
+```bash
+uv sync
+source .venv/bin/activate
+```
+
+The `qa worker` and `qa flow` commands run openQA jobs and are intended
+to run inside mock containers or CI rather than on the host system.
+
+See [Running tests locally](#running-tests-locally) for the mock and remote
+openQA workflows.
+
### What's tested
#### Install test suite (`install-system`)
@@ -91,7 +108,7 @@ Nota bene: if you change anything in `extensions/`, to re-create sysext image wi
```
podman exec -it openqa-single-instance bash
-mkfs.erofs --quiet -L "kde-openqa-ext" openqa-sysext.img "$CASEDIR/extensions/openqa"
+./qa build-sysext
```
#### Full local stack (worker + webui)
@@ -108,9 +125,9 @@ Spins up a local OpenQA webui and worker together.
4. Open a shell in the container and submit jobs:
```bash
podman exec -it openqa-single-instance bash
- bash utils/jobs.sh # add --upgrade for the upgrade flow, add --encrypt to test with full-disk-encryption
+ ./qa flow # add --upgrade for the upgrade flow, add --encrypt to test with full-disk-encryption
```
- You can also edit `utils/jobs.sh` to comment out jobs and run only the ones you want. Note that
+ You can also run `./qa job` to run a specific job (use `./qa job --help` to see its options). Note that
`sanity-test` needs `install-system` to have run first, because it installs the system to a virtual disk.
5. Tear down when done (this cleans up volumes):
```bash
@@ -143,33 +160,36 @@ The worker will register with the remote server, submit jobs, and stream results
#### Running a SUT Python `unittest` on your own machine
-This assumes you're running KDE Linux, which will have `selenium-webdriver-at-spi` installed.
+This assumes you're running KDE Linux, which will have
+`selenium-webdriver-at-spi` installed.
-Inside the repository, go into the sysext test directory:
-```
-cd ./extensions/openqa/usr/lib/kde-linux-openqa
-```
+From the repository root, build the SUT system extension. This generates its
+requirements and copies the shared Python libraries into place:
-Then, create a venv and download test dependencies:
+```bash
+./qa build-sysext
```
+
+Go into the sysext test directory, create a venv, and install
+the SUT dependency group:
+
+```bash
+cd ./extensions/openqa/usr/lib/kde-linux-openqa
python3 -m venv --system-site-packages --upgrade-deps ./venv
+uv pip install --python ./venv/bin/python --group sut
source ./venv/bin/activate
-pip3 install -r ./requirements.txt
```
-Copy the required shared Python libs from the toplevel repo into the sysext:
-```
-find -L "$(git rev-parse --show-toplevel)/lib" -maxdepth 1 -type f -exec cp -f {} ./lib/ \;
-```
+As root, create the directory where the tests write their results:
-As root, create required directories that the tests will output files to:
-```
+```bash
mkdir -p /var/log/kde-linux-openqa
chmod 777 /var/log/kde-linux-openqa
```
-Then, run your test:
-```
+Then run the desired test:
+
+```bash
PYTHONPATH=. TEST_WITH_CLEAN_HOME=0 TEST_WITH_VIDEO_RECORDER=0 \
KWIN_PID=$(pgrep -n kwin_wayland) \
selenium-webdriver-at-spi-run python3 tests/<name of test>.py
@@ -177,13 +197,15 @@ PYTHONPATH=. TEST_WITH_CLEAN_HOME=0 TEST_WITH_VIDEO_RECORDER=0 \
### Integration with GitLab CI
-The pipeline has three stages: `validate`, `test`, and `test-upgrade`.
+The pipeline has five stages: `validate`, `test`, `test-encrypt`, `test-upgrade`, and `test-upgrade-encrypt`.
| Stage | What it does |
|---|---|
| validate | runs [REUSE](https://reuse.software/) license compliance linting. This is skipped when the pipeline is triggered from another project. |
-| test | runs the install + sanity-test suite (`worker.sh`) against the hosted openQA server. |
-| test-upgrade | runs the upgrade suite (`worker.sh --upgrade`) against the hosted openQA server. |
+| test | runs the install + sanity-test suite (`qa worker`) against the hosted openQA server. |
+| test-upgrade | runs the upgrade suite (`qa worker --upgrade`) against the hosted openQA server. |
+| test-encrypt | runs the install + sanity-test suite with FDE (`qa worker --encrypt`) against the hosted openQA server. |
+| test-upgrade-encrypt | runs the upgrade suite with FDE (`qa worker --upgrade --encrypt`) against the hosted openQA server. |
Both test jobs use the upstream `openqa_worker` container image.
@@ -208,7 +230,7 @@ These groups should created on the server beforehand, under a `KDE Linux` folder
| `KDE Linux Installation` | standard install and sanity test flow |
| `KDE Linux Upgrade` | upgradeability flow |
-The names are matched server-side by `utils/jobs.sh`. If a group doesn't exist, openQA will simply leave them ungrouped
+The names are matched server-side by `lib/worker/job_flow.py`. If a group doesn't exist, openQA will simply leave them ungrouped
without any errors.
#### Triggering from another project
diff --git a/REUSE.toml b/REUSE.toml
index 0a8ee01..fc36911 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -10,6 +10,16 @@ path = "needles/**.png"
SPDX-FileCopyrightText = "None"
SPDX-License-Identifier = "CC0-1.0"
+[[annotations]]
+path = ".python-version"
+SPDX-FileCopyrightText = "None"
+SPDX-License-Identifier = "CC0-1.0"
+
+[[annotations]]
+path = "uv.lock"
+SPDX-FileCopyrightText = "None"
+SPDX-License-Identifier = "CC0-1.0"
+
[[annotations]]
path = "README.md"
SPDX-FileCopyrightText = [
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/requirements.txt b/extensions/openqa/usr/lib/kde-linux-openqa/requirements.txt
deleted file mode 100644
index 76b56b9..0000000
--- a/extensions/openqa/usr/lib/kde-linux-openqa/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-# SPDX-License-Identifier: CC0-1.0
-# SPDX-FileCopyrightText: None
-numpy>=2.3.3 # pending https://invent.kde.org/sdk/selenium-webdriver-at-spi/-/merge_requests/99
-unittest-xml-reporting
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/basic_test.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/basic_test.py
index a3ba273..3f807a3 100755
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/basic_test.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/basic_test.py
@@ -6,7 +6,6 @@
import json
import subprocess
-import sys
import time
import unittest
from pathlib import Path
@@ -17,6 +16,7 @@ BLESS_FILE = Path('/run/user/1000/kde-linux-bless-session')
POLL_INTERVAL = 5
TIMEOUT = 120
+
class BasicTests(unittest.TestCase):
def test_1_bless_file_appears(self):
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/calamares_install.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/calamares_install.py
index 64f63fb..bf2f0c2 100755
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/calamares_install.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/calamares_install.py
@@ -5,18 +5,18 @@ import unittest
from appium import webdriver
from appium.webdriver.common.appiumby import AppiumBy
from appium.options.common.base import AppiumOptions
-import selenium.common.exceptions
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as ec
-from lib import user_manager
+from lib.common import user_manager
from lib.sut import openqa_junit_xml
import subprocess
import sys
# Installs the system through Calamares.
+
class CalamaresTests(unittest.TestCase):
@classmethod
def setUpClass(self):
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/configure_automatic_login.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/configure_automatic_login.py
index 0608117..b1c5dec 100644
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/configure_automatic_login.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/configure_automatic_login.py
@@ -7,26 +7,16 @@ import subprocess
import time
import unittest
from typing import Final
-
from appium import webdriver
from appium.options.common.base import AppiumOptions
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
-
-import selenium.common.exceptions
-from selenium.webdriver.common.action_chains import ActionChains
-from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as ec
-
from lib.sut import openqa_junit_xml
from lib.sut.polkit import PolkitAgent
-import sys
-import time
-import subprocess
-
# Enables automatic login for the installed user through System Settings.
KDE_VERSION: Final = 6
@@ -105,5 +95,6 @@ class AutoLoginTest(unittest.TestCase):
time.sleep(5)
+
if __name__ == '__main__':
- unittest.main()
+ openqa_junit_xml.run(AutoLoginTest, "configure_automatic_login")
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/default_applications.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/default_applications.py
index d193f7e..5dddbbd 100644
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/default_applications.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/default_applications.py
@@ -15,6 +15,7 @@ import subprocess
# Verifies the set default applications, through mimetype and system settings.
+
class DefaultApplicationsTests(unittest.TestCase):
@classmethod
def setUpClass(self):
@@ -126,6 +127,5 @@ class DefaultApplicationsTests(unittest.TestCase):
])
-
if __name__ == "__main__":
openqa_junit_xml.run(DefaultApplicationsTests, "default_applications")
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/discover_install.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/discover_install.py
index d2f7f4c..f12943a 100755
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/discover_install.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/discover_install.py
@@ -5,19 +5,17 @@ import unittest
from appium import webdriver
from appium.webdriver.common.appiumby import AppiumBy
from appium.options.common.base import AppiumOptions
-import selenium.common.exceptions
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as ec
from lib.sut import openqa_junit_xml
-
-import sys
import subprocess
import time
# Installs, launches, and uninstalls an application through Discover.
+
class DiscoverTests(unittest.TestCase):
@classmethod
def setUpClass(self):
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/discover_upgrade.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/discover_upgrade.py
index 95d5da5..51bba9d 100755
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/discover_upgrade.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/discover_upgrade.py
@@ -5,16 +5,15 @@ import unittest
from appium import webdriver
from appium.webdriver.common.appiumby import AppiumBy
from appium.options.common.base import AppiumOptions
-import selenium.common.exceptions
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as ec
from lib.sut import openqa_junit_xml
-import sys
# Runs a system update through Discover.
+
class DiscoverTests(unittest.TestCase):
@classmethod
def setUpClass(self):
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/drkonqi.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/drkonqi.py
index 92849a8..ca465dc 100644
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/drkonqi.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/drkonqi.py
@@ -1,29 +1,21 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Bhushan Shah <[email protected]>
-#
-
import unittest
from appium import webdriver
from appium.webdriver.common.appiumby import AppiumBy
from appium.options.common.base import AppiumOptions
-import selenium.common.exceptions
-
-from selenium.webdriver.common.action_chains import ActionChains
-from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as ec
-
from lib.sut import openqa_junit_xml
from lib.sut.atspi import find_pid_on_atspi_bus
-
-import sys
import time
import subprocess
import signal
# Crashes an application and checks that DrKonqi produces a useful crash report.
+
class DrkonqiTests(unittest.TestCase):
@classmethod
def setUpClass(self):
@@ -77,13 +69,13 @@ class DrkonqiTests(unittest.TestCase):
# make sure crash report is useful
wait = WebDriverWait(drkonqi_driver, 120)
# Go to backtrace mode
- info_label = wait.until(
+ wait.until(
ec.presence_of_all_elements_located((AppiumBy.NAME, "The generated crash information is useful."))
)
# cleanup
drkonqi_driver.quit()
-
+
if __name__ == "__main__":
openqa_junit_xml.run(DrkonqiTests, "drkonqi")
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/firefox.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/firefox.py
index e52db56..977aec3 100644
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/firefox.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/firefox.py
@@ -7,7 +7,7 @@ import unittest
from pathlib import Path
from lib.sut import openqa_junit_xml
from lib.sut import flatpak
-from lib import user_manager
+from lib.common import user_manager
# Check if Firefox's Plasma Integration addon is installed and enabled.
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/plasma_setup.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/plasma_setup.py
index 8070263..494c08f 100644
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/plasma_setup.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/plasma_setup.py
@@ -10,7 +10,7 @@ from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as ec
from lib.sut import openqa_junit_xml
from lib.sut.atspi import find_pid_on_atspi_bus
-from lib import user_manager
+from lib.common import user_manager
# Walks through the Plasma Setup wizard to set up the SUT.
# Fatal test - it's necessary for the system to work in later testing.
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/plasma_welcome.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/plasma_welcome.py
index 08dae9e..145ae1d 100755
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/plasma_welcome.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/plasma_welcome.py
@@ -5,17 +5,15 @@ import unittest
from appium import webdriver
from appium.webdriver.common.appiumby import AppiumBy
from appium.options.common.base import AppiumOptions
-import selenium.common.exceptions
-from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as ec
from lib.sut import openqa_junit_xml
-import sys
import subprocess
# Goes through the Plasma Welcome app until completion.
# Will become a bit more useful if we ship our own Welcome page, we can test its functionality.
+
class PlasmaWelcomeTests(unittest.TestCase):
@classmethod
def setUpClass(self):
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/secret_service.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/secret_service.py
index 1f6d43f..d2c7e9b 100644
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/secret_service.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/secret_service.py
@@ -13,13 +13,14 @@ from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as ec
from lib.sut import openqa_junit_xml
from lib.sut import flatpak
-from lib import user_manager
+from lib.common import user_manager
# Verifies the Secret Service provider is ksecretd and works through KeepSecret.
SECRETS_BUS_NAME = 'org.freedesktop.secrets'
KEEPSECRET_APP_ID = 'org.kde.keepsecret'
+
class SecretServiceTests(unittest.TestCase):
@classmethod
def setUpClass(self):
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/system_development.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/system_development.py
index 8a6f9c9..7dc59ac 100644
--- a/extensions/openqa/usr/lib/kde-linux-openqa/tests/system_development.py
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/system_development.py
@@ -35,7 +35,7 @@ class SystemDevelopmentTests(unittest.TestCase):
def _toggle(self):
proc = subprocess.Popen(
- ['run0','--empower',TOGGLE_DEVELOPER_MODE],
+ ['run0', '--empower', TOGGLE_DEVELOPER_MODE],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self.polkit.authenticate()
try:
@@ -93,7 +93,7 @@ class SystemDevelopmentTests(unittest.TestCase):
# Downloads and installs kde-builder, so it needs network and some time.
first_run = subprocess.Popen(
- ['run0','--empower',LOCAL_BIN_PATH,SET_UP_SYSTEM_DEVELOPMENT],
+ ['run0', '--empower', LOCAL_BIN_PATH, SET_UP_SYSTEM_DEVELOPMENT],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self.polkit.authenticate()
try:
@@ -113,7 +113,7 @@ class SystemDevelopmentTests(unittest.TestCase):
# A second run must find everything already in place and do nothing.
second_run = subprocess.Popen(
- ['run0','--empower',LOCAL_BIN_PATH,SET_UP_SYSTEM_DEVELOPMENT],
+ ['run0', '--empower', LOCAL_BIN_PATH, SET_UP_SYSTEM_DEVELOPMENT],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self.polkit.authenticate()
try:
diff --git a/lib/common/__init__.py b/lib/common/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lib/common/log.py b/lib/common/log.py
new file mode 100644
index 0000000..98de9b0
--- /dev/null
+++ b/lib/common/log.py
@@ -0,0 +1,106 @@
+# SPDX-License-Identifier: LGPL-2.0-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+import logging
+import time
+import shutil
+from colorlog import ColoredFormatter
+from typing import Any, cast
+
+_SUCCESS_LEVEL = logging.ERROR - 5
+_MESSAGE_LEVEL = logging.WARNING - 5
+_configured = False
+
+
+class MessageFormatter(ColoredFormatter):
+ def format(self, record):
+ formatted = super().format(record)
+
+ if record.levelname in ["MESSAGE", "SUCCESS", "WARNING", "ERROR"]:
+ line = "─" * shutil.get_terminal_size(fallback=(100, 24)).columns
+ return f"{line}\n{formatted}\n{line}"
+
+ return formatted
+
+
+class Logger(logging.Logger):
+ def success(
+ self,
+ message: object,
+ *args: object,
+ **kwargs: Any,
+ ) -> None:
+ if self.isEnabledFor(_SUCCESS_LEVEL):
+ self._log(_SUCCESS_LEVEL, message, args, **kwargs)
+
+ def message(
+ self,
+ message: object,
+ *args: object,
+ **kwargs: Any,
+ ) -> None:
+ if self.isEnabledFor(_MESSAGE_LEVEL):
+ self._log(_MESSAGE_LEVEL, message, args, **kwargs)
+
+
+def configure_logging() -> None:
+ """Configure logging on module import."""
+
+ global _configured
+
+ if _configured:
+ return
+
+ logging.addLevelName(_SUCCESS_LEVEL, "SUCCESS")
+ logging.addLevelName(_MESSAGE_LEVEL, "MESSAGE")
+ logging.setLoggerClass(Logger)
+
+ handler = logging.StreamHandler()
+ formatter = MessageFormatter(
+ "[%(asctime)s.%(msecs)03dZ] "
+ "%(log_color)s%(levelname)-10s%(reset)s "
+ "%(purple)s%(name)-30s%(reset)s "
+ "%(message_log_color)s%(message)s%(reset)s",
+ datefmt="%Y-%m-%dT%H:%M:%S",
+ log_colors={
+ "DEBUG": "cyan",
+ "INFO": "blue",
+ "MESSAGE": "bold_cyan",
+ "WARNING": "bold_yellow",
+ "SUCCESS": "bold_green",
+ "ERROR": "bold_red",
+ "CRITICAL": "bold_red,bg_white",
+ },
+ secondary_log_colors={
+ "message": {
+ "DEBUG": "white",
+ "INFO": "white",
+ "MESSAGE": "bold_white",
+ "WARNING": "bold_white",
+ "SUCCESS": "bold_white",
+ "ERROR": "bold_white",
+ "CRITICAL": "bold_white",
+ },
+ },
+ )
+ formatter.converter = time.gmtime
+ handler.setFormatter(formatter)
+
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.INFO)
+ root_logger.handlers.clear()
+ root_logger.addHandler(handler)
+
+ _configured = True
+
+
+def get_logger(name: str) -> Logger:
+ """Get a logger to use in each calling module."""
+ return cast(Logger, logging.getLogger(name))
+
+
+def set_log_level(level: int) -> None:
+ """Set the log level."""
+ logging.getLogger().setLevel(level)
+
+
+configure_logging()
diff --git a/lib/paths.py b/lib/common/paths.py
similarity index 54%
rename from lib/paths.py
rename to lib/common/paths.py
index f947bf3..ba1da12 100644
--- a/lib/paths.py
+++ b/lib/common/paths.py
@@ -1,9 +1,21 @@
# SPDX-License-Identifier: LGPL-2.0-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
-# Paths on the SUT
+import subprocess
+from pathlib import Path
VAR_DIR = '/var/lib/kde-linux-openqa'
VENV_DIR = f'{VAR_DIR}/venv'
LIB_DIR = '/usr/lib/kde-linux-openqa'
RESULTS_DIR = '/var/log/kde-linux-openqa'
+
+
+def git_root() -> Path:
+ return Path(
+ subprocess.run(
+ ["git", "rev-parse", "--show-toplevel"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+ )
diff --git a/lib/user_manager.py b/lib/common/user_manager.py
similarity index 98%
rename from lib/user_manager.py
rename to lib/common/user_manager.py
index 11f3d7d..9f5f64e 100644
--- a/lib/user_manager.py
+++ b/lib/common/user_manager.py
@@ -3,19 +3,24 @@
from dataclasses import dataclass
+
@dataclass
class User:
name: str
pw: str | None
+
def live():
return User(name="live", pw=None)
+
def installed():
return User(name="user", pw="user")
+
def root():
return User(name="root", pw=None)
+
def plasma_setup():
return User(name="plasma-setup", pw=None)
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/__init__.py b/lib/sut/__init__.py
similarity index 100%
rename from extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/__init__.py
rename to lib/sut/__init__.py
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/atspi.py b/lib/sut/atspi.py
similarity index 100%
rename from extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/atspi.py
rename to lib/sut/atspi.py
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/flatpak.py b/lib/sut/flatpak.py
similarity index 100%
rename from extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/flatpak.py
rename to lib/sut/flatpak.py
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/openqa_junit_xml.py b/lib/sut/openqa_junit_xml.py
similarity index 99%
rename from extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/openqa_junit_xml.py
rename to lib/sut/openqa_junit_xml.py
index 8ed0d33..6245129 100644
--- a/extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/openqa_junit_xml.py
+++ b/lib/sut/openqa_junit_xml.py
@@ -7,6 +7,7 @@ import sys
RESULTS_DIR = '/var/log/kde-linux-openqa'
+
def run(test_class: type, name: str):
"""Run a unittest class and write JUnit XML to the expected results dir"""
output_dir = f'{RESULTS_DIR}/{name}'
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/polkit.py b/lib/sut/polkit.py
similarity index 98%
rename from extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/polkit.py
rename to lib/sut/polkit.py
index 78245f1..7ef17fa 100644
--- a/extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/polkit.py
+++ b/lib/sut/polkit.py
@@ -13,7 +13,7 @@ from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as ec
import selenium.common.exceptions
from lib.sut.atspi import find_pid_on_atspi_bus
-from lib import user_manager
+from lib.common import user_manager
class PolkitAgent:
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/systemtray.py b/lib/sut/systemtray.py
similarity index 100%
rename from extensions/openqa/usr/lib/kde-linux-openqa/lib/sut/systemtray.py
rename to lib/sut/systemtray.py
diff --git a/lib/openqa/__init__.py b/lib/test/__init__.py
similarity index 100%
rename from lib/openqa/__init__.py
rename to lib/test/__init__.py
diff --git a/lib/openqa/cli_session.py b/lib/test/cli_session.py
similarity index 99%
rename from lib/openqa/cli_session.py
rename to lib/test/cli_session.py
index aa1a5a0..996f508 100644
--- a/lib/openqa/cli_session.py
+++ b/lib/test/cli_session.py
@@ -8,6 +8,7 @@ import testapi
_SSH_HOST = '127.0.0.1'
_SSH_PORT = 2222
+
def _get_connection() -> Connection:
return Connection(
host=_SSH_HOST,
diff --git a/lib/openqa/cli_test.py b/lib/test/cli_test.py
similarity index 80%
rename from lib/openqa/cli_test.py
rename to lib/test/cli_test.py
index d220873..74e1dc3 100644
--- a/lib/openqa/cli_test.py
+++ b/lib/test/cli_test.py
@@ -2,9 +2,10 @@
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa.cli_session import session
-from lib.paths import VENV_DIR, LIB_DIR, RESULTS_DIR
-from lib import user_manager
+from lib.test.cli_session import session
+from lib.common.paths import VENV_DIR, LIB_DIR, RESULTS_DIR
+from lib.common import user_manager
+from lib.common.log import get_logger
import shlex
import os
import uuid
@@ -13,11 +14,14 @@ import shutil
from pathlib import Path
from testapi import perl
+log = get_logger(__name__)
+
+
def _cli_command(method):
@functools.wraps(method)
def wrapper(self, *args, user: user_manager.User | None = None, **kwargs) -> str:
cmdline = method(self, *args, **kwargs)
- diag(f'Running {cmdline} on SUT')
+ log.info(f'Running {cmdline} on SUT')
try:
output = self._run_transient(cmdline, user=user)
finally:
@@ -27,6 +31,7 @@ def _cli_command(method):
return output
return wrapper
+
class CliTest:
def __init__(self, name: str, artifacts: list[str] | None = None, timeout: int = 90):
self.name = name
@@ -56,7 +61,7 @@ class CliTest:
else:
journal_cmd = f'journalctl _SYSTEMD_USER_UNIT={unit}.service --no-pager -o cat'
output = session.run(journal_cmd, wait_result=True)
- diag(f'{unit} outputted:\n{output}')
+ log.info(f'{unit} outputted:\n{output}')
return output or ''
@@ -65,19 +70,19 @@ class CliTest:
return cmdline
@_cli_command
- def run_script(self, script_name: str = None, directory: str = None) -> str:
+ def run_script(self, script_name: str | None = None, directory: str | None = None) -> str:
script_name = script_name or f"{self.name}.sh"
script_path = Path(directory or LIB_DIR) / "tests" / script_name
return f"{script_path}"
@_cli_command
- def run_python(self, script_name: str = None, directory: str = None) -> str:
+ def run_python(self, script_name: str | None = None, directory: str | None = None) -> str:
script_name = script_name or f"{self.name}.py"
script_path = Path(directory or LIB_DIR) / "tests" / script_name
return f'source {VENV_DIR}/bin/activate && python3 {script_path}'
@_cli_command
- def run_selenium(self, script_name: str = None, directory: str = None, args: str = "") -> str:
+ def run_selenium(self, script_name: str | None = None, directory: str | None = None, args: str | None = None) -> str:
script_name = script_name or f"{self.name}.py"
script_path = Path(directory or LIB_DIR) / "tests" / script_name
return f'source {VENV_DIR}/bin/activate && {LIB_DIR}/openqa-selenium-webdriver-at-spi-run {script_path} {args}'
@@ -85,7 +90,7 @@ class CliTest:
def _collect(self):
try:
session.run(f'test -f {self._remote_results}', wait_result=False)
- diag(f'JUnit XML exists for {self.name}, collecting...')
+ log.info(f'JUnit XML exists for {self.name}, collecting...')
local_results = f'/tmp/junit-{self.name}.xml'
session.get(self._remote_results, local_results)
@@ -111,9 +116,17 @@ class CliTest:
""")
except RuntimeError:
- diag(f'No JUnit XML for {self.name}, not collecting.')
+ log.info(f'No JUnit XML for {self.name}, not collecting.')
for artifact_path in self._artifacts:
local_artifact = f'/tmp/{self.name}-{os.path.basename(artifact_path)}'
- session.get(artifact_path, local_artifact)
+ try:
+ session.get(artifact_path, local_artifact)
+ except FileNotFoundError:
+ log.warning(
+ 'Artifact %s was not produced by %s',
+ artifact_path,
+ self.name,
+ )
+ continue
shutil.copy(local_artifact, f'ulogs/{os.path.basename(local_artifact)}')
diff --git a/lib/openqa/sessions/__init__.py b/lib/test/sessions/__init__.py
similarity index 100%
rename from lib/openqa/sessions/__init__.py
rename to lib/test/sessions/__init__.py
diff --git a/lib/openqa/sessions/base.py b/lib/test/sessions/base.py
similarity index 100%
rename from lib/openqa/sessions/base.py
rename to lib/test/sessions/base.py
diff --git a/lib/openqa/sessions/syscore/__init__.py b/lib/test/sessions/syscore/__init__.py
similarity index 100%
rename from lib/openqa/sessions/syscore/__init__.py
rename to lib/test/sessions/syscore/__init__.py
diff --git a/lib/openqa/sessions/syscore/plasma_desktop.py b/lib/test/sessions/syscore/plasma_desktop.py
similarity index 93%
rename from lib/openqa/sessions/syscore/plasma_desktop.py
rename to lib/test/sessions/syscore/plasma_desktop.py
index 8ba31c7..a81d016 100644
--- a/lib/openqa/sessions/syscore/plasma_desktop.py
+++ b/lib/test/sessions/syscore/plasma_desktop.py
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2025 Anicaa (Kangwei Zhu) <[email protected]>
from testapi import *
-from lib.openqa.sessions.base import BaseSession
+from lib.test.sessions.base import BaseSession
class PlasmaDesktopSession(BaseSession):
diff --git a/lib/worker/__init__.py b/lib/worker/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lib/worker/bootstrap.py b/lib/worker/bootstrap.py
new file mode 100644
index 0000000..f7a0e7c
--- /dev/null
+++ b/lib/worker/bootstrap.py
@@ -0,0 +1,92 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+
+import os
+import ssl
+import subprocess
+import time
+import urllib.request
+import uuid
+from functools import cache
+from pathlib import Path
+from lib.common.log import get_logger
+
+
+logger = get_logger(__name__)
+
+_WORKERS_CONFIG = Path("/etc/openqa/workers.ini")
+_CLIENT_CONFIG = Path("/etc/openqa/client.conf")
+_CACHE_DATABASE = Path("/var/lib/openqa/cache/cache.sqlite")
+_WORKER_LAUNCHER = Path("/run_openqa_worker.sh")
+
+
+class WorkerConfigurationError(Exception):
+ """Raised when the openQA worker cannot be configured."""
+
+
+# Sets a unique WORKER_CLASS so submitted jobs are pinned to this worker instance.
+# Lazily-evaluated global constant.
+@cache
+def get_worker_class() -> str:
+ return f"kde-linux-worker-{uuid.uuid4()}"
+
+
+def start_worker() -> subprocess.Popen[bytes]:
+ """Configures and starts the openQA worker daemon for CI and local worker runs."""
+
+ host = os.environ["OPENQA_HOST_ADDR"]
+ api_key = os.environ["OPENQA_API_KEY"]
+ api_secret = os.environ["OPENQA_API_SECRET"]
+ scheme = os.environ.get("OPENQA_SCHEME", "https")
+
+ # Setting CRITICAL_LOAD_AVG_THRESHOLD might prevent jobs getting stuck in the scheduled
+ # state, see https://gitlab.gnome.org/GNOME/openqa-tests/-/work_items/126
+ _WORKERS_CONFIG.write_text(
+ "[global]\n"
+ f"HOST = {scheme}://{host}\n"
+ "BACKEND = qemu\n"
+ f"WORKER_CLASS = {get_worker_class()}\n"
+ "CRITICAL_LOAD_AVG_THRESHOLD = 100\n"
+ )
+
+ _CLIENT_CONFIG.write_text(
+ f"[{host}]\n"
+ f"key = {api_key}\n"
+ f"secret = {api_secret}\n"
+ )
+
+ context = ssl._create_unverified_context()
+
+ for _ in range(10):
+ try:
+ with urllib.request.urlopen(
+ f"{scheme}://{host}/api/v1/jobs",
+ context=context,
+ ):
+ logger.info("openQA is ready")
+ break
+ except OSError:
+ logger.info("Waiting for openQA… (polling every 2 seconds)")
+ time.sleep(2)
+
+ _CACHE_DATABASE.unlink(missing_ok=True)
+
+ # Start the worker with --no-cleanup so it preserves the pool after each job instead of
+ # wiping it. install-system's published qcow2 then stays in the pool, and we don't have to
+ # re-download it from the server. This isn't great, but the upstream script hardcodes the
+ # OpenQA worker's arguments, and we shouldn't be reimplementing their code.
+ # Also strip the launcher's hardcoded --verbose so we don't spam CI.
+ launcher = _WORKER_LAUNCHER.read_text()
+ launcher = launcher.replace(
+ "/usr/share/openqa/script/worker ",
+ "/usr/share/openqa/script/worker --no-cleanup ",
+ )
+ launcher = launcher.replace(" --verbose", "")
+ _WORKER_LAUNCHER.write_text(launcher)
+
+ if "--no-cleanup" not in launcher:
+ raise WorkerConfigurationError(
+ "Could not enable --no-cleanup on the openQA worker launcher"
+ )
+
+ return subprocess.Popen([_WORKER_LAUNCHER])
diff --git a/lib/worker/download_image.py b/lib/worker/download_image.py
new file mode 100755
index 0000000..f200ff9
--- /dev/null
+++ b/lib/worker/download_image.py
@@ -0,0 +1,96 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2025 Anicaa (Kangwei Zhu) <[email protected]>
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+
+import requests
+from bs4 import BeautifulSoup
+from dataclasses import dataclass
+import re
+from lib.common.log import get_logger
+
+_BASE_URL = "https://files.kde.org/kde-linux/"
+_ISO_PATTERN = re.compile(r"kde-linux_(\d{12})\.iso$")
+
+log = get_logger(__name__)
+
+
+class DownloadError(Exception):
+ """Raised when an error occurs while attempting to download .iso files."""
+
+
+@dataclass(frozen=True)
+class Image:
+ version: str
+ filename: str
+
+
+def download_file(download_url: str, filename: str) -> None:
+ log.info(f"Started downloading from: {download_url}")
+ with requests.get(download_url, stream=True) as req:
+ req.raise_for_status()
+ with open(filename, 'wb') as file:
+ for chunk in req.iter_content(chunk_size=8192):
+ file.write(chunk)
+ log.info("Download completed")
+ log.info(f"Downloaded file: {filename}")
+
+
+def _available_images() -> list[Image]:
+ url = _BASE_URL + "?C=M;O=D"
+ resp = requests.get(url)
+ resp.raise_for_status()
+ soup = BeautifulSoup(resp.text, "html.parser")
+
+ images: dict[str, Image] = {}
+ for link in soup.find_all("a", href=_ISO_PATTERN):
+ href = link["href"]
+ match = _ISO_PATTERN.search(href)
+ if match is not None:
+ version = match.group(1)
+ images.setdefault(version, Image(version, href))
+
+ if not images:
+ raise DownloadError(".iso files not found on the server")
+
+ return list(images.values())
+
+
+def _download_image(filename: str) -> str:
+ download_file(_BASE_URL + filename, filename)
+ return filename
+
+
+def download_latest() -> str:
+ latest_image = max(
+ _available_images(),
+ key=lambda image: image.version,
+ )
+ return _download_image(latest_image.filename)
+
+
+def download_previous(build_version: str) -> str:
+ previous_images = [
+ image
+ for image in _available_images()
+ if image.version < build_version
+ ]
+
+ if not previous_images:
+ raise DownloadError(
+ f"No public .iso image exists before build {build_version}"
+ )
+
+ previous_image = max(
+ previous_images,
+ key=lambda image: image.version,
+ )
+ return _download_image(previous_image.filename)
+
+
+def download_specific(build_version: str) -> None:
+ filename = f"kde-linux_{build_version}.iso"
+ download_url = _BASE_URL + filename
+ resp = requests.head(download_url)
+ if resp.status_code != 200:
+ raise DownloadError(f"Specified build not found: {build_version}")
+ download_file(download_url, filename)
diff --git a/lib/worker/job.py b/lib/worker/job.py
new file mode 100644
index 0000000..c142267
--- /dev/null
+++ b/lib/worker/job.py
@@ -0,0 +1,461 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+
+import json
+import shutil
+import subprocess
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+import os
+from lib.common.log import get_logger
+
+
+logger = get_logger(__name__)
+
+_OPENQA_SHARE_PATH = Path("/var/lib/openqa/share/factory/hdd")
+_OPENQA_POOL_PATH = Path("/var/lib/openqa/pool")
+_PFLASH_PATH = Path("/usr/share/qemu/ovmf-x86_64-4m-vars.bin")
+_PFLASH_OVERLAY_PATH = (
+ _OPENQA_POOL_PATH / "1" / "raid" / "pflash-vars-overlay0"
+)
+_PFLASH_TEMP_PATH = Path("/tmp/pflash-vars-overlay0")
+
+_POLL_SECONDS = 5
+_SCHEDULED_TIMEOUT = 30
+
+_TERMINAL_RESULTS = {
+ "passed",
+ "softfailed",
+ "failed",
+ "incomplete",
+ "timeout",
+ "user_cancelled",
+ "obsoleted",
+ "cancelled",
+ "skipped",
+}
+
+
+class InvalidJobError(Exception):
+ """Raised when an openQA job is invalid."""
+
+
+@dataclass(frozen=True)
+class JobSuccess:
+ result: str
+
+
+@dataclass(frozen=True)
+class JobNonFatalFailure:
+ reason: str
+
+
+@dataclass(frozen=True)
+class JobFatalFailure:
+ reason: str
+
+
+type JobResult = JobSuccess | JobNonFatalFailure | JobFatalFailure
+
+
+@dataclass(frozen=True)
+class JobConfig:
+ hdd: Path
+ sysext: Path
+ build: str
+ name: str
+ flavor: str
+ casedir: Path
+ live: Path | None = None
+ group: str | None = None
+ after: int | None = None
+ worker_class: str | None = None
+ upgrade: bool = False
+ encrypt: bool = False
+
+ def __post_init__(self) -> None:
+ if self.live is not None and self.upgrade:
+ raise InvalidJobError(
+ "Live installation and upgrade are mutually exclusive"
+ )
+
+
+class OpenQAClient:
+ def __init__(self, host: str, scheme: str = "https") -> None:
+ self.host = host
+ self.scheme = scheme
+
+ def request(self, *args: str) -> dict[str, Any]:
+ logger.debug("Running openQA API request: %s", " ".join(args))
+
+ result = subprocess.run(
+ [
+ "openqa-cli",
+ "api",
+ "--host",
+ f"{self.scheme}://{self.host}",
+ *args,
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+ response = json.loads(result.stdout)
+
+ if not isinstance(response, dict):
+ raise InvalidJobError("openQA returned an invalid response")
+
+ return response
+
+
+class Job:
+ def __init__(self, client: OpenQAClient, config: JobConfig) -> None:
+ self.client = client
+ self.config = config
+ self._job_id: int | None = None
+
+ @property
+ def job_id(self) -> int:
+ if self._job_id is None:
+ raise InvalidJobError("Job has not been submitted")
+
+ return self._job_id
+
+ @property
+ def web_url(self) -> str:
+ host = self.client.host
+
+ if host == "localhost":
+ host = "localhost:1080"
+
+ return f"{self.client.scheme}://{host}/tests/{self.job_id}"
+
+ def run(self) -> JobResult:
+ logger.info("Running test job %s", self.config.name)
+
+ if self.config.live is not None:
+ self._stage_asset(self.config.live)
+ self._stage_asset(_PFLASH_PATH)
+ else:
+ self._stage_asset(self.config.hdd)
+
+ self._stage_asset(self.config.sysext)
+
+ self.submit()
+ result = self.poll_until_finished()
+
+ if not isinstance(result, JobFatalFailure):
+ if self.config.live is not None or self.config.upgrade:
+ self._produce_installed_hdd()
+ self._produce_pflash_vars()
+
+ clean_pool()
+
+ return result
+
+ def submit(self) -> int:
+ response = self.client.request(
+ "-X",
+ "POST",
+ "jobs",
+ *(
+ f"{key}={value}"
+ for key, value in self._submission_settings().items()
+ ),
+ )
+
+ logger.debug("Job creation response: %s", response)
+
+ job_id = response.get("id")
+
+ if not isinstance(job_id, int):
+ raise InvalidJobError(
+ f"Job creation returned an invalid ID: {job_id!r}"
+ )
+
+ self._job_id = job_id
+ logger.info("Submitted job %s", job_id)
+
+ return job_id
+
+ def poll_until_finished(self) -> JobResult:
+ logger.info("Polling job %s for its result", self.job_id)
+ logger.message(
+ "View the running %s job at %s",
+ self.config.name,
+ self.web_url,
+ )
+
+ previous_state: str | None = None
+ previous_result: str | None = None
+ scheduled_since: float | None = None
+
+ while True:
+ response = self.client.request(f"jobs/{self.job_id}")
+ job_data = response.get("job")
+
+ if not isinstance(job_data, dict):
+ raise InvalidJobError(
+ "openQA job response contains no job object"
+ )
+
+ state = job_data.get("state")
+ result = job_data.get("result")
+
+ if not isinstance(state, str) or not isinstance(result, str):
+ raise InvalidJobError(
+ "openQA returned an invalid job state or result"
+ )
+
+ if state != previous_state or result != previous_result:
+ logger.info(
+ "Job state changed to %s; result changed to %s",
+ state,
+ result,
+ )
+ previous_state = state
+ previous_result = result
+
+ if result in _TERMINAL_RESULTS:
+ break
+
+ if state == "scheduled":
+ if scheduled_since is None:
+ scheduled_since = time.monotonic()
+ elif time.monotonic() - scheduled_since > _SCHEDULED_TIMEOUT:
+ return JobFatalFailure(
+ f"job {self.job_id} stayed scheduled for over "
+ f"{_SCHEDULED_TIMEOUT} seconds"
+ )
+ else:
+ scheduled_since = None
+
+ time.sleep(_POLL_SECONDS)
+
+ if result in {"passed", "softfailed"}:
+ logger.success(
+ "Job %s completed with result %s",
+ self.job_id,
+ result,
+ )
+ return JobSuccess(result)
+
+ if result != "failed":
+ return JobFatalFailure(
+ f"job {self.job_id} ended with unexpected result {result}"
+ )
+
+ return self._classify_failed_job()
+
+ def _classify_failed_job(self) -> JobResult:
+ response = self.client.request(f"jobs/{self.job_id}/details")
+ job_data = response.get("job")
+
+ if not isinstance(job_data, dict):
+ raise InvalidJobError(
+ "openQA details response contains no job object"
+ )
+
+ test_results = job_data.get("testresults")
+
+ if not isinstance(test_results, list):
+ raise InvalidJobError(
+ "openQA details response contains no test results"
+ )
+
+ fatal_modules = [
+ result["name"]
+ for result in test_results
+ if isinstance(result, dict)
+ and result.get("result") == "failed"
+ and result.get("fatal") == 1
+ and isinstance(result.get("name"), str)
+ ]
+
+ if fatal_modules:
+ reason = (
+ f"Job {self.job_id} aborted; fatal modules failed: "
+ + ", ".join(fatal_modules)
+ )
+ logger.error(reason)
+ return JobFatalFailure(reason)
+
+ reason = (
+ f"job {self.job_id} failed; no fatal modules were involved"
+ )
+ logger.warning(reason)
+
+ return JobNonFatalFailure(reason)
+
+ def _submission_settings(self) -> dict[str, str]:
+ config = self.config
+
+ settings = {
+ "DISTRI": "KDE-Linux",
+ "VERSION": config.build,
+ "FLAVOR": config.flavor,
+ "ARCH": "x86_64",
+ "BUILD": config.build,
+ "TEST": config.name,
+ "MACHINE": "general_64bit",
+ "HDD_2": config.sysext.name,
+ "BOOT_HDD_IMAGE": "1",
+ "BACKEND": "qemu",
+ "UEFI": "1",
+ "UEFI_PFLASH_CODE": (
+ "/usr/share/qemu/ovmf-x86_64-4m-code.bin"
+ ),
+ "UEFI_PFLASH_VARS": (
+ _PFLASH_PATH.name
+ ),
+ "QEMUCPUS": "4",
+ "QEMURAM": "4096",
+ "QEMUCPU": "host",
+ "CASEDIR": str(config.casedir),
+ "NEEDLES_DIR": "%%CASEDIR%%/needles",
+ "TIMEOUT_SCALE": "3",
+ "VIRTIO_CONSOLE": "1",
+ "NICTYPE_USER_OPTIONS": "hostfwd=tcp::2222-:22",
+ }
+
+ if config.live is not None:
+ settings.update(
+ {
+ "HDD_3": config.live.name,
+ "DO_INSTALL": "1",
+ "HDDSIZEGB": "30",
+ "NUMDISKS": "3",
+ }
+ )
+ else:
+ settings.update(
+ {
+ "HDD_1": config.hdd.name,
+ "NUMDISKS": "2",
+ }
+ )
+
+ if config.upgrade:
+ settings["DO_UPGRADE"] = "1"
+
+ if config.encrypt:
+ settings["FDE_INSTALL"] = "1"
+
+ if config.after is not None:
+ settings["_START_AFTER_JOBS"] = str(config.after)
+
+ if config.group is not None:
+ settings["_GROUP"] = config.group
+
+ if not os.environ.get("MOCK_MODE") and config.worker_class is not None:
+ # No point setting a worker class in mock mode - just schedule
+ # to the only worker there is.
+ settings["WORKER_CLASS"] = config.worker_class
+
+ return settings
+
+ def _stage_asset(self, path: Path) -> None:
+ if not path.is_file():
+ logger.info(
+ "%s is already in the worker share, skipping staging",
+ path.name,
+ )
+ return
+
+ _OPENQA_SHARE_PATH.mkdir(parents=True, exist_ok=True)
+ shutil.copy(path, _OPENQA_SHARE_PATH / path.name)
+
+ logger.info("Staged %s into the worker share", path.name)
+
+ def _produce_installed_hdd(self) -> None:
+ pool_disk = next(
+ _OPENQA_POOL_PATH.glob("*/raid/hd0*"),
+ None,
+ )
+
+ if pool_disk is None:
+ raise InvalidJobError(
+ "Install target disk was not found in the worker pool"
+ )
+
+ destination = _OPENQA_SHARE_PATH / self.config.hdd.name
+ _OPENQA_SHARE_PATH.mkdir(parents=True, exist_ok=True)
+
+ logger.info(
+ "Flattening installed disk %s into %s",
+ pool_disk,
+ destination,
+ )
+
+ if pool_disk.name == "hd0-overlay0":
+ subprocess.run(
+ ["qemu-img", "commit", pool_disk],
+ check=True,
+ )
+ else:
+ subprocess.run(
+ [
+ "qemu-img",
+ "convert",
+ "-O",
+ "qcow2",
+ pool_disk,
+ destination,
+ ],
+ check=True,
+ )
+
+ def _produce_pflash_vars(self) -> None:
+ pflash_factory = _OPENQA_SHARE_PATH / _PFLASH_PATH.name
+
+ logger.info(
+ "Updating %s for the next job",
+ pflash_factory,
+ )
+
+ subprocess.run(
+ [
+ "qemu-img",
+ "convert",
+ "-f",
+ "qcow2",
+ "-O",
+ "raw",
+ _PFLASH_OVERLAY_PATH,
+ _PFLASH_TEMP_PATH,
+ ],
+ check=True,
+ )
+ _PFLASH_TEMP_PATH.replace(pflash_factory)
+
+
+def clean_pool() -> None:
+ logger.info("Cleaning openQA worker pool")
+
+ if not _OPENQA_POOL_PATH.is_dir():
+ logger.info("openQA worker pool does not exist")
+ return
+
+ removed = 0
+
+ try:
+ for worker_pool in _OPENQA_POOL_PATH.iterdir():
+ if not worker_pool.is_dir() or worker_pool.is_symlink():
+ continue
+
+ for entry in worker_pool.iterdir():
+ if entry.is_dir() and not entry.is_symlink():
+ shutil.rmtree(entry)
+ else:
+ entry.unlink()
+
+ removed += 1
+ except OSError as error:
+ raise OSError(
+ f"Could not clean openQA worker pool: {error}"
+ ) from error
+
+ logger.info("Removed %d entries from the openQA worker pool", removed)
diff --git a/lib/worker/job_flow.py b/lib/worker/job_flow.py
new file mode 100644
index 0000000..397c0cb
--- /dev/null
+++ b/lib/worker/job_flow.py
@@ -0,0 +1,392 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+
+import os
+import re
+import subprocess
+from contextlib import chdir
+from dataclasses import dataclass, replace
+from pathlib import Path
+from urllib.parse import urlsplit
+import requests
+from lib.common.log import get_logger
+from lib.common.paths import git_root
+import lib.worker.job
+import lib.worker.sysext
+from lib.worker.download_image import (
+ download_file,
+ download_latest,
+ download_previous,
+)
+
+
+logger = get_logger(__name__)
+
+_ISO_VERSION_PATTERN = re.compile(r"kde-linux_(\d{12})\.iso$")
+
+
+class JobFlowError(Exception):
+ """Raised when an openQA job flow cannot continue."""
+
+
+@dataclass(frozen=True)
+class BuildUnderTest:
+ image: Path | None
+ output: str
+ version: str
+
+
+class JobFlow:
+ def __init__(
+ self,
+ client: lib.worker.job.OpenQAClient,
+ group: str | None,
+ worker_class: str | None,
+ ) -> None:
+ self.client = client
+ self.group = group
+ self.worker_class = worker_class
+ self.parent: int | None = None
+ self.tests_failed = False
+
+ def run_job(self, config: lib.worker.job.JobConfig) -> None:
+ config = replace(
+ config,
+ group=self.group,
+ after=self.parent,
+ worker_class=self.worker_class,
+ )
+
+ current_job = lib.worker.job.Job(self.client, config)
+ result = current_job.run()
+ self.parent = current_job.job_id
+
+ match result:
+ case lib.worker.job.JobSuccess():
+ pass
+ case lib.worker.job.JobNonFatalFailure():
+ self.tests_failed = True
+ case lib.worker.job.JobFatalFailure(reason=reason):
+ raise JobFlowError(reason)
+
+
+def _find_local_iso(
+ casedir: Path,
+ older_than: str | None = None,
+) -> Path | None:
+ images: dict[str, Path] = {}
+
+ for image in casedir.glob("*.iso"):
+ match = _ISO_VERSION_PATTERN.fullmatch(image.name)
+ if match is None:
+ continue
+
+ version = match.group(1)
+ if older_than is None or version < older_than:
+ images[version] = image
+
+ if not images:
+ return None
+
+ return images[max(images)]
+
+
+def _download_image_url(casedir: Path, image_url: str) -> Path:
+ image_name = Path(urlsplit(image_url).path).name
+
+ if not image_name:
+ raise JobFlowError(f"Invalid image URL: {image_url}")
+
+ image_path = casedir / image_name
+
+ download_file(
+ image_url,
+ str(image_path),
+ )
+
+ return image_path
+
+
+def _build_from_image(
+ image_name: str,
+ image: Path | None,
+) -> BuildUnderTest:
+ output = Path(image_name).stem
+
+ return BuildUnderTest(
+ image=image,
+ output=output,
+ version=output.rsplit("_", 1)[-1],
+ )
+
+
+def _version_from_image(image: Path) -> str:
+ match = _ISO_VERSION_PATTERN.fullmatch(image.name)
+ if match is None:
+ raise JobFlowError(
+ f"Could not determine build version from image {image.name}"
+ )
+
+ return match.group(1)
+
+
+def _validate_upgrade_base(
+ image: Path,
+ target: BuildUnderTest,
+) -> Path:
+ if _version_from_image(image) >= target.version:
+ raise JobFlowError(
+ f"Upgrade base {image.name} is not older than target "
+ f"{target.version}"
+ )
+
+ return image
+
+
+def _latest_public_version() -> str:
+ response = requests.get(
+ "https://storage.kde.org/"
+ "kde-linux/testing/sysupdate/v2/SHA256SUMS"
+ )
+ response.raise_for_status()
+
+ versions = re.findall(
+ r"kde-linux_([0-9]{12})",
+ response.text,
+ )
+
+ if not versions:
+ raise JobFlowError(
+ "Could not determine public version from upstream SHA256SUMS"
+ )
+
+ return max(versions)
+
+
+def _resolve_build_under_test(
+ casedir: Path,
+ upgrade: bool,
+) -> BuildUnderTest:
+ image_url = os.environ.get("IMAGE_URL")
+
+ if upgrade:
+ if image_url:
+ image_name = Path(urlsplit(image_url).path).name
+ logger.info("Using %s as the upgrade target", image_name)
+ return _build_from_image(image_name, None)
+
+ version = _latest_public_version()
+ logger.info("Using latest public build %s as the upgrade target", version)
+ return BuildUnderTest(
+ image=None,
+ output=f"kde-linux_{version}",
+ version=version,
+ )
+
+ image = _find_local_iso(casedir)
+
+ if image is None:
+ if image_url:
+ image = _download_image_url(casedir, image_url)
+ else:
+ logger.info("No .iso image found, downloading latest")
+ with chdir(casedir):
+ image = casedir / download_latest()
+
+ return _build_from_image(image.name, image)
+
+
+def _resolve_install_image(
+ casedir: Path,
+ build: BuildUnderTest,
+ upgrade: bool,
+) -> Path:
+ if not upgrade:
+ if build.image is None:
+ raise JobFlowError(
+ "Non-upgrade flow has no installation image"
+ )
+
+ return build.image
+
+ # The upgrade flow installs an older base, then upgrades it to the build
+ # under test. Staged builds should start from the latest published image;
+ # published builds should start from the previous one.
+ if os.environ.get("STAGING_CHANNEL_URL"):
+ # The build under test is an unpublished staged image, so install the
+ # latest published image.
+ logger.info(
+ "Downloading latest published image as the upgrade base"
+ )
+ with chdir(casedir):
+ image = casedir / download_latest()
+ return _validate_upgrade_base(image, build)
+
+ # The build under test is a published image, so use the newest local image
+ # that is still older than the target when one is available.
+ local_image = _find_local_iso(casedir, older_than=build.version)
+ if local_image is not None:
+ logger.info(
+ "Using existing older image %s as the upgrade base",
+ local_image.name,
+ )
+ return _validate_upgrade_base(local_image, build)
+
+ logger.info("Downloading previous image for upgrade test")
+ with chdir(casedir):
+ image = casedir / download_previous(build.version)
+ return _validate_upgrade_base(image, build)
+
+
+def _stage_mock_tests(casedir: Path) -> None:
+ # Copy test cases to the directory where openQA expects so that the needle
+ # editor works.
+ destination = Path("/var/lib/openqa/tests/kde-linux")
+ destination.mkdir(parents=True, exist_ok=True)
+
+ subprocess.run(
+ [
+ "rsync",
+ "-a",
+ "--delete",
+ "--exclude",
+ "*.iso",
+ "--exclude",
+ "*.img",
+ "--exclude",
+ "/.*",
+ f"{casedir}/",
+ destination,
+ ],
+ check=True,
+ )
+
+ subprocess.run(
+ [
+ "chown",
+ "-R",
+ "geekotest:geekotest",
+ destination,
+ ],
+ check=True,
+ )
+
+
+def _flavors(
+ upgrade: bool,
+ encrypt: bool,
+) -> tuple[str, str]:
+ if upgrade:
+ live = "live-upgrade"
+ installed = "installed-upgrade"
+ else:
+ live = "live"
+ installed = "installed"
+
+ if encrypt:
+ live += "-encrypted"
+ installed += "-encrypted"
+
+ return live, installed
+
+
+def _job_group(upgrade: bool) -> str | None:
+ # Mock doesn't have any groups.
+ if os.environ.get("MOCK_MODE"):
+ return None
+
+ if upgrade:
+ return "KDE Linux Upgrade"
+
+ return "KDE Linux Installation"
+
+
+def run_jobs(
+ *,
+ worker_class: str | None = None,
+ upgrade: bool = False,
+ encrypt: bool = False,
+) -> None:
+ """Submit and poll all test jobs for a build."""
+
+ casedir = git_root()
+ mock_mode = bool(os.environ.get("MOCK_MODE"))
+
+ if not mock_mode and worker_class is None:
+ raise JobFlowError(
+ "worker_class is required outside mock mode"
+ )
+
+ sysext_image = lib.worker.sysext.build_sysext()
+ build = _resolve_build_under_test(casedir, upgrade)
+ install_image = _resolve_install_image(
+ casedir,
+ build,
+ upgrade,
+ )
+ disk = Path(f"{build.output}.qcow2")
+
+ if mock_mode:
+ _stage_mock_tests(casedir)
+
+ live_flavor, installed_flavor = _flavors(
+ upgrade,
+ encrypt,
+ )
+
+ flow = JobFlow(
+ client=lib.worker.job.OpenQAClient(
+ host=os.environ["OPENQA_HOST_ADDR"],
+ scheme=os.environ.get("OPENQA_SCHEME", "https"),
+ ),
+ group=_job_group(upgrade),
+ worker_class=worker_class,
+ )
+
+ # Chain each job onto the previous one, so we get a nice dependency graph.
+ # A successful job continues normally, while a non-fatal test failure is
+ # recorded and the remaining jobs continue. Everything else is a fatal
+ # failure, so fail fast.
+ flow.run_job(
+ lib.worker.job.JobConfig(
+ name="install-system",
+ flavor=live_flavor,
+ live=install_image,
+ hdd=disk,
+ sysext=sysext_image,
+ build=build.version,
+ casedir=casedir,
+ encrypt=encrypt,
+ )
+ )
+
+ if upgrade:
+ flow.run_job(
+ lib.worker.job.JobConfig(
+ name="upgrade-system",
+ flavor=installed_flavor,
+ hdd=disk,
+ sysext=sysext_image,
+ build=build.version,
+ casedir=casedir,
+ upgrade=True,
+ encrypt=encrypt,
+ )
+ )
+
+ flow.run_job(
+ lib.worker.job.JobConfig(
+ name="sanity-test",
+ flavor=installed_flavor,
+ hdd=disk,
+ sysext=sysext_image,
+ build=build.version,
+ casedir=casedir,
+ encrypt=encrypt,
+ )
+ )
+
+ if flow.tests_failed:
+ raise JobFlowError(
+ "one or more jobs had failing tests"
+ )
diff --git a/lib/worker/sysext.py b/lib/worker/sysext.py
new file mode 100644
index 0000000..7a8a769
--- /dev/null
+++ b/lib/worker/sysext.py
@@ -0,0 +1,131 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+
+import base64
+import os
+import shutil
+import subprocess
+from pathlib import Path
+from lib.common.log import get_logger
+
+logger = get_logger(__name__)
+
+
+def _compile_requirements(casedir: Path, sysext_root: Path) -> None:
+ requirements = (
+ sysext_root
+ / "usr"
+ / "lib"
+ / "kde-linux-openqa"
+ / "requirements.txt"
+ )
+ requirements.parent.mkdir(parents=True, exist_ok=True)
+
+ subprocess.run(
+ [
+ "uv",
+ "export",
+ "--quiet",
+ "--only-group",
+ "sut",
+ "--format",
+ "requirements.txt",
+ "--no-emit-project",
+ "--output-file",
+ requirements,
+ ],
+ cwd=casedir,
+ check=True,
+ )
+
+
+def build_sysext() -> Path:
+ casedir = Path(
+ subprocess.run(
+ ["git", "rev-parse", "--show-toplevel"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+ )
+
+ sysext_root = casedir / "extensions" / "openqa"
+
+ # Build the openQA sysext for the SUT.
+ # The top-level files in ./lib are shared between the host and the SUT, so copy them in at sysext build time.
+ # These are in .gitignore.
+ sysext_lib = sysext_root / "usr" / "lib" / "kde-linux-openqa" / "lib"
+
+ shutil.copytree(
+ casedir / "lib",
+ sysext_lib,
+ dirs_exist_ok=True,
+ )
+
+ # Build the requirements declared in pyproject.toml into the sysext.
+ _compile_requirements(casedir, sysext_root)
+
+ sysupdate_dir = sysext_root / "usr" / "lib" / "sysupdate.d"
+ pubring = (
+ sysext_root
+ / "usr"
+ / "lib"
+ / "systemd"
+ / "import-pubring.pgp"
+ )
+
+ staging_channel_url = os.environ.get("STAGING_CHANNEL_URL")
+ pubkey_b64 = os.environ.get("SYSUPDATE_PUBKEY_B64")
+
+ # Clean up before creating the sysext.
+ if not staging_channel_url and sysupdate_dir.is_dir():
+ # If we haven't been passed a URL, clean up any dropins that may point to one from a previous run.
+ shutil.rmtree(sysupdate_dir)
+
+ if not pubkey_b64 and pubring.is_file():
+ # If we haven't been passed a signing key, clean up any that exist from a previous run.
+ pubring.unlink()
+
+ if staging_channel_url:
+ # Create sysupdate.d dropins to redirect updates to our staged S3 image in CI.
+ override = f"[Source]\nPath={staging_channel_url}\n"
+
+ dropins = [
+ sysupdate_dir / "50-root-x86-64-caibx.transfer.d",
+ sysupdate_dir / "50-root-x86-64-erofs.transfer.d",
+ sysupdate_dir / "60-esp.transfer.d",
+ ]
+
+ for dropin in dropins:
+ dropin.mkdir(parents=True, exist_ok=True)
+ (dropin / "99-openqa-override.conf").write_text(override)
+
+ # Give it an ephemeral signing key, generated in CI, so it can verify updates from a staging channel outside of master upstream.
+ if pubkey_b64:
+ pubring.parent.mkdir(parents=True, exist_ok=True)
+
+ result = subprocess.run(
+ ["gpg", "--dearmor"],
+ input=base64.b64decode(pubkey_b64),
+ capture_output=True,
+ check=True,
+ )
+ pubring.write_bytes(result.stdout)
+
+ sysext_image = Path("openqa-sysext.img")
+ os.environ["SYSEXT_IMG"] = str(sysext_image)
+
+ subprocess.run(
+ [
+ "mkfs.erofs",
+ "--quiet",
+ "-L",
+ "kde-openqa-ext",
+ sysext_image,
+ sysext_root,
+ ],
+ check=True,
+ )
+
+ logger.info("Built openQA sysext at %s", sysext_image)
+ return sysext_image
diff --git a/lib/worker/system_packages.py b/lib/worker/system_packages.py
new file mode 100644
index 0000000..d0c2303
--- /dev/null
+++ b/lib/worker/system_packages.py
@@ -0,0 +1,88 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+
+import os
+import platform
+import subprocess
+from lib.common.log import get_logger
+
+
+logger = get_logger(__name__)
+
+_PACKAGES = (
+ "python3-requests",
+ "python3-beautifulsoup4",
+ "python3-colorlog",
+ "dos2unix",
+ "vim",
+ "erofs-utils",
+ "gpg2",
+ "python3-fabric",
+ "perl-Inline-Python",
+)
+
+
+def install() -> None:
+ """Configure package repositories and install missing worker dependencies."""
+ logger.info("Installing worker system packages…")
+
+ if os.environ.get("MOCK_MODE"):
+ subprocess.run(
+ ["zypper", "removerepo", "devel-languages-perl"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ check=False,
+ )
+ else:
+ version = platform.freedesktop_os_release()["VERSION_ID"]
+ subprocess.run(
+ [
+ "zypper",
+ "--non-interactive",
+ "addrepo",
+ "--refresh",
+ (
+ "https://download.opensuse.org/repositories/"
+ "devel:/languages:/perl/"
+ f"openSUSE_Leap_{version}/"
+ ),
+ "devel-languages-perl",
+ ],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ check=False,
+ )
+
+ missing = [
+ package
+ for package in _PACKAGES
+ if subprocess.run(
+ ["rpm", "-q", package],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ check=False,
+ ).returncode
+ != 0
+ ]
+
+ if not missing:
+ logger.info("Worker system packages are already installed")
+ return
+
+ result = subprocess.run(
+ [
+ "zypper",
+ "--non-interactive",
+ "--gpg-auto-import-keys",
+ "install",
+ *missing,
+ ],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ check=False,
+ )
+
+ if result.returncode not in (0, 106):
+ raise subprocess.CalledProcessError(result.returncode, result.args)
+
+ logger.info("Installed worker system packages")
diff --git a/mocks/single-instance.yml b/mocks/single-instance.yml
index d8ff182..62ffaec 100644
--- a/mocks/single-instance.yml
+++ b/mocks/single-instance.yml
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+
services:
openqa:
image: registry.opensuse.org/devel/openqa/containers/openqa-single-instance
@@ -17,9 +18,21 @@ services:
environment:
- OPENQA_HOST_ADDR=localhost
- OPENQA_SCHEME=http
+ - MOCK_MODE=1
+ - CASEDIR=/casedir
+ - PYTHONPATH=/casedir
+ - VIRTUAL_ENV=/var/lib/kde-linux-openqa/worker-venv
+ - PATH=/var/lib/kde-linux-openqa/worker-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
volumes:
- ..:/casedir
- entrypoint: ["/casedir/mocks/start-single-instance.sh"]
+ entrypoint:
+ - /bin/sh
+ - -c
+ - |
+ set -e
+ /casedir/setup.sh
+ exec /var/lib/kde-linux-openqa/worker-venv/bin/python \
+ /casedir/mocks/start-single-instance
volumes:
openqa-pgsql:
diff --git a/mocks/start-single-instance b/mocks/start-single-instance
new file mode 100755
index 0000000..f9aec06
--- /dev/null
+++ b/mocks/start-single-instance
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+
+import json
+import subprocess
+import time
+import urllib.error
+import urllib.request
+import os
+import signal
+from pathlib import Path
+from lib.worker import system_packages
+from lib.common.log import get_logger
+
+
+log = get_logger("start-single-instance")
+
+_OPENQA_BOOTSTRAP = Path("/usr/share/openqa/script/openqa-bootstrap")
+_POLL_SECONDS = 2
+
+
+def _wait_for_webui() -> None:
+ log.info("Waiting for OpenQA WebUI to start…")
+
+ while True:
+ try:
+ request = urllib.request.Request(
+ "http://localhost/",
+ method="HEAD",
+ )
+
+ with urllib.request.urlopen(request) as response:
+ if response.status == 200:
+ return
+ except urllib.error.URLError:
+ pass
+
+ time.sleep(_POLL_SECONDS)
+
+
+def _worker_is_running() -> bool:
+ for process in Path("/proc").iterdir():
+ if not process.name.isdigit():
+ continue
+
+ try:
+ command = (process / "cmdline").read_bytes().replace(b"\0", b" ")
+ except (FileNotFoundError, PermissionError, ProcessLookupError):
+ continue
+
+ if b"openqa/script/worker" in command:
+ return True
+
+ return False
+
+
+def _wait_for_worker() -> None:
+ log.info("Waiting for worker to start...")
+
+ while not _worker_is_running():
+ time.sleep(_POLL_SECONDS)
+
+
+def _cancel_scheduled_jobs() -> None:
+ log.info("Cancelling stale scheduled jobs...")
+
+ result = subprocess.run(
+ ["openqa-cli", "api", "jobs", "state=scheduled"],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+ for job in json.loads(result.stdout).get("jobs", []):
+ subprocess.run(
+ [
+ "openqa-cli",
+ "api",
+ "-X",
+ "POST",
+ f"jobs/{job['id']}/cancel",
+ ],
+ check=False,
+ )
+
+
+def main() -> None:
+ Path("/var/log/apache2").mkdir(parents=True, exist_ok=True)
+ Path("/srv/www/htdocs").mkdir(parents=True, exist_ok=True)
+
+ # Start the OpenQA stack using the image's own entrypoint
+ # The bundled worker wipes its pool after each job. We take the installed disk
+ # from the pool to give to the next job, so the pool must survive the first job.
+ # openqa-bootstrap starts the worker without --no-cleanup, so swap it for
+ # a --no-cleanup launched one.
+ openqa_bootstrap = _OPENQA_BOOTSTRAP.read_text()
+ openqa_bootstrap = openqa_bootstrap.replace(
+ "OPENQA_DIR/script/worker ",
+ "OPENQA_DIR/script/worker --no-cleanup ",
+ )
+ _OPENQA_BOOTSTRAP.write_text(openqa_bootstrap)
+
+ if "--no-cleanup" not in openqa_bootstrap:
+ raise RuntimeError(
+ "Could not enable --no-cleanup on the openQA worker launcher"
+ )
+
+ system_packages.install()
+
+ os.environ["skip_suse_tests"] = "1"
+ os.environ["skip_suse_specifics"] = "1"
+ subprocess.Popen([_OPENQA_BOOTSTRAP])
+
+ _wait_for_webui()
+ _wait_for_worker()
+ _cancel_scheduled_jobs()
+
+ # Don't auto submit jobs in the mock. Let the user choose what jobs to run.
+ log.message("Open a shell in the container to run jobs: podman exec -it openqa-single-instance bash")
+ log.message("Refer to README.md for instructions.")
+ signal.pause()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mocks/start-single-instance.sh b/mocks/start-single-instance.sh
deleted file mode 100755
index 8622c89..0000000
--- a/mocks/start-single-instance.sh
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/usr/bin/env bash
-# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
-# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
-
-# Start the OpenQA stack using the image's own entrypoint
-# The bundled worker wipes its pool after each job. produce_installed_hdd takes the installed disk
-# from the pool to give to the next job, so the pool must survive the first job.
-# openqa-bootstrap starts the worker without --no-cleanup, so swap it for a --no-cleanup launched one.
-sed -i 's#OPENQA_DIR/script/worker #&--no-cleanup #' /usr/share/openqa/script/openqa-bootstrap
-grep -q -- '--no-cleanup' /usr/share/openqa/script/openqa-bootstrap \
- || { echo "[ERROR] Could not enable --no-cleanup on the openQA worker launcher" >&2; exit 1; }
-
-mkdir -p /var/log/apache2
-mkdir -p /srv/www/htdocs
-
-skip_suse_tests=1 skip_suse_specifics=1 /usr/share/openqa/script/openqa-bootstrap &
-
-echo "[INFO] Waiting for OpenQA WebUI to start..."
-while ! curl -o /dev/null -w "%{http_code}" -sIL http://localhost/ | grep 200; do
- sleep 3
-done
-
-echo "[INFO] Waiting for worker to start..."
-until ps -ef | grep -q "[o]penqa/script/worker"; do sleep 2; done
-
-echo "[INFO] Cancelling stale scheduled jobs..."
-openqa-cli api jobs state=scheduled \
- | jq -r '.jobs[].id' \
- | while read -r id; do
- openqa-cli api -X POST "jobs/$id/cancel" || true
- done
-
-export MOCK_MODE=1
-export CASEDIR=/casedir
-
-cat > /root/.bashrc <<EOF
-export MOCK_MODE=1
-export CASEDIR=/casedir
-EOF
-
-# Don't auto submit jobs in the mock. Let the user choose what jobs to run.
-/casedir/worker.sh || true
-
-echo "[INFO] Open a shell in the container to run jobs:"
-echo " podman exec -it openqa-single-instance bash"
-echo "[INFO] Refer to README.md for instructions."
-sleep infinity
diff --git a/mocks/worker.yml b/mocks/worker.yml
index 934cccd..36a9104 100644
--- a/mocks/worker.yml
+++ b/mocks/worker.yml
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+
services:
openqa-worker:
image: registry.opensuse.org/devel/openqa/containers16.0/openqa_worker:latest
@@ -10,7 +11,13 @@ services:
volumes:
- ..:/casedir
- openqa-share:/var/lib/openqa/share
- entrypoint: /casedir/worker.sh
+ entrypoint:
+ - /bin/sh
+ - -c
+ - |
+ set -e
+ /casedir/setup.sh
+ exec /casedir/qa worker
volumes:
openqa-share:
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..cf7ced5
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,25 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+
+[project]
+name = "os-autoinst-distri-kdelinux"
+version = "0.1.0"
+description = "End-to-end tests for KDE Linux using openQA."
+readme = "README.md"
+requires-python = ">=3.14"
+dependencies = [
+ "bs4>=0.0.2",
+ "colorlog>=6.12.0",
+ "requests>=2.34.2",
+ "fabric>=3.2.3",
+]
+
+# This is transformed into requirements.txt on the SUT,
+# which is then installed with pip
+[dependency-groups]
+sut = [
+ "unittest-xml-reporting>=4.0.0",
+]
+
+[tool.uv]
+default-groups = []
diff --git a/qa b/qa
new file mode 100755
index 0000000..83f168a
--- /dev/null
+++ b/qa
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+
+import argparse
+import os
+from pathlib import Path
+from lib.worker import job
+from lib.common.log import get_logger
+from lib.common.paths import git_root
+from lib.worker import bootstrap, job_flow, system_packages, sysext
+from lib.worker.job_flow import JobFlowError
+
+log = get_logger(__name__)
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(dest="command", required=True)
+
+ commands.add_parser("clean-pool")
+ commands.add_parser("build-sysext")
+ command_parser = commands.add_parser("job")
+ job_mode = command_parser.add_mutually_exclusive_group()
+ job_mode.add_argument("--live", type=Path, metavar="PATH")
+ job_mode.add_argument("--upgrade", action="store_true")
+ command_parser.add_argument("--encrypt", action="store_true")
+ command_parser.add_argument("--hdd", type=Path, required=True)
+ command_parser.add_argument("--sysext", type=Path, required=True)
+ command_parser.add_argument("--build", required=True)
+ command_parser.add_argument("--name", required=True)
+ command_parser.add_argument("--flavor", required=True)
+ command_parser.add_argument("--group")
+ command_parser.add_argument("--after", type=int)
+
+ for command in ("worker", "flow"):
+ command_parser = commands.add_parser(command)
+ command_parser.add_argument("--upgrade", action="store_true")
+ command_parser.add_argument("--encrypt", action="store_true")
+
+ return parser
+
+
+def _load_environment() -> None:
+ if os.environ.get("MOCK_MODE"):
+ return
+
+ environment = Path(__file__).resolve().parent / ".env"
+
+ if not environment.is_file():
+ return
+
+ for line in environment.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+
+ name, value = line.split("=", 1)
+ os.environ.setdefault(name.strip(), value.strip())
+
+
+def _log_artifact_url() -> None:
+ image_url = os.environ.get("IMAGE_URL")
+ if not image_url:
+ return
+
+ bucket_url = image_url.rsplit("/", 1)[0]
+ bucket_url = bucket_url.replace(
+ "/ci-artifacts/",
+ "/ci-artifacts/#",
+ 1,
+ )
+ log.message(
+ "In case of failure, inspect and download the image and sysupdate "
+ "tree at: https://qoomon.github.io/aws-s3-bucket-browser/"
+ "index.html?bucket=%s",
+ bucket_url,
+ )
+
+
+def main() -> None:
+ args = _parser().parse_args()
+
+ _load_environment()
+
+ if args.command == "clean-pool":
+ job.clean_pool()
+ return
+
+ if args.command == "build-sysext":
+ sysext.build_sysext()
+ return
+
+ if args.command == "flow":
+ job_flow.run_jobs(
+ upgrade=args.upgrade,
+ encrypt=args.encrypt,
+ )
+ return
+
+ if args.command == "job":
+ job_config = job.JobConfig(
+ live=args.live,
+ upgrade=args.upgrade,
+ encrypt=args.encrypt,
+ hdd=args.hdd,
+ sysext=args.sysext,
+ build=args.build,
+ name=args.name,
+ flavor=args.flavor,
+ casedir=git_root(),
+ group=args.group,
+ after=args.after,
+ )
+
+ running_job = job.Job(
+ job.OpenQAClient(
+ host=os.environ["OPENQA_HOST_ADDR"],
+ scheme=os.environ.get("OPENQA_SCHEME", "https"),
+ ),
+ job_config,
+ )
+
+ result = running_job.run()
+
+ match result:
+ case job.JobSuccess():
+ pass
+ case job.JobNonFatalFailure():
+ raise SystemExit(2)
+ case job.JobFatalFailure(reason=reason):
+ log.critical("Job failed: %s", reason)
+ raise SystemExit(1)
+
+ return
+
+ system_packages.install()
+
+ # The single-instance mock already starts its own worker.
+ if os.environ.get("MOCK_MODE"):
+ return
+
+ worker = bootstrap.start_worker()
+ try:
+ job_flow.run_jobs(
+ worker_class=bootstrap.get_worker_class(),
+ upgrade=args.upgrade,
+ encrypt=args.encrypt,
+ )
+ finally:
+ worker.terminate()
+ worker.wait()
+ _log_artifact_url()
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except OSError as error:
+ log.critical("Worker filesystem operation failed: %s", error)
+ raise SystemExit(1)
+ except (JobFlowError, job.InvalidJobError) as error:
+ log.critical("Job flow failed: %s", error)
+ raise SystemExit(1)
diff --git a/setup.sh b/setup.sh
new file mode 100755
index 0000000..dba407d
--- /dev/null
+++ b/setup.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+set -euo pipefail
+
+CASEDIR="$(git rev-parse --show-toplevel)"
+cd "$CASEDIR"
+
+zypper --non-interactive install awk || true
+curl -LsSf https://astral.sh/uv/install.sh | env UV_UNMANAGED_INSTALL=/usr/local/bin sh
+export UV_PROJECT_ENVIRONMENT=/var/lib/kde-linux-openqa/worker-venv
+uv sync
+source "$UV_PROJECT_ENVIRONMENT/bin/activate"
+
+if (( $# )); then
+ exec "$@"
+fi
diff --git a/tests/common/basic_test.py b/tests/common/basic_test.py
index 4233218..b30ec54 100644
--- a/tests/common/basic_test.py
+++ b/tests/common/basic_test.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import paths
+from lib.test import cli_test
+from lib.common import paths
def run(self):
test = cli_test.CliTest('basic_test')
diff --git a/tests/common/bootup.py b/tests/common/bootup.py
index 73577e1..ce580fb 100644
--- a/tests/common/bootup.py
+++ b/tests/common/bootup.py
@@ -3,8 +3,8 @@
# SPDX-FileCopyrightText: 2026 Bhushan Shah <[email protected]>
from testapi import *
-from lib import user_manager
import time
+from lib.common import user_manager
def test_flags(self):
return {'fatal': 1}
diff --git a/tests/common/network.py b/tests/common/network.py
index 0e2aae3..630c633 100644
--- a/tests/common/network.py
+++ b/tests/common/network.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import paths
+from lib.test import cli_test
+from lib.common import paths
def test_flags(self):
return {'fatal': 1}
diff --git a/tests/common/reboot.py b/tests/common/reboot.py
index 97b44a5..a988ae6 100644
--- a/tests/common/reboot.py
+++ b/tests/common/reboot.py
@@ -2,8 +2,8 @@
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa.cli_session import session
-from lib import user_manager
+from lib.test.cli_session import session
+from lib.common import user_manager
def run(self):
# Enable timeout on the UEFI screen so we can select previous boot
diff --git a/tests/common/shutdown.py b/tests/common/shutdown.py
index 0c17ac0..af8e6c0 100644
--- a/tests/common/shutdown.py
+++ b/tests/common/shutdown.py
@@ -2,8 +2,8 @@
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa.cli_session import session
-from lib import user_manager
+from lib.test.cli_session import session
+from lib.common import user_manager
def run(self):
try:
diff --git a/tests/common/system_settings/disable_screen_lock.py b/tests/common/system_settings/disable_screen_lock.py
index 3510c19..9a00482 100644
--- a/tests/common/system_settings/disable_screen_lock.py
+++ b/tests/common/system_settings/disable_screen_lock.py
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2025 Anicaa (Kangwei Zhu) <[email protected]>
from testapi import *
-from lib.openqa.sessions.app.system_settings import SystemSettingsSession
+from lib.test.sessions.app.system_settings import SystemSettingsSession
def run(self):
diff --git a/tests/kdelinux-live/calamares_install.py b/tests/kdelinux-live/calamares_install.py
index 6d27a51..68dd588 100644
--- a/tests/kdelinux-live/calamares_install.py
+++ b/tests/kdelinux-live/calamares_install.py
@@ -3,9 +3,9 @@
# SPDX-FileCopyrightText: 2026 Bhushan Shah <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import paths
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import paths
+from lib.common import user_manager
def test_flags(self):
return {'fatal': 1}
diff --git a/tests/kdelinux/app/discover_install.py b/tests/kdelinux/app/discover_install.py
index 9299542..a151d50 100644
--- a/tests/kdelinux/app/discover_install.py
+++ b/tests/kdelinux/app/discover_install.py
@@ -2,9 +2,9 @@
# SPDX-FileCopyrightText: 2026 Bhushan Shah <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import paths
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import paths
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('discover_install', timeout=800)
diff --git a/tests/kdelinux/app/discover_upgrade.py b/tests/kdelinux/app/discover_upgrade.py
index e15b099..0fc533a 100644
--- a/tests/kdelinux/app/discover_upgrade.py
+++ b/tests/kdelinux/app/discover_upgrade.py
@@ -2,9 +2,9 @@
# SPDX-FileCopyrightText: 2026 Bhushan Shah <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import paths
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import paths
+from lib.common import user_manager
def test_flags(self):
return {'fatal': 1}
diff --git a/tests/kdelinux/app/dolphin.py b/tests/kdelinux/app/dolphin.py
index 4bd79bd..ca74848 100644
--- a/tests/kdelinux/app/dolphin.py
+++ b/tests/kdelinux/app/dolphin.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('dolphin')
diff --git a/tests/kdelinux/app/firefox.py b/tests/kdelinux/app/firefox.py
index 4cf256e..2922d7e 100644
--- a/tests/kdelinux/app/firefox.py
+++ b/tests/kdelinux/app/firefox.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
# Launches Firefox to trigger the policy-driven add-on install, so it needs the
diff --git a/tests/kdelinux/app/package_compatibility_helper.py b/tests/kdelinux/app/package_compatibility_helper.py
index eec1642..3387e34 100644
--- a/tests/kdelinux/app/package_compatibility_helper.py
+++ b/tests/kdelinux/app/package_compatibility_helper.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('package_compatibility_helper')
diff --git a/tests/kdelinux/desktop/clipboard.py b/tests/kdelinux/desktop/clipboard.py
index 030a5b2..aea0b12 100644
--- a/tests/kdelinux/desktop/clipboard.py
+++ b/tests/kdelinux/desktop/clipboard.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('clipboard')
diff --git a/tests/kdelinux/desktop/create_file.py b/tests/kdelinux/desktop/create_file.py
index 73bc391..b7a8bf6 100644
--- a/tests/kdelinux/desktop/create_file.py
+++ b/tests/kdelinux/desktop/create_file.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('create_file')
diff --git a/tests/kdelinux/desktop/drkonqi.py b/tests/kdelinux/desktop/drkonqi.py
index dd370a4..8044f53 100644
--- a/tests/kdelinux/desktop/drkonqi.py
+++ b/tests/kdelinux/desktop/drkonqi.py
@@ -4,9 +4,9 @@
from testapi import *
-from lib.openqa import cli_test
-from lib import paths
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import paths
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('drkonqi', timeout=120)
diff --git a/tests/kdelinux/desktop/krunner.py b/tests/kdelinux/desktop/krunner.py
index 0d8c5de..5adbcf0 100644
--- a/tests/kdelinux/desktop/krunner.py
+++ b/tests/kdelinux/desktop/krunner.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('krunner')
diff --git a/tests/kdelinux/desktop/panel.py b/tests/kdelinux/desktop/panel.py
index 5d6bdc6..c5f777e 100644
--- a/tests/kdelinux/desktop/panel.py
+++ b/tests/kdelinux/desktop/panel.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('panel')
diff --git a/tests/kdelinux/desktop/plasma_setup.py b/tests/kdelinux/desktop/plasma_setup.py
index 9e30977..67cbbc3 100644
--- a/tests/kdelinux/desktop/plasma_setup.py
+++ b/tests/kdelinux/desktop/plasma_setup.py
@@ -1,11 +1,11 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa.sessions.syscore.plasma_desktop import PlasmaDesktopSession
-from lib.openqa.cli_session import session
-from lib.openqa import cli_test
-from lib import paths
-from lib import user_manager
+from lib.test.sessions.syscore.plasma_desktop import PlasmaDesktopSession
+from lib.test.cli_session import session
+from lib.test import cli_test
+from lib.common import paths
+from lib.common import user_manager
def test_flags(self):
return {'fatal': 1}
diff --git a/tests/kdelinux/desktop/plasma_welcome.py b/tests/kdelinux/desktop/plasma_welcome.py
index d93ec95..7185aa5 100644
--- a/tests/kdelinux/desktop/plasma_welcome.py
+++ b/tests/kdelinux/desktop/plasma_welcome.py
@@ -2,9 +2,9 @@
# SPDX-FileCopyrightText: 2026 Bhushan Shah <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import paths
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import paths
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('plasma_welcome', timeout=300)
diff --git a/tests/kdelinux/desktop/secret_service.py b/tests/kdelinux/desktop/secret_service.py
index 11c18ff..dcbe4d9 100644
--- a/tests/kdelinux/desktop/secret_service.py
+++ b/tests/kdelinux/desktop/secret_service.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('secret_service')
diff --git a/tests/kdelinux/desktop/task_switcher.py b/tests/kdelinux/desktop/task_switcher.py
index 8077dd5..48d2648 100644
--- a/tests/kdelinux/desktop/task_switcher.py
+++ b/tests/kdelinux/desktop/task_switcher.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('task_switcher')
diff --git a/tests/kdelinux/desktop/vaults.py b/tests/kdelinux/desktop/vaults.py
index 95ff419..5cbccbe 100644
--- a/tests/kdelinux/desktop/vaults.py
+++ b/tests/kdelinux/desktop/vaults.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('vaults')
diff --git a/tests/kdelinux/sddm/sddm_password_login.py b/tests/kdelinux/sddm/sddm_password_login.py
index 7f8315d..b9727ad 100644
--- a/tests/kdelinux/sddm/sddm_password_login.py
+++ b/tests/kdelinux/sddm/sddm_password_login.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2025 Anicaa (Kangwei Zhu) <[email protected]>
from testapi import *
-from lib.openqa.sessions.syscore.plasma_desktop import PlasmaDesktopSession
-from lib import user_manager
+from lib.test.sessions.syscore.plasma_desktop import PlasmaDesktopSession
+from lib.common import user_manager
def run(self):
# check whether booted into desktop screen
diff --git a/tests/kdelinux/system/collect_logs.py b/tests/kdelinux/system/collect_logs.py
index 827d408..1ca7ee9 100644
--- a/tests/kdelinux/system/collect_logs.py
+++ b/tests/kdelinux/system/collect_logs.py
@@ -1,12 +1,27 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.test.cli_session import session
+from lib.common import user_manager
+
def test_flags(self):
return {'always_run': 1}
+
def run(self):
test = cli_test.CliTest('collect_logs', artifacts=['/tmp/kde-linux-collected-logs.tar.zst'], timeout=400)
- test.run_selenium(user=user_manager.installed())
+ # This test is always_run, so installation might have failed while still in
+ # the live account or setup account.
+ users = (
+ user_manager.installed(),
+ user_manager.plasma_setup(),
+ user_manager.live(),
+ )
+ user = next(
+ candidate
+ for candidate in users
+ if session.run(f'id -u {candidate.name} || true').strip()
+ )
+ test.run_selenium(user=user)
diff --git a/tests/kdelinux/system/desktop_session_services.py b/tests/kdelinux/system/desktop_session_services.py
index be8536d..0c1ce46 100644
--- a/tests/kdelinux/system/desktop_session_services.py
+++ b/tests/kdelinux/system/desktop_session_services.py
@@ -4,9 +4,9 @@
from testapi import *
-from lib.openqa import cli_test
-from lib import paths
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import paths
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('desktop_session_services')
diff --git a/tests/kdelinux/system/system_development.py b/tests/kdelinux/system/system_development.py
index 233d844..26f3bd0 100644
--- a/tests/kdelinux/system/system_development.py
+++ b/tests/kdelinux/system/system_development.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('system_development', timeout=400)
diff --git a/tests/kdelinux/system/verify_upgrade.py b/tests/kdelinux/system/verify_upgrade.py
index 898fa5d..15ee4a1 100644
--- a/tests/kdelinux/system/verify_upgrade.py
+++ b/tests/kdelinux/system/verify_upgrade.py
@@ -2,8 +2,8 @@
# SPDX-FileCopyrightText: 2026 Bhushan Shah <[email protected]>
from testapi import *
-from lib.openqa.cli_session import session
-from lib import user_manager
+from lib.test.cli_session import session
+from lib.common import user_manager
def run(self):
diff --git a/tests/kdelinux/system_settings/configure_automatic_login.py b/tests/kdelinux/system_settings/configure_automatic_login.py
index 47917e8..543057d 100644
--- a/tests/kdelinux/system_settings/configure_automatic_login.py
+++ b/tests/kdelinux/system_settings/configure_automatic_login.py
@@ -4,10 +4,10 @@
# SPDX-License-Identifier: MIT
from testapi import *
-from lib.openqa.sessions.syscore.plasma_desktop import PlasmaDesktopSession
-from lib.openqa import cli_test
-from lib import paths
-from lib import user_manager
+from lib.test.sessions.syscore.plasma_desktop import PlasmaDesktopSession
+from lib.test import cli_test
+from lib.common import paths
+from lib.common import user_manager
def test_flags(self):
return {'fatal': 1}
diff --git a/tests/kdelinux/system_settings/default_applications.py b/tests/kdelinux/system_settings/default_applications.py
index 9943b7a..f786088 100644
--- a/tests/kdelinux/system_settings/default_applications.py
+++ b/tests/kdelinux/system_settings/default_applications.py
@@ -1,8 +1,8 @@
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
from testapi import *
-from lib.openqa import cli_test
-from lib import user_manager
+from lib.test import cli_test
+from lib.common import user_manager
def run(self):
test = cli_test.CliTest('default_applications')
diff --git a/utils/banner.sh b/utils/banner.sh
deleted file mode 100644
index fab0d1c..0000000
--- a/utils/banner.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
-# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
-set -euo pipefail
-
-banner() {
- local level="$1" message="$2"
- local rule color out=1
-
- case "$level" in
- ERROR) rule='\e[1;91m'; color='\e[1;91m'; out=2 ;;
- WARN) rule='\e[1;93m'; color='\e[1;93m'; out=2 ;;
- *) rule='\e[1;95m'; color='\e[1;96m' ;;
- esac
-
- local line='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
- local message_line
- local prefix="[$level]"
-
- {
- printf '\n%b%s\e[0m\n' "$rule" "$line"
-
- while IFS= read -r message_line || [[ -n "$message_line" ]]; do
- printf '%-9s %b%s\e[0m\n' "$prefix" "$color" "$message_line"
- prefix=''
- done <<< "$message"
-
- printf '%b%s\e[0m\n\n' "$rule" "$line"
- } >&"$out"
-}
diff --git a/utils/bootstrap-worker.sh b/utils/bootstrap-worker.sh
deleted file mode 100755
index 8f95b0b..0000000
--- a/utils/bootstrap-worker.sh
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/usr/bin/env bash
-# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
-# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
-set -euo pipefail
-
-# Configures and starts the openQA worker daemon for CI and local worker runs.
-# Sets a unique WORKER_CLASS so submitted jobs are pinned to this worker instance.
-
-export WORKER_CLASS="kde-linux-worker-$(cat /proc/sys/kernel/random/uuid)"
-
-# Setting CRITICAL_LOAD_AVG_THRESHOLD might prevent jobs getting stuck in the scheduled
-# state, see https://gitlab.gnome.org/GNOME/openqa-tests/-/work_items/126
-cat > /etc/openqa/workers.ini <<EOF
-[global]
-HOST = ${OPENQA_SCHEME:-https}://${OPENQA_HOST_ADDR}
-BACKEND = qemu
-WORKER_CLASS = ${WORKER_CLASS}
-CRITICAL_LOAD_AVG_THRESHOLD = 100
-EOF
-
-cat > /etc/openqa/client.conf <<EOF
-[${OPENQA_HOST_ADDR}]
-key = ${OPENQA_API_KEY}
-secret = ${OPENQA_API_SECRET}
-EOF
-
-for i in {1..10}; do
- if curl -sk ${OPENQA_SCHEME:-https}://${OPENQA_HOST_ADDR}/api/v1/jobs >/dev/null; then
- echo "[INFO] openQA is ready"
- break
- fi
- echo "[INFO] Waiting for openQA..."
- sleep 3
-done
-
-rm -f /var/lib/openqa/cache/cache.sqlite
-
-# Start the worker with --no-cleanup so it preserves the pool after each job instead of
-# wiping it. install-system's published qcow2 then stays in the pool, and we don't have to
-# re-download it from the server. This isn't great, but the upstream script hardcodes the
-# OpenQA worker's arguments, and we shouldn't be reimplementing their code.
-# Also strip the launcher's hardcoded --verbose so we don't spam CI.
-sed -i -e 's#/usr/share/openqa/script/worker #&--no-cleanup #' -e 's# --verbose##' /run_openqa_worker.sh
-grep -q -- '--no-cleanup' /run_openqa_worker.sh \
- || { echo "[ERROR] Could not enable --no-cleanup on the openQA worker launcher" >&2; exit 1; }
-
-/run_openqa_worker.sh &
diff --git a/utils/download_image.py b/utils/download_image.py
deleted file mode 100755
index e10db61..0000000
--- a/utils/download_image.py
+++ /dev/null
@@ -1,73 +0,0 @@
-#!/usr/bin/python3
-# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
-# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
-import requests
-from bs4 import BeautifulSoup
-import re
-import sys
-import os
-import glob
-
-base_url = "https://files.kde.org/kde-linux/"
-
-def download_file(download_url, filename):
- print(f"Start downloading from: {download_url}", file=sys.stderr)
- with requests.get(download_url, stream=True) as req:
- req.raise_for_status()
- with open(filename, 'wb') as file:
- for chunk in req.iter_content(chunk_size=8192):
- file.write(chunk)
- print("Download completed", file=sys.stderr)
- print(f"Downloaded file: {filename}", file=sys.stderr)
- print(f"{filename}")
-
-def download(build_index):
- url = base_url + "?C=M;O=D"
- resp = requests.get(url)
- resp.raise_for_status()
- soup = BeautifulSoup(resp.text, "html.parser")
- pattern = re.compile(r'kde-linux_\d+\.iso$')
- links = soup.find_all("a", href=pattern)
- if links:
- latest_href = links[build_index]["href"]
- download_url = base_url + latest_href
- download_file(download_url, latest_href)
- else:
- print(".iso files not found")
- sys.exit(1)
-
-def download_specific(build_version):
- filename = f"kde-linux_{build_version}.iso"
- download_url = base_url + filename
- try:
- resp = requests.head(download_url)
- if resp.status_code != 200:
- print(f"Specified build not found: {build_version}")
- sys.exit(1)
- except Exception as e:
- print(f"Error checking build: {e}")
- sys.exit(1)
- download_file(download_url, filename)
-
-if __name__ == "__main__":
- if len(sys.argv) != 2:
- print("Usage: download_image.py [--latest | --build=VERSION | --previous-image]")
- sys.exit(1)
- arg = sys.argv[1]
- if arg == "--latest":
- download(build_index=0)
- elif arg.startswith("--build="):
- build_version = arg.split("=", 1)[1]
- if not re.fullmatch(r"\d{12}", build_version):
- print("Invalid version format. Expected format: YYYYMMDDHHMM")
- sys.exit(1)
- download_specific(build_version)
- elif arg == "--previous-image":
- """
- Each build appears twice in the list: one icon link and one text link.
- To get the previous successful build, we skip the latest (index 0 and 1) and pick index 2.
- """
- download(build_index=2)
- else:
- print("Unknown argument")
- sys.exit(1)
diff --git a/utils/jobs.sh b/utils/jobs.sh
deleted file mode 100755
index 78ce57a..0000000
--- a/utils/jobs.sh
+++ /dev/null
@@ -1,199 +0,0 @@
-#!/usr/bin/env bash
-# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
-# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
-set -euo pipefail
-
-# Submits and polls all test jobs for a build.
-
-if [[ -z "${CASEDIR:-}" ]]; then
- echo "[ERROR] CASEDIR is not set. Wait until the worker has fully initialised itself before entering this shell." >&2
- exit 1
-fi
-
-UPGRADE=0
-FDE=0
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --upgrade) UPGRADE=1; shift ;;
- --encrypt) FDE=1; shift ;;
- *) echo "[ERROR] Unknown argument: $1" >&2; exit 1 ;;
- esac
-done
-
-SYSEXT_IMG="${SYSEXT_IMG:-openqa-sysext.img}"
-
-find_local_iso() {
- find "$CASEDIR" -maxdepth 1 -name '*.iso' -print -quit
-}
-
-download_public_image() {
- local image_arg="$1"
- local image_name
-
- image_name=$(cd "$CASEDIR" && python3 "$CASEDIR/utils/download_image.py" "$image_arg")
- printf '%s/%s\n' "$CASEDIR" "$image_name"
-}
-
-download_image_url() {
- local image_url="$1"
- local image_name
- local image_path
-
- image_name=$(basename "$image_url")
- image_path="$CASEDIR/$image_name"
-
- echo "[INFO] Downloading image from $image_url..." >&2
- curl -L -o "$image_path" "$image_url"
- printf '%s\n' "$image_path"
-}
-
-set_build_from_image_name() {
- local image_name="$1"
-
- OUTPUT=${image_name%.iso}
- VERSION=${OUTPUT##*_}
-}
-
-latest_public_version() {
- local version
-
- version=$(curl -fsSL "https://storage.kde.org/kde-linux/testing/sysupdate/v2/SHA256SUMS" | sed -nE 's/.*kde-linux_([0-9]{12}).*/\1/p' | head -n1)
- if [[ -z "$version" ]]; then
- echo "[ERROR] Could not determine public version from upstream SHA256SUMS." >&2
- exit 1
- fi
-
- printf '%s\n' "$version"
-}
-
-resolve_build_under_test() {
- IMG_PATH=$(find_local_iso)
-
- if [[ -z "$IMG_PATH" && "$UPGRADE" -ne 1 ]]; then
- if [[ -n "${IMAGE_URL:-}" ]]; then
- IMG_PATH=$(download_image_url "$IMAGE_URL")
- else
- echo "[INFO] No .iso image found, downloading latest..."
- IMG_PATH=$(download_public_image --latest)
- fi
- fi
-
- if [[ -n "$IMG_PATH" ]]; then
- set_build_from_image_name "$(basename "$IMG_PATH")"
- elif [[ -n "${IMAGE_URL:-}" ]]; then
- set_build_from_image_name "$(basename "$IMAGE_URL")"
- else
- VERSION=$(latest_public_version)
- OUTPUT="kde-linux_$VERSION"
- fi
-}
-
-resolve_install_live() {
- if [[ "$UPGRADE" -ne 1 ]]; then
- INSTALL_LIVE="$IMG_PATH"
- return
- fi
-
- # The upgrade flow installs an older base, then upgrades it to the build under test.
- # Staged builds should start from the latest published image; published builds should start
- # from the previous one.
- if [[ -n "${STAGING_CHANNEL_URL:-}" ]]; then
- # The build under test is an unpublished staged image so install the latest published image.
- echo "[INFO] Downloading latest published image as the upgrade base..."
- INSTALL_LIVE=$(download_public_image --latest)
- else
- # The build under test is the latest published image, so install the previous one.
- echo "[INFO] Downloading previous image for upgrade test..."
- INSTALL_LIVE=$(download_public_image --previous-image)
- fi
-}
-
-resolve_build_under_test
-resolve_install_live
-DISK=${OUTPUT}.qcow2
-
-# Copy test cases to the directory where openQA expects so that needle editor works
-if [[ -n "${MOCK_MODE:-}" ]]; then
- mkdir -p /var/lib/openqa/tests/kde-linux
- rsync -a --delete --exclude '*.iso' --exclude '*.img' "$CASEDIR"/* /var/lib/openqa/tests/kde-linux
- chown -R geekotest:geekotest /var/lib/openqa/tests/kde-linux
-fi
-
-# Chain each job onto the previous one, so we get a nice dependency graph.
-# retcode 0 = passed, retcode 2 = non-fatal tests failed
-# Everything else is a fatal failure, so fail fast.
-# Created job id is emitted through fd 3 and stdout goes into fd 4 so PARENT doesn't
-# eat the logs. A bit of weird plumbing.
-TESTS_FAILED=0
-PARENT=
-exec 4>&1
-run_job() {
- local retcode=0
- PARENT=$(bash "$CASEDIR"/utils/run_job.sh "$@" --group "$GROUP" --after "$PARENT" 3>&1 1>&4) || retcode=$?
- case "$retcode" in
- 0) ;;
- 2) TESTS_FAILED=1 ;;
- *) exit "$retcode" ;;
- esac
-}
-
-# The upgrade flow uses install-system and sanity-test as well, so these jobs get their own flavor.
-if [[ "$UPGRADE" -eq 1 ]]; then
- LIVE_FLAVOR=live-upgrade
- INSTALLED_FLAVOR=installed-upgrade
-else
- LIVE_FLAVOR=live
- INSTALLED_FLAVOR=installed
-fi
-
-if [[ "$FDE" -eq 1 ]]; then
- LIVE_FLAVOR+="-encrypted"
- INSTALLED_FLAVOR+="-encrypted"
-fi
-
-# Put the whole flow in its own job group so each flow gets its own build overview,
-# and the dependency chain stays within one group. Mock doesn't have any groups, so don't set it here.
-GROUP=
-if [[ -z "${MOCK_MODE:-}" ]]; then
- if [[ "$UPGRADE" -eq 1 ]]; then
- GROUP="KDE Linux Upgrade"
- else
- GROUP="KDE Linux Installation"
- fi
-fi
-
-# In a mock single-instance container, comment out any of the below test jobs you don't want to run if you aim to test a specific one.
-# Then you'll be able to run utils/jobs.sh in the shell that you get dropped into after the single-instance test suites are done.
-# The install job will at least have to have run before you're able to do this. This will happen on first mock container run.
-run_job \
- --name install-system \
- --flavor "$LIVE_FLAVOR" \
- --live "$INSTALL_LIVE" \
- --hdd "$DISK" \
- --sysext "$SYSEXT_IMG" \
- --build "$VERSION" \
- $( [[ "$FDE" -eq 1 ]] && echo "--encrypt" )
-
-if [[ "$UPGRADE" -eq 1 ]]; then
- run_job \
- --name upgrade-system \
- --flavor "$INSTALLED_FLAVOR" \
- --hdd "$DISK" \
- --sysext "$SYSEXT_IMG" \
- --build "$VERSION" \
- --upgrade \
- $( [[ "$FDE" -eq 1 ]] && echo "--encrypt" )
-fi
-
-run_job \
- --name sanity-test \
- --flavor "$INSTALLED_FLAVOR" \
- --hdd "$DISK" \
- --sysext "$SYSEXT_IMG" \
- --build "$VERSION" \
- $( [[ "$FDE" -eq 1 ]] && echo "--encrypt" )
-
-if [[ "$TESTS_FAILED" -ne 0 ]]; then
- echo "[ERROR] One or more jobs had failing tests." >&2
- exit 1
-fi
diff --git a/utils/run_job.sh b/utils/run_job.sh
deleted file mode 100755
index 364e67f..0000000
--- a/utils/run_job.sh
+++ /dev/null
@@ -1,339 +0,0 @@
-#!/usr/bin/env bash
-# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
-# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
-set -euo pipefail
-
-source "$CASEDIR"/utils/banner.sh
-
-# Run an OpenQA job from within a worker.
-
-HDD=
-LIVE=
-SYSEXT=
-BUILD=
-TEST=
-UPGRADE=0
-FDE=
-FLAVOR=
-AFTER=
-GROUP=
-PFLASH_PATH=/usr/share/qemu/ovmf-x86_64-4m-vars.bin
-
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --live)
- LIVE="$2"
- shift 2
- ;;
- --hdd)
- HDD="$2"
- shift 2
- ;;
- --sysext)
- SYSEXT="$2"
- shift 2
- ;;
- --build)
- BUILD="$2"
- shift 2
- ;;
- --name)
- TEST="$2"
- shift 2
- ;;
- --flavor)
- FLAVOR="$2"
- shift 2
- ;;
- --group)
- GROUP="$2"
- shift 2
- ;;
- # Have a chained dependency on an already-submitted job so webui links them
- # in its dependency graph.
- --after)
- AFTER="$2"
- shift 2
- ;;
- --upgrade)
- UPGRADE=1
- shift
- ;;
- --encrypt)
- FDE=1
- shift
- ;;
- *)
- echo "[ERROR] Unknown argument: $1" >&2
- exit 1
- ;;
- esac
-done
-
-if [[ -z "$HDD" ]]; then
- echo "[ERROR] --hdd is required" >&2
- exit 1
-fi
-
-if [[ -z "$BUILD" ]]; then
- echo "[ERROR] --build is required" >&2
- exit 1
-fi
-
-if [[ -z "$TEST" ]]; then
- echo "[ERROR] --name is required" >&2
- exit 1
-fi
-
-if [[ -z "$SYSEXT" ]]; then
- echo "[ERROR] --sysext is required" >&2
- exit 1
-fi
-
-if [[ -z "$FLAVOR" ]]; then
- echo "[ERROR] --flavor is required" >&2
- exit 1
-fi
-
-if [[ -n "$LIVE" && "$UPGRADE" -eq 1 ]]; then
- echo "[ERROR] --live and --upgrade are mutually exclusive" >&2
- exit 1
-fi
-
-required_vars=(OPENQA_HOST_ADDR CASEDIR)
-[[ -z "${MOCK_MODE:-}" ]] && required_vars+=(WORKER_CLASS)
-for var in "${required_vars[@]}"; do
- if [[ -z "${!var}" ]]; then
- echo "[ERROR] $var is not set in environment" >&2
- exit 1
- fi
-done
-
-echo "[INFO] Running test job $TEST..."
-
-if [[ -n "$LIVE" ]]; then
- # HDD_1 is the blank install target, HDD_2 the sysext, and HDD_3 the live boot medium..
- HDD_2="$(basename "$SYSEXT")"
- HDD_3="$(basename "$LIVE")"
- NUMDISKS=3
-else
- HDD_1="$(basename "$HDD")"
- HDD_2="$(basename "$SYSEXT")"
- NUMDISKS=2
-fi
-
-openqa() {
- openqa-cli api --host "${OPENQA_SCHEME:-https}://${OPENQA_HOST_ADDR}" "$@"
-}
-
-stage_asset() {
- local path="$1"
- local name
- name=$(basename "$path")
-
- # This will be the system disk, which has already been placed into the share.
- if [[ ! -f "$path" ]]; then
- echo "[INFO] $name is the installed disk, already in the worker share; skipping."
- return 0
- fi
-
- # Actually stage the asset by dropping it in the local share.
- # No assets are required to be uploaded to the server.
- local share=/var/lib/openqa/share/factory/hdd
- mkdir -p "$share"
- cp "$path" "$share/$name"
- echo "[INFO] Staged $name into the worker share."
-}
-
-if [[ -n "$LIVE" ]]; then
- stage_asset "$LIVE"
- stage_asset "$PFLASH_PATH"
-else
- stage_asset "$HDD"
-fi
-stage_asset "$SYSEXT"
-
-# Since we purposefully don't PUBLISH_HDD_1, flatten the install disk straight into the share.
-# This would otherwise have been done by PUBLISH_HDD_1, but then we have a wasteful upload rigamarole
-# to the server, even while all assets are completely self-contained within the worker.
-produce_installed_hdd() {
- local name="$1"
- local share=/var/lib/openqa/share/factory/hdd
-
- local pool_disk
- pool_disk=$(find /var/lib/openqa/pool -path '*/raid/hd0*' -print -quit 2>/dev/null || true)
- if [[ -z "$pool_disk" ]]; then
- echo "[ERROR] Install target disk not found in the worker pool. Pool contents:" >&2
- find /var/lib/openqa/pool -maxdepth 3 >&2 || true
- exit 1
- fi
-
- echo "[INFO] Flattening the installed disk ($pool_disk) into $share/$name, for the next job."
- mkdir -p "$share"
- local pool_diskname="$(basename "$pool_disk")"
- if [[ "$pool_diskname" == "hd0-overlay0" ]]; then
- qemu-img commit $pool_disk
- else
- qemu-img convert -O qcow2 "$pool_disk" "$share/$name"
- fi
-}
-
-produce_pflash_vars() {
- local pflash_overlay=/var/lib/openqa/pool/1/raid/pflash-vars-overlay0
- local pflash_factory=/var/lib/openqa/share/factory/hdd/ovmf-x86_64-4m-vars.bin
- local tmp_pflash=/tmp/pflash-vars-overlay0
- echo "[INFO] Updating $pflash_factory for the next job."
- qemu-img convert -f qcow2 -O raw $pflash_overlay $tmp_pflash
- mv $tmp_pflash $pflash_factory
-}
-
-# Clean up the pool ourselves, because we set `--no-cleanup` in the worker so we're able to
-# pre-seed the qcow2 image.
-clean_pool() {
- rm -rf /var/lib/openqa/pool/*/* 2>/dev/null || true
-}
-
-poll_openqa_job() {
- local job_id="$1"
- local openqa_web_host="$OPENQA_HOST_ADDR"
-
- if [[ "$openqa_web_host" == "localhost" ]]; then
- openqa_web_host+=":1080"
- fi
-
- echo "[INFO] Job ${job_id} submitted. Polling for result..."
-
- # Jobs should immediately schedule as the worker basically submits its own job.
- local scheduled_timeout=30
- local scheduled_since=
-
- banner INFO "${TEST} test job is now running.
-View the running job, along with its logs, here: ${OPENQA_SCHEME:-https}://${openqa_web_host}/tests/${job_id}"
-
- local result=
- local state=
-
- while true; do
- local job_data new_state new_result
- job_data=$(openqa jobs/${job_id})
-
- new_state="$(echo "$job_data" | jq -r '.job.state // empty')"
- new_result="$(echo "$job_data" | jq -r '.job.result // empty')"
-
- if [[ "${state}" != "${new_state}" \
- || "${result}" != "${new_result}" ]]; then
- state="$new_state"
- result="$new_result"
- echo -e "[INFO] Job state changed to: ${state}\t Job result changed to: ${result}"
- fi
-
-
- if [[ "${result}" =~ ^(passed|softfailed|failed|incomplete|timeout|user_cancelled|obsoleted|cancelled|skipped)$ ]]; then
- break
- fi
-
- if [[ "${state}" == "scheduled" ]]; then
- [[ -z "$scheduled_since" ]] && scheduled_since=$SECONDS
- if (( SECONDS - scheduled_since > scheduled_timeout )); then
- banner ERROR "Job ${job_id} stayed in the scheduled state for over ${scheduled_timeout}s."
- return 1
- fi
- else
- scheduled_since=
- fi
-
- sleep 5
- done
-
- if [[ "${result}" == "passed" || "${result}" == "softfailed" ]]; then
- banner INFO "Job ${job_id} completed with result: ${result}"
- return 0
- fi
-
- if [[ "${result}" != "failed" ]]; then
- banner ERROR "Job ${job_id} ended with unexpected result: ${result}"
- return 1
- fi
-
- # Job failed; check whether a fatal-flagged module caused the failure.
- # If so, the test suite was aborted early and subsequent jobs should not run.
- local details fatal_failures
- details=$(openqa jobs/${job_id}/details)
- fatal_failures=$(echo "$details" | jq -r '
- .job.testresults[]
- | select(.result == "failed" and .fatal == 1)
- | .name
- ')
-
- if [[ -n "$fatal_failures" ]]; then
- banner ERROR "Job ${job_id} aborted; fatal module(s) failed: $(echo "$fatal_failures" | tr '\n' ' ')"
- return 1
- fi
-
- banner WARN "Job ${job_id} failed; no fatal modules involved."
- return 2
-}
-
-GROUP_ARG=()
-[[ -n "$GROUP" ]] && GROUP_ARG=("_GROUP=$GROUP")
-
-JOB_RESPONSE=$(openqa -X POST jobs \
- DISTRI=KDE-Linux \
- VERSION="$BUILD" \
- FLAVOR="$FLAVOR" \
- ARCH=x86_64 \
- BUILD="$BUILD" \
- TEST="$TEST" \
- MACHINE=general_64bit \
- $( [[ -z "$LIVE" ]] && echo HDD_1="$HDD_1" ) \
- HDD_2="$HDD_2" \
- $( [[ -n "$LIVE" ]] && echo HDD_3="$HDD_3" ) \
- $( [[ -n "$LIVE" ]] && echo DO_INSTALL=1 ) \
- $( [[ -n "$LIVE" ]] && echo HDDSIZEGB=30 ) \
- $( [[ "$UPGRADE" -eq 1 ]] && echo DO_UPGRADE=1 ) \
- $( [[ "$FDE" -eq 1 ]] && echo FDE_INSTALL=1 ) \
- BOOT_HDD_IMAGE=1 \
- BACKEND=qemu \
- UEFI=1 \
- UEFI_PFLASH_CODE=/usr/share/qemu/ovmf-x86_64-4m-code.bin \
- UEFI_PFLASH_VARS=ovmf-x86_64-4m-vars.bin \
- QEMUCPUS=4 \
- QEMURAM=4096 \
- QEMUCPU=host \
- NUMDISKS="$NUMDISKS" \
- CASEDIR="$CASEDIR" \
- NEEDLES_DIR="%%CASEDIR%%/needles" \
- TIMEOUT_SCALE=3 \
- VIRTIO_CONSOLE=1 \
- NICTYPE_USER_OPTIONS="hostfwd=tcp::2222-:22" \
- $( [[ -n "$AFTER" ]] && echo _START_AFTER_JOBS="$AFTER" ) \
- "${GROUP_ARG[@]}" \
- $( [[ -z "${MOCK_MODE:-}" ]] && echo WORKER_CLASS="$WORKER_CLASS" ) )
-
-echo "[INFO] Job creation response: $JOB_RESPONSE"
-JOB_ID=$(echo "$JOB_RESPONSE" | jq -r .id)
-if [[ -z "$JOB_ID" || "$JOB_ID" == "null" ]]; then
- banner ERROR "Job creation failed"
- exit 1
-fi
-
-# Emit the job id on fd 3 so jobs.sh can chain the next job, only if fd 3 was created.
-if { true >&3; } 2>/dev/null; then
- echo "$JOB_ID" >&3
-fi
-
-poll_retcode=0
-poll_openqa_job "$JOB_ID" "$OPENQA_HOST_ADDR" || poll_retcode=$?
-
-# Move installed disk out of the pool into the share so the next job resolves
-# it, then clear the pool now that the job is done - since this isn't done automatically.
-# A fatal failure is marked by retcode 1. In this case fail fast.
-if [[ "$poll_retcode" -ne 1 ]]; then
- if [[ -n "$LIVE" || "$UPGRADE" -eq 1 ]]; then
- produce_installed_hdd "$(basename "$HDD")"
- produce_pflash_vars
- fi
- clean_pool
-fi
-
-exit "$poll_retcode"
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..012349c
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,522 @@
+version = 1
+revision = 3
+requires-python = ">=3.14"
+
+[[package]]
+name = "bcrypt"
+version = "5.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
+ { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
+ { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
+ { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
+ { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
+ { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
+ { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
+ { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
+ { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
+ { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
+ { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
+ { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
+ { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
+ { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
+ { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
+ { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
+ { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
+ { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
+ { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
+ { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
+ { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
+ { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
+]
+
+[[package]]
+name = "bs4"
+version = "0.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beautifulsoup4" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698, upload-time = "2024-01-17T18:15:47.371Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.7.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
+ { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
+ { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
+ { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
+ { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
+ { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
+ { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
+ { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
+ { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
+ { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
+ { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
+ { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
+ { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
+ { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
+ { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
+ { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
+ { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
+ { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
+ { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
+ { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
+ { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
+ { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "colorlog"
+version = "6.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8c/55/ba79756cb90c8d69d599d57785398ac87bba7b19c80e87f4e8a562197c93/colorlog-6.12.0.tar.gz", hash = "sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f", size = 18151, upload-time = "2026-07-23T13:40:40.71Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "49.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
+ { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
+ { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
+ { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
+ { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
+ { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
+ { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
+ { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
+ { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
+ { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
+ { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
+ { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
+ { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
+ { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
+ { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
+]
+
+[[package]]
+name = "decorator"
+version = "5.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
+]
+
+[[package]]
+name = "deprecated"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
+]
+
+[[package]]
+name = "fabric"
+version = "3.2.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "decorator" },
+ { name = "deprecated" },
+ { name = "invoke" },
+ { name = "paramiko" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e3/7e/29cd6237c3b7ce79c3ca945eb99ab5affd101db54b2f7a78dde0cfa19fd4/fabric-3.2.3.tar.gz", hash = "sha256:dcbd2c47ad87688facaef5cc11aab6d1ec9ed05645fed97a5de7204d5d17cc44", size = 183497, upload-time = "2026-04-06T00:00:11.481Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/f9/f8497ef8b873a8bb2a750ee2a6c5f0fc22258e1acb6245fd237042a6c279/fabric-3.2.3-py3-none-any.whl", hash = "sha256:ce61917f4f398018337ce279b357650a3a74baecf3fdd53a5839013944af965e", size = 59502, upload-time = "2026-04-06T00:00:10.176Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "invoke"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" },
+]
+
+[[package]]
+name = "lxml"
+version = "6.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
+ { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
+ { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
+ { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
+ { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
+ { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
+ { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
+ { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
+ { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
+ { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
+ { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
+ { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
+ { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
+ { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
+]
+
+[[package]]
+name = "os-autoinst-distri-kdelinux"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "bs4" },
+ { name = "colorlog" },
+ { name = "fabric" },
+ { name = "requests" },
+]
+
+[package.dev-dependencies]
+sut = [
+ { name = "unittest-xml-reporting" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "bs4", specifier = ">=0.0.2" },
+ { name = "colorlog", specifier = ">=6.12.0" },
+ { name = "fabric", specifier = ">=3.2.3" },
+ { name = "requests", specifier = ">=2.34.2" },
+]
+
+[package.metadata.requires-dev]
+sut = [{ name = "unittest-xml-reporting", specifier = ">=4.0.0" }]
+
+[[package]]
+name = "paramiko"
+version = "5.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "bcrypt" },
+ { name = "cryptography" },
+ { name = "invoke" },
+ { name = "pynacl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pynacl"
+version = "1.6.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" },
+ { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" },
+ { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" },
+ { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" },
+ { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" },
+ { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" },
+ { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" },
+ { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" },
+ { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" },
+ { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" },
+ { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "unittest-xml-reporting"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lxml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/6b/5847d0e6e95d08e056f23b3f8cd95bede2d3ade10a1c1a9d5b50916454e1/unittest_xml_reporting-4.0.0.tar.gz", hash = "sha256:bfa1ed65e9e6f33c161d04470d89050458cfb65a5a5d0358834ef7ce037d9136", size = 43152, upload-time = "2026-01-07T15:50:58.983Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/5d/18d3c571c3a842a0ffbc4ad04637646a991d05eb3eab412b5226bbdbf294/unittest_xml_reporting-4.0.0-py2.py3-none-any.whl", hash = "sha256:e3e24bac8ea27c454d8be1d718851b2c5c8f712d75281920b6af81bdfef2f9bc", size = 20298, upload-time = "2026-01-07T15:50:57.222Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "wrapt"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" },
+ { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" },
+ { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" },
+ { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" },
+ { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" },
+ { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" },
+]
diff --git a/worker.sh b/worker.sh
deleted file mode 100755
index f6b4b0b..0000000
--- a/worker.sh
+++ /dev/null
@@ -1,140 +0,0 @@
-#!/usr/bin/env bash
-# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
-# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
-set -eo pipefail
-
-# Set the casedir to the git repo
-export CASEDIR="$(git rev-parse --show-toplevel)"
-
-source "$CASEDIR"/utils/banner.sh
-
-# Parse cmdline to see if we're doing an upgrade and/or encryption job
-UPGRADE=0
-FDE=0
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --upgrade) UPGRADE=1; shift ;;
- --encrypt) FDE=1; shift ;;
- *) echo "[ERROR] Unknown argument: $1" >&2; exit 1 ;;
- esac
-done
-
-# Get environment variables
-if [[ -z "${MOCK_MODE:-}" && -f "$CASEDIR/.env" ]]; then
- set -a
- source "$CASEDIR/.env"
- set +a
-fi
-
-# Bootstrap the worker's configuration if we aren't in a mock environment where this is already done
-if [[ -z "${MOCK_MODE:-}" ]]; then
- source "$CASEDIR/utils/bootstrap-worker.sh"
-fi
-
-# Install dependencies for the worker to run jobs
-DEPS=(
- python3-requests
- python3-beautifulsoup4
- dos2unix
- vim
- erofs-utils
- gpg2
- python3-fabric
- perl-Inline-Python
-)
-
-if [[ -z "${MOCK_MODE:-}" ]]; then
- OS_VERSION=$(. /etc/os-release && echo "$VERSION_ID")
- zypper --non-interactive addrepo --refresh \
- "https://download.opensuse.org/repositories/devel:/languages:/perl/openSUSE_Leap_${OS_VERSION}/" \
- devel-languages-perl || true
-else
- # The single-instance bootstrap installs os-autoinst-distri-opensuse-deps which adds this Leap repo
- zypper removerepo devel-languages-perl 2>/dev/null || true
-fi
-
-MISSING=()
-for pkg in "${DEPS[@]}"; do
- rpm -q "$pkg" &>/dev/null || MISSING+=("$pkg")
-done
-[[ ${#MISSING[@]} -gt 0 ]] && zypper --non-interactive --gpg-auto-import-keys install "${MISSING[@]}" || true
-
-# Build the openqa sysext for the SUT.
-# The top-level files in ./lib are shared between the host and the SUT, so copy them in at sysext build time.
-# These are in .gitignore.
-SYSEXT_LIB="$CASEDIR/extensions/openqa/usr/lib/kde-linux-openqa/lib"
-mkdir -p "$SYSEXT_LIB"
-find -L "$CASEDIR/lib" -maxdepth 1 -type f -exec cp -f {} "$SYSEXT_LIB/" \;
-
-# Clean up before creating the sysext.
-if [[ -z "${STAGING_CHANNEL_URL:-}" && -d "$CASEDIR/extensions/openqa/usr/lib/sysupdate.d/" ]]; then
- # If we haven't been passed a URL, clean up any dropins that may point to one from a previous run.
- rm -rf "$CASEDIR/extensions/openqa/usr/lib/sysupdate.d/"
-fi
-if [[ -z "${SYSUPDATE_PUBKEY_B64:-}" && -f "$CASEDIR/extensions/openqa/usr/lib/systemd/import-pubring.pgp" ]]; then
- # If we haven't been passed a signing key, clean up any that exist from a previous run.
- rm -f "$CASEDIR/extensions/openqa/usr/lib/systemd/import-pubring.pgp"
-fi
-
-if [[ -n "${STAGING_CHANNEL_URL:-}" ]]; then
- # Create sysupdate.d dropins to redirect updates to our staged S3 image in CI.
- mkdir -p "$CASEDIR/extensions/openqa/usr/lib/sysupdate.d/50-root-x86-64-caibx.transfer.d/"
- mkdir -p "$CASEDIR/extensions/openqa/usr/lib/sysupdate.d/50-root-x86-64-erofs.transfer.d/"
- mkdir -p "$CASEDIR/extensions/openqa/usr/lib/sysupdate.d/60-esp.transfer.d/"
-
- tee "$CASEDIR/extensions/openqa/usr/lib/sysupdate.d/50-root-x86-64-caibx.transfer.d/99-openqa-override.conf" \
- "$CASEDIR/extensions/openqa/usr/lib/sysupdate.d/50-root-x86-64-erofs.transfer.d/99-openqa-override.conf" \
- "$CASEDIR/extensions/openqa/usr/lib/sysupdate.d/60-esp.transfer.d/99-openqa-override.conf" > /dev/null <<EOF
-[Source]
-Path=${STAGING_CHANNEL_URL}
-EOF
- # Give it an ephemeral signing key, generated in CI, so it can verify updates from a staging channel outside of master upstream.
- if [[ -n "${SYSUPDATE_PUBKEY_B64:-}" ]]; then
- mkdir -p "$CASEDIR/extensions/openqa/usr/lib/systemd"
- echo "$SYSUPDATE_PUBKEY_B64" | base64 -d | gpg --dearmor > "$CASEDIR/extensions/openqa/usr/lib/systemd/import-pubring.pgp"
- fi
-fi
-
-export SYSEXT_IMG="openqa-sysext.img"
-mkfs.erofs --quiet -L "kde-openqa-ext" "$SYSEXT_IMG" "$CASEDIR/extensions/openqa"
-
-# Check if all the required environment variables exist outside of a single-instance mock, where they aren't required
-required_vars=(OPENQA_HOST_ADDR)
-[[ -z "${MOCK_MODE:-}" ]] && required_vars+=(OPENQA_API_KEY OPENQA_API_SECRET)
-for var in "${required_vars[@]}"; do
- if [[ -z "${!var}" ]]; then
- echo "[ERROR] $var is not set in environment" >&2
- exit 1
- fi
-done
-
-# In the single-instance mock users should submit jobs themselves.
-if [[ -n "${MOCK_MODE:-}" ]]; then
- exit 0
-fi
-
-# Run test jobs
-jobs_status=0
-if [[ "$UPGRADE" -eq 1 ]]; then
- if bash "$CASEDIR/utils/jobs.sh" --upgrade $( [[ "$FDE" -eq 1 ]] && echo "--encrypt" ); then
- :
- else
- jobs_status=$?
- fi
-else
- if bash "$CASEDIR/utils/jobs.sh" $( [[ "$FDE" -eq 1 ]] && echo "--encrypt" ); then
- :
- else
- jobs_status=$?
- fi
-fi
-
-# Send a message to maintainers to tell them where to inspect built images that have been staged.
-if [[ -z "${MOCK_MODE:-}" && -n "${IMAGE_URL:-}" ]]; then
- BUCKET_URL="${IMAGE_URL%/*}"
- URL="https://qoomon.github.io/aws-s3-bucket-browser/index.html?bucket=${BUCKET_URL/\/ci-artifacts\//\/ci-artifacts\/#}"
- banner INFO "In case of failure, you can inspect and download the .iso image and sysupdate tree at:
-$URL"
-fi
-
-exit "$jobs_status"