diff --git a/.github/workflows/e2e-py-check.yml b/.github/workflows/e2e-py-check.yml new file mode 100644 index 0000000000..5cbf3cfb6b --- /dev/null +++ b/.github/workflows/e2e-py-check.yml @@ -0,0 +1,36 @@ +name: e2e-tests Python Quality Check + +on: + pull_request: + paths: + - 'e2e-tests/**/*.py' + - 'pyproject.toml' + - 'uv.lock' + +permissions: + contents: read + +jobs: + quality-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: "pyproject.toml" + + - name: Install dependencies + run: uv sync --locked + + - name: Run ruff check + run: uv run ruff check e2e-tests/ + + - name: Run mypy + run: uv run mypy e2e-tests/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2c8d6a1d78..69c56fdfa3 100644 --- a/.gitignore +++ b/.gitignore @@ -189,3 +189,7 @@ bin/ projects/ installers/olm/operator_*.yaml installers/olm/bundles + +# Test Reports +e2e-tests/reports/ +e2e-tests/**/__pycache__/ \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index 38c8350dfc..3a0a8da64e 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ import groovy.transform.Field -@Field def region = 'us-central1-a' +@Field def region = 'us-central1' @Field def testUrlPrefix = 'https://percona-jenkins-artifactory-public.s3.amazonaws.com/cloud-psmdb-operator' @Field def tests = [] @@ -21,8 +21,8 @@ void createCluster(String CLUSTER_SUFFIX) { gcloud container clusters list --filter ${CLUSTER_NAME}-${CLUSTER_SUFFIX} --zone ${region} --format='csv[no-heading](name)' | xargs gcloud container clusters delete --zone ${region} --quiet || true echo "Creating GKE cluster ${CLUSTER_NAME}-${CLUSTER_SUFFIX} with Kubernetes version \${GKE_VERSION} from the stable release channel" gcloud container clusters create ${CLUSTER_NAME}-${CLUSTER_SUFFIX} \ - --preemptible \ - --zone=${region} \ + --spot \ + --region=${region} \ --machine-type='n1-standard-4' \ --cluster-version="\${GKE_VERSION}" \ --num-nodes=3 \ @@ -30,7 +30,7 @@ void createCluster(String CLUSTER_SUFFIX) { --disk-size=30 \ --network=jenkins-vpc \ --subnetwork=jenkins-${CLUSTER_SUFFIX} \ - --cluster-ipv4-cidr=/21 \ + --cluster-ipv4-cidr=/20 \ --enable-ip-alias \ --no-enable-autoupgrade \ --monitoring=NONE \ @@ -43,7 +43,7 @@ void createCluster(String CLUSTER_SUFFIX) { ret_num=\$((ret_num + 1)) done if [ \${ret_num} -eq 15 ]; then - gcloud container clusters list --filter ${CLUSTER_NAME}-${CLUSTER_SUFFIX} --zone ${region} --format='csv[no-heading](name)' | xargs gcloud container clusters delete --zone ${region} --quiet || true + gcloud container clusters list --filter ${CLUSTER_NAME}-${CLUSTER_SUFFIX} --region ${region} --format='csv[no-heading](name)' | xargs gcloud container clusters delete --region ${region} --quiet || true exit 1 fi """ @@ -65,7 +65,7 @@ void shutdownCluster(String CLUSTER_SUFFIX) { kubectl delete pods --all -n \$namespace --force --grace-period=0 || true done kubectl get svc --all-namespaces || true - gcloud container clusters delete --zone ${region} ${CLUSTER_NAME}-${CLUSTER_SUFFIX} + gcloud container clusters delete --region ${region} ${CLUSTER_NAME}-${CLUSTER_SUFFIX} """ } } @@ -89,7 +89,7 @@ void deleteOldClusters(String FILTER) { break fi done - gcloud container clusters delete --async --zone ${region} --quiet \$GKE_CLUSTER || true + gcloud container clusters delete --async --region ${region} --quiet \$GKE_CLUSTER || true done fi """ @@ -102,9 +102,27 @@ void pushLogFile(String FILE_NAME) { echo "Push logfile $LOG_FILE_NAME file to S3!" withCredentials([aws(credentialsId: 'AMI/OVF', accessKeyVariable: 'AWS_ACCESS_KEY_ID', secretKeyVariable: 'AWS_SECRET_ACCESS_KEY')]) { sh """ - S3_PATH=s3://percona-jenkins-artifactory-public/\$JOB_NAME/\$(git rev-parse --short HEAD) + S3_PATH=s3://percona-jenkins-artifactory-public/\$JOB_NAME/${env.GIT_SHORT_COMMIT} + if [ ! -f ${LOG_FILE_PATH} ]; then + mkdir -p e2e-tests/logs + cat > ${LOG_FILE_PATH} </dev/null 2>&1", returnStatus: true) - - if (retFileExists == 0) { + def file="${env.GIT_BRANCH}-${env.GIT_SHORT_COMMIT}-${tests[i]['name']}" + if (existingFiles.contains(file)) { tests[i]["result"] = "passed" } } } } -void printKubernetesStatus(String LOCATION, String CLUSTER_SUFFIX) { - sh """ - export KUBECONFIG=/tmp/${CLUSTER_NAME}-${CLUSTER_SUFFIX} - echo "========== KUBERNETES STATUS $LOCATION TEST ==========" - gcloud container clusters list|grep -E "NAME|${CLUSTER_NAME}-${CLUSTER_SUFFIX} " - echo - kubectl get nodes - echo - kubectl top nodes - echo - kubectl get pods --all-namespaces - echo - kubectl top pod --all-namespaces - echo - kubectl get events --field-selector type!=Normal --all-namespaces --sort-by=".lastTimestamp" - echo "======================================================" - """ -} String formatTime(def time) { if (!time || time == "N/A") return "N/A" @@ -191,7 +190,6 @@ String formatTime(def time) { } @Field def TestsReport = '| Test Name | Result | Time |\r\n| ----------- | -------- | ------ |' -@Field def TestsReportXML = '\n' void makeReport() { def wholeTestAmount = tests.size() @@ -212,17 +210,62 @@ void makeReport() { startedTestAmount++ } TestsReport = TestsReport + "\r\n| " + testName + " | [" + testResult + "](" + testUrl + ") | " + formatTime(testTime) + " |" - TestsReportXML = TestsReportXML + '<'+ testResult +'/>\n' } TestsReport = TestsReport + "\r\n\r\n| Summary | Value |\r\n| ------- | ----- |" TestsReport = TestsReport + "\r\n| Tests Run | $startedTestAmount/$wholeTestAmount |" TestsReport = TestsReport + "\r\n| Job Duration | " + formatTime(currentBuild.duration / 1000) + " |" TestsReport = TestsReport + "\r\n| Total Test Time | " + formatTime(totalTestTime) + " |" - TestsReportXML = TestsReportXML + '\n' +} - sh """ - echo "${TestsReportXML}" > TestsReport.xml - """ +void normalizeReports() { + sh "mkdir -p e2e-tests/reports" + + for (int i = 0; i < tests.size(); i++) { + def testName = tests[i]["name"] + def testResult = tests[i]["result"] + def testTime = tests[i]["time"] ?: 0 + + if (testResult == "skipped") { + continue + } + + def xmlFile = "e2e-tests/reports/${testName}.xml" + def htmlFile = "e2e-tests/reports/${testName}.html" + + // Always collapse to a single testcase per test so python (multi-method) and + // bash-wrapper tests are counted identically in JUnit. Detail stays in the HTML. + def failures = testResult == "failure" ? 1 : 0 + def failureElement = testResult == "failure" ? + 'Jenkins reported this test as failed. See the HTML report for details.' : '' + + writeFile file: xmlFile, text: """ + + + +${failureElement} + + +""" + + if (!fileExists(htmlFile)) { + def resultCapitalized = testResult == "failure" ? "Failed" : "Passed" + def formattedTime = formatTime(testTime) + def logMessage = testResult == "failure" ? + "Test did not produce a report" : + "Test marked as passed (from previous run)" + + writeFile file: htmlFile, text: """ + + + +${testName}.html + + +
+ +""" + } + } } void clusterRunner(String cluster) { @@ -248,52 +291,55 @@ void clusterRunner(String cluster) { } void runTest(Integer TEST_ID) { - def retryCount = 0 def testName = tests[TEST_ID]["name"] def clusterSuffix = tests[TEST_ID]["cluster"] + def timeStart = new Date().getTime() - waitUntil { - def timeStart = new Date().getTime() - try { - echo "The $testName test was started on cluster ${CLUSTER_NAME}-${clusterSuffix} !" - tests[TEST_ID]["result"] = "failure" - - timeout(time: 90, unit: 'MINUTES') { - withCredentials([string(credentialsId: 'GCP_PROJECT_ID', variable: 'GCP_PROJECT')]) { - sh """ - if [ $retryCount -eq 0 ]; then - export DEBUG_TESTS=0 - else - export DEBUG_TESTS=1 - fi - export KUBECONFIG=/tmp/${CLUSTER_NAME}-${clusterSuffix} - export GCP_PROJECT=\$GCP_PROJECT - export GCS_WI_SERVICE_ACCOUNT=percona-psmdb-operator-wi@\$GCP_PROJECT.iam.gserviceaccount.com - time ./e2e-tests/$testName/run - """ - } - } - pushArtifactFile("${env.GIT_BRANCH}-${env.GIT_SHORT_COMMIT}-$testName") - tests[TEST_ID]["result"] = "passed" - return true - } - catch (exc) { - printKubernetesStatus("AFTER","$clusterSuffix") - echo "Test $testName has failed!" - if (retryCount >= 1 || currentBuild.nextBuild != null) { - currentBuild.result = 'FAILURE' - return true - } - retryCount++ - return false - } - finally { - def timeStop = new Date().getTime() - def durationSec = (timeStop - timeStart) / 1000 - tests[TEST_ID]["time"] = durationSec - pushLogFile("$testName") - echo "The $testName test was finished!" + try { + echo "The $testName test was started on cluster ${CLUSTER_NAME}-${clusterSuffix} !" + tests[TEST_ID]["result"] = "failure" + + timeout(time: 100, unit: 'MINUTES') { + sh """ + export DEBUG_TESTS=1 + export SKIP_DELETE=0 + export KUBECONFIG=/tmp/${CLUSTER_NAME}-${clusterSuffix} + export GCP_PROJECT=\$GCP_PROJECT + export GCS_WI_SERVICE_ACCOUNT=percona-psmdb-operator-wi@\$GCP_PROJECT.iam.gserviceaccount.com + export PATH="\$HOME/.local/bin:\$PATH" + mkdir -p e2e-tests/reports e2e-tests/logs + + REPORT_OPTS="--html=e2e-tests/reports/${testName}.html --junitxml=e2e-tests/reports/${testName}.xml" + + # Run native pytest if test_*.py exists, otherwise run bash via wrapper + bash -o pipefail </dev/null 2>&1; then + uv run pytest e2e-tests/${testName}/ \$REPORT_OPTS + else + uv run pytest e2e-tests/test_pytest_wrapper.py --test-name=${testName} \$REPORT_OPTS + fi + } 2>&1 | tee e2e-tests/logs/${testName}.log +BASH + """ } + pushArtifactFile("${env.GIT_BRANCH}-${env.GIT_SHORT_COMMIT}-$testName") + tests[TEST_ID]["result"] = "passed" + } + catch (org.jenkinsci.plugins.workflow.steps.FlowInterruptedException exc) { + echo "Test $testName was interrupted!" + throw exc + } + catch (exc) { + echo "Test $testName has failed!" + currentBuild.result = 'FAILURE' + } + finally { + def timeStop = new Date().getTime() + def durationSec = (timeStop - timeStart) / 1000 + tests[TEST_ID]["time"] = durationSec + pushLogFile("$testName") + echo "The $testName test was finished!" } } @@ -319,6 +365,10 @@ EOF sudo yum install -y google-cloud-cli google-cloud-cli-gke-gcloud-auth-plugin curl -sL https://github.com/mitchellh/golicense/releases/latest/download/golicense_0.2.0_linux_x86_64.tar.gz | sudo tar -C /usr/local/bin -xzf - golicense + + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="\$HOME/.local/bin:\$PATH" + uv sync --locked """ installAzureCLI() azureAuth() @@ -441,8 +491,7 @@ pipeline { GIT_SHORT_COMMIT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim() VERSION = "${env.GIT_BRANCH}-${env.GIT_SHORT_COMMIT}" CLUSTER_NAME = sh(script: "echo jen-psmdb-${env.CHANGE_ID}-${GIT_SHORT_COMMIT}-${env.BUILD_NUMBER} | tr '[:upper:]' '[:lower:]'", returnStdout: true).trim() - AUTHOR_NAME = sh(script: "echo ${CHANGE_AUTHOR_EMAIL} | awk -F'@' '{print \$1}'", , returnStdout: true).trim() - ENABLE_LOGGING = "true" + AUTHOR_NAME = sh(script: "echo ${CHANGE_AUTHOR_EMAIL} | awk -F'@' '{print \$1}'", returnStdout: true).trim() } agent { label 'docker-x64-min' @@ -473,7 +522,7 @@ pipeline { prepareNode() script { if (AUTHOR_NAME == 'null') { - AUTHOR_NAME = sh(script: "git show -s --pretty=%ae | awk -F'@' '{print \$1}'", , returnStdout: true).trim() + AUTHOR_NAME = sh(script: "git show -s --pretty=%ae | awk -F'@' '{print \$1}'", returnStdout: true).trim() } for (comment in pullRequest.comments) { println("Author: ${comment.user}, Comment: ${comment.body}") @@ -586,7 +635,7 @@ pipeline { } } options { - timeout(time: 4, unit: 'HOURS') + timeout(time: 5, unit: 'HOURS') } parallel { stage('cluster1') { @@ -690,12 +739,20 @@ pipeline { } } makeReport() - junit testResults: '*.xml', healthScaleFactor: 1.0 - archiveArtifacts '*.xml' + normalizeReports() + + sh """ + export PATH="\$HOME/.local/bin:\$PATH" + uv run pytest_html_merger -i e2e-tests/reports -o final_report.html + uv run junitparser merge --glob 'e2e-tests/reports/*.xml' final_report.xml + """ + junit testResults: 'final_report.xml', healthScaleFactor: 1.0 + archiveArtifacts 'final_report.xml, final_report.html' + pushReportFile() unstash 'IMAGE' def IMAGE = sh(returnStdout: true, script: "cat results/docker/TAG").trim() - TestsReport = TestsReport + "\r\n\r\ncommit: ${env.CHANGE_URL}/commits/${env.GIT_COMMIT}\r\nimage: `${IMAGE}`\r\n" + TestsReport = TestsReport + "\r\n\r\nCommit: ${env.CHANGE_URL}/commits/${env.GIT_COMMIT}\r\nImage: `${IMAGE}`\r\nTest report: [report](${testUrlPrefix}/${env.GIT_BRANCH}/${env.GIT_SHORT_COMMIT}/final_report.html)\r\n" pullRequest.comment(TestsReport) } deleteOldClusters("$CLUSTER_NAME") diff --git a/Makefile b/Makefile index bb3695a4b7..5e7495e027 100644 --- a/Makefile +++ b/Makefile @@ -71,6 +71,20 @@ undeploy: ## Undeploy operator test: envtest generate ## Run tests. DISABLE_TELEMETRY=true KUBEBUILDER_ASSETS="$(shell $(ENVTEST) --arch=amd64 use $(ENVTEST_K8S_VERSION) -p path)" go test ./... -coverprofile cover.out +py-deps: uv ## Install e2e-tests Python dependencies + $(UV) sync --locked + +py-update-deps: uv ## Update e2e-tests Python dependencies + $(UV) lock --upgrade + +py-fmt: uv ## Format and organize imports in e2e-tests + $(UV) run ruff check --select I --fix e2e-tests/ + $(UV) run ruff format e2e-tests/ + +py-check: uv ## Run ruff and mypy checks on e2e-tests + $(UV) run ruff check e2e-tests/ + $(UV) run mypy e2e-tests/ + # go-get-tool will 'go get' any package $2 and install it to $1. PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) define go-get-tool @@ -105,6 +119,12 @@ MOCKGEN = $(shell pwd)/bin/mockgen mockgen: ## Download mockgen locally if necessary. $(call go-get-tool,$(MOCKGEN), github.com/golang/mock/mockgen@latest) +UV = $(shell pwd)/bin/uv +uv: ## Download uv locally if necessary. + @[ -f $(UV) ] || { \ + set -e ;\ + curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=$(PROJECT_DIR)/bin sh ;\ + } update-version: echo $(NEXT_VER) > pkg/version/version.txt diff --git a/e2e-tests/README.md b/e2e-tests/README.md index 0f74d5de36..a6aed454b2 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -132,6 +132,65 @@ Test execution produces excessive output. It is recommended to redirect the outp ./e2e-tests/run >> /tmp/tests-run.out 2>&1 ``` +## Python development setup + +The e2e tests are being migrated to pytest. This section covers setting up the Python environment. + +### Installing uv + +[uv](https://github.com/astral-sh/uv) is used for Python dependency management. Install it via make: + +``` +make uv +``` + +Or manually: + +``` +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +### Python make targets + +``` +make py-deps # Install Python dependencies (locked versions) +make py-update-deps # Update Python dependencies +make py-fmt # Format code and organize imports with ruff +make py-check # Run ruff linter and mypy type checks +``` + +### Running tests with pytest + +First, install dependencies: + +``` +make py-deps +``` + +Run all pytest-based tests: + +``` +uv run pytest e2e-tests/ +``` + +Run a specific test file: + +``` +uv run pytest e2e-tests/init-deploy/test_init_deploy.py +``` + +Run a specific test: + +``` +uv run pytest e2e-tests/init-deploy/test_init_deploy.py::TestInitDeploy::test_cluster_creation +``` + +Run tests matching a pattern: + +``` +uv run pytest e2e-tests/ -k "init" +``` + ## Using environment variables to customize the testing process ### Re-declaring default image names diff --git a/e2e-tests/conftest.py b/e2e-tests/conftest.py new file mode 100644 index 0000000000..3f95ebe473 --- /dev/null +++ b/e2e-tests/conftest.py @@ -0,0 +1,575 @@ +import logging +import os +import random +import subprocess +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any, Callable, Dict, Generator + +import pytest +import yaml +from lib.kubectl import ( + clean_all_namespaces, + get_k8s_versions, + is_minikube, + is_openshift, + kubectl_bin, + wait_pod, +) +from lib.mongo import MongoManager +from lib.operator import check_crd_for_deletion, delete_crd_rbac, deploy_operator +from lib.secrets import get_cloud_secret_default +from lib.utils import ( + K8sHighlighter, + get_cr_version, + get_git_branch, + get_git_commit, + k8s_theme, + qualify_image, + retry, +) +from rich.console import Console +from rich.logging import RichHandler + +pytest_plugins = ["lib.report_generator"] + +logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO").upper(), + format="%(message)s", + handlers=[ + RichHandler( + console=Console(theme=k8s_theme), + highlighter=K8sHighlighter(), + show_time=True, + show_path=False, + markup=False, + rich_tracebacks=True, + log_time_format="[%X.%f]", + ) + ], +) +logging.getLogger("pytest_dependency").setLevel(logging.WARNING) +logger = logging.getLogger(__name__) + +_current_namespace: str | None = None + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption("--test-name", action="store", default=None, help="Bash test name to run") + + +def pytest_collection_modifyitems( + session: pytest.Session, config: pytest.Config, items: list[pytest.Item] +) -> None: + """Rename bash wrapper tests to show actual test name.""" + test_name = config.getoption("--test-name") + if not test_name: + return + + for item in items: + if item.name == "test_bash_wrapper": + item._nodeid = item._nodeid.replace( + "test_bash_wrapper", f"test_bash_wrapper[{test_name}]" + ) + + +@pytest.hookimpl(tryfirst=True) +def pytest_runtest_setup(item: pytest.Item) -> None: + """Print newline after pytest's verbose test name output.""" + print() + + +def _get_current_namespace() -> str | None: + """Get namespace from global or temp file (for bash wrapper tests).""" + if _current_namespace: + return _current_namespace + try: + with open("/tmp/pytest_current_namespace") as f: + return f.read().strip() or None + except Exception: + return None + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport( + item: pytest.Item, call: pytest.CallInfo[None] +) -> Generator[None, None, None]: + """Collect K8s resources when a test fails and add to HTML report.""" + outcome: Any = yield + report = outcome.get_result() + + if report.when == "call" and report.failed: + namespace = _get_current_namespace() + if not namespace: + return + + try: + from lib import report_generator + + report.extras = report_generator.generate_report(namespace) + except Exception as e: + logger.warning(f"Failed to generate HTML report extras: {e}") + + +@pytest.fixture(scope="session", autouse=True) +def setup_env_vars() -> None: + """Setup environment variables for the test session.""" + git_branch = get_git_branch() + git_version, kube_version = get_k8s_versions() + + defaults = { + "KUBE_VERSION": kube_version, + "EKS": "1" if "eks" in git_version else "0", + "GKE": "1" if "gke" in git_version else "0", + "OPENSHIFT": is_openshift(), + "MINIKUBE": is_minikube(), + "API": "psmdb.percona.com/v1", + "REGISTRY_NAME": "docker.io", + "GIT_COMMIT": get_git_commit(), + "GIT_BRANCH": git_branch, + "OPERATOR_VERSION": get_cr_version(), + "IMAGE": f"perconalab/percona-server-mongodb-operator:{git_branch}", + "IMAGE_MONGOD": "perconalab/percona-server-mongodb-operator:main-mongod8.0", + "IMAGE_MONGOD_CHAIN": ( + "perconalab/percona-server-mongodb-operator:main-mongod6.0\n" + "perconalab/percona-server-mongodb-operator:main-mongod7.0\n" + "perconalab/percona-server-mongodb-operator:main-mongod8.0" + ), + "IMAGE_SEARCH": "perconalab/percona-server-mongodb-operator:main-mongot", + "IMAGE_BACKUP": "perconalab/percona-server-mongodb-operator:main-backup", + "IMAGE_PMM_CLIENT": "percona/pmm-client:2.44.1-1", + "IMAGE_PMM_SERVER": "perconalab/pmm-server:dev-latest", + "IMAGE_PMM3_CLIENT": "perconalab/pmm-client:3-dev-latest", + "IMAGE_PMM3_SERVER": "perconalab/pmm-server:3-dev-latest", + "IMAGE_LOGCOLLECTOR": "perconalab/fluentbit:main-logcollector", + "IMAGE_CLUSTERSYNC": "percona/percona-clustersync-mongodb:0.9.0", + "CERT_MANAGER_VER": "1.19.3", + "CHAOS_MESH_VER": "2.7.1", + "MINIO_VER": "5.4.0", + "PMM_SERVER_VER": "9.9.9", + "CLEAN_NAMESPACE": "0", + "DELETE_CRD_ON_START": "1", + "SKIP_DELETE": "1", + "SKIP_BACKUPS_TO_AWS_GCP_AZURE": get_cloud_secret_default(), + "UPDATE_COMPARE_FILES": "0", + } + + for key, value in defaults.items(): + os.environ.setdefault(key, value) + + registry_full = os.environ["REGISTRY_NAME"].rstrip("/") + "/" + image_vars = [ + "IMAGE", + "IMAGE_MONGOD", + "IMAGE_MONGOD_CHAIN", + "IMAGE_BACKUP", + "IMAGE_PMM_CLIENT", + "IMAGE_PMM_SERVER", + "IMAGE_PMM3_CLIENT", + "IMAGE_PMM3_SERVER", + "IMAGE_LOGCOLLECTOR", + "IMAGE_CLUSTERSYNC", + ] + for var in image_vars: + raw_value = os.environ.get(var) + if raw_value: + os.environ[var] = "\n".join( + qualify_image(line, registry_full) for line in raw_value.splitlines() if line + ) + + env_lines = [f"{key}={os.environ.get(key)}" for key in defaults] + logger.info("Environment variables:\n" + "\n".join(env_lines)) + + +@pytest.fixture(scope="class") +def test_paths(request: pytest.FixtureRequest) -> Dict[str, str]: + """Fixture to provide paths relative to the test file.""" + test_file = request.path + test_dir = test_file.parent + conf_dir = test_dir.parent / "conf" + src_dir = test_dir.parent.parent + + return { + "test_file": str(test_file), + "test_dir": str(test_dir), + "conf_dir": str(conf_dir), + "src_dir": str(src_dir), + } + + +def _wait_for_project_delete(project: str, timeout: int = 180) -> None: + """Wait for OpenShift project to be fully deleted.""" + start = time.time() + while time.time() - start < timeout: + result = subprocess.run( + ["oc", "get", "project", project], + capture_output=True, + check=False, + ) + if result.returncode != 0: + return + time.sleep(5) + logger.warning(f"Project {project} not deleted within {timeout}s, continuing anyway") + + +@pytest.fixture(scope="class") +def create_namespace() -> Callable[[str], str]: + def _create_namespace(namespace: str) -> str: + """Create kubernetes namespace and clean up if exists.""" + + if int(os.environ.get("CLEAN_NAMESPACE") or "0"): + clean_all_namespaces() + + if int(os.environ.get("OPENSHIFT") or "0"): + logger.info("Cleaning up existing OpenShift project if exists") + subprocess.run( + ["oc", "delete", "project", namespace, "--ignore-not-found"], + check=False, + ) + _wait_for_project_delete(namespace) + + logger.info(f"Create namespace {namespace}") + subprocess.run(["oc", "new-project", namespace], check=True) + subprocess.run(["oc", "project", namespace], check=True) + subprocess.run( + ["oc", "adm", "policy", "add-scc-to-user", "hostaccess", "-z", "default"], + check=False, + ) + else: + logger.info("Cleaning up existing namespace") + + # Delete namespace if exists + try: + kubectl_bin("delete", "namespace", namespace, "--ignore-not-found") + kubectl_bin("wait", "--for=delete", f"namespace/{namespace}") + except subprocess.CalledProcessError: + pass + + logger.info(f"Create namespace {namespace}") + kubectl_bin("create", "namespace", namespace) + kubectl_bin("config", "set-context", "--current", f"--namespace={namespace}") + return namespace + + return _create_namespace + + +@pytest.fixture(scope="class") +def create_infra( + test_paths: Dict[str, str], create_namespace: Callable[[str], str] +) -> Generator[Callable[[str], str], None, None]: + global _current_namespace + created_namespaces: list[str] = [] + + def _create_infra(test_name: str) -> str: + """Create the necessary infrastructure for the tests.""" + global _current_namespace + logger.info("Creating test environment") + if os.environ.get("DELETE_CRD_ON_START") == "1": + delete_crd_rbac(Path(test_paths["src_dir"])) + check_crd_for_deletion(f"{test_paths['src_dir']}/deploy/crd.yaml") + + operator_ns = os.environ.get("OPERATOR_NS") + if operator_ns: + create_namespace(operator_ns) + deploy_operator(test_paths["test_dir"], test_paths["src_dir"]) + namespace = create_namespace(f"{test_name}-{random.randint(0, 32767)}") + else: + namespace = create_namespace(f"{test_name}-{random.randint(0, 32767)}") + deploy_operator(test_paths["test_dir"], test_paths["src_dir"]) + + # Track created namespace for cleanup and failure collection + created_namespaces.append(namespace) + _current_namespace = namespace + return namespace + + yield _create_infra + + # Teardown code + _current_namespace = None + + if os.environ.get("SKIP_DELETE") == "1": + logger.info("SKIP_DELETE=1. Skipping test environment cleanup") + return + + def run_cmd(cmd: list[str]) -> None: + try: + kubectl_bin(*cmd) + except (subprocess.CalledProcessError, FileNotFoundError, OSError) as e: + logger.debug(f"Command failed (continuing cleanup): {' '.join(cmd)}, error: {e}") + + def cleanup_crd() -> None: + crd_file = f"{test_paths['src_dir']}/deploy/crd.yaml" + run_cmd(["delete", "-f", crd_file, "--ignore-not-found", "--wait=false"]) + + try: + with open(crd_file, "r") as f: + for doc in f.read().split("---"): + if not doc.strip(): + continue + crd_name = yaml.safe_load(doc)["metadata"]["name"] + run_cmd( + [ + "patch", + "crd", + crd_name, + "--type=merge", + "-p", + '{"metadata":{"finalizers":[]}}', + ] + ) + run_cmd(["wait", "--for=delete", "crd", crd_name, "--timeout=60s"]) + except (FileNotFoundError, yaml.YAMLError, KeyError, TypeError) as e: + logger.debug(f"CRD cleanup failed (continuing): {e}") + + logger.info("Cleaning up test environment") + + commands = [ + ["delete", "psmdb-backup", "--all", "--ignore-not-found"], + [ + "delete", + "-f", + f"{test_paths['test_dir']}/../conf/container-rc.yaml", + "--ignore-not-found", + ], + [ + "delete", + "-f", + f"{test_paths['src_dir']}/deploy/{'cw-' if os.environ.get('OPERATOR_NS') else ''}rbac.yaml", + "--ignore-not-found", + ], + ] + + with ThreadPoolExecutor(max_workers=3) as executor: + futures = [executor.submit(run_cmd, cmd) for cmd in commands] + futures.append(executor.submit(cleanup_crd)) + + # Clean up all created namespaces + namespaces_to_delete = created_namespaces.copy() + operator_ns = os.environ.get("OPERATOR_NS") + if operator_ns: + namespaces_to_delete.append(operator_ns) + + for ns in namespaces_to_delete: + run_cmd(["delete", "--grace-period=0", "--force", "namespace", ns, "--ignore-not-found"]) + + +@pytest.fixture(scope="class") +def deploy_chaos_mesh() -> Generator[Callable[[str], None], None, None]: + """Deploy Chaos Mesh and clean up after tests.""" + deployed_namespaces = [] + + def _deploy(namespace: str) -> None: + subprocess.run( + ["helm", "repo", "add", "chaos-mesh", "https://charts.chaos-mesh.org"], check=True + ) + subprocess.run(["helm", "repo", "update"], check=True) + subprocess.run( + [ + "helm", + "install", + "chaos-mesh", + "chaos-mesh/chaos-mesh", + "--namespace", + namespace, + "--version", + os.environ["CHAOS_MESH_VER"], + "--set", + "dashboard.create=false", + "--set", + "chaosDaemon.runtime=containerd", + "--set", + "chaosDaemon.socketPath=/run/containerd/containerd.sock", + "--wait", + ], + check=True, + ) + deployed_namespaces.append(namespace) + + yield _deploy + + for ns in deployed_namespaces: + try: + subprocess.run( + [ + "helm", + "uninstall", + "chaos-mesh", + "--namespace", + ns, + "--wait", + "--timeout", + "60s", + ], + check=True, + ) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to cleanup chaos-mesh in {ns}: {e}") + + +@pytest.fixture(scope="class") +def deploy_cert_manager() -> Generator[None, None, None]: + """Deploy Cert Manager and clean up after tests.""" + logger.info("Deploying cert-manager") + cert_manager_url = f"https://github.com/cert-manager/cert-manager/releases/download/v{os.environ.get('CERT_MANAGER_VER')}/cert-manager.yaml" + try: + kubectl_bin("create", "namespace", "cert-manager") + kubectl_bin( + "label", "namespace", "cert-manager", "certmanager.k8s.io/disable-validation=true" + ) + kubectl_bin("apply", "-f", cert_manager_url, "--validate=false") + kubectl_bin( + "wait", + "pod", + "-l", + "app.kubernetes.io/instance=cert-manager", + "--for=condition=ready", + "-n", + "cert-manager", + ) + except Exception as e: + try: + kubectl_bin("delete", "-f", cert_manager_url, "--ignore-not-found") + except (subprocess.CalledProcessError, FileNotFoundError, OSError) as cleanup_error: + logger.warning( + f"Failed to cleanup cert-manager during error handling: {cleanup_error}" + ) + raise e + + yield + + try: + kubectl_bin("delete", "-f", cert_manager_url, "--ignore-not-found") + except Exception as e: + logger.error(f"Failed to cleanup cert-manager: {e}") + + +@pytest.fixture(scope="class") +def deploy_minio() -> Generator[None, None, None]: + """Deploy MinIO and clean up after tests.""" + service_name = "minio-service" + bucket = "operator-testing" + + logger.info(f"Installing MinIO: {service_name}") + + subprocess.run(["helm", "uninstall", service_name], capture_output=True, check=False) + subprocess.run(["helm", "repo", "remove", "minio"], capture_output=True, check=False) + subprocess.run(["helm", "repo", "add", "minio", "https://charts.min.io/"], check=True) + + endpoint = f"http://{service_name}:9000" + minio_ver = os.environ.get("MINIO_VER") or "" + minio_args = [ + "helm", + "install", + service_name, + "minio/minio", + "--version", + minio_ver, + "--set", + "replicas=1", + "--set", + "mode=standalone", + "--set", + "resources.requests.memory=256Mi", + "--set", + "rootUser=rootuser", + "--set", + "rootPassword=rootpass123", + "--set", + "users[0].accessKey=some-access-key", + "--set", + "users[0].secretKey=some-secret-key", + "--set", + "users[0].policy=consoleAdmin", + "--set", + "service.type=ClusterIP", + "--set", + "configPathmc=/tmp/", + "--set", + "securityContext.enabled=false", + "--set", + "persistence.size=2G", + "--set", + f"fullnameOverride={service_name}", + "--set", + "serviceAccount.create=true", + "--set", + f"serviceAccount.name={service_name}-sa", + ] + + retry(lambda: subprocess.run(minio_args, check=True), max_attempts=10, delay=60) + + minio_pod = kubectl_bin( + "get", + "pods", + f"--selector=release={service_name}", + "-o", + "jsonpath={.items[].metadata.name}", + ).strip() + wait_pod(minio_pod) + + operator_ns = os.environ.get("OPERATOR_NS") + if operator_ns: + namespace = kubectl_bin( + "config", "view", "--minify", "-o", "jsonpath={..namespace}" + ).strip() + kubectl_bin( + "create", + "svc", + "-n", + operator_ns, + "externalname", + service_name, + f"--external-name={service_name}.{namespace}.svc.cluster.local", + "--tcp=9000", + ) + + logger.info(f"Creating MinIO bucket: {bucket}") + kubectl_bin( + "run", + "-i", + "--rm", + "aws-cli", + "--image=perconalab/awscli", + "--restart=Never", + "--", + "bash", + "-c", + "AWS_ACCESS_KEY_ID=some-access-key " + "AWS_SECRET_ACCESS_KEY=some-secret-key " + "AWS_DEFAULT_REGION=us-east-1 " + f"/usr/bin/aws --no-verify-ssl --endpoint-url {endpoint} s3 mb s3://{bucket}", + ) + + yield + + try: + subprocess.run( + ["helm", "uninstall", service_name, "--wait", "--timeout", "60s"], + check=True, + ) + except subprocess.CalledProcessError as e: + logger.warning(f"Failed to cleanup minio: {e}") + + +@pytest.fixture(scope="class") +def psmdb_client(test_paths: Dict[str, str]) -> MongoManager: + """Deploy and get the client pod name.""" + kubectl_bin("apply", "-f", f"{test_paths['conf_dir']}/client-70.yml") + + result = retry( + lambda: kubectl_bin( + "get", + "pods", + "--selector=name=psmdb-client-mongosh", + "-o", + "jsonpath={range .items[*]}{.metadata.name}{end}", + ), + max_attempts=30, + delay=2, + condition=lambda result: result.strip() != "", + ) + + pod_name = result.strip() + wait_pod(pod_name) + return MongoManager(pod_name) diff --git a/e2e-tests/demand-backup-physical-sharded-oss-s3/compare/statefulset_some-name-rs0_restore_sharded-oc.yml b/e2e-tests/demand-backup-physical-sharded-oss-s3/compare/statefulset_some-name-rs0_restore_sharded-oc.yml index 8c2f92ca51..2ebba74b53 100644 --- a/e2e-tests/demand-backup-physical-sharded-oss-s3/compare/statefulset_some-name-rs0_restore_sharded-oc.yml +++ b/e2e-tests/demand-backup-physical-sharded-oss-s3/compare/statefulset_some-name-rs0_restore_sharded-oc.yml @@ -213,7 +213,7 @@ spec: securityContext: {} serviceAccount: default serviceAccountName: default - terminationGracePeriodSeconds: 60 + terminationGracePeriodSeconds: 30 volumes: - emptyDir: {} name: bin diff --git a/e2e-tests/finalizer/test_finalizer.py b/e2e-tests/finalizer/test_finalizer.py new file mode 100644 index 0000000000..227ca804c9 --- /dev/null +++ b/e2e-tests/finalizer/test_finalizer.py @@ -0,0 +1,70 @@ +import logging +from typing import Callable, Dict, TypedDict + +import pytest +from lib.config import apply_cluster +from lib.kubectl import kubectl_bin, wait_cluster_consistency, wait_for_delete, wait_for_running +from lib.mongo import MongoManager + +logger = logging.getLogger(__name__) + + +class FinalizerConfig(TypedDict): + namespace: str + cluster: str + + +@pytest.fixture(scope="class", autouse=True) +def config(create_infra: Callable[[str], str]) -> FinalizerConfig: + """Configuration for tests""" + return { + "namespace": create_infra("finalizer"), + "cluster": "some-name", + } + + +@pytest.fixture(scope="class", autouse=True) +def setup_tests(test_paths: Dict[str, str]) -> None: + """Setup test environment""" + kubectl_bin("apply", "-f", f"{test_paths['conf_dir']}/secrets_with_tls.yml") + + +class TestFinalizer: + """Test MongoDB cluster finalizers""" + + @pytest.mark.dependency() + def test_create_cluster(self, config: FinalizerConfig, test_paths: Dict[str, str]) -> None: + apply_cluster(f"{test_paths['test_dir']}/conf/{config['cluster']}.yml") + wait_for_running(f"{config['cluster']}-rs0", 3, False) + wait_for_running(f"{config['cluster']}-cfg", 3) + wait_cluster_consistency(config["cluster"]) + + @pytest.mark.dependency(depends=["TestFinalizer::test_create_cluster"]) + def test_kill_primary_should_elect_new_one( + self, config: FinalizerConfig, psmdb_client: MongoManager + ) -> None: + primary = psmdb_client.get_mongo_primary( + f"clusterAdmin:clusterAdmin123456@{config['cluster']}-rs0.{config['namespace']}", + config["cluster"], + ) + if primary == f"{config['cluster']}-rs0-0": + kubectl_bin("delete", "pod", "--grace-period=0", "--force", primary) + wait_for_running(f"{config['cluster']}-rs0", 3) + wait_cluster_consistency(config["cluster"]) + new_primary = psmdb_client.get_mongo_primary( + f"clusterAdmin:clusterAdmin123456@{config['cluster']}-rs0.{config['namespace']}", + config["cluster"], + ) + assert new_primary != f"{config['cluster']}-rs0-0", "Primary is the same as before" + + @pytest.mark.dependency(depends=["TestFinalizer::test_kill_primary_should_elect_new_one"]) + def test_delete_cluster(self, config: FinalizerConfig) -> None: + kubectl_bin("delete", "psmdb", config["cluster"], "--wait=false") + wait_for_delete(f"psmdb/{config['cluster']}") + + wait_for_delete(f"pvc/mongod-data-{config['cluster']}-cfg-0") + wait_for_delete(f"pvc/mongod-data-{config['cluster']}-cfg-1") + wait_for_delete(f"pvc/mongod-data-{config['cluster']}-cfg-2") + wait_for_delete(f"pvc/mongod-data-{config['cluster']}-rs0-0") + wait_for_delete(f"pvc/mongod-data-{config['cluster']}-rs0-1") + wait_for_delete(f"pvc/mongod-data-{config['cluster']}-rs0-2") diff --git a/e2e-tests/functions b/e2e-tests/functions index 57e62278a8..9c4c93226b 100755 --- a/e2e-tests/functions +++ b/e2e-tests/functions @@ -2309,6 +2309,8 @@ setup_azure_credentials() { create_infra() { local ns="$1" + echo "$ns" > /tmp/pytest_current_namespace + if [[ ${DELETE_CRD_ON_START} == 1 ]]; then delete_crd check_crd_for_deletion "${GIT_BRANCH}" diff --git a/e2e-tests/init-deploy/compare/find-1.json b/e2e-tests/init-deploy/compare/find-1.json new file mode 100644 index 0000000000..2d84f1ffee --- /dev/null +++ b/e2e-tests/init-deploy/compare/find-1.json @@ -0,0 +1 @@ +[ { "x": 100500 } ] \ No newline at end of file diff --git a/e2e-tests/init-deploy/compare/find-2.json b/e2e-tests/init-deploy/compare/find-2.json new file mode 100644 index 0000000000..38993741e3 --- /dev/null +++ b/e2e-tests/init-deploy/compare/find-2.json @@ -0,0 +1 @@ +[ { "x": 100500 }, { "x" : 100501 } ] \ No newline at end of file diff --git a/e2e-tests/init-deploy/compare/find-3.json b/e2e-tests/init-deploy/compare/find-3.json new file mode 100644 index 0000000000..665e842fe7 --- /dev/null +++ b/e2e-tests/init-deploy/compare/find-3.json @@ -0,0 +1 @@ +[ { "x": 100502 } ] \ No newline at end of file diff --git a/e2e-tests/init-deploy/compare/statefulset_another-name-rs0-oc.yml b/e2e-tests/init-deploy/compare/statefulset_another-name-rs0-oc.yml index 69610fa1a2..220c4abd7b 100644 --- a/e2e-tests/init-deploy/compare/statefulset_another-name-rs0-oc.yml +++ b/e2e-tests/init-deploy/compare/statefulset_another-name-rs0-oc.yml @@ -292,7 +292,7 @@ spec: securityContext: {} serviceAccount: default serviceAccountName: default - terminationGracePeriodSeconds: 300 + terminationGracePeriodSeconds: 30 volumes: - name: another-name-mongodb-keyfile secret: diff --git a/e2e-tests/init-deploy/compare/statefulset_another-name-rs0.yml b/e2e-tests/init-deploy/compare/statefulset_another-name-rs0.yml index d65ed7f6f7..f3aa0c91d5 100644 --- a/e2e-tests/init-deploy/compare/statefulset_another-name-rs0.yml +++ b/e2e-tests/init-deploy/compare/statefulset_another-name-rs0.yml @@ -295,7 +295,7 @@ spec: fsGroup: 1001 serviceAccount: default serviceAccountName: default - terminationGracePeriodSeconds: 300 + terminationGracePeriodSeconds: 30 volumes: - name: another-name-mongodb-keyfile secret: diff --git a/e2e-tests/init-deploy/conf/another-name-rs0.yml b/e2e-tests/init-deploy/conf/another-name-rs0.yml index 88609a378d..920e0a30eb 100644 --- a/e2e-tests/init-deploy/conf/another-name-rs0.yml +++ b/e2e-tests/init-deploy/conf/another-name-rs0.yml @@ -30,7 +30,7 @@ spec: insecureSkipTLSVerify: false replsets: - name: rs0 - terminationGracePeriodSeconds: 300 + terminationGracePeriodSeconds: 30 configuration: | operationProfiling: mode: slowOp diff --git a/e2e-tests/init-deploy/test_init_deploy.py b/e2e-tests/init-deploy/test_init_deploy.py new file mode 100644 index 0000000000..ff66fa1403 --- /dev/null +++ b/e2e-tests/init-deploy/test_init_deploy.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 + +import logging +import time +from typing import Callable, Dict, TypedDict + +import pytest +from lib.config import apply_cluster, apply_runtime_class, compare_kubectl +from lib.kubectl import kubectl_bin, wait_for_running +from lib.mongo import MongoManager +from lib.secrets import apply_s3_storage_secrets, get_user_data +from lib.utils import retry + +logger = logging.getLogger(__name__) + + +class InitDeployConfig(TypedDict): + namespace: str + cluster: str + cluster2: str + max_conn: int + + +@pytest.fixture(scope="class", autouse=True) +def config(create_infra: Callable[[str], str]) -> InitDeployConfig: + """Configuration for tests""" + return { + "namespace": create_infra("init-deploy"), + "cluster": "some-name-rs0", + "cluster2": "another-name-rs0", + "max_conn": 17, + } + + +@pytest.fixture(scope="class", autouse=True) +def setup_tests(test_paths: Dict[str, str]) -> None: + """Setup test environment""" + kubectl_bin("apply", "-f", f"{test_paths['conf_dir']}/secrets_with_tls.yml") + apply_runtime_class(test_paths["test_dir"]) + + +class TestInitDeploy: + """Test MongoDB cluster deployment and operations""" + + @pytest.mark.dependency() + def test_create_first_cluster( + self, config: InitDeployConfig, test_paths: Dict[str, str] + ) -> None: + """Create first PSMDB cluster""" + apply_cluster(f"{test_paths['test_dir']}/../conf/{config['cluster']}.yml") + wait_for_running(config["cluster"], 3) + + compare_kubectl( + test_paths["test_dir"], f"statefulset/{config['cluster']}", config["namespace"] + ) + compare_kubectl( + test_paths["test_dir"], f"service/{config['cluster']}", config["namespace"] + ) + + @pytest.mark.dependency(depends=["TestInitDeploy::test_create_first_cluster"]) + def test_verify_users_created( + self, + config: InitDeployConfig, + test_paths: Dict[str, str], + psmdb_client: MongoManager, + ) -> None: + """Check if users created with correct permissions""" + secret_name = "some-users" + + # Test userAdmin user + user = get_user_data(secret_name, "MONGODB_USER_ADMIN_USER") + password = get_user_data(secret_name, "MONGODB_USER_ADMIN_PASSWORD") + psmdb_client.compare_mongo_user( + f"{user}:{password}@{config['cluster']}.{config['namespace']}", + "userAdmin", + test_paths["test_dir"], + ) + + # Test backup user + user = get_user_data(secret_name, "MONGODB_BACKUP_USER") + password = get_user_data(secret_name, "MONGODB_BACKUP_PASSWORD") + psmdb_client.compare_mongo_user( + f"{user}:{password}@{config['cluster']}.{config['namespace']}", + "backup", + test_paths["test_dir"], + ) + + # Test clusterAdmin user + user = get_user_data(secret_name, "MONGODB_CLUSTER_ADMIN_USER") + password = get_user_data(secret_name, "MONGODB_CLUSTER_ADMIN_PASSWORD") + psmdb_client.compare_mongo_user( + f"{user}:{password}@{config['cluster']}.{config['namespace']}", + "clusterAdmin", + test_paths["test_dir"], + ) + + # Test clusterMonitor user + user = get_user_data(secret_name, "MONGODB_CLUSTER_MONITOR_USER") + password = get_user_data(secret_name, "MONGODB_CLUSTER_MONITOR_PASSWORD") + psmdb_client.compare_mongo_user( + f"{user}:{password}@{config['cluster']}.{config['namespace']}", + "clusterMonitor", + test_paths["test_dir"], + ) + + # Test that unauthorized user is rejected + result = psmdb_client.run_mongosh( + "db.runCommand({connectionStatus:1,showPrivileges:true})", + f"test:test@{config['cluster']}.{config['namespace']}", + check=False, + ) + assert "Authentication failed" in result + + @pytest.mark.dependency(depends=["TestInitDeploy::test_verify_users_created"]) + def test_write_and_read_data( + self, + config: InitDeployConfig, + test_paths: Dict[str, str], + psmdb_client: MongoManager, + ) -> None: + """Write data and read from all nodes""" + + psmdb_client.run_mongosh( + 'db.createUser({user:"myApp",pwd:"myPass",roles:[{db:"myApp",role:"readWrite"}]})', + f"userAdmin:userAdmin123456@{config['cluster']}.{config['namespace']}", + ) + + retry( + lambda: psmdb_client.run_mongosh( + "db.getSiblingDB('myApp').test.insertOne({ x: 100500 })", + f"myApp:myPass@{config['cluster']}.{config['namespace']}", + ), + condition=lambda result: "acknowledged: true" in result, + ) + + for i in range(3): + psmdb_client.compare_mongo_cmd( + "find({}, { _id: 0 }).toArray()", + f"myApp:myPass@{config['cluster']}-{i}.{config['cluster']}.{config['namespace']}", + test_file=f"{test_paths['test_dir']}/compare/find-1.json", + ) + + @pytest.mark.dependency(depends=["TestInitDeploy::test_write_and_read_data"]) + def test_connection_count(self, config: InitDeployConfig, psmdb_client: MongoManager) -> None: + """Check number of connections doesn't exceed maximum""" + conn_count = int( + psmdb_client.run_mongosh( + "db.serverStatus().connections.current", + f"clusterAdmin:clusterAdmin123456@{config['cluster']}.{config['namespace']}", + ).strip() + ) + assert conn_count <= config["max_conn"], ( + f"Connection count {conn_count} exceeds maximum {config['max_conn']}" + ) + + @pytest.mark.dependency(depends=["TestInitDeploy::test_connection_count"]) + def test_primary_failover( + self, + config: InitDeployConfig, + test_paths: Dict[str, str], + psmdb_client: MongoManager, + ) -> None: + """Kill Primary Pod, check reelection, check data""" + initial_primary = psmdb_client.get_mongo_primary( + f"clusterAdmin:clusterAdmin123456@{config['cluster']}.{config['namespace']}", + config["cluster"], + ) + assert initial_primary, "Failed to get initial primary" + + kubectl_bin( + "delete", + "pods", + "--grace-period=0", + "--force", + initial_primary, + "-n", + config["namespace"], + ) + wait_for_running(config["cluster"], 3) + + changed_primary = psmdb_client.get_mongo_primary( + f"clusterAdmin:clusterAdmin123456@{config['cluster']}.{config['namespace']}", + config["cluster"], + ) + assert initial_primary != changed_primary, "Primary didn't change after pod deletion" + + psmdb_client.run_mongosh( + "db.getSiblingDB('myApp').test.insertOne({ x: 100501 })", + f"myApp:myPass@{config['cluster']}.{config['namespace']}", + ) + + for i in range(3): + psmdb_client.compare_mongo_cmd( + "find({}, { _id: 0 }).toArray()", + f"myApp:myPass@{config['cluster']}-{i}.{config['cluster']}.{config['namespace']}", + test_file=f"{test_paths['test_dir']}/compare/find-2.json", + ) + + @pytest.mark.dependency(depends=["TestInitDeploy::test_primary_failover"]) + def test_create_second_cluster( + self, config: InitDeployConfig, test_paths: Dict[str, str] + ) -> None: + """Check if possible to create second cluster""" + apply_s3_storage_secrets(test_paths["conf_dir"]) + apply_cluster(f"{test_paths['test_dir']}/conf/{config['cluster2']}.yml") + wait_for_running(config["cluster2"], 3) + + compare_kubectl( + test_paths["test_dir"], f"statefulset/{config['cluster2']}", config["namespace"] + ) + compare_kubectl( + test_paths["test_dir"], f"service/{config['cluster2']}", config["namespace"] + ) + + @pytest.mark.dependency(depends=["TestInitDeploy::test_create_second_cluster"]) + def test_second_cluster_data_operations( + self, + config: InitDeployConfig, + test_paths: Dict[str, str], + psmdb_client: MongoManager, + ) -> None: + """Write data and read from all nodes in second cluster""" + # Create user + psmdb_client.run_mongosh( + 'db.createUser({user:"myApp",pwd:"myPass",roles:[{db:"myApp",role:"readWrite"}]})', + f"userAdmin:userAdmin123456@{config['cluster2']}.{config['namespace']}", + ) + + # Write data + psmdb_client.run_mongosh( + "db.getSiblingDB('myApp').test.insertOne({ x: 100502 })", + f"myApp:myPass@{config['cluster2']}.{config['namespace']}", + ) + + # Read from all nodes + for i in range(3): + psmdb_client.compare_mongo_cmd( + "find({}, { _id: 0 }).toArray()", + f"myApp:myPass@{config['cluster2']}-{i}.{config['cluster2']}.{config['namespace']}", + test_file=f"{test_paths['test_dir']}/compare/find-3.json", + ) + + @pytest.mark.dependency(depends=["TestInitDeploy::test_second_cluster_data_operations"]) + def test_connection_count_with_backup( + self, config: InitDeployConfig, psmdb_client: MongoManager + ) -> None: + """Check number of connections doesn't exceed maximum with backup enabled""" + max_conn = 50 + time.sleep(300) # Wait for backup agent connections + + conn_count = int( + psmdb_client.run_mongosh( + "db.serverStatus().connections.current", + f"clusterAdmin:clusterAdmin123456@{config['cluster2']}.{config['namespace']}", + ).strip() + ) + assert conn_count <= max_conn, ( + f"Connection count {conn_count} exceeds maximum {max_conn} with backup enabled" + ) + + @pytest.mark.dependency(depends=["TestInitDeploy::test_connection_count_with_backup"]) + def test_log_files_exist(self, config: InitDeployConfig) -> None: + """Check if mongod log files exist in pod""" + result = kubectl_bin( + "exec", f"{config['cluster2']}-0", "-c", "mongod", "--", "ls", "/data/db/logs" + ) + + assert "mongod.log" in result, "mongod.log not found" + assert "mongod.full.log" in result, "mongod.full.log not found" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/e2e-tests/lib/__init__.py b/e2e-tests/lib/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/e2e-tests/lib/__init__.py @@ -0,0 +1 @@ + diff --git a/e2e-tests/lib/bash_wrapper.py b/e2e-tests/lib/bash_wrapper.py new file mode 100644 index 0000000000..7b4e9e397a --- /dev/null +++ b/e2e-tests/lib/bash_wrapper.py @@ -0,0 +1,30 @@ +import os +import subprocess +from pathlib import Path + +import pytest + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption("--test-name", action="store", help="Name of the bash test to run") + + +def run_bash_test(test_name: str) -> None: + """Run bash script with stdout/stderr inherited from the pytest process.""" + script_path = Path(__file__).parent.parent / test_name / "run" + + if not script_path.exists(): + pytest.fail(f"Script not found: {script_path}") + + original_cwd = os.getcwd() + script_dir = script_path.parent + + try: + os.chdir(script_dir) + result = subprocess.run(["bash", "run"]) + + if result.returncode != 0: + pytest.fail(f"Test {test_name} failed with exit code {result.returncode}") + + finally: + os.chdir(original_cwd) diff --git a/e2e-tests/lib/config.py b/e2e-tests/lib/config.py new file mode 100644 index 0000000000..cec56c19ab --- /dev/null +++ b/e2e-tests/lib/config.py @@ -0,0 +1,176 @@ +import logging +import os +import subprocess +from pathlib import Path + +import yaml +from deepdiff import DeepDiff + +from .kubectl import kubectl_bin + +logger = logging.getLogger(__name__) + + +def cat_config(config_file: str) -> str: + """Process config file with yq transformations""" + with open(config_file, "r") as f: + config = yaml.safe_load(f) + + if "spec" in config: + spec = config["spec"] + + if "image" not in spec or spec["image"] is None: + spec["image"] = os.environ.get("IMAGE_MONGOD") + + if "pmm" in spec: + spec["pmm"]["image"] = os.environ.get("IMAGE_PMM_CLIENT") + + if "initImage" in spec: + spec["initImage"] = os.environ.get("IMAGE") + + if "backup" in spec: + spec["backup"]["image"] = os.environ.get("IMAGE_BACKUP") + + if "upgradeOptions" not in spec: + spec["upgradeOptions"] = {} + spec["upgradeOptions"]["apply"] = "Never" + + return yaml.dump(config) + + +def apply_cluster(config_file: str) -> None: + """Apply cluster configuration""" + logger.info("Creating PSMDB cluster") + config_yaml = cat_config(config_file) + + if not os.environ.get("SKIP_BACKUPS_TO_AWS_GCP_AZURE"): + kubectl_bin("apply", "-f", "-", input_data=config_yaml) + else: + config = yaml.safe_load(config_yaml) + if "spec" in config and "backup" in config["spec"] and "tasks" in config["spec"]["backup"]: + config["spec"]["backup"]["tasks"] = config["spec"]["backup"]["tasks"][:1] + kubectl_bin("apply", "-f", "-", input_data=yaml.dump(config)) + + +def filter_yaml( + yaml_content: str, namespace: str, resource: str = "", skip_generation_check: bool = False +) -> str: + """Filter YAML content using yq command""" + + # TODO: consider using Python for filtering instead of yq + yq_filter = f""" + del(.metadata.ownerReferences[].apiVersion) | + del(.metadata.managedFields) | + del(.. | select(has("creationTimestamp")).creationTimestamp) | + del(.. | select(has("namespace")).namespace) | + del(.. | select(has("uid")).uid) | + del(.metadata.resourceVersion) | + del(.spec.template.spec.containers[].env[] | select(.name == "NAMESPACE")) | + del(.metadata.selfLink) | + del(.metadata.annotations."cloud.google.com/neg") | + del(.metadata.annotations."kubectl.kubernetes.io/last-applied-configuration") | + del(.. | select(has("image")).image) | + del(.. | select(has("clusterIP")).clusterIP) | + del(.. | select(has("clusterIPs")).clusterIPs) | + del(.. | select(has("dataSource")).dataSource) | + del(.. | select(has("procMount")).procMount) | + del(.. | select(has("storageClassName")).storageClassName) | + del(.. | select(has("finalizers")).finalizers) | + del(.. | select(has("kubernetes.io/pvc-protection"))."kubernetes.io/pvc-protection") | + del(.. | select(has("volumeName")).volumeName) | + del(.. | select(has("volume.beta.kubernetes.io/storage-provisioner"))."volume.beta.kubernetes.io/storage-provisioner") | + del(.. | select(has("volume.kubernetes.io/storage-provisioner"))."volume.kubernetes.io/storage-provisioner") | + del(.spec.volumeMode) | + del(.. | select(has("volume.kubernetes.io/selected-node"))."volume.kubernetes.io/selected-node") | + del(.. | select(has("percona.com/last-config-hash"))."percona.com/last-config-hash") | + del(.. | select(has("percona.com/configuration-hash"))."percona.com/configuration-hash") | + del(.. | select(has("percona.com/ssl-hash"))."percona.com/ssl-hash") | + del(.. | select(has("percona.com/ssl-internal-hash"))."percona.com/ssl-internal-hash") | + del(.spec.volumeClaimTemplates[].spec.volumeMode | select(. == "Filesystem")) | + del(.. | select(has("healthCheckNodePort")).healthCheckNodePort) | + del(.. | select(has("nodePort")).nodePort) | + del(.status) | + (.. | select(tag == "!!str")) |= sub("{namespace}"; "NAME_SPACE") | + del(.spec.volumeClaimTemplates[].apiVersion) | + del(.spec.volumeClaimTemplates[].kind) | + del(.spec.ipFamilies) | + del(.spec.ipFamilyPolicy) | + del(.spec.persistentVolumeClaimRetentionPolicy) | + del(.spec.internalTrafficPolicy) | + del(.spec.allocateLoadBalancerNodePorts) | + (.. | select(. == "extensions/v1beta1")) = "apps/v1" | + (.. | select(. == "batch/v1beta1")) = "batch/v1" + """ + + cmd = ["yq", "eval", yq_filter.strip(), "-"] + result = subprocess.run(cmd, input=yaml_content, text=True, capture_output=True, check=True) + filtered_yaml = result.stdout + + if "cronjob" in resource.lower() or skip_generation_check: + cmd = ["yq", "eval", "del(.metadata.generation)", "-"] + result = subprocess.run( + cmd, input=filtered_yaml, text=True, capture_output=True, check=True + ) + filtered_yaml = result.stdout + + return filtered_yaml + + +def _expected_compare_path(test_dir: str, resource: str, postfix: str = "") -> Path: + expected_path = Path(test_dir) / "compare" / f"{resource.replace('/', '_')}{postfix}.yml" + if not os.environ.get("OPENSHIFT"): + return expected_path + + oc_path = expected_path.with_name(f"{expected_path.stem}-oc{expected_path.suffix}") + if not oc_path.exists(): + return expected_path + + oc4_path = expected_path.with_name(f"{expected_path.stem}-4-oc{expected_path.suffix}") + if os.environ.get("OPENSHIFT") == "4" and oc4_path.exists(): + return oc4_path + + return oc_path + + +def compare_kubectl( + test_dir: str, + resource: str, + namespace: str, + postfix: str = "", + skip_generation_check: bool = False, +) -> None: + """Compare kubectl resource with expected output using yq filtering""" + expected_result = _expected_compare_path(test_dir, resource, postfix) + + try: + actual_yaml = kubectl_bin("get", resource, "-o", "yaml") + expected_yaml = expected_result.read_text() + + filtered_actual = filter_yaml(actual_yaml, namespace, resource, skip_generation_check) + filtered_expected = filter_yaml(expected_yaml, namespace, resource, skip_generation_check) + + if os.environ.get("UPDATE_COMPARE_FILES") == "1": + expected_result.write_text(filtered_actual) + return + + actual_data = yaml.safe_load(filtered_actual) + expected_data = yaml.safe_load(filtered_expected) + + diff = DeepDiff(expected_data, actual_data) + assert not diff, f"YAML files differ: {diff.pretty()}" + + except subprocess.CalledProcessError as e: + raise ValueError(f"Failed to process resource {resource}: {e}") + + +def apply_runtime_class(test_dir: str) -> None: + """Create RuntimeClass with the runc handler.""" + kubectl_bin("delete", "runtimeclass", "container-rc", "--ignore-not-found") + + with open(f"{test_dir}/../conf/container-rc.yaml", "r") as f: + config = yaml.safe_load(f) + + handler = "runc" + logger.info(f"Create RuntimeClass with handler {handler}") + config["handler"] = handler + kubectl_bin("apply", "-f", "-", input_data=yaml.dump(config)) diff --git a/e2e-tests/lib/k8s_collector.py b/e2e-tests/lib/k8s_collector.py new file mode 100644 index 0000000000..9932df7fba --- /dev/null +++ b/e2e-tests/lib/k8s_collector.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 + +import logging +import os +import tarfile +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from typing import Dict, List, Optional + +from lib.kubectl import kubectl_bin + +logger = logging.getLogger(__name__) + +REPORTS_DIR = os.path.join(os.path.dirname(__file__), "..", "reports") + + +class K8sCollector: + def __init__(self, namespace: str, custom_resources: Optional[List[str]] = None): + self.namespace = namespace + self.custom_resources = custom_resources or [] + self.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.output_dir = os.path.join(REPORTS_DIR, f"{namespace}_{self.timestamp}") + + def kubectl(self, *args: str) -> str: + """Run kubectl command and return stdout""" + return kubectl_bin(*args, check=False, return_stderr=True) + + def kubectl_ns(self, *args: str) -> str: + """Run kubectl command with namespace flag""" + return kubectl_bin(*args, "-n", self.namespace, check=False, return_stderr=True) + + def get_names(self, resource_type: str) -> List[str]: + """Get list of resource names""" + output = self.kubectl_ns("get", resource_type, "-o", "name") + return [line.split("/")[-1] for line in output.strip().split("\n") if line] + + def save(self, path: str, content: str) -> None: + """Save content to file, creating directories as needed""" + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(content) + + def process_resource(self, resource_type: str) -> None: + """Process a resource type: get list, describe each, get yaml""" + logger.debug(f"Processing {resource_type}...") + base = f"{self.output_dir}/get/{resource_type}" + + self.save( + f"{base}/{resource_type}.txt", self.kubectl_ns("get", resource_type, "-o", "wide") + ) + + for name in self.get_names(resource_type): + self.save( + f"{self.output_dir}/describe/{resource_type}_{name}.txt", + self.kubectl_ns("describe", resource_type, name), + ) + self.save( + f"{base}/{name}.yaml", self.kubectl_ns("get", resource_type, name, "-o", "yaml") + ) + + def extract_pod_logs(self, pod: str) -> None: + """Extract logs for all containers in a pod""" + containers = self.kubectl_ns( + "get", "pod", pod, "-o", "jsonpath={.spec.containers[*].name}" + ).split() + + for container in containers: + logger.debug(f"Extracting logs: {pod}/{container}") + logs = self.kubectl_ns("logs", pod, "-c", container) + self.save(f"{self.output_dir}/logs/{pod}/{container}.log", logs) + + def process_pods(self) -> None: + """Process pods with parallel log extraction""" + logger.debug("Processing pods...") + base = f"{self.output_dir}/get/pods" + + self.save(f"{base}/pods.txt", self.kubectl_ns("get", "pods", "-o", "wide")) + + pods = self.get_names("pods") + for pod in pods: + self.save( + f"{self.output_dir}/describe/pod_{pod}.txt", + self.kubectl_ns("describe", "pod", pod), + ) + self.save(f"{base}/{pod}.yaml", self.kubectl_ns("get", "pod", pod, "-o", "yaml")) + + with ThreadPoolExecutor(max_workers=5) as executor: + list(executor.map(self.extract_pod_logs, pods)) + + def extract_events(self) -> None: + """Extract namespace events""" + logger.debug("Extracting events...") + self.save( + f"{self.output_dir}/events/events.txt", self.kubectl_ns("get", "events", "-o", "wide") + ) + self.save( + f"{self.output_dir}/events/events.json", self.kubectl_ns("get", "events", "-o", "json") + ) + + def extract_errors(self) -> None: + """Extract error lines from all logs into summary""" + logs_dir = f"{self.output_dir}/logs" + if not os.path.exists(logs_dir): + return + + errors = [] + for root, _, files in os.walk(logs_dir): + for file in files: + if not file.endswith(".log"): + continue + path = os.path.join(root, file) + with open(path) as f: + error_lines = [line for line in f if "error" in line.lower()] + if error_lines: + rel_path = os.path.relpath(path, logs_dir) + errors.append(f"=== {rel_path} ===\n" + "".join(error_lines)) + + if errors: + self.save( + f"{self.output_dir}/error_summary.log", + f"Errors for {self.namespace} ({self.timestamp})\n{'=' * 50}\n\n" + + "\n\n".join(errors), + ) + + def capture_summary(self) -> Dict[str, str]: + """Capture simplified resources for HTML report. Returns dict with resources, logs, events.""" + sections = [] + + sections.append("=== Nodes ===") + sections.append(self.kubectl("get", "nodes") or "(no output)") + sections.append("") + + sections.append(f"=== All from namespace {self.namespace} ===") + sections.append(self.kubectl_ns("get", "all") or "(no output)") + sections.append("") + + sections.append("=== Secrets ===") + sections.append(self.kubectl_ns("get", "secrets") or "(no output)") + sections.append("") + + sections.append("=== PSMDB Cluster ===") + sections.append( + self.kubectl_ns( + "get", "psmdb", "-o", "custom-columns=NAME:.metadata.name,STATE:.status.state" + ) + or "(no output)" + ) + sections.append("") + + sections.append("=== PSMDB Backup ===") + sections.append(self.kubectl_ns("get", "psmdb-backup") or "(no output)") + sections.append("") + + sections.append("=== PSMDB Restore ===") + sections.append(self.kubectl_ns("get", "psmdb-restore") or "(no output)") + + logs = self.kubectl_ns( + "logs", "-l", "app.kubernetes.io/name=percona-server-mongodb-operator", "--tail=50" + ) + + events = self.kubectl_ns("get", "events", "--sort-by=.lastTimestamp") + + return { + "resources": "\n".join(sections), + "logs": f"=== PSMDB Operator Logs ===\n{logs or '(no output)'}", + "events": f"=== Kubernetes Events ===\n{events or '(no output)'}", + } + + def collect_all(self) -> None: + """Main collection method""" + logger.info(f"Collecting from namespace: {self.namespace}") + + self.process_pods() + + resources = ["statefulsets", "deployments", "secrets", "jobs", "configmaps", "services"] + resources.extend(r for r in self.custom_resources if r) + + with ThreadPoolExecutor(max_workers=6) as executor: + futures = [executor.submit(self.process_resource, r) for r in resources] + futures.append(executor.submit(self.extract_events)) + for f in as_completed(futures): + try: + f.result() + except Exception as e: + logger.error(f"Error: {e}") + + self.extract_errors() + logger.info(f"Done. Output: {self.output_dir}") + + +def collect_resources( + namespace: str, custom_resources: Optional[List[str]] = None, output_dir: Optional[str] = None +) -> None: + """Collect Kubernetes resources for a given namespace.""" + collector = K8sCollector(namespace, custom_resources) + if output_dir: + collector.output_dir = os.path.join(REPORTS_DIR, f"{output_dir}_{collector.timestamp}") + + os.makedirs(REPORTS_DIR, exist_ok=True) + + try: + collector.collect_all() + with tarfile.open(f"{collector.output_dir}.tar.gz", "w:gz") as tar: + tar.add(collector.output_dir, arcname=os.path.basename(collector.output_dir)) + except Exception as e: + logger.error(f"Error collecting from {namespace}: {e}") diff --git a/e2e-tests/lib/kubectl.py b/e2e-tests/lib/kubectl.py new file mode 100644 index 0000000000..9f5e6d20fb --- /dev/null +++ b/e2e-tests/lib/kubectl.py @@ -0,0 +1,216 @@ +import json +import logging +import subprocess +import time + +logger = logging.getLogger(__name__) + + +def kubectl_bin( + *args: str, check: bool = True, input_data: str = "", return_stderr: bool = False +) -> str: + """Execute kubectl command""" + cmd = ["kubectl"] + list(args) + logger.debug(" ".join(map(str, cmd))) + result = subprocess.run(cmd, capture_output=True, text=True, input=input_data) + + if result.stderr: + logger.warning(f"kubectl error: {result.stderr}") + + if check and result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, cmd, output=result.stdout, stderr=result.stderr + ) + + if result.returncode != 0 and return_stderr and not result.stdout: + return result.stderr + + return result.stdout + + +def wait_pod(pod_name: str, timeout: int = 360) -> None: + """Wait for pod to be ready.""" + start_time = time.time() + logger.info(f"Waiting for pod/{pod_name} to be ready...") + while time.time() - start_time < timeout: + try: + result = kubectl_bin( + "get", + "pod", + pod_name, + "-o", + "jsonpath={.status.conditions[?(@.type=='Ready')].status}", + ).strip("'") + if result == "True": + logger.info(f"Pod {pod_name} is ready") + return + except subprocess.CalledProcessError: + pass + time.sleep(1) + + status = ( + kubectl_bin( + "get", + "pod", + pod_name, + "-o", + "jsonpath={.status.phase} (Ready={.status.conditions[?(@.type=='Ready')].status})", + check=False, + return_stderr=True, + ).strip() + or "not found" + ) + raise TimeoutError(f"Timeout waiting for {pod_name} to be ready. Last status: {status}") + + +def wait_for_running( + cluster_name: str, expected_pods: int, check_cluster_readyness: bool = True, timeout: int = 600 +) -> None: + """Wait for pods to be in running state using custom label selector""" + last_pod = expected_pods - 1 + rs_name = cluster_name.split("-")[-1] + + for i in range(last_pod + 1): + if i == last_pod and get_jsonpath(cluster_name, rs_name, "arbiter.enabled") == "true": + wait_pod(f"{cluster_name}-arbiter-0") + else: + wait_pod(f"{cluster_name}-{i}") + + for pod_type, path_prefix in [("nv", "nonvoting"), ("hidden", "hidden")]: + if get_jsonpath(cluster_name, rs_name, f"{path_prefix}.enabled") == "true": + size = get_jsonpath(cluster_name, rs_name, f"{path_prefix}.size") + if size: + for i in range(int(size)): + wait_pod(f"{cluster_name}-{pod_type}-{i}") + + cluster_name = cluster_name.replace(f"-{rs_name}", "") + if check_cluster_readyness: + start_time = time.time() + logger.info(f"Waiting for cluster {cluster_name} readiness") + while time.time() - start_time < timeout: + try: + state = kubectl_bin( + "get", "psmdb", cluster_name, "-o", "jsonpath={.status.state}" + ).strip("'") + if state == "ready": + logger.info(f"Cluster {cluster_name} is ready") + return + except subprocess.CalledProcessError: + pass + time.sleep(1) + + state = ( + kubectl_bin( + "get", + "psmdb", + cluster_name, + "-o", + "jsonpath={.status.state}", + check=False, + return_stderr=True, + ).strip("'") + or "unknown" + ) + raise TimeoutError(f"Timeout waiting for {cluster_name} to be ready. Last state: {state}") + + +def wait_for_delete(resource: str, timeout: int = 180) -> None: + """Wait for a specific resource to be deleted""" + logger.info(f"Waiting for {resource} to be deleted") + time.sleep(1) + try: + kubectl_bin("wait", "--for=delete", resource, f"--timeout={timeout}s") + except subprocess.CalledProcessError as e: + raise TimeoutError(f"Resource {resource} was not deleted within {timeout}s") from e + logger.info(f"{resource} was deleted") + + +def wait_cluster_consistency(cluster_name: str, wait_time: int = 32) -> None: + """Wait for the PSMDB cluster status to settle back to ready.""" + time.sleep(7) + logger.info(f"Waiting for cluster {cluster_name} consistency") + for _ in range(wait_time): + state = kubectl_bin("get", "psmdb", cluster_name, "-o", "jsonpath={.status.state}").strip( + "'" + ) + if state == "ready": + logger.info(f"Cluster {cluster_name} is consistent") + return + time.sleep(5) + + raise TimeoutError(f"Cluster {cluster_name} did not become ready within {wait_time} retries") + + +def get_jsonpath(cluster_name: str, rs_name: str, path: str) -> str: + """Get value from PSMDB resource using JSONPath""" + jsonpath = f'{{.spec.replsets[?(@.name=="{rs_name}")].{path}}}' + try: + return kubectl_bin("get", "psmdb", cluster_name, "-o", f"jsonpath={jsonpath}") + except subprocess.CalledProcessError: + return "" + + +def clean_all_namespaces() -> None: + """Delete all namespaces except system ones.""" + try: + logger.info("Cleaning up all old namespaces") + result = kubectl_bin( + "get", + "ns", + "-o", + "jsonpath={range .items[*]}{.metadata.name} {.status.phase}{'\\n'}{end}", + ) + excluded = ("kube-", "default", "psmdb-operator", "openshift", "gke-", "gmp-") + + namespaces = [ + parts[0] + for line in result.strip().splitlines() + if (parts := line.split()) + and len(parts) == 2 + and not any(ex in parts[0] for ex in excluded) + and parts[1] != "Terminating" + ] + + if namespaces: + kubectl_bin("delete", "ns", *namespaces) + except subprocess.CalledProcessError: + logger.error("Failed to clean namespaces") + + +def detect_k8s_provider(provider: str) -> str: + """Detect if the Kubernetes provider matches the given string""" + try: + output = kubectl_bin("version", "-o", "json") + git_version = json.loads(output)["serverVersion"]["gitVersion"] + return "1" if provider in git_version else "0" + except Exception as e: + logger.error(f"Failed to detect Kubernetes provider: {e}") + return "0" + + +def get_k8s_versions() -> tuple[str, str]: + """Get Kubernetes git version and semantic version.""" + output = kubectl_bin("version", "-o", "json") + version_info = json.loads(output)["serverVersion"] + + git_version = version_info["gitVersion"] + major = version_info["major"] + minor = version_info["minor"].rstrip("+") + kube_version = f"{major}.{minor}" + + return git_version, kube_version + + +def is_openshift() -> str: + """Detect if running on OpenShift. Returns '1' or ''.""" + try: + result = subprocess.run(["oc", "get", "projects"], capture_output=True) + return "1" if result.returncode == 0 else "" + except FileNotFoundError: + return "" + + +def is_minikube() -> str: + """Detect if running on Minikube. Returns '1' or ''.""" + result = kubectl_bin("get", "nodes", check=False) + return "1" if any(line.startswith("minikube") for line in result.splitlines()) else "" diff --git a/e2e-tests/lib/mongo.py b/e2e-tests/lib/mongo.py new file mode 100644 index 0000000000..9ec479134d --- /dev/null +++ b/e2e-tests/lib/mongo.py @@ -0,0 +1,155 @@ +import json +import logging +import os +import re +from pathlib import Path +from typing import Any, Dict + +from deepdiff import DeepDiff + +from .kubectl import kubectl_bin +from .utils import retry + +logger = logging.getLogger(__name__) + + +class MongoManager: + def __init__(self, client: str): + self.client = client + + def run_mongosh( + self, + command: str, + uri: str, + driver: str = "mongodb+srv", + suffix: str = ".svc.cluster.local", + mongo_flag: str = "", + timeout: int = 30, + check: bool = True, + ) -> str: + """Execute mongosh command in PSMDB client container.""" + replica_set = "cfg" if "cfg" in uri else "rs0" + connection_string = f"{driver}://{uri}{suffix}/admin?ssl=false&replicaSet={replica_set}" + + result = kubectl_bin( + "exec", + self.client, + "--", + "timeout", + str(timeout), + "env", + "HOME=/tmp", + "mongosh", + connection_string, + *mongo_flag.split(), + "--eval", + command, + "--quiet", + check=check, + return_stderr=not check, + ) + return result + + def compare_mongo_user(self, uri: str, expected_role: str, test_dir: str) -> None: + """Compare MongoDB user permissions""" + + def get_expected_file(test_dir: str, user: str) -> Any: + """Get the appropriate expected file based on MongoDB version""" + base_path = Path(test_dir) / "compare" + base_file = base_path / f"{user}.json" + + image_mongod = os.environ.get("IMAGE_MONGOD", "") + version_mappings = [("8.0", "-80"), ("7.0", "-70"), ("6.0", "-60")] + + for version, suffix in version_mappings: + if version in image_mongod: + version_file = base_path / f"{user}{suffix}.json" + if version_file.exists(): + logger.info(f"Using version-specific file: {version_file}") + with open(version_file) as f: + return json.load(f) + + if base_file.exists(): + logger.info(f"Using base file: {base_file}") + with open(base_file) as f: + return json.load(f) + else: + raise FileNotFoundError(f"Expected file not found: {base_file}") + + def clean_mongo_json(data: Dict[str, Any]) -> Dict[str, Any]: + """Remove timestamps and metadata from MongoDB response""" + + def remove_timestamps(obj: Any) -> Any: + if isinstance(obj, dict): + return { + k: remove_timestamps(v) + for k, v in obj.items() + if k not in {"ok", "$clusterTime", "operationTime"} + } + elif isinstance(obj, list): + return [remove_timestamps(v) for v in obj] + elif isinstance(obj, str): + return re.sub( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[\+\-]\d{4}", "", obj + ) + else: + return obj + + cleaned = remove_timestamps(data) + if not isinstance(cleaned, dict): + raise TypeError("Expected cleaned MongoDB response to be a dict") + return cleaned + + result = retry( + lambda: self.run_mongosh( + "EJSON.stringify(db.runCommand({connectionStatus:1,showPrivileges:true}))", + uri, + ) + ) + actual_data = clean_mongo_json(json.loads(result)) + expected_data = get_expected_file(test_dir, expected_role) + + diff = DeepDiff(expected_data, actual_data, ignore_order=True) + assert not diff, f"MongoDB user permissions differ: {diff.pretty()}" + + def compare_mongo_cmd( + self, + command: str, + uri: str, + database: str = "myApp", + collection: str = "test", + sort: str = "", + test_file: str = "", + ) -> None: + """Compare MongoDB command output""" + full_cmd = f"{collection}.{command}" + if sort: + full_cmd = f"{collection}.{command}.{sort}" + + logger.info(f"Running: {full_cmd} on db {database}") + + mongo_expr = f"EJSON.stringify(db.getSiblingDB('{database}').{full_cmd})" + result = json.loads(self.run_mongosh(mongo_expr, uri, "mongodb")) + + logger.info(f"MongoDB output: {result}") + + with open(test_file) as file: + expected = json.load(file) + + diff = DeepDiff(expected, result) + assert not diff, f"MongoDB command output differs: {diff.pretty()}" + + def get_mongo_primary(self, uri: str, cluster_name: str) -> str: + """Get current MongoDB primary node""" + primary_endpoint = self.run_mongosh("EJSON.stringify(db.hello().me)", uri) + + if cluster_name in primary_endpoint: + return primary_endpoint.split(".")[0].replace('"', "") + else: + endpoint_host = primary_endpoint.split(":")[0] + result = kubectl_bin("get", "service", "-o", "wide") + + for line in result.splitlines(): + if endpoint_host in line: + return line.split()[0].replace('"', "") + raise ValueError("Primary node not found in service list") diff --git a/e2e-tests/lib/operator.py b/e2e-tests/lib/operator.py new file mode 100644 index 0000000000..471bf1afef --- /dev/null +++ b/e2e-tests/lib/operator.py @@ -0,0 +1,189 @@ +import json +import logging +import os +import re +import subprocess +from pathlib import Path + +import yaml + +from .kubectl import kubectl_bin, wait_pod + +logger = logging.getLogger(__name__) + + +def deploy_operator(test_dir: str, src_dir: str) -> None: + """Deploy the operator""" + logger.info("Start PSMDB operator") + operator_ns = os.environ.get("OPERATOR_NS") + + crd_file = f"{test_dir}/conf/crd.yaml" + if not os.path.isfile(crd_file): + crd_file = f"{src_dir}/deploy/crd.yaml" + + kubectl_bin("apply", "--server-side", "--force-conflicts", "-f", crd_file) + + rbac_type = "cw-rbac" if operator_ns else "rbac" + operator_file = f"{src_dir}/deploy/{'cw-' if operator_ns else ''}operator.yaml" + + apply_rbac(src_dir, rbac_type) + + with open(operator_file, "r") as f: + data = yaml.safe_load(f) + + for container in data["spec"]["template"]["spec"]["containers"]: + container["image"] = os.environ.get("IMAGE") + if "env" in container: + env_vars = {env["name"]: env for env in container["env"]} + if "DISABLE_TELEMETRY" in env_vars: + env_vars["DISABLE_TELEMETRY"]["value"] = "true" + if "LOG_LEVEL" in env_vars: + env_vars["LOG_LEVEL"]["value"] = "DEBUG" + + yaml_content = yaml.dump(data, default_flow_style=False) + kubectl_bin("apply", "-f", "-", input_data=yaml_content) + operator_pod = get_operator_pod() + wait_pod(operator_pod) + + logs = kubectl_bin("logs", operator_pod) + startup_logs = [line for line in logs.splitlines() if "Manager starting up" in line] + if startup_logs: + logger.info(f"Operator startup: {startup_logs[0]}") + else: + logger.warning("No 'Manager starting up' message found in logs") + + +def get_operator_pod() -> str: + """Get the operator pod name""" + args = [ + "get", + "pods", + "--selector=name=percona-server-mongodb-operator", + "-o", + "jsonpath={.items[].metadata.name}", + ] + operator_ns = os.environ.get("OPERATOR_NS") + if operator_ns: + args.extend(["-n", operator_ns]) + try: + out = kubectl_bin(*args) + names = [n for n in out.strip().split() if n] + if not names: + raise RuntimeError( + "No Running operator pod found. Ensure the operator deployment succeeded" + ) + if len(names) > 1: + raise RuntimeError(f"Multiple operator pods found: {names}") + return names[0] + except Exception as e: + raise RuntimeError(f"Failed to get operator pod: {e}") from e + + +def apply_rbac(src_dir: str, rbac: str = "rbac") -> None: + """Apply RBAC YAML with namespace substitution""" + operator_ns = os.getenv("OPERATOR_NS", "psmdb-operator") + path = Path(src_dir) / "deploy" / f"{rbac}.yaml" + + yaml_content = path.read_text() + modified_yaml = re.sub( + r"^(\s*)namespace:\s*.*$", rf"\1namespace: {operator_ns}", yaml_content, flags=re.MULTILINE + ) + + args = ["apply", "-f", "-"] + if os.getenv("OPERATOR_NS"): + args = ["apply", "-n", operator_ns, "-f", "-"] + + kubectl_bin(*args, input_data=modified_yaml) + + +def delete_crd_rbac(src_dir: Path) -> None: + logger.info("Deleting old CRDs and RBACs") + crd_path = (src_dir / "deploy" / "crd.yaml").resolve() + + docs = list(yaml.safe_load_all(crd_path.read_text())) + crd_names = [] + resource_kinds = [] + for doc in docs: + if doc and doc.get("kind") == "CustomResourceDefinition": + crd_names.append(doc["metadata"]["name"]) + group = doc["spec"]["group"] + plural = doc["spec"]["names"]["plural"] + resource_kinds.append(f"{plural}.{group}") + + kubectl_bin("delete", "-f", str(crd_path), "--ignore-not-found", "--wait=false", check=False) + + for kind in resource_kinds: + try: + items_json = kubectl_bin("get", kind, "--all-namespaces", "-o", "json") + data = json.loads(items_json) + for item in data.get("items", []): + ns = item["metadata"]["namespace"] + name = item["metadata"]["name"] + kubectl_bin( + "patch", + kind, + "-n", + ns, + name, + "--type=merge", + "-p", + '{"metadata":{"finalizers":[]}}', + ) + except subprocess.CalledProcessError: + pass + + for name in crd_names: + kubectl_bin("wait", "--for=delete", "crd", name, check=False) + + +def check_crd_for_deletion(file_path: str) -> None: + """Check and remove finalizers from CRDs to allow deletion""" + with open(file_path, "r") as f: + yaml_content = f.read() + + for doc in yaml_content.split("---"): + if not doc.strip(): + continue + try: + parsed_doc = yaml.safe_load(doc) + if not parsed_doc or "metadata" not in parsed_doc: + continue + + crd_name = parsed_doc["metadata"]["name"] + + result = kubectl_bin( + "get", + f"crd/{crd_name}", + "-o", + "jsonpath={.status.conditions[-1].type}", + "--ignore-not-found", + ) + is_crd_terminating = result.strip() == "Terminating" + + if is_crd_terminating: + logger.info(f"Removing finalizers from CRD {crd_name} to allow deletion") + kubectl_bin( + "patch", + f"crd/{crd_name}", + "--type=merge", + "-p", + '{"metadata":{"finalizers":[]}}', + ) + try: + kubectl_bin( + "patch", + crd_name, + "--all-namespaces", + "--type=merge", + "-p", + '{"metadata":{"finalizers":[]}}', + ) + except Exception as patch_error: + logger.warning( + f"Could not patch {crd_name} instances (may not exist): {patch_error}" + ) + + except yaml.YAMLError as yaml_error: + logger.error(f"Error parsing YAML document: {yaml_error}") + except Exception as e: + logger.error(f"Error removing finalizers from CRD: {e}") diff --git a/e2e-tests/lib/report_generator.py b/e2e-tests/lib/report_generator.py new file mode 100644 index 0000000000..cb0f470273 --- /dev/null +++ b/e2e-tests/lib/report_generator.py @@ -0,0 +1,151 @@ +import html +import os +import re +from typing import Any, List, MutableSequence + +from pytest_html import extras + +from lib.k8s_collector import K8sCollector + +ENV_VARS_TO_REPORT = [ + "KUBE_VERSION", + "EKS", + "GKE", + "OPENSHIFT", + "MINIKUBE", + "GIT_COMMIT", + "GIT_BRANCH", + "OPERATOR_VERSION", + "OPERATOR_NS", + "IMAGE", + "IMAGE_MONGOD", + "IMAGE_SEARCH", + "IMAGE_BACKUP", + "IMAGE_PMM_CLIENT", + "IMAGE_PMM_SERVER", + "IMAGE_PMM3_CLIENT", + "IMAGE_PMM3_SERVER", + "IMAGE_LOGCOLLECTOR", + "IMAGE_CLUSTERSYNC", + "CERT_MANAGER_VER", + "CHAOS_MESH_VER", + "MINIO_VER", + "CLEAN_NAMESPACE", + "DELETE_CRD_ON_START", + "SKIP_DELETE", + "SKIP_BACKUPS_TO_AWS_GCP_AZURE", +] + + +def pytest_html_results_summary( + prefix: MutableSequence[str], summary: MutableSequence[str], postfix: MutableSequence[str] +) -> None: + """Add environment variables table to HTML report summary.""" + rows = "" + for i, var in enumerate(ENV_VARS_TO_REPORT): + value = os.environ.get(var, "") + escaped_value = value.replace("<", "<").replace(">", ">") + bg = "#f9f9f9" if i % 2 == 0 else "#ffffff" + rows += f'{var}{escaped_value}\n' + + if rows: + table = f""" +

Environment Variables

+ + + + + + + + + {rows} + +
VariableValue
+ """ + prefix.append(table) + + +def pytest_html_results_table_header(cells: MutableSequence[str]) -> None: + """Add Class column to HTML report.""" + cells.insert(1, 'Class') + + +def pytest_html_results_table_row(report: Any, cells: MutableSequence[str]) -> None: + """Populate Class column with the e2e-tests subdir (test name) for both python and bash.""" + nodeid = report.nodeid + if "test_bash_wrapper[" in nodeid: + # test_pytest_wrapper.py::test_bash_wrapper[test-name] + class_name = nodeid.split("[")[1].rstrip("]") + else: + # e2e-tests//test_*.py::Class::method -> + path_parts = nodeid.split("::")[0].split("/") + class_name = path_parts[-2] if len(path_parts) >= 2 else "-" + cells.insert(1, f"{class_name}") + + +LOG_LEVEL_COLORS = { + "ERROR": "#ff6b6b", + "WARN": "#ffa500", + "INFO": "#00CC66", + "DEBUG": "#3B8EFF", + "Normal": "#00CC66", + "Warning": "#ffa500", +} +LOG_LEVEL_PATTERN = re.compile(r"\b(" + "|".join(LOG_LEVEL_COLORS.keys()) + r")\b") + + +def highlight_log_levels(logs: str) -> str: + """HTML-escape logs and add basic color highlighting for common log levels""" + return LOG_LEVEL_PATTERN.sub( + lambda m: f'{m.group(1)}', + html.escape(logs), + ) + + +def generate_report(namespace: str) -> List[Any]: + def create_collapsible_section(title: str, content: str) -> str: + return f""" +
+
+ + {title} + +
+
{content}
+
+
+
+ """ + + summary = K8sCollector(namespace).capture_summary() + + return [ + extras.html( + create_collapsible_section("Operator Pod Logs", highlight_log_levels(summary["logs"])) + ), + extras.html( + create_collapsible_section("Kubernetes Resources", html.escape(summary["resources"])) + ), + extras.html( + create_collapsible_section( + "Kubernetes Events", highlight_log_levels(summary["events"]) + ) + ), + ] diff --git a/e2e-tests/lib/secrets.py b/e2e-tests/lib/secrets.py new file mode 100644 index 0000000000..df4d26ca6d --- /dev/null +++ b/e2e-tests/lib/secrets.py @@ -0,0 +1,110 @@ +import atexit +import base64 +import logging +import os +import subprocess +import tempfile +import urllib.parse +from pathlib import Path +from typing import Optional + +from .kubectl import kubectl_bin + +logger = logging.getLogger(__name__) + +_temp_files_to_cleanup: list[str] = [] + + +def _cleanup_temp_files() -> None: + for path in _temp_files_to_cleanup: + try: + if os.path.exists(path): + os.unlink(path) + except OSError: + pass + + +atexit.register(_cleanup_temp_files) + + +def get_secret_data(secret_name: str, data_key: str) -> str: + """Get and decode secret data from Kubernetes""" + try: + result = kubectl_bin( + "get", f"secrets/{secret_name}", "-o", f"jsonpath={{.data.{data_key}}}" + ).strip() + decoded_data = base64.b64decode(result).decode("utf-8") + return decoded_data + except subprocess.CalledProcessError as e: + logger.error(f"Error: {e.stderr}") + return "" + + +def get_user_data(secret_name: str, data_key: str) -> str: + """Get and URL-encode secret data""" + secret_data = get_secret_data(secret_name, data_key) + return urllib.parse.quote(secret_data, safe="") + + +def get_cloud_secret_default(conf_dir: Optional[Path] = None) -> str: + """Return default for SKIP_BACKUPS_TO_AWS_GCP_AZURE based on cloud-secret.yml existence.""" + if conf_dir is None: + conf_dir = Path(__file__).parent.parent / "conf" + if (conf_dir / "cloud-secret.yml").exists(): + return "" + return "1" + + +def apply_s3_storage_secrets(conf_dir: str) -> None: + """Apply secrets for cloud storages.""" + if not os.environ.get("SKIP_BACKUPS_TO_AWS_GCP_AZURE"): + logger.info("Creating secrets for cloud storages (minio + cloud)") + kubectl_bin( + "apply", + "-f", + f"{conf_dir}/minio-secret.yml", + "-f", + f"{conf_dir}/cloud-secret.yml", + ) + else: + logger.info("Creating secrets for cloud storages (minio only)") + kubectl_bin("apply", "-f", f"{conf_dir}/minio-secret.yml") + + +def setup_gcs_credentials(secret_name: str = "gcp-cs-secret") -> bool: + """Setup GCS credentials from K8s secret for gsutil.""" + result = subprocess.run(["gsutil", "ls"], capture_output=True, check=False) + if result.returncode == 0: + logger.info("GCS credentials already set in environment") + return True + + logger.info(f"Setting up GCS credentials from K8s secret: {secret_name}") + + access_key = get_secret_data(secret_name, "AWS_ACCESS_KEY_ID") + secret_key = get_secret_data(secret_name, "AWS_SECRET_ACCESS_KEY") + + if not access_key or not secret_key: + logger.error("Failed to extract GCS credentials from secret") + return False + + boto_fd, boto_path = tempfile.mkstemp(prefix="boto.", suffix=".cfg") + try: + with os.fdopen(boto_fd, "w") as f: + f.write("[Credentials]\n") + f.write(f"gs_access_key_id = {access_key}\n") + f.write(f"gs_secret_access_key = {secret_key}\n") + os.chmod(boto_path, 0o600) + os.environ["BOTO_CONFIG"] = boto_path + _temp_files_to_cleanup.append(boto_path) + logger.info("GCS credentials configured successfully") + return True + except Exception as e: + logger.error(f"Failed to create boto config: {e}") + os.unlink(boto_path) + return False + + +# TODO: implement this function +def check_passwords_leak(namespace: Optional[str] = None) -> None: + """Check for password leaks in Kubernetes pod logs.""" + pass diff --git a/e2e-tests/lib/utils.py b/e2e-tests/lib/utils.py new file mode 100644 index 0000000000..9f4eae0558 --- /dev/null +++ b/e2e-tests/lib/utils.py @@ -0,0 +1,110 @@ +import logging +import os +import re +import subprocess +import time +from typing import Any, Callable, Optional + +from rich.highlighter import RegexHighlighter +from rich.theme import Theme + +logger = logging.getLogger(__name__) + + +class K8sHighlighter(RegexHighlighter): + """Highlight Kubernetes resources in logs.""" + + base_style = "k8s." + highlights = [ + r"(?Pkubectl)\s+(?Pexec)\s+(?P[\w-]+)\s+--\s+(?Ptimeout\s+\d+)\s+(?Pmongosh)\s+(?Pmongodb(?:\+srv)?://[^ ]+)\s+--eval\s+(?P\S.+?)\s+--quiet", + r"(?P(?(?(?pending|failed|error|deleted)", + r"(?Pready|running)", + ] + + +k8s_theme = Theme( + { + "logging.level.debug": "blue", + "logging.level.info": "green", + "k8s.pod": "green", + "k8s.uri": "magenta", + "k8s.resource": "cyan", + "k8s.namespace": "green", + "k8s.bad_state": "red", + "k8s.state": "blue", + } +) + + +def retry( + func: Callable[[], Any], + max_attempts: int = 5, + delay: int = 1, + condition: Optional[Callable[[Any], bool]] = None, +) -> Any: + """Retry a function until it succeeds or max attempts reached.""" + for attempt in range(max_attempts): + try: + result = func() + if condition is None or condition(result): + return result + except Exception: + if attempt == max_attempts - 1: + raise + + time.sleep(delay) + + raise Exception(f"Max attempts ({max_attempts}) reached") + + +def qualify_image(ref: str, registry_full: str) -> str: + """Prefix percona/perconalab images with the registry (mirrors bash with_registry).""" + if not ref: + return ref + host = ref.split("/")[0] + if "." in host or ":" in host or host == "localhost": + return ref + if ref.startswith(("percona/", "perconalab/")): + return f"{registry_full}{ref}" + return ref + + +def get_git_commit() -> str: + result = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True) + return result.stdout.strip() + + +def get_cr_version() -> str: + """Get CR version from cr.yaml""" + try: + with open( + os.path.realpath( + os.path.join(os.path.dirname(__file__), "..", "..", "deploy", "cr.yaml") + ) + ) as f: + return next(line.split()[1] for line in f if "crVersion" in line) + except (StopIteration, Exception) as e: + logger.error(f"Failed to get CR version: {e}") + raise RuntimeError("CR version not found in cr.yaml") + + +def get_git_branch() -> str: + """Get current git branch or version from environment variable""" + if version := os.environ.get("VERSION"): + return version + + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + branch = result.stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return "unknown" + + return re.sub(r"[^a-zA-Z0-9-]", "-", branch.lower()) diff --git a/e2e-tests/liveness/test_liveness.py b/e2e-tests/liveness/test_liveness.py new file mode 100644 index 0000000000..6eb0505a83 --- /dev/null +++ b/e2e-tests/liveness/test_liveness.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 + +import logging +import re +from typing import Any, Callable, Dict + +import pytest +from lib.config import apply_cluster, compare_kubectl +from lib.kubectl import kubectl_bin, wait_for_running + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="class", autouse=True) +def config(create_infra: Callable[[str], str]) -> Dict[str, str]: + """Configuration for tests""" + return { + "namespace": create_infra("liveness"), + "cluster": "liveness", + } + + +@pytest.fixture(scope="class", autouse=True) +def setup_tests(test_paths: Dict[str, str], deploy_minio: Any) -> None: + """Setup test environment""" + kubectl_bin( + "apply", + "-f", + f"{test_paths['conf_dir']}/secrets.yml", + "-f", + f"{test_paths['conf_dir']}/minio-secret.yml", + ) + + +class TestLiveness: + @pytest.mark.dependency() + def test_create_first_cluster( + self, config: Dict[str, str], test_paths: Dict[str, str] + ) -> None: + """Create first PSMDB cluster""" + apply_cluster(f"{test_paths['test_dir']}/conf/{config['cluster']}-rs0.yml") + wait_for_running(f"{config['cluster']}-rs0", 3) + + compare_kubectl( + test_paths["test_dir"], f"statefulset/{config['cluster']}-rs0", config["namespace"] + ) + + @pytest.mark.dependency(depends=["TestLiveness::test_create_first_cluster"]) + def test_liveness_check_fails_with_invalid_ssl_option(self, config: Dict[str, str]) -> None: + kubectl_bin( + "exec", + f"{config['cluster']}-rs0-0", + "-c", + "mongod", + "--", + "bash", + "-c", + "/opt/percona/mongodb-healthcheck k8s liveness --ssl", + check=False, + ) + + logs_output = kubectl_bin( + "exec", + f"{config['cluster']}-rs0-0", + "-c", + "mongod", + "--", + "bash", + "-c", + "ls /data/db/mongod-data/logs", + ) + log_count = logs_output.count("mongodb-healthcheck.log") + assert log_count == 1, f"Expected 1 healthcheck log file, got {log_count}" + + rotated_count = len(re.findall(r"mongodb-healthcheck-.*\.log\.gz", logs_output)) + assert rotated_count >= 1, f"Expected >=1 rotated logs, got {rotated_count}" + + @pytest.mark.dependency( + depends=["TestLiveness::test_liveness_check_fails_with_invalid_ssl_option"] + ) + def test_change_liveness_config( + self, config: Dict[str, str], test_paths: Dict[str, str] + ) -> None: + apply_cluster(f"{test_paths['test_dir']}/conf/{config['cluster']}-rs0-changed.yml") + + wait_for_running(f"{config['cluster']}-rs0", 3) + + compare_kubectl( + test_paths["test_dir"], + f"statefulset/{config['cluster']}-rs0", + config["namespace"], + "-changed", + ) diff --git a/e2e-tests/test_pytest_wrapper.py b/e2e-tests/test_pytest_wrapper.py new file mode 100644 index 0000000000..ec2cca67bb --- /dev/null +++ b/e2e-tests/test_pytest_wrapper.py @@ -0,0 +1,10 @@ +import pytest +from lib.bash_wrapper import run_bash_test + + +def test_bash_wrapper(request: pytest.FixtureRequest) -> None: + """Run a bash test script via pytest wrapper.""" + test_name = request.config.getoption("--test-name") + if not test_name: + pytest.skip("No --test-name provided") + run_bash_test(test_name) diff --git a/e2e-tests/update-upgrade-consistency.py b/e2e-tests/update-upgrade-consistency.py new file mode 100644 index 0000000000..9b42724e8f --- /dev/null +++ b/e2e-tests/update-upgrade-consistency.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 + +import argparse +import re +import shutil +from pathlib import Path + + +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") + + +def version_suffix(version: str) -> str: + return version.replace(".", "") + + +def read_text(path: Path) -> str: + return path.read_text() + + +def write_text(path: Path, text: str) -> None: + path.write_text(text) + + +def replace_single(pattern: str, repl: str, text: str, *, flags: int = 0) -> str: + result, count = re.subn(pattern, repl, text, count=1, flags=flags) + if count != 1: + raise ValueError(f"Expected 1 match for pattern: {pattern}") + return result + + +def replace_ordered(pattern: str, replacements: list[str], text: str, *, flags: int = 0) -> str: + compiled = re.compile(pattern, flags) + idx = 0 + + def _repl(_match: re.Match[str]) -> str: + nonlocal idx + if idx >= len(replacements): + raise ValueError(f"Too many matches for pattern: {pattern}") + value = replacements[idx] + idx += 1 + return value + + result = compiled.sub(_repl, text) + if idx != len(replacements): + raise ValueError( + f"Expected {len(replacements)} matches for pattern: {pattern}, got {idx}" + ) + return result + + +def parse_suffixes(path: Path, resource_pattern: str, expected_count: int) -> list[str]: + text = read_text(path) + matches = re.findall(resource_pattern, text) + if len(matches) != expected_count: + raise ValueError( + f"{path}: expected {expected_count} suffix matches, got {len(matches)}" + ) + return matches + + +def render_compare_path(compare_dir: Path, template: str, suffix: str) -> Path: + return compare_dir / template.format(suffix=suffix) + + +def read_generation(compare_dir: Path, template: str, suffix: str) -> int: + path = render_compare_path(compare_dir, template, suffix) + if not path.exists(): + raise FileNotFoundError(f"Missing compare file: {path}") + text = read_text(path) + match = re.search(r"^ generation:\s*(\d+)\s*$", text, flags=re.MULTILINE) + if not match: + raise ValueError(f"Missing metadata.generation in: {path}") + return int(match.group(1)) + + +def set_generation(path: Path, generation: int) -> None: + text = read_text(path) + updated, count = re.subn( + r"^ generation:\s*\d+\s*$", + f" generation: {generation}", + text, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise ValueError(f"Failed updating metadata.generation in: {path}") + write_text(path, updated) + + +def ensure_file(target: Path, source: Path) -> None: + if target.exists(): + return + if not source.exists(): + raise FileNotFoundError(f"Cannot create {target}, source missing: {source}") + shutil.copyfile(source, target) + + +def ensure_and_set_generations( + compare_dir: Path, + templates: list[str], + old_suffixes: list[str], + new_suffixes: list[str], + stage_generations: list[int], +) -> None: + for template in templates: + for idx, new_suffix in enumerate(new_suffixes): + target = render_compare_path(compare_dir, template, new_suffix) + source = render_compare_path(compare_dir, template, old_suffixes[idx]) + ensure_file(target, source) + if template.startswith("statefulset_"): + set_generation(target, stage_generations[idx]) + + +def update_conf(conf_path: Path, start_version: str) -> None: + text = read_text(conf_path) + text = replace_single(r"(crVersion:\s*)\d+\.\d+\.\d+", rf"\g<1>{start_version}", text) + write_text(conf_path, text) + + +def update_run_upgrade_consistency(run_path: Path, versions: list[str], suffixes: list[str]) -> None: + start, mid, final = versions + start_suffix, mid_suffix, final_suffix = suffixes + + text = read_text(run_path) + text = replace_single( + r'desc "create first PSMDB cluster \d+\.\d+\.\d+ \$CLUSTER"', + f'desc "create first PSMDB cluster {start} $CLUSTER"', + text, + ) + text = replace_ordered(r"desc 'test \d+\.\d+\.\d+'", [f"desc 'test {mid}'", f"desc 'test {final}'"], text) + text = replace_ordered( + r'"spec": \{"crVersion":"\d+\.\d+\.\d+"\}', + [f'"spec": {{"crVersion":"{mid}"}}', f'"spec": {{"crVersion":"{final}"}}'], + text, + ) + text = replace_ordered( + r'compare_kubectl service/\$\{CLUSTER\}-rs0 "-\d+"', + [ + f'compare_kubectl service/${{CLUSTER}}-rs0 "-{start_suffix}"', + f'compare_kubectl service/${{CLUSTER}}-rs0 "-{mid_suffix}"', + f'compare_kubectl service/${{CLUSTER}}-rs0 "-{final_suffix}"', + ], + text, + ) + text = replace_ordered( + r'compare_kubectl statefulset/\$\{CLUSTER\}-rs0 "-\d+"', + [ + f'compare_kubectl statefulset/${{CLUSTER}}-rs0 "-{start_suffix}"', + f'compare_kubectl statefulset/${{CLUSTER}}-rs0 "-{mid_suffix}"', + f'compare_kubectl statefulset/${{CLUSTER}}-rs0 "-{final_suffix}"', + ], + text, + ) + write_text(run_path, text) + + +def update_run_upgrade_consistency_sharded_tls( + run_path: Path, versions: list[str], suffixes: list[str] +) -> None: + start, mid, final = versions + start_suffix, mid_suffix, final_suffix = suffixes + + text = read_text(run_path) + text = replace_single( + r'desc "create first PSMDB cluster \d+\.\d+\.\d+ \$CLUSTER"', + f'desc "create first PSMDB cluster {start} $CLUSTER"', + text, + ) + text = replace_ordered(r"desc 'test \d+\.\d+\.\d+'", [f"desc 'test {mid}'", f"desc 'test {final}'"], text) + text = replace_ordered( + r'"spec": \{"crVersion":"\d+\.\d+\.\d+"\}', + [f'"spec": {{"crVersion":"{mid}"}}', f'"spec": {{"crVersion":"{final}"}}'], + text, + ) + + text = replace_ordered( + r'compare_kubectl service/\$\{CLUSTER\}-rs0 "-\d+"', + [ + f'compare_kubectl service/${{CLUSTER}}-rs0 "-{start_suffix}"', + f'compare_kubectl service/${{CLUSTER}}-rs0 "-{mid_suffix}"', + f'compare_kubectl service/${{CLUSTER}}-rs0 "-{final_suffix}"', + ], + text, + ) + text = replace_ordered( + r'compare_kubectl service/\$\{CLUSTER\}-cfg "-\d+"', + [ + f'compare_kubectl service/${{CLUSTER}}-cfg "-{start_suffix}"', + f'compare_kubectl service/${{CLUSTER}}-cfg "-{mid_suffix}"', + f'compare_kubectl service/${{CLUSTER}}-cfg "-{final_suffix}"', + ], + text, + ) + text = replace_ordered( + r'compare_kubectl statefulset/\$\{CLUSTER\}-rs0 "-\d+"', + [ + f'compare_kubectl statefulset/${{CLUSTER}}-rs0 "-{start_suffix}"', + f'compare_kubectl statefulset/${{CLUSTER}}-rs0 "-{mid_suffix}"', + f'compare_kubectl statefulset/${{CLUSTER}}-rs0 "-{final_suffix}"', + ], + text, + ) + text = replace_ordered( + r'compare_kubectl statefulset/\$\{CLUSTER\}-cfg "-\d+"', + [ + f'compare_kubectl statefulset/${{CLUSTER}}-cfg "-{start_suffix}"', + f'compare_kubectl statefulset/${{CLUSTER}}-cfg "-{mid_suffix}"', + f'compare_kubectl statefulset/${{CLUSTER}}-cfg "-{final_suffix}"', + ], + text, + ) + + write_text(run_path, text) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Update upgrade-consistency e2e tests for a new release." + ) + parser.add_argument("--start", required=True, help="Start CR version, e.g. 1.21.2") + parser.add_argument("--mid", required=True, help="Middle CR version, e.g. 1.22.0") + parser.add_argument("--final", required=True, help="Final CR version, e.g. 1.23.0") + args = parser.parse_args() + + versions = [args.start, args.mid, args.final] + for value in versions: + if not VERSION_RE.match(value): + raise ValueError(f"Invalid version format: {value}") + + suffixes = [version_suffix(v) for v in versions] + + repo_root = Path(__file__).resolve().parent.parent + e2e_dir = repo_root / "e2e-tests" + + uc_run = e2e_dir / "upgrade-consistency" / "run" + uc_conf = e2e_dir / "upgrade-consistency" / "conf" / "some-name-rs0.yml" + uc_compare = e2e_dir / "upgrade-consistency" / "compare" + + sh_run = e2e_dir / "upgrade-consistency-sharded-tls" / "run" + sh_conf = e2e_dir / "upgrade-consistency-sharded-tls" / "conf" / "some-name.yml" + sh_compare = e2e_dir / "upgrade-consistency-sharded-tls" / "compare" + + old_uc_suffixes = parse_suffixes( + uc_run, r'compare_kubectl statefulset/\$\{CLUSTER\}-rs0 "-(\d+)"', 3 + ) + old_sh_suffixes = parse_suffixes( + sh_run, r'compare_kubectl statefulset/\$\{CLUSTER\}-rs0 "-(\d+)"', 3 + ) + + uc_stage_gens = [ + read_generation(uc_compare, "statefulset_some-name-rs0-{suffix}.yml", old_uc_suffixes[0]), + read_generation(uc_compare, "statefulset_some-name-rs0-{suffix}.yml", old_uc_suffixes[1]), + read_generation(uc_compare, "statefulset_some-name-rs0-{suffix}.yml", old_uc_suffixes[2]), + ] + sh_stage_gens = [ + read_generation(sh_compare, "statefulset_some-name-rs0-{suffix}.yml", old_sh_suffixes[0]), + read_generation(sh_compare, "statefulset_some-name-rs0-{suffix}.yml", old_sh_suffixes[1]), + read_generation(sh_compare, "statefulset_some-name-rs0-{suffix}.yml", old_sh_suffixes[2]), + ] + + update_conf(uc_conf, args.start) + update_conf(sh_conf, args.start) + + update_run_upgrade_consistency(uc_run, versions, suffixes) + update_run_upgrade_consistency_sharded_tls(sh_run, versions, suffixes) + + ensure_and_set_generations( + uc_compare, + [ + "service_some-name-rs0-{suffix}.yml", + "statefulset_some-name-rs0-{suffix}.yml", + "statefulset_some-name-rs0-{suffix}-oc.yml", + ], + old_uc_suffixes, + suffixes, + uc_stage_gens, + ) + + ensure_and_set_generations( + sh_compare, + [ + "service_some-name-rs0-{suffix}.yml", + "service_some-name-cfg-{suffix}.yml", + "statefulset_some-name-rs0-{suffix}.yml", + "statefulset_some-name-rs0-{suffix}-oc.yml", + "statefulset_some-name-cfg-{suffix}.yml", + "statefulset_some-name-cfg-{suffix}-oc.yml", + ], + old_sh_suffixes, + suffixes, + sh_stage_gens, + ) + + print( + "Updated upgrade-consistency suites:", + f"start={args.start}, mid={args.mid}, final={args.final}", + ) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..1fa089cad3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "psmdb-pytest" +version = "0.1.0" +description = "Tests for PSMDB Operator" +requires-python = ">=3.13" +dependencies = [ + "deepdiff>=8.5.0", + "junitparser>=3.2.0", + "mypy>=1.16.0", + "pytest>=8.4.0", + "pytest-dependency>=0.6.0", + "pytest-html>=4.1.1", + "pytest-html-merger>=0.1.0", + "pytest-json-report>=1.5.0", + "pyyaml>=6.0.2", + "rich>=14.0.0", + "ruff>=0.11.12", + "types-pyyaml>=6.0.12.20250915", +] + +[tool.pytest.ini_options] +addopts = "-v -s --self-contained-html" +render_collapsed = "all" +junit_family = "xunit2" +junit_suite_name = "psmdb-e2e" + +[[tool.mypy.overrides]] +module = ["pytest_html.*"] +follow_untyped_imports = true +strict = true + +[tool.ruff] +line-length = 99 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..174c7fddef --- /dev/null +++ b/uv.lock @@ -0,0 +1,614 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[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 = "cachebox" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/f6/85f176d2518cf1d1be5f981fc2dadf6b131e33fefd721f36b330e3434d6c/cachebox-5.2.3.tar.gz", hash = "sha256:b1f68246685aa739bbbd2734befb1465363a1e1042407c154feadb065f17a099", size = 63686, upload-time = "2026-04-10T12:21:35.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/3b/16d5c295f6ec2913ef595b39986dc7b7cc179fdd2e73f5ebd1814c38fd51/cachebox-5.2.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9527c5c70f8735f2d696331d8bcf77254f03b4dc8542046807823bd36ed4e8ba", size = 377408, upload-time = "2026-04-10T12:20:25.444Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/45f834154f79721e5b64a80ffab4f9710834c4f9c01fa977f94a9116c32a/cachebox-5.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40ac878af00d5969862c1f6bc076de1e34ca248662fce6aecca1761f52e33e32", size = 359274, upload-time = "2026-04-10T12:20:12.127Z" }, + { url = "https://files.pythonhosted.org/packages/46/17/794e5f93e0a172aa14ecd692f6d89bdf094f71eb35fa923d0a0af25cef1c/cachebox-5.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5ff26bfd8f7e95b3becf6d5f65c25edaca50fa68078868648b70d79bcccc260", size = 397520, upload-time = "2026-04-10T12:18:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/19/9470b1a96de6e480192b1a92b2fafa72aa052efc2509a5418a5652205b33/cachebox-5.2.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82e7002dd343afeeba2fcf0e483131b342a27ec3bc34b2214dc617691bda40d6", size = 353183, upload-time = "2026-04-10T12:19:07.797Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2b/72813f80397ed4640e337cbd1a14ab7eaafe33e479291d3623b6a6a55fec/cachebox-5.2.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ccbdc54a6c4b5758408c1083bdfa217bd382894a8331c7d0a54b84ba0cf51e5b", size = 372239, upload-time = "2026-04-10T12:19:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/05/17/47dc9687288fa55486573627089ecd9aae124de5924a4bce008af96d80b6/cachebox-5.2.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df5135a168f143d186b1cc3be0ca16b66446897ab5cedc03bd80bcc926fcd403", size = 392568, upload-time = "2026-04-10T12:19:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/13/95/450765b971a3bed9d7cf003c3833c1976482eb83b0241b6dbb840a25b43b/cachebox-5.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10bedf96db8f9766cc956f9adcc623e604264e5d6fa2e255432f8c2ed7519143", size = 397920, upload-time = "2026-04-10T12:19:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/dd8f4c1f92e58d479913ce9cbaa3227c911128e6046c82f4fd44309f685a/cachebox-5.2.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f22732d0d69bb84ad2dca7480bffdfd0430c647152d488936e152ecbbfee52fb", size = 427332, upload-time = "2026-04-10T12:19:45.888Z" }, + { url = "https://files.pythonhosted.org/packages/7e/20/80d8c26ce63e78da3874a5bb07a3a78de53a2b0356ba80583a4927f0a074/cachebox-5.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:26ae0b68979204d360327f4c0725cfdc95cfc34ab73ab1a8f528e3bd2f6d023c", size = 567494, upload-time = "2026-04-10T12:20:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/10/35/7249885dfed3602b3b48c1e67781197dcdc536c50f72caeabe3944348af8/cachebox-5.2.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f3d628b816e28a6e7661d460e02dd5b421247cc2cd275814f80ea79621245fc4", size = 669968, upload-time = "2026-04-10T12:20:55.155Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/e5b58f0bbd6fef74da5d8e5ab49e67898ce7e6df28c16280a0f2b78461f7/cachebox-5.2.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:64057caa6b741320655cd3c5997fe642dae5dbff571eb530e6f53e58272bb43b", size = 645547, upload-time = "2026-04-10T12:21:09.948Z" }, + { url = "https://files.pythonhosted.org/packages/d8/25/51783a4c6f25ca87ef1b4b762ff0364bd98053a02d597b30d26ff4cf13c5/cachebox-5.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa325306084aa2dc0b21e07723d7700f4d43dece3732c7fdaf7a269dc5e35aa7", size = 611844, upload-time = "2026-04-10T12:21:25.286Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/b26c4b046e296d0e249448fe297626b3caca2e851837712f03c358662cb7/cachebox-5.2.3-cp313-cp313-win32.whl", hash = "sha256:55003089d21c2f5515089c307be063b45558e884a4a1cc9593944374c89975c4", size = 279421, upload-time = "2026-04-10T12:21:54.921Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7f/a49420670393bfea618de7a893d45cae9294cf3293d7b158e7af20e8f39e/cachebox-5.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:dcc5edb6ecf2b516e90b773d232360c5e4ed8fdcda038b19441da2ed9cf208ab", size = 290702, upload-time = "2026-04-10T12:21:41.458Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/bf83bda13ef6fc490d208a1d4dd712034624526a88f61713cca0edc9884f/cachebox-5.2.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a4b7559fa4994c4032dd07466c2041d57e055feb814762e1f73f4e8beef188d0", size = 371704, upload-time = "2026-04-10T12:20:27.253Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ea/aa5162273238e84f9e41b33600c69299572dc1c8f0f768d07660b71be07d/cachebox-5.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f57afada3d9327adf87f3b5cf0094348c6fd49354ab2e9bd20b044648eb094ae", size = 353385, upload-time = "2026-04-10T12:20:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/96/3ca013e2e48df5c1d7855669b208f4bf8014ccb842ccf7a3a0eaac07bee0/cachebox-5.2.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8342ff350ce86f062492752d612e9f056ac5dc56375713d75c3bf6e83b4d18db", size = 392181, upload-time = "2026-04-10T12:18:56.385Z" }, + { url = "https://files.pythonhosted.org/packages/63/ca/1bacb4efa0b0ce8065d1fb7c8dc7c382ec4e1cc3f007eb08417732be2725/cachebox-5.2.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:405f9cc8492fc9d953b5a6b9e2b661e99583755c6639ab8d09a287fdf336503c", size = 349494, upload-time = "2026-04-10T12:19:09.505Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/75db4bda3768658f5baa5a54f6a4f643bc2de1a16788e40581a080e803c7/cachebox-5.2.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94aae393ec1d9b26565d346445bb6afa3963d2a0d3eb5e4188d0e510fab871a0", size = 369216, upload-time = "2026-04-10T12:19:22.224Z" }, + { url = "https://files.pythonhosted.org/packages/f5/82/e1f833be0d57e29a8c5eb0a0275cd34b962f3c7f5b9e0517ec4bf75e7cc3/cachebox-5.2.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8b0b575066fc09f6fae0d4bd30d6ff56584a6870cbe7d202916c5e0d725cfd4", size = 385922, upload-time = "2026-04-10T12:19:34.198Z" }, + { url = "https://files.pythonhosted.org/packages/53/d6/615a3c16c1d63839f2c67644eb414c4dc9769ab2e169d935110fd8e268d5/cachebox-5.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41e99c1240106d39b63ce7868a6cd8c9da9243fef08848b85d428164e0769fd2", size = 393276, upload-time = "2026-04-10T12:20:00.925Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a6/7844c9c84b170dae1005b22da174639968e64c8055d66a209a1598663771/cachebox-5.2.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:432ca62b99f7eafc21af669d76c88c1b7377db179b89fb6fca3ea93b8f9fff19", size = 421355, upload-time = "2026-04-10T12:19:47.691Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/43f62355846cae3dc41cb4daccac0a4bb2b7b8b3c7d77d1b6a220bae6d54/cachebox-5.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e51d9c59006b53447f806145406eb37a7fc3c25553d4fd24c3887f3b268d214e", size = 561656, upload-time = "2026-04-10T12:20:42.161Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fc/a453813c6d000d69a41a06c6a3143a6c4d0d0e41f23c155db2f82ea0edfa/cachebox-5.2.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:5e48a405f699fb001b8af120a6e0b4a981277f84eb5dd66a1faa21e4b6fe9485", size = 665791, upload-time = "2026-04-10T12:20:56.842Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a3/f6a9e75f1e602b67b6d67088a9a766adfc4e0a740a9c4b68e4e6207c1006/cachebox-5.2.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8cbfc007ea78af61d75d7d26e5854df53dc5da6877d074afd4b4696c074f4ee7", size = 640975, upload-time = "2026-04-10T12:21:11.641Z" }, + { url = "https://files.pythonhosted.org/packages/a3/15/4ac98277f7fd9d855c8ed337e8e2a3386d17997cce2dd3eadb23dedc08e3/cachebox-5.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6a94d0da8133b3a0707ae11c9ea321f8fc37e3b5a14517019a05d632218b0f56", size = 607242, upload-time = "2026-04-10T12:21:27.27Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0b/ce61907a803f75854e0cc91b84c16e14dce0e4e939efbda26293eb4c8784/cachebox-5.2.3-cp313-cp313t-win32.whl", hash = "sha256:5fee33549877c03c2494ec5359a57a7667f872fe8e296a7f39d3dfe08dd3914c", size = 271619, upload-time = "2026-04-10T12:21:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/b0/06/fece190ad5173d06b2779494aaad5528907f2e55c809618e5b67c2e3dbb5/cachebox-5.2.3-cp313-cp313t-win_amd64.whl", hash = "sha256:67548a05cd41fcc4f7af80a2f97f742fef3d436537ac2e1a1dce0fcba5d41190", size = 283133, upload-time = "2026-04-10T12:21:43.037Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8b/72c0e80aad08e09867ce14a621bce689a733552f20cdf2ef96d4b052da10/cachebox-5.2.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:37fa0891f0defee053c09f5f43f802f731e36e6e6ca055d7d174af07f77232ca", size = 380523, upload-time = "2026-04-10T12:20:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fc/62/33aaade81b181d5191cc39c867c297aa7c65f3191aa9749bf99b77496b88/cachebox-5.2.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dc6315902f2ef4afbf10bc8e08c54ff34de5ce124546b8e0016c9b0d327be21e", size = 362424, upload-time = "2026-04-10T12:20:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0b/3eedaf9ea4b41c931f4340bfa42056efe2bb5fe3a79649d6c8a1dce585a5/cachebox-5.2.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7df1735ca778480d51b8232fed397ffe3935158f20d34fb1c5ed171b53d5a6e2", size = 399572, upload-time = "2026-04-10T12:18:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/be/69/c79b8a6a5b889ac4a60800bacea3553cb3b86f6fd13b2262bade1cb962c6/cachebox-5.2.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e22451cde8f884051e941b21870e4fc91fcf58d0d8c285bb8964107e1f02445c", size = 353803, upload-time = "2026-04-10T12:19:11.21Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c3/bc7838de51039f8c50506d8dc82f22ff9a652794339a223b12af595e1d2f/cachebox-5.2.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dcbccf3015d9a42bcf41260fa5cc048a5bdb75aa10997d514d6c976117f30ee2", size = 374474, upload-time = "2026-04-10T12:19:23.658Z" }, + { url = "https://files.pythonhosted.org/packages/65/61/e5231ad2ae952ca482f9b9df55df4b96add1a80de28de537c5f574605987/cachebox-5.2.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:311eae5079e256cbbfafdc3dcff1714b6598a767f9c1ef8c3709e74ea0cc12b0", size = 393045, upload-time = "2026-04-10T12:19:35.651Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c9b3fa764ac5420a9e079ad53fa8840d4a26b74c4ccda56acbef49cf76ff/cachebox-5.2.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f4d2a80a5cd3380739c67f7d89e596634f5897b8d5a4a3dc1598312cb077535", size = 398700, upload-time = "2026-04-10T12:20:02.513Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3e/c4e3acd4cb04e01c5fb7cc7a4de16059b9594d90672fff85af8670275267/cachebox-5.2.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3977515b727a5203f494c44c4566fb936c4b940351c01d3d8e7b5d104dff4f53", size = 426725, upload-time = "2026-04-10T12:19:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/25/5d/610b79479719951581109d985244d34c97f86a308c3d7c83443e2b1dac46/cachebox-5.2.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c5be17dd5c4fabcfecd5bcf6d54f9c6fb719daed3ef01ac1c03a14af0e2b26c1", size = 570042, upload-time = "2026-04-10T12:20:43.793Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/cad8a05db4d0c0f5ba6bccb32e57d15c472276de9476f56004445b40711f/cachebox-5.2.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6d37334fc218fdaee31db8a4f938938716e7c3b1b4059e25de27c8447fc95fde", size = 670974, upload-time = "2026-04-10T12:20:58.528Z" }, + { url = "https://files.pythonhosted.org/packages/54/d1/9cff7c2b9048d1c38b7ad8199ce856596d09720b3bea74043f3bad71970b/cachebox-5.2.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1e5f1b7e23411b748d919348c3b65db1f9f8927ab8f6f3acae19bd617543df2d", size = 646213, upload-time = "2026-04-10T12:21:13.619Z" }, + { url = "https://files.pythonhosted.org/packages/27/ae/2e1ad162ec13903e84469c8a753baf385f1bc324279d6c7cb6365e7099df/cachebox-5.2.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7b06a75a898b31fd73c4d8bf727a9b9f8b5b7738cccd0ab5e6fd2a9cf659d3c", size = 612787, upload-time = "2026-04-10T12:21:29.271Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8a/07b5ffd841e1ff534bb6e8721c39fdfe0d7cdaac1398e1783b2a0c37bd22/cachebox-5.2.3-cp314-cp314-win32.whl", hash = "sha256:3b798052719f09a2ce7bf9fa9452dc0a7d4dc53b50a2d3aba6ce6ebc12d39df7", size = 278559, upload-time = "2026-04-10T12:21:58.482Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/b88a82ce9ec7a2fa0f09ed1cdd031692c8664c41f9ab71831e177c7ce2df/cachebox-5.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:4afc8b8575e3228a42ad8d819de5fbbecc6bd0b521295966b00244be37ae3b9b", size = 291928, upload-time = "2026-04-10T12:21:44.621Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/8c79c07c8c6517fb2fe7d479dd87044e38aac5b9af0245b33fcd695eae37/cachebox-5.2.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:0e8a34b82be30d3d9fb7dfaf9a86ec2b3ab9bc264715909ef27fc3d3587324d2", size = 374325, upload-time = "2026-04-10T12:20:30.923Z" }, + { url = "https://files.pythonhosted.org/packages/7f/51/0fc26b923e80ab857ac99d5f7f3784dc941e7b4de361c204835233176ddf/cachebox-5.2.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4d4e336aebf866463878ccd28a4d0ef4003ea216708cf4a02a7f198481b3af81", size = 355444, upload-time = "2026-04-10T12:20:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6d/a6b399221f8dc4b3e01b37d3240ef5b8a7eb78cd9bfbb99b0e655dd01649/cachebox-5.2.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b102fcdd97b0602bf5d6ba1a571bba3e3d6fa912b89fd768b0da5427408eab8", size = 393978, upload-time = "2026-04-10T12:18:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f1/4c8f998c117c1941a82bd824d6687280c50167f21fea6392e41531d641e2/cachebox-5.2.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245a79fb2c5d3bff252f4263f76210ef3ad7c2ff9b0234859b26974830a80491", size = 349298, upload-time = "2026-04-10T12:19:12.843Z" }, + { url = "https://files.pythonhosted.org/packages/d1/dd/683bc5a32a0da660d02fa248b880b71a2b834e9b54b8d272b5801282f402/cachebox-5.2.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd0e8dbd8fd4cf664c645c08f9e10508e133353756705c4a738e90a5406224b5", size = 370619, upload-time = "2026-04-10T12:19:25.298Z" }, + { url = "https://files.pythonhosted.org/packages/81/49/d6c47c78a7769b355076c5b635c2b538c8b88e8ceeb408e104d0f269b515/cachebox-5.2.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdb74294bdc33e39e26606919a9b2229038d5fac0edb80c9056683c08584d4a9", size = 385988, upload-time = "2026-04-10T12:19:37.638Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/b669555ada7fa1392e4cdb8a19f3367db5c6abef0fde8ab034a9747760df/cachebox-5.2.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bba3e9a7f52fa196b434522f39675f3b32a076976ef2373ded6f1065e99f4d20", size = 394090, upload-time = "2026-04-10T12:20:03.978Z" }, + { url = "https://files.pythonhosted.org/packages/8f/01/42916249e53fe4fcbdf0419fb55dbc09b9f377475376e1d7f4ae9c9bd6cd/cachebox-5.2.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abb21f0f937fb66528f1b9f1a04874d6aa503e78bbb26f4cf33bf67faddbdd68", size = 421632, upload-time = "2026-04-10T12:19:51.048Z" }, + { url = "https://files.pythonhosted.org/packages/a1/54/34eebe18c6ed8ba27b1331b5e3d08bd8bb62f03ba81fbf47a2db0fa646f7/cachebox-5.2.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dab6fd3189b0c746fb03e1915fd947aaca9112cedf26ef3a0c39383acf87d2e5", size = 563871, upload-time = "2026-04-10T12:20:45.417Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b2/f92da0d54e4f18609588709090de8c81dd7c8b20ed6ac30f9b91bedbedf5/cachebox-5.2.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e7d2935b9df11d3717f99c7237b6780f1f8c70e6a99b69b8430d89929ec825", size = 665677, upload-time = "2026-04-10T12:21:00.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/9d/bf2d3dc949afe4d21fc7eb15b7524255e834b9252df6bba111e6686d1c6f/cachebox-5.2.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:611aa260fe1b2506330ff72f415e2cb4053c9c4e3776ac68fe2eedee0e1b91b1", size = 642067, upload-time = "2026-04-10T12:21:15.727Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4f/a789eda189550d239fbaf165b9810f148e733e97a2a4eda7c4192295c7f8/cachebox-5.2.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a424ffb8514a9cb49bacff7995b7c767625cb2239692bd6524245e8579e375cc", size = 608048, upload-time = "2026-04-10T12:21:31.156Z" }, + { url = "https://files.pythonhosted.org/packages/41/c3/590e161c04ffbd36e33933e6dcca5ffa40b5548e3121a21d77aad42af138/cachebox-5.2.3-cp314-cp314t-win32.whl", hash = "sha256:83988dd8e9075ee837e8407e26db49a9944ae74924d5db57b477444d7d98622c", size = 271694, upload-time = "2026-04-10T12:22:00.589Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f60b8506df467261178afe918801df37c02c46ec2b8ce019760a14e2abe7/cachebox-5.2.3-cp314-cp314t-win_amd64.whl", hash = "sha256:dbda6390fa5070a19157ae35ab8066d3fe468634e0e9e21452c68ce7999c7d0c", size = 284212, upload-time = "2026-04-10T12:21:46.241Z" }, +] + +[[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 = "deepdiff" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachebox" }, + { name = "orderly-set" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/6b/6a4a5aaf38535eb332c2856aa08e73ed7c549d0851b1215401af0a2db1a7/deepdiff-9.1.0.tar.gz", hash = "sha256:07e9e366fab4297755153c4eab795ad4ef3cbd0d51660e847f5751c6bd727687", size = 382149, upload-time = "2026-05-15T20:18:05.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/26/4a2bad8eb430d8d805a4642c4bff25103a37548d74ab346f8b1e024abcc5/deepdiff-9.1.0-py3-none-any.whl", hash = "sha256:80c0460e1993b04f6f0ca79abf25548b129fd218478c4ebb08f80560f5d10610", size = 184662, upload-time = "2026-05-15T20:18:03.956Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "junitparser" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/ed/063362ed6c5e39273879ba3db91da13b00551c6277de6842e45ab55a1a22/junitparser-5.0.1.tar.gz", hash = "sha256:45d100ca35ce5e2596c1f251de5e0f9411827aa93edaba7ad2d8eef423eecdd0", size = 12051, upload-time = "2026-06-05T03:55:55.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/32/15c6dd4267d530c38ca8b661c8322d201a6766087efd81ef81d3456e3cad/junitparser-5.0.1-py3-none-any.whl", hash = "sha256:019410471ac82c6b49c3cd500b930b3f39a5dae34e5ca5d5f719c4dcd9bb7e9a", size = 15014, upload-time = "2026-06-05T03:55:54.478Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "orderly-set" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psmdb-pytest" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "deepdiff" }, + { name = "junitparser" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-dependency" }, + { name = "pytest-html" }, + { name = "pytest-html-merger" }, + { name = "pytest-json-report" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "deepdiff", specifier = ">=8.5.0" }, + { name = "junitparser", specifier = ">=3.2.0" }, + { name = "mypy", specifier = ">=1.16.0" }, + { name = "pytest", specifier = ">=8.4.0" }, + { name = "pytest-dependency", specifier = ">=0.6.0" }, + { name = "pytest-html", specifier = ">=4.1.1" }, + { name = "pytest-html-merger", specifier = ">=0.1.0" }, + { name = "pytest-json-report", specifier = ">=1.5.0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "rich", specifier = ">=14.0.0" }, + { name = "ruff", specifier = ">=0.11.12" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-dependency" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/ea/84509533e0f6477960b8a9179d240d93c909c6543e4dd8209932026d7815/pytest_dependency-0.6.1.tar.gz", hash = "sha256:246c24d2a5fc743a942cec4408853640e56a05ba58d46e5b213a1d4b738a2464", size = 20837, upload-time = "2026-02-15T18:08:43.927Z" } + +[[package]] +name = "pytest-html" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pytest" }, + { name = "pytest-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/08/2076aa09507e51c1119d16a84c6307354d16270558f1a44fc9a2c99fdf1d/pytest_html-4.2.0.tar.gz", hash = "sha256:b6a88cba507500d8709959201e2e757d3941e859fd17cfd4ed87b16fc0c67912", size = 108634, upload-time = "2026-01-19T11:25:26.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl", hash = "sha256:ff5caf3e17a974008e5816edda61168e6c3da442b078a44f8744865862a85636", size = 23801, upload-time = "2026-01-19T11:25:25.008Z" }, +] + +[[package]] +name = "pytest-html-merger" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/8d/8f1bc3282c636bf29c88579a136ae6add64ddf5f239d8bec52d7434e163c/pytest_html_merger-0.1.0.tar.gz", hash = "sha256:497b1e9c99c12eb06eee5fdf9abad42c10fec78524d740737def9d85b8f995e4", size = 17814, upload-time = "2024-07-12T16:29:45.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/33/6ebce34cf14de51d2a422e5f77493468e19afe042e35c3f78bebfa5275d6/pytest_html_merger-0.1.0-py3-none-any.whl", hash = "sha256:c1bf0574245dd67481b21630d68168fdf26a779f55b939f37e7aff4c438ea61b", size = 17200, upload-time = "2024-07-12T16:29:44.232Z" }, +] + +[[package]] +name = "pytest-json-report" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "pytest-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/d3/765dae9712fcd68d820338908c1337e077d5fdadccd5cacf95b9b0bea278/pytest-json-report-1.5.0.tar.gz", hash = "sha256:2dde3c647851a19b5f3700729e8310a6e66efb2077d674f27ddea3d34dc615de", size = 21241, upload-time = "2022-03-15T21:03:10.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/35/d07400c715bf8a88aa0c1ee9c9eb6050ca7fe5b39981f0eea773feeb0681/pytest_json_report-1.5.0-py3-none-any.whl", hash = "sha256:9897b68c910b12a2e48dd849f9a284b2c79a732a8a9cb398452ddd23d3c8c325", size = 13222, upload-time = "2022-03-15T21:03:08.65Z" }, +] + +[[package]] +name = "pytest-metadata" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/85/8c969f8bec4e559f8f2b958a15229a35495f5b4ce499f6b865eac54b878d/pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8", size = 9952, upload-time = "2024-02-12T19:38:44.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428, upload-time = "2024-02-12T19:38:42.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + +[[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" }, +]