[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"
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.