diff --git a/.github/workflows/perf-test.yaml b/.github/workflows/perf-test.yaml new file mode 100644 index 000000000..a8da8fc4b --- /dev/null +++ b/.github/workflows/perf-test.yaml @@ -0,0 +1,210 @@ +name: perf-test + +on: + issue_comment: + types: [created] + +jobs: + perf-test: + if: github.event.comment.body == '/perf-test' && github.event.issue.pull_request != null + runs-on: ubuntu-24.04 + + steps: + + - name: Check if Commenter is a Collaborator + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMENTER: ${{ github.event.comment.user.login }} + REPO: ${{ github.repository }} + run: | + PERMISSIONS=$(gh api repos/$REPO/collaborators/$COMMENTER/permission --jq '.permission') + echo "User Permission: $PERMISSIONS" + + if [[ "$PERMISSIONS" != "admin" && "$PERMISSIONS" != "write" && "$PERMISSIONS" != "maintain" ]]; then + echo "❌ User does not have permission to trigger this action." + exit 1 + fi + + - name: Acknowledge Command + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + COMMENT_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments \ + -f body="🛠️ Perf test has started! Follow the progress here: [Workflow Run]($COMMENT_URL)" + + - name: Maximize build space + run: | + df -h + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo docker image prune --all --force + sudo rm -Rf ${JAVA_HOME_8_X64} + sudo rm -Rf ${JAVA_HOME_11_X64} + sudo rm -Rf ${JAVA_HOME_17_X64} + sudo rm -Rf ${RUBY_PATH} + df -h + + - name: Get PR head ref + id: pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_JSON=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}) + HEAD_SHA=$(echo "$PR_JSON" | jq -r '.head.sha') + HEAD_REF=$(echo "$PR_JSON" | jq -r '.head.ref') + echo "sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "ref=$HEAD_REF" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v5 + with: + ref: ${{ steps.pr.outputs.sha }} + + - uses: ./.github/actions/setup-rust + - uses: Swatinem/rust-cache@v2 + + - name: Setup cargo-binstall + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash + + - name: Install oidc CLI + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cargo binstall -y --force oidc-cli + + - name: Install HTTPie + run: | + python -m pip install --upgrade pip wheel + python -m pip install httpie + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Build + run: | + cargo build --bin trustd --release + + - name: Start trustd + env: + NO_COLOR: "true" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: + nohup cargo run --bin trustd --release &> trustd.log & + + - name: Wait for the backend to be up + run: | + URL="http://localhost:8080/.well-known/trustify" + TIMEOUT=300 # 5 minutes + INTERVAL=5 # Interval between retries in seconds + START_TIME=$(date +%s) + + while true; do + # Check if the endpoint is up + if curl -s --fail "$URL"; then + echo + echo "Endpoint is up!" + exit 0 + fi + + # Check if timeout has passed + CURRENT_TIME=$(date +%s) + ELAPSED_TIME=$((CURRENT_TIME - START_TIME)) + + if [ "$ELAPSED_TIME" -ge "$TIMEOUT" ]; then + echo "Timeout reached. Endpoint is still down." + exit 1 + fi + + echo "Endpoint is down. Retrying in $INTERVAL seconds..." + sleep "$INTERVAL" + done + + - name: Setup OIDC + run: | + oidc create confidential trustify --issuer http://localhost:8090/realms/trustify --client-id walker --client-secret R8A6KFeyxJsMDBhjfHbpZTIF0GWt43HP --force # no-secret + + - name: Ingest DS3 dataset + run: | + .github/scripts/benchmark.sh + + - name: Run perf tests + working-directory: tools/perf + env: + TOOLS_PERF_SCENARIO_FILE: etc/scenarios/empty.json5 + run: | + uv run locust \ + --host http://localhost:8080 \ + -u 10 -r 2 -t 5m \ + --headless \ + --html=report.html \ + --csv=results + + - name: Upload trustd logs + uses: actions/upload-artifact@v7 + if: always() + with: + name: logs + path: | + trustd.log + if-no-files-found: error + + - name: Upload perf report + uses: actions/upload-artifact@v7 + if: always() + with: + name: report + path: | + tools/perf/report.html + tools/perf/results_*.csv + if-no-files-found: warn + + - name: Post comment + if: always() + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ARTIFACT_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + if [ -f tools/perf/results_stats.csv ]; then + # Build a markdown table from the CSV stats + STATS_TABLE=$(python3 -c " + import csv, io, sys + with open('tools/perf/results_stats.csv') as f: + reader = csv.reader(f) + rows = list(reader) + if not rows: + sys.exit(0) + header = rows[0] + # Select useful columns: Type, Name, # Requests, # Failures, + # Median, Average, Min, Max, Avg size, RPS + cols = [0, 1, 2, 3, 5, 6, 7, 8, 9, 10] + hdr = [header[c] for c in cols] + print('| ' + ' | '.join(hdr) + ' |') + print('| ' + ' | '.join(['---'] * len(hdr)) + ' |') + for row in rows[1:]: + vals = [row[c] if c < len(row) else '' for c in cols] + print('| ' + ' | '.join(vals) + ' |') + ") + else + STATS_TABLE="No stats CSV produced." + fi + + COMMENT_BODY=$(cat <<__EOF__ +
Perf Test Report (Locust) + + $STATS_TABLE + +
+ + 📄 **[Full Report]($ARTIFACT_URL)** (Go to "Artifacts" and download **report**) + + __EOF__ + ) + gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments \ + -f body="$COMMENT_BODY" diff --git a/common/src/db/mod.rs b/common/src/db/mod.rs index f0b7d791c..80c36aa4d 100644 --- a/common/src/db/mod.rs +++ b/common/src/db/mod.rs @@ -624,6 +624,7 @@ impl ReadOnly { } /// Begins a read-only transaction. + #[instrument(skip(self), err(level=tracing::Level::INFO))] pub async fn begin(&self) -> Result { Ok(self .0 @@ -634,6 +635,7 @@ impl ReadOnly { /// Begins a read-only transaction with the given isolation level. /// /// The access mode is always forced to `ReadOnly`; passing `ReadWrite` returns an error. + #[instrument(skip(self), err(level=tracing::Level::INFO))] pub async fn begin_with_config( &self, isolation_level: Option, diff --git a/etc/test-data/csaf/cve-2023-0044-multi-score.json b/etc/test-data/csaf/cve-2023-0044-multi-score.json new file mode 100644 index 000000000..440f24d7a --- /dev/null +++ b/etc/test-data/csaf/cve-2023-0044-multi-score.json @@ -0,0 +1,161 @@ +{ + "document": { + "aggregate_severity": { + "namespace": "https://access.redhat.com/security/updates/classification/", + "text": "low" + }, + "category": "csaf_vex", + "csaf_version": "2.0", + "distribution": { + "text": "Copyright © Red Hat, Inc. All rights reserved.", + "tlp": { + "label": "WHITE", + "url": "https://www.first.org/tlp/" + } + }, + "lang": "en", + "notes": [ + { + "category": "description", + "text": "Synthetic CSAF advisory created for trustify integration tests. Derived from cve-2023-0044.json with an additional CVSS v2 score to test multi-score deduplication.", + "title": "Test Data" + } + ], + "publisher": { + "category": "other", + "contact_details": "https://github.com/guacsec/trustify", + "issuing_authority": "Synthetic test data — not a real advisory.", + "name": "trustify test suite", + "namespace": "https://github.com/guacsec/trustify" + }, + "references": [ + { + "category": "self", + "summary": "Canonical URL", + "url": "https://access.redhat.com/security/data/csaf/beta/vex/2023/cve-2023-0044-multi-score.json" + } + ], + "title": "quarkus-vertx-http: multi-score test variant", + "tracking": { + "current_release_date": "2023-11-13T11:31:31+00:00", + "generator": { + "date": "2023-11-13T12:16:30+00:00", + "engine": { + "name": "trustify test suite (synthetic)", + "version": "0.0.0" + } + }, + "id": "CVE-2023-0044-multi-score", + "initial_release_date": "2023-01-04T00:00:00+00:00", + "revision_history": [ + { + "date": "2023-01-04T00:00:00+00:00", + "number": "1", + "summary": "Initial version" + } + ], + "status": "final", + "version": "1" + } + }, + "product_tree": { + "branches": [ + { + "branches": [ + { + "category": "product_name", + "name": "Red Hat build of Quarkus", + "product": { + "name": "Red Hat build of Quarkus", + "product_id": "red_hat_build_of_quarkus", + "product_identification_helper": { + "cpe": "cpe:/a:redhat:quarkus:2" + } + } + }, + { + "category": "product_version", + "name": "io.quarkus/quarkus-vertx-http", + "product": { + "name": "io.quarkus/quarkus-vertx-http", + "product_id": "io.quarkus/quarkus-vertx-http" + } + } + ], + "category": "vendor", + "name": "Red Hat" + } + ], + "relationships": [ + { + "category": "default_component_of", + "full_product_name": { + "name": "io.quarkus/quarkus-vertx-http as a component of Red Hat build of Quarkus", + "product_id": "red_hat_build_of_quarkus:io.quarkus/quarkus-vertx-http" + }, + "product_reference": "io.quarkus/quarkus-vertx-http", + "relates_to_product_reference": "red_hat_build_of_quarkus" + } + ] + }, + "vulnerabilities": [ + { + "cve": "CVE-2023-0044", + "discovery_date": "2023-01-04T00:00:00+00:00", + "product_status": { + "known_affected": [ + "red_hat_build_of_quarkus:io.quarkus/quarkus-vertx-http" + ] + }, + "references": [ + { + "category": "self", + "summary": "Canonical URL", + "url": "https://access.redhat.com/security/cve/CVE-2023-0044" + } + ], + "release_date": "2023-01-04T00:00:00+00:00", + "remediations": [ + { + "category": "none_available", + "details": "Affected", + "product_ids": [ + "red_hat_build_of_quarkus:io.quarkus/quarkus-vertx-http" + ] + } + ], + "scores": [ + { + "cvss_v3": { + "attackComplexity": "LOW", + "attackVector": "NETWORK", + "availabilityImpact": "NONE", + "baseScore": 5.3, + "baseSeverity": "MEDIUM", + "confidentialityImpact": "LOW", + "integrityImpact": "NONE", + "privilegesRequired": "NONE", + "scope": "UNCHANGED", + "userInteraction": "NONE", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N", + "version": "3.1" + }, + "products": [ + "red_hat_build_of_quarkus:io.quarkus/quarkus-vertx-http" + ] + }, + { + "cvss_v2": { + "baseScore": 3.5, + "vectorString": "AV:N/AC:M/Au:S/C:P/I:N/A:N", + "version": "2.0" + }, + "products": [ + "red_hat_build_of_quarkus:io.quarkus/quarkus-vertx-http" + ] + } + ], + "title": "quarkus-vertx-http: multi-score test" + } + ] + } diff --git a/etc/test-data/csaf/cve-2099-0001.json b/etc/test-data/csaf/cve-2099-0001.json new file mode 100644 index 000000000..cb4b75bf9 --- /dev/null +++ b/etc/test-data/csaf/cve-2099-0001.json @@ -0,0 +1,151 @@ +{ + "document": { + "aggregate_severity": { + "namespace": "https://access.redhat.com/security/updates/classification/", + "text": "high" + }, + "category": "csaf_vex", + "csaf_version": "2.0", + "distribution": { + "text": "Copyright © Red Hat, Inc. All rights reserved.", + "tlp": { + "label": "WHITE", + "url": "https://www.first.org/tlp/" + } + }, + "lang": "en", + "notes": [ + { + "category": "description", + "text": "Synthetic CSAF advisory created for trustify integration tests. Not a real vulnerability.", + "title": "Test Data" + } + ], + "publisher": { + "category": "other", + "contact_details": "https://github.com/guacsec/trustify", + "issuing_authority": "Synthetic test data — not a real advisory.", + "name": "trustify test suite", + "namespace": "https://github.com/guacsec/trustify" + }, + "references": [ + { + "category": "self", + "summary": "Canonical URL", + "url": "https://access.redhat.com/security/data/csaf/beta/vex/2099/cve-2099-0001.json" + } + ], + "title": "netty-handler: synthetic high-severity test advisory", + "tracking": { + "current_release_date": "2099-01-01T00:00:00+00:00", + "generator": { + "date": "2099-01-01T00:00:00+00:00", + "engine": { + "name": "trustify test suite (synthetic)", + "version": "0.0.0" + } + }, + "id": "CVE-2099-0001", + "initial_release_date": "2099-01-01T00:00:00+00:00", + "revision_history": [ + { + "date": "2099-01-01T00:00:00+00:00", + "number": "1", + "summary": "Initial version" + } + ], + "status": "final", + "version": "1" + } + }, + "product_tree": { + "branches": [ + { + "branches": [ + { + "category": "product_name", + "name": "Red Hat build of Apache ZooKeeper", + "product": { + "name": "Red Hat build of Apache ZooKeeper", + "product_id": "red_hat_zookeeper", + "product_identification_helper": { + "cpe": "cpe:/a:redhat:zookeeper:3" + } + } + }, + { + "category": "product_version", + "name": "io.netty/netty-handler", + "product": { + "name": "io.netty/netty-handler", + "product_id": "io.netty/netty-handler" + } + } + ], + "category": "vendor", + "name": "Red Hat" + } + ], + "relationships": [ + { + "category": "default_component_of", + "full_product_name": { + "name": "io.netty/netty-handler as a component of Red Hat build of Apache ZooKeeper", + "product_id": "red_hat_zookeeper:io.netty/netty-handler" + }, + "product_reference": "io.netty/netty-handler", + "relates_to_product_reference": "red_hat_zookeeper" + } + ] + }, + "vulnerabilities": [ + { + "cve": "CVE-2099-0001", + "discovery_date": "2099-01-01T00:00:00+00:00", + "product_status": { + "known_affected": [ + "red_hat_zookeeper:io.netty/netty-handler" + ] + }, + "references": [ + { + "category": "self", + "summary": "Canonical URL", + "url": "https://access.redhat.com/security/cve/CVE-2099-0001" + } + ], + "release_date": "2099-01-01T00:00:00+00:00", + "remediations": [ + { + "category": "none_available", + "details": "Affected", + "product_ids": [ + "red_hat_zookeeper:io.netty/netty-handler" + ] + } + ], + "scores": [ + { + "cvss_v3": { + "attackComplexity": "LOW", + "attackVector": "NETWORK", + "availabilityImpact": "HIGH", + "baseScore": 7.5, + "baseSeverity": "HIGH", + "confidentialityImpact": "NONE", + "integrityImpact": "NONE", + "privilegesRequired": "NONE", + "scope": "UNCHANGED", + "userInteraction": "NONE", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "version": "3.1" + }, + "products": [ + "red_hat_zookeeper:io.netty/netty-handler" + ] + } + ], + "title": "netty-handler: synthetic high-severity test vulnerability" + } + ] + } diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 051d32875..fe74055d9 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -63,6 +63,7 @@ mod m0002180_advisory_fk_indexes; mod m0002190_vulnerability_base_score_advisory; mod m0002200_source_document_ingested_index; mod m0002210_sbom_node_name_index; +mod m0002220_drop_qualified_purl_gist_indexes; pub trait MigratorExt: Send { fn build_migrations() -> Migrations; @@ -141,6 +142,7 @@ impl MigratorExt for Migrator { .normal(m0002130_sbom_ancestor::Migration) .normal(m0002200_source_document_ingested_index::Migration) .normal(m0002210_sbom_node_name_index::Migration) + .normal(m0002220_drop_qualified_purl_gist_indexes::Migration) } } diff --git a/migration/src/m0002220_drop_qualified_purl_gist_indexes.rs b/migration/src/m0002220_drop_qualified_purl_gist_indexes.rs new file mode 100644 index 000000000..934bcfd71 --- /dev/null +++ b/migration/src/m0002220_drop_qualified_purl_gist_indexes.rs @@ -0,0 +1,65 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +#[allow(deprecated)] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Drop GiST trigram indexes on qualified_purl that were + // accidentally reintroduced by the migration squash (a53ef329). + // These were intentionally removed in cab2b594 because the + // codebase only uses ILIKE queries, which are served by the + // existing GIN trigram indexes on the same columns. The GiST + // indexes cause the planner to pick a slower scan path. + for idx in GIST_INDEXES { + manager + .drop_index( + Index::drop() + .if_exists() + .name(*idx) + .table(QualifiedPurl::Table) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for (idx, expr) in GIST_INDEXES.iter().zip(GIST_EXPRS.iter()) { + manager + .get_connection() + .execute_unprepared(&format!( + r#"CREATE INDEX IF NOT EXISTS {idx} + ON qualified_purl + USING GIST (({expr}) gist_trgm_ops)"#, + )) + .await + .map(|_| ())?; + } + + Ok(()) + } +} + +const GIST_INDEXES: &[&str] = &[ + "qualifiedpurlnamejsongistidx", + "qualifiedpurlnamespacejsongistidx", + "qualifiedpurltypejsongistidx", + "qualifiedpurlversionjsongistidx", +]; + +const GIST_EXPRS: &[&str] = &[ + "purl ->> 'name'", + "purl ->> 'namespace'", + "purl ->> 'ty'", + "purl ->> 'version'", +]; + +#[derive(DeriveIden)] +pub enum QualifiedPurl { + Table, +} diff --git a/modules/fundamental/src/sbom/endpoints/mod.rs b/modules/fundamental/src/sbom/endpoints/mod.rs index c188af106..f9063b108 100644 --- a/modules/fundamental/src/sbom/endpoints/mod.rs +++ b/modules/fundamental/src/sbom/endpoints/mod.rs @@ -217,6 +217,13 @@ mod v2 { } } +#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Deserialize, IntoParams)] +pub struct SbomListParams { + /// Include advisory severity summary per SBOM + #[serde(default)] + pub advisories: bool, +} + mod v3 { use super::*; use crate::sbom::model::SbomPackageSummary; @@ -229,17 +236,20 @@ mod v3 { Query, Paginated, GroupFilterQuery, + SbomListParams, ), responses( (status = 200, description = "Matching SBOMs", body = PaginatedResults>), ), )] #[get("/v3/sbom")] + #[allow(clippy::too_many_arguments)] pub async fn all( fetch: web::Data, db: web::Data, web::Query(search): web::Query, web::Query(paginated): web::Query, + web::Query(params): web::Query, QsQuery(group_filter): QsQuery, authorizer: web::Data, user: UserInformation, @@ -247,7 +257,7 @@ mod v3 { authorizer.require(&user, Permission::ReadSbom)?; let tx = db.begin().await?; - let mut options = FetchOptions::default(); + let mut options = FetchOptions::default().advisories(params.advisories); if !group_filter.group.is_empty() { options = options.groups(group_filter.group); } diff --git a/modules/fundamental/src/sbom/endpoints/test.rs b/modules/fundamental/src/sbom/endpoints/test.rs index 229f260a2..bd1ccff7b 100644 --- a/modules/fundamental/src/sbom/endpoints/test.rs +++ b/modules/fundamental/src/sbom/endpoints/test.rs @@ -2228,3 +2228,89 @@ async fn related_by_hash(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { Ok(()) } + +/// Verify the optional `?advisories=true` enrichment on the SBOM list endpoint. +/// +/// The `expected` array contains the expected `advisories` value for each SBOM, +/// sorted alphabetically by SBOM name. +#[test_context(TrustifyContext)] +#[rstest] +// Without ?advisories param, the field should be absent from the response +#[case::without_param( + &["quarkus-bom-2.13.8.Final-redhat-00004.json"], + false, + json!([null]), +)] +// With ?advisories=true but no matching advisory data, the field is present but empty +#[case::no_advisory_data( + &["quarkus-bom-2.13.8.Final-redhat-00004.json"], + true, + json!([{}]), +)] +// CSAF advisory with CVSS 5.3 produces a medium severity count +#[case::medium_severity( + &["quarkus-bom-2.13.8.Final-redhat-00004.json", "csaf/cve-2023-0044.json"], + true, + json!([{"medium": 1}]), +)] +// CVE without CVSS scores maps to unknown severity +#[case::unknown_severity( + &["quarkus-bom-2.13.8.Final-redhat-00004.json", "cve/CVE-2024-26308.json"], + true, + json!([{"unknown": 1}]), +)] +// Multiple CVSS versions (v2 low + v3.1 medium) should pick the highest severity +#[case::multi_score_highest_wins( + &["quarkus-bom-2.13.8.Final-redhat-00004.json", "csaf/cve-2023-0044-multi-score.json"], + true, + json!([{"medium": 1}]), +)] +// Multiple severities from different advisories for the same SBOM +#[case::multiple_severities( + &["quarkus-bom-2.13.8.Final-redhat-00004.json", "csaf/cve-2023-0044.json", "cve/CVE-2024-26308.json"], + true, + json!([{"medium": 1, "unknown": 1}]), +)] +// Multiple SBOMs: only one matches the advisory (sorted by name: quarkus, zookeeper) +#[case::multiple_sboms_one_match( + &["quarkus-bom-2.13.8.Final-redhat-00004.json", "zookeeper-3.9.2-cyclonedx.json", "csaf/cve-2023-0044.json"], + true, + json!([{"medium": 1}, {}]), +)] +// Multiple SBOMs: both match different advisories with different severities +#[case::multiple_sboms_both_match( + &["quarkus-bom-2.13.8.Final-redhat-00004.json", "zookeeper-3.9.2-cyclonedx.json", "csaf/cve-2023-0044.json", "csaf/cve-2099-0001.json"], + true, + json!([{"medium": 1}, {"high": 1}]), +)] +#[test_log::test(actix_web::test)] +async fn list_sboms_advisory_summary( + ctx: &TrustifyContext, + #[case] docs: &[&str], + #[case] advisories_param: bool, + #[case] expected: Value, +) -> Result<(), anyhow::Error> { + let app = caller(ctx).await?; + ctx.ingest_documents(docs).await?; + + let advisories_query = if advisories_param { + "&advisories=true" + } else { + "" + }; + let uri = format!("/api/v3/sbom?sort=name{advisories_query}"); + let response: Value = app + .call_and_read_body_json(TestRequest::get().uri(&uri).to_request()) + .await; + + let items = response["items"] + .as_array() + .expect("items should be an array"); + let actual = items + .iter() + .map(|i| i["advisories"].clone()) + .collect::>(); + assert_eq!(Value::Array(actual), expected); + + Ok(()) +} diff --git a/modules/fundamental/src/sbom/model/mod.rs b/modules/fundamental/src/sbom/model/mod.rs index 08fc61e1c..a67178710 100644 --- a/modules/fundamental/src/sbom/model/mod.rs +++ b/modules/fundamental/src/sbom/model/mod.rs @@ -4,22 +4,57 @@ pub mod raw_sql; use super::service::SbomService; use crate::{ Error, - common::{LicenseInfo, LicenseRefMapping}, + common::{LicenseInfo, LicenseRefMapping, model::Severity}, purl::model::summary::purl::PurlSummary, sbom::service::sbom::IntoPackage, source_document::model::SourceDocument, }; +use isx::IsDefault; use sea_orm::{ConnectionTrait, FromQueryResult, ModelTrait, PaginatorTrait, prelude::Uuid}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use time::OffsetDateTime; use tracing::{info_span, instrument}; use tracing_futures::Instrument; -use trustify_common::{cpe::Cpe, purl::Purl, requested_field::RequestedField}; +use trustify_common::{ + cpe::Cpe, + purl::Purl, + requested_field::{BoolRequestedField, RequestedField}, +}; use trustify_entity::{ labels::Labels, relationship::Relationship, sbom, sbom_node, sbom_package, source_document, }; use utoipa::ToSchema; +/// Severity level for affected vulnerabilities, extending the shared `Severity` +/// enum with an `Unknown` variant for vulnerabilities that have no CVSS score. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum AffectedSeverity { + Unknown, + None, + Low, + Medium, + High, + Critical, +} + +impl From> for AffectedSeverity { + fn from(value: Option) -> Self { + match value { + Option::None => AffectedSeverity::Unknown, + Some(Severity::None) => AffectedSeverity::None, + Some(Severity::Low) => AffectedSeverity::Low, + Some(Severity::Medium) => AffectedSeverity::Medium, + Some(Severity::High) => AffectedSeverity::High, + Some(Severity::Critical) => AffectedSeverity::Critical, + } + } +} + +/// Per-SBOM summary of affected vulnerability counts grouped by severity. +pub type SbomAdvisorySummary = HashMap; + #[derive(Serialize, Deserialize, Debug, Clone, ToSchema, Default)] pub struct SbomHead { #[serde(with = "uuid::serde::urn")] @@ -86,6 +121,9 @@ pub struct SbomSummary { pub source_document: SourceDocument, pub described_by: Vec

, + + #[serde(default, skip_serializing_if = "IsDefault::is_default")] + pub advisories: RequestedField, } impl SbomSummary

{ @@ -102,6 +140,7 @@ impl SbomSummary

{ head: SbomHead::from_entity(&sbom, &node, db).await?, source_document: SourceDocument::from_entity(&source_document), described_by, + advisories: RequestedField::NotRequested, }) } @@ -111,6 +150,7 @@ impl SbomSummary

{ pub async fn from_entities( entities: Vec<(sbom::Model, sbom_node::Model, source_document::Model)>, service: &SbomService, + include_advisories: bool, db: &C, ) -> Result, Error> { if entities.is_empty() { @@ -121,23 +161,39 @@ impl SbomSummary

{ let mut describes_map = service.batch_describes_packages(&sbom_ids, db).await?; let counts_map = service.batch_package_counts(&sbom_ids, db).await?; + let advisories_map = if include_advisories { + Some( + service + .batch_advisory_severity_counts(&sbom_ids, db) + .await?, + ) + } else { + None + }; let results = entities .into_iter() - .map(|(sbom, node, source_document)| SbomSummary { - head: SbomHead { - id: sbom.sbom_id, - document_id: sbom.document_id, - labels: sbom.labels, - published: sbom.published, - authors: sbom.authors, - suppliers: sbom.suppliers, - name: node.name, - data_licenses: sbom.data_licenses, - number_of_packages: counts_map.get(&sbom.sbom_id).copied().unwrap_or(0), - }, - source_document: SourceDocument::from_entity(&source_document), - described_by: describes_map.remove(&sbom.sbom_id).unwrap_or_default(), + .map(|(sbom, node, source_document)| { + let advisory_summary = advisories_map + .as_ref() + .and_then(|m| m.get(&sbom.sbom_id).cloned()); + SbomSummary { + head: SbomHead { + id: sbom.sbom_id, + document_id: sbom.document_id, + labels: sbom.labels, + published: sbom.published, + authors: sbom.authors, + suppliers: sbom.suppliers, + name: node.name, + data_licenses: sbom.data_licenses, + number_of_packages: counts_map.get(&sbom.sbom_id).copied().unwrap_or(0), + }, + source_document: SourceDocument::from_entity(&source_document), + described_by: describes_map.remove(&sbom.sbom_id).unwrap_or_default(), + advisories: include_advisories + .then_requested(|| Some(advisory_summary.unwrap_or_default())), + } }) .collect(); diff --git a/modules/fundamental/src/sbom/model/raw_sql.rs b/modules/fundamental/src/sbom/model/raw_sql.rs index 6ba8119d8..287bb023b 100644 --- a/modules/fundamental/src/sbom/model/raw_sql.rs +++ b/modules/fundamental/src/sbom/model/raw_sql.rs @@ -34,6 +34,171 @@ pub const CONTEXT_CPE_FILTER_SQL: &str = r#" ) "#; +/// Returns SQL that counts affected vulnerabilities grouped by severity for +/// multiple SBOMs in a single query. Combines both PURL-based matching (via +/// `purl_status` + `version_matches()`) and CPE-based matching (via +/// `product_status` + package name matching). Takes `$1 = Uuid[]` and returns +/// `(sbom_id, severity, count)` rows. +/// +/// Uses a shared `sbom_purl_info` CTE (referenced 3x) that PostgreSQL +/// auto-materializes, acting as a barrier that prevents the planner from +/// inlining the SBOM's package set and scanning the full `versioned_purl` +/// table. The advisory filter is deferred to a separate CTE so the +/// expensive `version_matches()` narrows the set before any advisory +/// lookups. +pub fn batch_severity_counts_sql() -> &'static str { + r#" + WITH + -- Unnest the input array of SBOM IDs + input_sboms AS ( + SELECT unnest($1::uuid[]) AS sbom_id + ), + + -- Shared CTE: SBOM package info including version, base_purl_id, + -- and name/namespace. Referenced 3x so PostgreSQL auto-materializes + -- it, preventing the planner from inlining and scanning the full + -- versioned_purl table. Including base_purl here nudges the planner + -- to hash the small side (20k rows) instead of the large one (1.6M). + sbom_purl_info AS ( + SELECT + spr.sbom_id, + vp.version, + vp.base_purl_id, + bp.name, + bp.namespace + FROM input_sboms i + JOIN sbom_node_purl_ref spr ON spr.sbom_id = i.sbom_id + JOIN qualified_purl qp ON spr.qualified_purl_id = qp.id + JOIN versioned_purl vp ON qp.versioned_purl_id = vp.id + JOIN base_purl bp ON vp.base_purl_id = bp.id + ), + + -- PURL-based matching: version_matches called only for SBOM's packages, + -- advisory filter deferred to avoid unnecessary lookups. + purl_version_matches AS ( + SELECT DISTINCT + sp.sbom_id, + pst.advisory_id, + pst.vulnerability_id + FROM sbom_purl_info sp + JOIN purl_status pst ON pst.base_purl_id = sp.base_purl_id + JOIN version_range vr ON pst.version_range_id = vr.id + JOIN status ON pst.status_id = status.id + WHERE status.slug = 'affected' + AND version_matches(sp.version, vr.*) + ), + purl_matches AS ( + SELECT pm.sbom_id, pm.advisory_id, pm.vulnerability_id + FROM purl_version_matches pm + WHERE NOT EXISTS ( + SELECT 1 FROM advisory a + WHERE a.id = pm.advisory_id AND a.deprecated + ) + ), + + -- CPE-based matching: per-SBOM allowed CPE IDs with generalized matching + sbom_cpes AS ( + SELECT i.sbom_id, cpe.* + FROM input_sboms i + JOIN sbom_describing_cpe sdc ON sdc.sbom_id = i.sbom_id + JOIN cpe ON sdc.cpe_id = cpe.id + ), + sbom_generalized_cpes AS ( + SELECT sc.sbom_id, c.* + FROM sbom_cpes sc + JOIN cpe c ON c.vendor = sc.vendor + AND c.product = sc.product + AND c.version = split_part(sc.version, '.', 1) + AND (c.edition IS NULL OR c.edition = '*') + ), + sbom_allowed_cpes AS ( + SELECT sbom_id, id AS cpe_id FROM sbom_cpes + UNION + SELECT sbom_id, id AS cpe_id FROM sbom_generalized_cpes + ), + sbom_has_cpes AS ( + SELECT DISTINCT sbom_id FROM sbom_cpes + ), + + -- CPE product_status matches by name + cpe_matches_name AS ( + SELECT DISTINCT + sp.sbom_id, + ps.advisory_id, + ps.vulnerability_id + FROM product_status ps + JOIN sbom_purl_info sp ON ps.package = sp.name + JOIN status ON ps.status_id = status.id + JOIN advisory ON ps.advisory_id = advisory.id + WHERE status.slug = 'affected' + AND advisory.deprecated = false + AND ( + ps.context_cpe_id IS NULL + OR ps.context_cpe_id IN (SELECT cpe_id FROM sbom_allowed_cpes sac WHERE sac.sbom_id = sp.sbom_id) + OR sp.sbom_id NOT IN (SELECT sbom_id FROM sbom_has_cpes) + ) + ), + + -- CPE product_status matches by namespace/name + cpe_matches_ns AS ( + SELECT DISTINCT + sp.sbom_id, + ps.advisory_id, + ps.vulnerability_id + FROM product_status ps + JOIN sbom_purl_info sp ON ps.package = CONCAT(sp.namespace, '/', sp.name) + JOIN status ON ps.status_id = status.id + JOIN advisory ON ps.advisory_id = advisory.id + WHERE sp.namespace IS NOT NULL + AND status.slug = 'affected' + AND advisory.deprecated = false + AND ( + ps.context_cpe_id IS NULL + OR ps.context_cpe_id IN (SELECT cpe_id FROM sbom_allowed_cpes sac WHERE sac.sbom_id = sp.sbom_id) + OR sp.sbom_id NOT IN (SELECT sbom_id FROM sbom_has_cpes) + ) + ), + + -- Union all matches + all_affected AS ( + SELECT * FROM purl_matches + UNION + SELECT * FROM cpe_matches_name + UNION + SELECT * FROM cpe_matches_ns + ), + + -- Pick the highest severity per (sbom, advisory, vulnerability), + -- collapsing multiple CVSS versions (e.g. v2 + v3.1) into one row. + -- Unknown (no CVSS score) is treated as the highest severity. + scored AS ( + SELECT DISTINCT ON (a.sbom_id, a.advisory_id, a.vulnerability_id) + a.sbom_id, + COALESCE(avs.severity::text, 'unknown') AS severity + FROM all_affected a + LEFT JOIN advisory_vulnerability_score avs + ON avs.advisory_id = a.advisory_id + AND avs.vulnerability_id = a.vulnerability_id + ORDER BY a.sbom_id, a.advisory_id, a.vulnerability_id, + CASE avs.severity::text + WHEN 'critical' THEN 5 + WHEN 'high' THEN 4 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 2 + WHEN 'none' THEN 1 + ELSE 6 + END DESC + ) + + SELECT + sbom_id, + severity, + COUNT(*) AS count + FROM scored + GROUP BY sbom_id, severity + "# +} + pub fn product_advisory_info_sql() -> String { r#" WITH diff --git a/modules/fundamental/src/sbom/service/sbom.rs b/modules/fundamental/src/sbom/service/sbom.rs index 854e81a12..d7e9fa428 100644 --- a/modules/fundamental/src/sbom/service/sbom.rs +++ b/modules/fundamental/src/sbom/service/sbom.rs @@ -4,8 +4,9 @@ use crate::{ common::license_filtering::{LICENSE, license_text_coalesce}, purl::model::summary::purl::PurlSummary, sbom::model::{ - ModelCatcher, SbomExternalPackageReference, SbomModel, SbomNodeReference, SbomPackage, - SbomPackageRelation, SbomPackageSummary, SbomSummary, Which, details::SbomDetails, + AffectedSeverity, ModelCatcher, SbomAdvisorySummary, SbomExternalPackageReference, + SbomModel, SbomNodeReference, SbomPackage, SbomPackageRelation, SbomPackageSummary, + SbomSummary, Which, details::SbomDetails, raw_sql, }, }; use sea_orm::{ @@ -46,6 +47,7 @@ use trustify_entity::{ pub struct FetchOptions { labels: Labels, groups: Option>, + pub advisories: bool, } impl FetchOptions { @@ -63,6 +65,12 @@ impl FetchOptions { ); self } + + /// Include advisory severity summary counts in the response. + pub fn advisories(mut self, advisories: bool) -> Self { + self.advisories = advisories; + self + } } impl SbomService { @@ -191,6 +199,10 @@ impl SbomService { } /// fetch all SBOMs + #[instrument( + skip(self, connection), + err(level=tracing::Level::INFO) + )] pub async fn fetch_sboms( &self, search: Query, @@ -300,9 +312,8 @@ impl SbomService { .filter_map(|(sbom, node, source_document)| Some((sbom, node?, source_document?))) .collect(); - let items = SbomSummary::from_entities(filtered, self, connection) - .instrument(info_span!("from_entities")) - .await?; + let items = + SbomSummary::from_entities(filtered, self, options.advisories, connection).await?; Ok(PaginatedResults { total, items }) } @@ -643,6 +654,50 @@ impl SbomService { Ok(result) } + /// Count affected vulnerabilities grouped by severity for multiple SBOMs + /// in a single batch query, combining both PURL and CPE matching paths. + #[instrument(skip(self, db), err(level=tracing::Level::INFO))] + pub async fn batch_advisory_severity_counts( + &self, + sbom_ids: &[Uuid], + db: &C, + ) -> Result, Error> { + if sbom_ids.is_empty() { + return Ok(HashMap::new()); + } + + let stmt = Statement::from_sql_and_values( + db.get_database_backend(), + raw_sql::batch_severity_counts_sql(), + vec![sbom_ids.to_vec().into()], + ); + + let rows = db.query_all(stmt).await?; + + let mut result: HashMap = HashMap::new(); + for row in rows { + let sbom_id: Uuid = row.try_get("", "sbom_id")?; + let severity_str: String = row.try_get("", "severity")?; + let count: i64 = row.try_get("", "count")?; + + let severity = match severity_str.as_str() { + "none" => AffectedSeverity::None, + "low" => AffectedSeverity::Low, + "medium" => AffectedSeverity::Medium, + "high" => AffectedSeverity::High, + "critical" => AffectedSeverity::Critical, + _ => AffectedSeverity::Unknown, + }; + + result + .entry(sbom_id) + .or_default() + .insert(severity, count as u64); + } + + Ok(result) + } + #[instrument(skip(self, connection), err(level=tracing::Level::INFO))] pub async fn count_related_sboms( &self, @@ -769,9 +824,7 @@ impl SbomService { .filter_map(|(sbom, node, source_document)| Some((sbom, node?, source_document?))) .collect(); - let items = SbomSummary::from_entities(filtered, self, connection) - .instrument(info_span!("from_entities")) - .await?; + let items = SbomSummary::from_entities(filtered, self, false, connection).await?; Ok(PaginatedResults { items, total }) } diff --git a/openapi.yaml b/openapi.yaml index b5bbc2ba9..93525342c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2668,6 +2668,12 @@ paths: type: array items: type: string + - name: advisories + in: query + description: Include advisory severity summary per SBOM + required: false + schema: + type: boolean responses: '200': description: Matching SBOMs @@ -5550,6 +5556,8 @@ components: required: - described_by properties: + advisories: + $ref: '#/components/schemas/RequestedField_HashMap_HashMap' described_by: type: array items: @@ -5575,6 +5583,8 @@ components: required: - described_by properties: + advisories: + $ref: '#/components/schemas/RequestedField_HashMap_HashMap' described_by: type: array items: @@ -5650,6 +5660,8 @@ components: required: - described_by properties: + advisories: + $ref: '#/components/schemas/RequestedField_HashMap_HashMap' described_by: type: array items: @@ -6095,6 +6107,26 @@ components: type: string format: date-time description: Start of the import run + RequestedField_HashMap_HashMap: + oneOf: + - type: 'null' + - type: object + additionalProperties: + type: integer + format: int64 + minimum: 0 + propertyNames: + type: string + description: |- + Severity level for affected vulnerabilities, extending the shared `Severity` + enum with an `Unknown` variant for vulnerabilities that have no CVSS score. + enum: + - unknown + - none + - low + - medium + - high + - critical RequestedField_Vec_Vec_ScoredVector: oneOf: - type: 'null' @@ -6371,6 +6403,8 @@ components: required: - described_by properties: + advisories: + $ref: '#/components/schemas/RequestedField_HashMap_HashMap' described_by: type: array items: diff --git a/tools/perf/.gitignore b/tools/perf/.gitignore new file mode 100644 index 000000000..bc44d541e --- /dev/null +++ b/tools/perf/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +.venv/ +*.egg-info/ +report.html +results_*.csv diff --git a/tools/perf/Makefile b/tools/perf/Makefile new file mode 100644 index 000000000..975b59a35 --- /dev/null +++ b/tools/perf/Makefile @@ -0,0 +1,81 @@ +# Trustify Locust performance tests +# +# Usage: +# make help Show available targets +# make test Run all v3 tests (5m, 10 users) +# make test-v2 Run all v2 tests +# make test-analysis-v2 Run v2 analysis tests only +# make test HOST=my-host:8080 Override target host +# +# All TOOLS_PERF_* env vars are forwarded automatically. + +.PHONY: help test test-v3 test-v2 test-all test-analysis-v3 test-analysis-v2 \ + test-rest-v3 test-rest-v2 test-slow-v3 test-slow-v2 test-labels-v3 \ + test-website ui clean + +# -- Defaults (override on command line or via env) ------------------------- + +HOST ?= http://localhost:8080 +USERS ?= 10 +SPAWN_RATE ?= 2 +DURATION ?= 5m +REPORT ?= report.html +SCENARIO_FILE ?= + +# -- Internal --------------------------------------------------------------- + +LOCUST := uv run locust +BASE := --host $(HOST) -u $(USERS) -r $(SPAWN_RATE) -t $(DURATION) \ + --headless --html=$(REPORT) + +ifdef SCENARIO_FILE + export TOOLS_PERF_SCENARIO_FILE := $(SCENARIO_FILE) +endif + +# -- Targets ---------------------------------------------------------------- + +help: ## Show this help + @grep -E '^[a-z][a-z0-9_-]+:.*## ' $(MAKEFILE_LIST) \ + | awk -F ':.*## ' '{printf " %-22s %s\n", $$1, $$2}' + +test: test-v3 ## Run all v3 tests (default) + +test-v3: ## Run all v3 tests + $(LOCUST) $(BASE) + +test-v2: ## Run all v2 tests + TOOLS_PERF_API_VERSION=v2 $(LOCUST) $(BASE) + +test-all: ## Run v2 and v3 tests together + TOOLS_PERF_API_VERSION=all $(LOCUST) $(BASE) + +test-rest-v3: ## Run v3 REST API tests only + $(LOCUST) RestAPIUserV3 $(BASE) + +test-rest-v2: ## Run v2 REST API tests only + TOOLS_PERF_API_VERSION=v2 $(LOCUST) RestAPIUserV2 $(BASE) + +test-analysis-v3: ## Run v3 analysis tests only + $(LOCUST) AnalysisUserV3 $(BASE) + +test-analysis-v2: ## Run v2 analysis tests only + TOOLS_PERF_API_VERSION=v2 $(LOCUST) AnalysisUserV2 $(BASE) + +test-slow-v3: ## Run v3 slow license queries only + $(LOCUST) RestAPIUserSlowV3 $(BASE) + +test-slow-v2: ## Run v2 slow license queries only + TOOLS_PERF_API_VERSION=v2 $(LOCUST) RestAPIUserSlowV2 $(BASE) + +test-labels-v3: ## Run v3 label mutation tests only + $(LOCUST) AdvisoryLabelUserV3 SBOMLabelUserV3 $(BASE) + +test-website: ## Run website UI tests only + $(LOCUST) WebsiteUser $(BASE) + +ui: ## Launch interactive web UI (no timeout) + $(LOCUST) --host $(HOST) + +clean: ## Remove reports, CSV outputs, and cached files + rm -f report.html results_*.csv + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true diff --git a/tools/perf/README.md b/tools/perf/README.md new file mode 100644 index 000000000..2d332468c --- /dev/null +++ b/tools/perf/README.md @@ -0,0 +1,379 @@ +# Trustify Locust Performance Tests + +Locust-based load tests for the trustify REST API, mirroring the endpoint +coverage from [trustify-scale-testing](https://github.com/guacsec/trustify-scale-testing) +(Goose/Rust). + +## Prerequisites + +- Python 3.13+ +- [uv](https://docs.astral.sh/uv/) +- A running trustify instance + +## Quickstart (Makefile) + +A Makefile provides shortcuts for common test runs. All commands assume +you are in the `tools/perf/` directory. + +```bash +make help # list all targets +make test # v3 tests, 10 users, 5 min, HTML report +make test-v2 # v2 tests +make test-all # v2 + v3 together +make test-analysis-v2 # v2 analysis tests only +make test-rest-v3 # v3 REST API tests only +make test-labels-v3 # v3 label mutation tests only +make test-website # website UI tests only +make ui # launch interactive web UI +``` + +Override defaults on the command line: + +```bash +make test-v2 SCENARIO_FILE=etc/scenarios/main/full-20260412.json5 +make test HOST=https://my-server:8443 USERS=20 DURATION=10m +make test-analysis-v2 SCENARIO_FILE=etc/scenarios/releases/0.4.z/full-20260412_atlas.json5 DURATION=1m +make test REPORT=my-report.html +``` + +| Variable | Default | Description | +|----------|---------|-------------| +| `HOST` | `http://localhost:8080` | Target trustify instance | +| `USERS` | `10` | Concurrent users | +| `SPAWN_RATE` | `2` | Users spawned per second | +| `DURATION` | `5m` | Test duration | +| `REPORT` | `report.html` | HTML report output path | +| `SCENARIO_FILE` | (unset) | Path to JSON5 scenario file | + +Run `make clean` to remove generated reports and cached files. + +## Run (uv run) + +For full control, invoke `uv run locust` directly. `uv run` handles +the virtual environment and dependency installation automatically. + +### Web UI (interactive) + +```bash +uv run locust --host http://localhost:8080 +``` + +Open http://localhost:8089 in your browser, set user count and spawn rate, +then start the test. + +### Headless + +```bash +uv run locust --host http://localhost:8080 -u 10 -r 2 -t 5m --headless +``` + +| Flag | Description | +|------|-------------| +| `-u 10` | 10 concurrent users | +| `-r 2` | Spawn 2 users per second | +| `-t 5m` | Run for 5 minutes | +| `--headless` | No web UI | + +### Wait time between requests + +By default each user waits 1-3 seconds between requests. Override with +environment variables: + +```bash +# Max throughput (no delay) +TOOLS_PERF_WAIT_TIME_FROM=0 TOOLS_PERF_WAIT_TIME_TO=0 uv run locust --host http://localhost:8080 -u 10 -t 1m --headless + +# Simulate slower users +TOOLS_PERF_WAIT_TIME_FROM=5 TOOLS_PERF_WAIT_TIME_TO=15 uv run locust --host http://localhost:8080 -u 10 +``` + +### Generate an HTML report + +```bash +uv run locust --host http://localhost:8080 -u 10 -t 5m --headless --html=report.html +``` + +### Export CSV stats + +```bash +uv run locust --host http://localhost:8080 -u 10 -t 5m --headless --csv=results +``` + +This produces `results_stats.csv`, `results_stats_history.csv`, +`results_failures.csv`, and `results_exceptions.csv`. + +## API version selection + +By default, only v3 endpoints are tested. Use the `TOOLS_PERF_API_VERSION` environment +variable to switch: + +```bash +# v3 only (default) +uv run locust --host http://localhost:8080 -u 10 + +# v2 only +TOOLS_PERF_API_VERSION=v2 uv run locust --host http://localhost:8080 -u 10 + +# Both v2 and v3 +TOOLS_PERF_API_VERSION=all uv run locust --host http://localhost:8080 -u 10 +``` + +When `TOOLS_PERF_API_VERSION=all`, you can also filter by version tag: + +```bash +TOOLS_PERF_API_VERSION=all uv run locust --host http://localhost:8080 -u 10 --tags v3 +TOOLS_PERF_API_VERSION=all uv run locust --host http://localhost:8080 -u 10 --tags v2 advisory +``` + +## User classes + +Tests are organized into user classes with weights that control how +frequently Locust assigns simulated users to each class. Each API version +has its own set of classes: + +| Class | Weight | Version | Description | +|-------|--------|---------|-------------| +| `RestAPIUserV3` | 10 | v3 | Main REST API -- list, filter, sort, detail GETs and POSTs | +| `RestAPIUserSlowV3` | 1 | v3 | License-heavy queries (slow) | +| `AnalysisUserV3` | 2 | v3 | Analysis status, component lookup, graph render | +| `AdvisoryLabelUserV3` | 2 | v3 | Random advisory discovery + PUT/PATCH labels | +| `SBOMLabelUserV3` | 2 | v3 | SBOM label PUT/PATCH | +| `RestAPIUserV2` | 10 | v2 | Main REST API (v2) | +| `RestAPIUserSlowV2` | 1 | v2 | License-heavy queries (v2) | +| `AnalysisUserV2` | 1 | v2 | Analysis endpoints (v2) | +| `WebsiteUser` | 1 | -- | UI page browsing (version-agnostic) | + +### Run specific user classes + +Pass class names as positional arguments: + +```bash +uv run locust RestAPIUserV3 --host http://localhost:8080 -u 10 +uv run locust AnalysisUserV2 --host http://localhost:8080 -u 10 +uv run locust --host http://localhost:8080 -u 10 --class-picker # choose in web UI +``` + +### Filter by tag + +Every task is tagged with its API version (`v2` or `v3`) and category +(e.g. `advisory`, `sbom`, `list`, `detail`, `labels`, `slow`). +Run only tasks matching specific tags: + +```bash +uv run locust --host http://localhost:8080 -u 10 --tags advisory +uv run locust --host http://localhost:8080 -u 10 --tags sbom detail +uv run locust --host http://localhost:8080 -u 10 --exclude-tags slow labels +``` + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `TOOLS_PERF_API_VERSION` | `v3` | Which API version to test: `v2`, `v3`, or `all` | +| `TOOLS_PERF_WAIT_TIME_FROM` | `1` | Min seconds between requests per user | +| `TOOLS_PERF_WAIT_TIME_TO` | `3` | Max seconds between requests per user | +| `TOOLS_PERF_SCENARIO_FILE` | (unset) | Path to a JSON5 scenario file with pre-computed IDs | + +Set both wait time variables to `0` for max throughput (no delay between requests). + +## Scenario files + +Some tests need pre-computed IDs (specific SBOMs, advisories, PURLs, etc.) +to hit detail endpoints. These come from a JSON5 **scenario file**, set via +the `TOOLS_PERF_SCENARIO_FILE` environment variable: + +```bash +TOOLS_PERF_SCENARIO_FILE=etc/scenarios/empty.json5 uv run locust --host http://localhost:8080 -u 10 +``` + +Without a scenario file, only the list/static endpoints run. The included +`etc/scenarios/empty.json5` has all fields set to `null` (everything +disabled). To enable detail tests, copy it and fill in real IDs from your +database: + +```json5 +{ + "get_sbom": "sha256:abc123...", + "get_vulnerability": "CVE-2024-1234", + "get_advisory": "f1e5eb17-2f31-4...", + // ... see etc/scenarios/empty.json5 for all fields +} +``` + +Pre-built scenario files are organized under `etc/scenarios/`: + +```bash +# Main branch scenarios (for use with the DS3/DS4 datasets) +TOOLS_PERF_SCENARIO_FILE=etc/scenarios/main/full-20260412.json5 uv run locust --host http://localhost:8080 -u 10 + +# Release-specific scenarios +TOOLS_PERF_SCENARIO_FILE=etc/scenarios/releases/0.4.z/full-20260412_atlas.json5 uv run locust --host http://localhost:8080 -u 10 +``` + +## Writing new tests + +### Adding a task to an existing user class + +Open the relevant file in `users/v3/` (or `users/v2/`) and add a new +method with the `@task` decorator. Always include the version tag: + +```python +# users/v3/rest_api.py + +@tag("v3", "advisory", "list") +@task +def list_advisory_by_severity(self) -> None: + self.client.get( + "/api/v3/advisory?q=severity=critical", + name="/api/v3/advisory?q=severity=critical", + ) +``` + +The `name` parameter controls how the endpoint appears in reports. Use the +raw URL for static queries, or a descriptive name with truncated IDs for +parameterized ones (e.g. `f"get_sbom[{key[:16]}...]"`). + +### Adding a scenario-dependent task + +If the test needs a pre-computed ID, read it from `SCENARIO` and return +early when it is `None`: + +```python +from scenario import SCENARIO + +@tag("v3", "sbom", "detail") +@task +def get_sbom_something(self) -> None: + if not SCENARIO.get_sbom: + return + key = SCENARIO.get_sbom + self.client.get( + f"/api/v3/sbom/{quote(key, safe='')}/something", + name=f"get_sbom_something[{key[:16]}...]", + ) +``` + +If the test needs a new scenario field, add it to the `Scenario` dataclass +in `scenario.py`: + +```python +@dataclass(frozen=True) +class Scenario: + # ... existing fields ... + my_new_field: str | None = None +``` + +Then add the field to `etc/empty.json5` (and any other scenario files). + +### Adding a new user class + +Create a new file in `users/v3/` (or `users/v2/`, or `users/` if +version-agnostic): + +```python +# users/v3/my_feature.py + +from locust import HttpUser, tag, task +from config import WAIT_TIME + +class MyFeatureUserV3(HttpUser): + weight = 2 + wait_time = WAIT_TIME + + @tag("v3", "my_feature") + @task + def do_something(self) -> None: + self.client.get("/api/v3/something", name="/api/v3/something") +``` + +Then import it in `locustfile.py` under the appropriate `TOOLS_PERF_API_VERSION` block: + +```python +if TOOLS_PERF_API_VERSION in ("v3", "all"): + from users.v3.my_feature import MyFeatureUserV3 # noqa: F401 +``` + +### Task weights + +Use `@task(N)` to make a task run N times more often than `@task` (which +defaults to 1): + +```python +@task(3) # runs 3x as often as @task(1) tasks in the same class +def hot_endpoint(self) -> None: + self.client.get("/api/v3/sbom", name="/api/v3/sbom") +``` + +### POST / PUT / PATCH requests + +```python +@task +def create_something(self) -> None: + self.client.post( + "/api/v3/something", + json={"key": "value"}, + name="create_something", + ) + +@task +def update_labels(self) -> None: + self.client.put( + "/api/v3/sbom/some-id/label", + json={"source": "load-test"}, + name="put_labels", + ) +``` + +### Response validation + +Use `catch_response=True` to mark requests as pass/fail based on content: + +```python +@task +def validated_get(self) -> None: + with self.client.get( + "/api/v3/advisory", + name="/api/v3/advisory", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + elif not resp.json().get("items"): + resp.failure("empty result set") +``` + +## File structure + +``` +tools/perf/ +├── pyproject.toml # Dependencies (locust, json5) +├── locustfile.py # Entry point -- API version dispatch +├── config.py # Shared wait time configuration +├── scenario.py # Scenario data loader (JSON5) +├── etc/ +│ └── scenarios/ +│ ├── empty.json5 # Empty scenario (all fields null) +│ ├── main/ # Main-branch scenarios +│ │ ├── full-20250323.json5 +│ │ ├── full-20250604.json5 +│ │ ├── full-20260317T023702Z.json5 +│ │ └── full-20260412.json5 # Latest full scenario +│ └── releases/ +│ └── 0.4.z/ +│ ├── full-20260412_atlas.json5 # Atlas analysis-only +│ └── full-20260412_qe_atlas.json5 # QE Atlas analysis-only +└── users/ + ├── __init__.py + ├── website.py # WebsiteUser (version-agnostic) + ├── v3/ + │ ├── __init__.py + │ ├── rest_api.py # RestAPIUserV3 (weight 10) + │ ├── rest_api_slow.py # RestAPIUserSlowV3 (weight 1) + │ ├── analysis.py # AnalysisUserV3 (weight 2) + │ └── labels.py # AdvisoryLabelUserV3 (2) + SBOMLabelUserV3 (2) + └── v2/ + ├── __init__.py + ├── rest_api.py # RestAPIUserV2 (weight 10) + ├── rest_api_slow.py # RestAPIUserSlowV2 (weight 1) + └── analysis.py # AnalysisUserV2 (weight 1) +``` diff --git a/tools/perf/config.py b/tools/perf/config.py new file mode 100644 index 000000000..8fe2fa4bc --- /dev/null +++ b/tools/perf/config.py @@ -0,0 +1,18 @@ +"""Shared configuration for all user classes. + +Reads TOOLS_PERF_WAIT_TIME_FROM / TOOLS_PERF_WAIT_TIME_TO from +environment variables and exports a WAIT_TIME callable for use +as HttpUser.wait_time. +""" + +import os + +from locust import between + +WAIT_TIME_FROM = float(os.environ.get("TOOLS_PERF_WAIT_TIME_FROM", "1")) +WAIT_TIME_TO = float(os.environ.get("TOOLS_PERF_WAIT_TIME_TO", "3")) +WAIT_TIME = ( + between(WAIT_TIME_FROM, WAIT_TIME_TO) + if WAIT_TIME_TO > 0 + else between(0, 0) +) diff --git a/tools/perf/etc/scenarios/empty.json5 b/tools/perf/etc/scenarios/empty.json5 new file mode 100644 index 000000000..5798b3396 --- /dev/null +++ b/tools/perf/etc/scenarios/empty.json5 @@ -0,0 +1,29 @@ +// Empty scenario -- all scenario-dependent tests disabled. +// Copy this file and fill in real IDs from your database to enable them. +{ + "get_sbom": null, + "get_sbom_advisories": null, + "get_sbom_packages": null, + "get_sbom_related": null, + "get_vulnerability": null, + "sbom_by_package": null, + "sbom_license_ids": null, + "analyze_purl": null, + "get_purl_details": null, + "get_recommendations": null, + "download_advisory": null, + "get_advisory": null, + "download_sbom": null, + "get_sbom_license_export": null, + "count_sbom_by_package": null, + "get_sbom_group": null, + "get_product": null, + "get_organization": null, + "get_base_purl": null, + "get_analysis_component": null, + "render_sbom_graph": null, + "get_importer": null, + "get_weakness": null, + "get_spdx_license": null, + "delete_sbom_pool": null, +} diff --git a/tools/perf/etc/scenarios/main/full-20250323.json5 b/tools/perf/etc/scenarios/main/full-20250323.json5 new file mode 100644 index 000000000..0a661282f --- /dev/null +++ b/tools/perf/etc/scenarios/main/full-20250323.json5 @@ -0,0 +1,12 @@ +{ + // RHOSE-4.14 seems to be the biggest (by packages) + "get_sbom": "sha256:f293eb898192085804419f9dd40a738f20d67dd81846e88c6720f692ec5f3081", + "get_sbom_advisories": "sha256:f293eb898192085804419f9dd40a738f20d67dd81846e88c6720f692ec5f3081", + "get_sbom_packages": "0195baea-42e3-7842-a0e3-4c7874263954", + "get_sbom_related": "0195baea-42e3-7842-a0e3-4c7874263954", + "get_vulnerability": "CVE-2023-39325", + "sbom_by_package": "pkg:maven/io.quarkus/quarkus-smallrye-metrics@2.13.8.Final-redhat-00004?type=jar&repository_url=https%3a%2f%2fmaven.repository.redhat.com%2fga%2f", + "sbom_license_ids": "urn:uuid:0195baea-42e3-7842-a0e3-4c7874263954", + "analyze_purl": "pkg:rpm/redhat/eap7-activemq-artemis-native@1.0.2-3.redhat_00004.1.el7eap?arch=noarch&epoch=1", + "get_recommendations": ["pkg:rpm/redhat/eap7-activemq-artemis-native@1.0.2", "pkg:maven/io.quarkus/quarkus-smallrye-metrics@2.13.8"] +} diff --git a/tools/perf/etc/scenarios/main/full-20250604.json5 b/tools/perf/etc/scenarios/main/full-20250604.json5 new file mode 100644 index 000000000..3972d76bf --- /dev/null +++ b/tools/perf/etc/scenarios/main/full-20250604.json5 @@ -0,0 +1,117 @@ +{ + // RHOSE-4.14 seems to be the biggest (by packages) + "get_sbom": "sha256:720e4451b0c13f028e55e48b9d522fee8a20124b41379c99a939656247164447", + "get_sbom_advisories": "sha256:720e4451b0c13f028e55e48b9d522fee8a20124b41379c99a939656247164447", + "get_sbom_packages": "01973124-d4ab-7163-b104-331632a21144", + "get_sbom_related": "01973124-d4ab-7163-b104-331632a21144", + "get_vulnerability": "CVE-2023-39325", + "sbom_by_package": "pkg:maven/io.quarkus/quarkus-smallrye-metrics@2.13.8.Final-redhat-00004?type=jar&repository_url=https%3a%2f%2fmaven.repository.redhat.com%2fga%2f", + "sbom_license_ids": "urn:uuid:01973124-d4ab-7163-b104-331632a21144", + "analyze_purl": "pkg:rpm/redhat/eap7-activemq-artemis-native@1.0.2-3.redhat_00004.1.el7eap?arch=noarch&epoch=1", + "get_purl_details": "b00df2ca-df21-5c10-8874-304e9c54e2bd", + "get_recommendations": ["pkg:rpm/redhat/eap7-activemq-artemis-native@1.0.2", "pkg:maven/io.quarkus/quarkus-smallrye-metrics@2.13.8"], + "delete_sbom_pool": [ + "urn:uuid:01973121-1e94-7112-8bb3-636be0596bdb", + "urn:uuid:01973121-221a-78b0-93ff-4dea01eeaaf7", + "urn:uuid:01973121-251a-70b2-8bec-6c271300128a", + "urn:uuid:01973121-28aa-78d2-b150-8c2e59c8f346", + "urn:uuid:01973121-2be1-7a90-9dcf-9ffd8f324586", + "urn:uuid:01973121-2ecb-7ed3-bdd4-7db215134b77", + "urn:uuid:01973121-3260-7290-8100-227265538de2", + "urn:uuid:01973121-357e-7992-a4b4-dd4076a937fc", + "urn:uuid:01973121-38b1-7760-939e-763b1ddcbf45", + "urn:uuid:01973121-3d67-72c3-a505-62c81ff63baa", + "urn:uuid:01973121-411e-7af0-969b-b4a5f9f84d1f", + "urn:uuid:01973121-4378-7be3-8fb9-da2c6544289c", + "urn:uuid:01973121-464f-76c2-b07e-13d2ecc4eadb", + "urn:uuid:01973121-4998-7d32-bb8b-de8f1e2bc2c4", + "urn:uuid:01973121-4c82-7c11-8074-641e83eed0c5", + "urn:uuid:01973121-4f39-7a90-be4b-4a0d3cfab6f1", + "urn:uuid:01973121-5337-78a2-a8eb-63fe7d106e9e", + "urn:uuid:01973121-5613-7412-8365-6bf9ca9164ae", + "urn:uuid:01973121-590f-7952-8853-f6424188fcd8", + "urn:uuid:01973121-5c6b-7122-a46f-1025dedbe8dd", + "urn:uuid:01973121-5f4c-7d81-8239-001ebe99a389", + "urn:uuid:01973121-6289-72f2-95a9-0c16bb2724e9", + "urn:uuid:01973121-6648-73f3-9e35-2af740a2563a", + "urn:uuid:01973121-6923-7432-9e12-eddae207f35b", + "urn:uuid:01973121-6c4b-7981-b4c2-74650cf09c5e", + "urn:uuid:01973121-6ed1-7051-a0c4-f83a43145f78", + "urn:uuid:01973121-7160-7650-8e5f-ced7c80a5509", + "urn:uuid:01973121-74ca-7762-8136-b848dfb7c7ce", + "urn:uuid:01973121-771a-7cf3-84f2-f91e87306f71", + "urn:uuid:01973121-7acd-7dc1-97fa-43e682a630b9", + "urn:uuid:01973121-7cc5-7d02-bb40-e14421a84dfc", + "urn:uuid:01973121-7f7a-7693-80d4-ed2be905fc8a", + "urn:uuid:01973121-81fa-7d73-a44d-3258e2de289e", + "urn:uuid:01973121-85a8-7fb2-870b-e4ff0de62fa4", + "urn:uuid:01973121-8864-7233-a3f8-e67717cd8083", + "urn:uuid:01973121-8bcd-7643-bccd-2526723de5a6", + "urn:uuid:01973121-8f78-7b90-804b-414ea1456aba", + "urn:uuid:01973121-920b-7943-adfb-7b95defc881a", + "urn:uuid:01973121-9433-7d51-bc03-d55148b3204b", + "urn:uuid:01973121-9640-7491-8790-6695164cb1c1", + "urn:uuid:01973121-9903-7c60-9b28-71d4d79a3e6c", + "urn:uuid:01973121-9bb0-7ef2-ab8e-53fe530a691e", + "urn:uuid:01973121-9e14-7f31-a2b5-a2de14572ce9", + "urn:uuid:01973121-a058-78e1-9e17-47c267a5fc03", + "urn:uuid:01973121-a29b-7c13-b01c-c98b3d0f7412", + "urn:uuid:01973121-a564-7280-92c4-0855e6e39ee6", + "urn:uuid:01973121-a908-7932-868c-2b52e99b0b40", + "urn:uuid:01973121-ab8b-7d21-b1fa-ce2a60703e24", + "urn:uuid:01973121-b01c-7af1-8b80-bf65d202e29f", + "urn:uuid:01973121-b4c8-72a0-ad3e-100a67b746a5", + "urn:uuid:01973121-b7d0-7220-8e76-b57269b9d513", + "urn:uuid:01973121-bad1-7d02-8bac-6d72a7d43ceb", + "urn:uuid:01973121-bdb1-7b80-94d3-237352b2f4cb", + "urn:uuid:01973121-bfe5-7eb3-b36a-711052c70f2b", + "urn:uuid:01973121-c364-7823-8f99-ad819bd2e732", + "urn:uuid:01973121-c652-76e3-8fca-70184db024fd", + "urn:uuid:01973121-ca20-7e33-93a3-2bbd9e2eb33d", + "urn:uuid:01973121-cca9-7aa2-bf2f-216c1de8f8aa", + "urn:uuid:01973121-cf27-7ef3-97b1-e6d6f7f52fc5", + "urn:uuid:01973121-d1fd-7480-825f-2ec926c083d4", + "urn:uuid:01973121-d56e-7922-b3d6-32ebc11f0a16", + "urn:uuid:01973121-d7a6-7883-97b3-012dc4da2123", + "urn:uuid:01973121-da3e-72a0-af50-09a71b641159", + "urn:uuid:01973121-dc0c-7ce1-8763-6ea41ef06586", + "urn:uuid:01973121-dea4-7091-8418-ac150303c1ef", + "urn:uuid:01973121-e20b-72c1-b3d7-8d691a949ce7", + "urn:uuid:01973121-e49a-75d3-941f-8b276222b98c", + "urn:uuid:01973121-e720-7920-bb60-3b935df74219", + "urn:uuid:01973121-ea0d-7102-8dbb-22159c7e8f7c", + "urn:uuid:01973121-ecba-7100-9f1a-9ed0fff0b020", + "urn:uuid:01973121-ef23-76a2-b2ab-352f1bf14b7e", + "urn:uuid:01973121-f0e9-7e21-bfdd-f1b6129f98df", + "urn:uuid:01973121-f427-70d1-b91e-6d3018e3e492", + "urn:uuid:01973121-f68d-72c2-863e-c181bf67ba00", + "urn:uuid:01973121-f914-7c92-9fc5-48fae8dc9599", + "urn:uuid:01973121-fb6e-7ce0-8068-685b8242f775", + "urn:uuid:01973121-fd88-7ff0-9539-1d95f4c8c1fa", + "urn:uuid:01973121-ffba-7432-86fa-5ec42dc4749a", + "urn:uuid:01973122-032a-7563-baf9-c9d8ac99749c", + "urn:uuid:01973122-069b-71f3-b9de-849bb2d94ea7", + "urn:uuid:01973122-0a83-7112-a519-ab748ed33687", + "urn:uuid:01973122-0ce6-7910-8175-841f1f619b0e", + "urn:uuid:01973122-0eef-70a3-9438-1f09fb8007e3", + "urn:uuid:01973122-1111-7c20-bb2a-bea6df54e126", + "urn:uuid:01973122-157a-7173-8476-7fd47e8cb4ce", + "urn:uuid:01973122-1858-7ff3-b830-ab9e61861134", + "urn:uuid:01973122-1b3e-7b02-9189-fd947ad9295c", + "urn:uuid:01973122-1f2e-73a3-b9eb-7556dfb451a1", + "urn:uuid:01973122-21b1-7851-bd7e-4f439a7ae659", + "urn:uuid:01973122-2442-73a3-b250-d8a41f7884ff", + "urn:uuid:01973122-2666-7fa1-ad92-592f5619856a", + "urn:uuid:01973122-299b-74a2-a8a0-98b00e04f48f", + "urn:uuid:01973122-2cc3-70d2-8ae7-f2656277727e", + "urn:uuid:01973122-2f62-7160-b580-733d6b2f11dc", + "urn:uuid:01973122-31ea-74c0-823d-25e422efcb00", + "urn:uuid:01973122-3428-7641-b3ed-9eb0b834f830", + "urn:uuid:01973122-3666-7823-bb4b-d3f610d1820b", + "urn:uuid:01973122-384d-7002-886f-11dddf5040ad", + "urn:uuid:01973122-3a0e-73f0-9596-baceb84383aa", + "urn:uuid:01973122-3bdd-78a3-898a-fb67c06387ed" + ], + "download_advisory": "24ae57c3-4b57-4f4e-82c1-83ae26059a89", + "get_advisory": "24ae57c3-4b57-4f4e-82c1-83ae26059a89" +} diff --git a/tools/perf/etc/scenarios/main/full-20260317T023702Z.json5 b/tools/perf/etc/scenarios/main/full-20260317T023702Z.json5 new file mode 100644 index 000000000..ec5c008ed --- /dev/null +++ b/tools/perf/etc/scenarios/main/full-20260317T023702Z.json5 @@ -0,0 +1,47 @@ +{ + "get_sbom": "sha256:a04260d90fbbbc5f4e1f25754bbfa46771140c06c2bc351ea6046a828fcd17e5", + // FIXME: "get_sbom_advisories": "sha256:a04260d90fbbbc5f4e1f25754bbfa46771140c06c2bc351ea6046a828fcd17e5", + "get_sbom_advisories": null, + // "get_sbom_packages": null, + "get_sbom_packages": "urn:uuid:019cf03d-89b1-7aa1-876e-43a3bfd8a2f5", + // "get_sbom_related": null, + "get_sbom_related": "urn:uuid:019cf03d-89b1-7aa1-876e-43a3bfd8a2f5", + // FIXME: "get_vulnerability": "CVE-2023-39325", + "get_vulnerability": null, + "sbom_by_package": "pkg:oci/web-terminal-operator-bundle@sha256:376684e2041f93229961a5d42caea9676974bae7b04c3359ca79b62b7843ff9d?repository_url=registry.redhat.io/web-terminal/web-terminal-operator-bundle&tag=1.15-1770672845", + "sbom_license_ids": "urn:uuid:019cf03d-89b1-7aa1-876e-43a3bfd8a2f5", + "analyze_purl": "pkg:rpm/redhat/squid", + "get_purl_details": "00001592-edf0-525b-8ed9-396e8c9d149f", + "get_recommendations": [ + "pkg:maven/org.apache.cxf/cxf-tools-wsdlto-frontend-javascript@4.1.4.rhbac-redhat-00015?repository_url=https://maven.repository.redhat.com/ga/&type=pom", + "pkg:maven/org.apache.cxf/cxf-rt-ws-addr@4.1.4.rhbac-redhat-00015?repository_url=https://maven.repository.redhat.com/ga/&type=pom", + "pkg:maven/org.apache.cxf/cxf-rt-frontend-jaxws@4.1.4.rhbac-redhat-00015?repository_url=https://maven.repository.redhat.com/ga/&type=pom", + "pkg:maven/org.apache.maven/maven-settings@3.8.1.redhat-00004?repository_url=https://maven.repository.redhat.com/ga/&type=pom", + "pkg:maven/org.jboss.eap/wildfly-security-api@7.4.23.GA-redhat-00003", + "pkg:maven/org.jboss.eap/wildfly-clustering-infinispan-client@7.4.0.GA-redhat-00005?classifier=sources&repository_url=https://maven.repository.redhat.com/ga/&type=jar", + "pkg:maven/org.apache.activemq/artemis-hqclient-protocol@2.16.0.redhat-00034?repository_url=https://maven.repository.redhat.com/ga/&type=pom", + "pkg:maven/org.jboss.eap/wildfly-clustering-infinispan-extension@7.4.8.GA-redhat-00002?repository_url=https://maven.repository.redhat.com/ga/&type=pom", + "pkg:maven/org.jboss.eap/wildfly-clustering-ee-cache@7.4.6.GA-redhat-00002?repository_url=https://maven.repository.redhat.com/ga/&type=jar", + "pkg:maven/org.apache.activemq/artemis-selector@2.16.0.redhat-00045?repository_url=https://maven.repository.redhat.com/ga/&type=pom" + ], + "delete_sbom_pool": null, + "download_advisory": "f1e5eb17-2f31-418f-9b46-c11f52398375", + "get_advisory": "f1e5eb17-2f31-418f-9b46-c11f52398375", + "download_sbom": "sha256:a04260d90fbbbc5f4e1f25754bbfa46771140c06c2bc351ea6046a828fcd17e5", + "get_sbom_license_export": "urn:uuid:019cf03d-89b1-7aa1-876e-43a3bfd8a2f5", + "count_sbom_by_package": "pkg:oci/web-terminal-operator-bundle@sha256:376684e2041f93229961a5d42caea9676974bae7b04c3359ca79b62b7843ff9d?repository_url=registry.redhat.io/web-terminal/web-terminal-operator-bundle&tag=1.15-1770672845", + // missing data + "get_sbom_group": null, + "get_product": "761f2bc9-f3a4-50d5-8ee5-fd2aa3aef38b", + "get_organization": "53d3fae7-574a-4a52-b66b-5a704e8b289a", + "get_base_purl": "pkg:golang/k8s.io/apimachinery/third_party/forked/golang/reflect", + "get_analysis_component": "sha256:a04260d90fbbbc5f4e1f25754bbfa46771140c06c2bc351ea6046a828fcd17e5", + // FIXME: "render_sbom_graph": "urn:uuid:019cf03d-89b1-7aa1-876e-43a3bfd8a2f5", + "render_sbom_graph": null, + // missing data + "get_importer": null, + // FIXME: "get_weakness": "CWE-79", + "get_weakness": null, + // we don't have weakness data + "get_spdx_license": "MIT" +} diff --git a/tools/perf/etc/scenarios/main/full-20260412.json5 b/tools/perf/etc/scenarios/main/full-20260412.json5 new file mode 100644 index 000000000..b9f0ca106 --- /dev/null +++ b/tools/perf/etc/scenarios/main/full-20260412.json5 @@ -0,0 +1,157 @@ +{ + "get_sbom": "sha256:b42cf13f3318f5293a41bf0dc0cb9ec1333fde8b0d3f4bb79a6ed3916d3b06d6", + "get_sbom_advisories": "sha256:b42cf13f3318f5293a41bf0dc0cb9ec1333fde8b0d3f4bb79a6ed3916d3b06d6", + "get_sbom_packages": "019a4555-a1ae-7830-85aa-bc505cdd1de8", + "get_sbom_related": "019a4555-a1ae-7830-85aa-bc505cdd1de8", + "get_vulnerability": "CVE-2023-39325", + "sbom_by_package": "pkg:maven/io.smallrye/smallrye-fault-tolerance-tracing-propagation@5.5.0.redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "sbom_license_ids": "urn:uuid:019a4555-a1ae-7830-85aa-bc505cdd1de8", + "analyze_purl": "pkg:rpm/redhat/java-1.8.0-openjdk-fastdebug@1.8.0.345.b01-5.el9?arch=x86_64&epoch=1", + "get_purl_details": "e2d62bc2-18b6-58b6-8872-aaf2823b603a", + "get_recommendations": [ + "pkg:maven/io.netty/netty-buffer@4.1.94.Final-redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.wildfly.security/wildfly-elytron-sasl-gssapi@2.5.1.Final-redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.vertx/vertx-mail-client@4.4.4.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.apache.kafka/kafka-clients@3.7.1.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.smallrye.reactive/vertx-mutiny-generator@3.10.0.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:generic/tomcat@9.0.43.redhat-00011", + "pkg:maven/org.apache.camel.quarkus/camel-quarkus-jms-deployment@3.8.0.redhat-00004?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:generic/tomcat@9.0.50.redhat-00006", + "pkg:maven/io.quarkus/quarkus-arc@3.2.9.Final-redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-arc@3.2.11.Final-redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-websockets-deployment@3.2.11.Final-redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.fabric8/openshift-model-operatorhub@5.12.4.redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/com.github.javaparser/javaparser-core@3.24.2.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-bootstrap-app-model@2.13.9.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-devservices-common@3.2.9.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-security-runtime-spi@3.2.9.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-spring-security@3.2.9.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-spring-cloud-config-client-deployment@3.2.11.Final-redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-micrometer-deployment@3.2.12.Final-redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.wildfly.security/wildfly-elytron-mechanism@2.2.3.Final-redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-vertx-latebound-mdc-provider@2.13.8.Final-redhat-00006?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-jaxrs-client-reactive-deployment@2.13.8.Final-redhat-00006?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-agroal-spi@2.13.8.Final-redhat-00006?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-datasource-common@2.13.8.Final-redhat-00006?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-cache@3.8.5.redhat-00004?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-resteasy-reactive-jsonb-common@3.8.5.redhat-00004?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.smallrye/smallrye-metrics@3.0.5.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-builder@3.2.11.Final-redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-keycloak-authorization-deployment@3.2.11.Final-redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-resteasy-deployment@3.2.11.Final-redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-kafka-client-deployment@3.2.11.Final-redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.jvnet.mimepull/mimepull@1.10.0.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.smallrye.reactive/smallrye-mutiny-vertx-mail-client@3.5.0.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.apache.camel/camel-base-engine@4.4.0.redhat-00017?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.apache.camel/camel-base@4.4.0.redhat-00017?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.vertx/vertx-uri-template@4.4.4.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.apache.camel/camel-mybatis@4.4.0.redhat-00017?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.vertx/vertx-pg-client@4.4.4.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.vertx/vertx-grpc@4.4.4.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.apache.camel/camel-cxf-soap@4.4.0.redhat-00017?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.vertx/vertx-sql-client@4.4.4.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-oidc-client@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-virtual-threads@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-hibernate-orm-rest-data-panache@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-narayana-jta@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-devservices-mysql@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.apache.camel.quarkus/camel-quarkus-grpc-codegen@3.8.0.redhat-00004?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.apache.camel.quarkus/camel-quarkus-jacksonxml@3.8.0.redhat-00004?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/software.amazon.awssdk/annotations@2.24.9.redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.fabric8/kubernetes-model-batch@6.7.2.redhat-00008?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.fabric8/kubernetes-client-api@6.7.2.redhat-00008?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.fabric8/openshift-model-console@6.7.2.redhat-00008?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-grpc-stubs@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-mutiny-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-datasource-common@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-smallrye-reactive-messaging@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-bootstrap-app-model@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-jaxb-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-arc-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-security-runtime-spi@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-spring-security@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-resteasy-common@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-elasticsearch-rest-client-common@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-mongodb-client-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-resteasy-reactive-jsonb-common-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-kafka-client@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-hibernate-reactive@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-spring-data-rest@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-bootstrap-maven-resolver@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-resteasy-server-common-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-credentials-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-openshift-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-kubernetes@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-smallrye-openapi@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-hibernate-orm-rest-data-panache@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-devservices-mysql@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-devservices-h2@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-devtools-base-codestarts@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-smallrye-context-propagation-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-cache-deployment-spi@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-smallrye-graphql@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-logging-json-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus.resteasy.reactive/resteasy-reactive-client-processor@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-apache-httpclient@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus.arc/arc-processor@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-core@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-smallrye-jwt-build-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-mutiny-reactive-streams-operators@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-cache@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-qute@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-rest-client-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-jdbc-mssql@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-spring-cloud-config-client-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-transaction-annotations@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-websockets-client@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-kubernetes-client-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-jdbc-mysql-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-reactive-oracle-client-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-datasource-deployment@3.2.6.Final-redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.netty/netty-handler-proxy@4.1.94.Final-redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.smallrye/smallrye-graphql-client@2.2.1.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/com.google.code.findbugs/jsr305@3.0.2.redhat-00014?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.netty/netty-codec@4.1.94.Final-redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-smallrye-context-propagation-deployment@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-hibernate-orm-deployment@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-jackson@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-jacoco@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-jdbc-mariadb-deployment@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-grpc-common@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-mutiny-reactive-streams-operators@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-kubernetes-service-binding@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-logging-json-deployment@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-scheduler-api@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-core@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-arc-deployment@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkus/quarkus-resteasy-common-deployment@3.8.3.redhat-00003?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/com.google.http-client/google-http-client@1.43.3.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.quarkiverse.cxf/quarkus-cxf@3.8.3.redhat-00004?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.apache.camel/camel-xpath@4.4.0.redhat-00017?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.apache.camel.quarkus/camel-quarkus-paho@3.8.0.redhat-00004?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.apache.camel.quarkus/camel-quarkus-soap-deployment@3.8.0.redhat-00004?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.apache.camel.quarkus/camel-quarkus-xj@3.8.0.redhat-00004?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.glassfish.jersey.media/jersey-media-json-jettison@2.35.0.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/ca.uhn.hapi.fhir/org.hl7.fhir.dstu2016may@6.1.2.2-redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.smallrye.reactive/smallrye-mutiny-vertx-sql-client@3.5.0.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/org.eclipse.microprofile.context-propagation/microprofile-context-propagation-api@1.3.0.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/io.smallrye.reactive/smallrye-mutiny-vertx-core@3.5.0.redhat-00001?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "pkg:maven/jakarta.activation/jakarta.activation-api@2.1.2.redhat-00004?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar" + ], + "download_advisory": "6958b1f5-2cc0-480f-b242-9b489b8de627", + "get_advisory": "6958b1f5-2cc0-480f-b242-9b489b8de627", + "download_sbom": "sha256:b42cf13f3318f5293a41bf0dc0cb9ec1333fde8b0d3f4bb79a6ed3916d3b06d6", + "get_sbom_license_export": "urn:uuid:019a4555-a1ae-7830-85aa-bc505cdd1de8", + "count_sbom_by_package": "pkg:maven/io.smallrye/smallrye-fault-tolerance-tracing-propagation@5.5.0.redhat-00002?repository_url=https%3A%2F%2Fmaven.repository.redhat.com%2Fga%2F&type=jar", + "get_sbom_group": null, + "get_product": "a0dee755-d03e-5837-be5b-b516f5986eef", + "get_organization": "9410ac75-c8fb-483b-ae03-f8c26732d7fc", + "get_base_purl": "pkg:maven/io.quarkus/quarkus-smallrye-fault-tolerance-deployment", + "get_analysis_component": [ + "stratisd", "sankit", "nbdkit", "tang", "clevis", "libqb" + ], + "render_sbom_graph": "urn:uuid:019a4555-a1ae-7830-85aa-bc505cdd1de8", + "get_importer": null, + "get_weakness": "CWE-79", + "get_spdx_license": "MIT" +} \ No newline at end of file diff --git a/tools/perf/etc/scenarios/releases/0.4.z/full-20260412_atlas.json5 b/tools/perf/etc/scenarios/releases/0.4.z/full-20260412_atlas.json5 new file mode 100644 index 000000000..38bc874ab --- /dev/null +++ b/tools/perf/etc/scenarios/releases/0.4.z/full-20260412_atlas.json5 @@ -0,0 +1,26 @@ +{ + "get_sbom": null, + "get_sbom_advisories": null, + "get_sbom_packages": null, + "get_sbom_related": null, + "get_vulnerability": null, + "sbom_by_package": null, + "sbom_license_ids": null, + "analyze_purl": null, + "get_purl_details": null, + "delete_sbom_pool": null, + "download_advisory": null, + "get_advisory": null, + "download_sbom": null, + "get_sbom_license_export": null, + "count_sbom_by_package": null, + "get_sbom_group": null, + "get_product": null, + "get_organization": null, + "get_base_purl": null, + "render_sbom_graph": null, + "get_importer": null, + "get_weakness": null, + "get_spdx_license": null, + "get_analysis_component": ["stratisd", "sankit", "nbdkit", "tang", "clevis", "libqb"], +} \ No newline at end of file diff --git a/tools/perf/etc/scenarios/releases/0.4.z/full-20260412_qe_atlas.json5 b/tools/perf/etc/scenarios/releases/0.4.z/full-20260412_qe_atlas.json5 new file mode 100644 index 000000000..a1e316adf --- /dev/null +++ b/tools/perf/etc/scenarios/releases/0.4.z/full-20260412_qe_atlas.json5 @@ -0,0 +1,30 @@ +{ + "get_sbom": null, + "get_sbom_advisories": null, + "get_sbom_packages": null, + "get_sbom_related": null, + "get_vulnerability": null, + "sbom_by_package": null, + "sbom_license_ids": null, + "analyze_purl": null, + "get_purl_details": null, + "delete_sbom_pool": null, + "download_advisory": null, + "get_advisory": null, + "download_sbom": null, + "get_sbom_license_export": null, + "count_sbom_by_package": null, + "get_sbom_group": null, + "get_product": null, + "get_organization": null, + "get_base_purl": null, + "render_sbom_graph": null, + "get_importer": null, + "get_weakness": null, + "get_spdx_license": null, + "get_analysis_component": [ + "pkg:oci/quay-builder-qemu-rhcos-rhel8", "pkg:pypi/pypdf", "pkg:pypi/requests", "pkg:rpm/redhat/libsoup", "pkg:rpm/redhat/harfbuzz", "pkg:rpm/redhat/python-setuptools", "pkg:maven/com.fasterxml.jackson.core/jackson-databind", "pkg:golang/crypto/x509", "pkg:golang/k8s.io/api", + "glibc", "openssl", "bash", "coreutils", "systemd", "kernel", "zlib", + "stratisd", "sankit", "nbdkit", "tang", "clevis", "libqb" + ], +} \ No newline at end of file diff --git a/tools/perf/locustfile.py b/tools/perf/locustfile.py new file mode 100644 index 000000000..baf6a80e6 --- /dev/null +++ b/tools/perf/locustfile.py @@ -0,0 +1,54 @@ +"""Trustify Locust load tests -- main entry point. + +Imports user classes based on the TOOLS_PERF_API_VERSION environment variable so +Locust discovers them automatically. + +Run from the ``tools/perf/`` directory: + + uv run locust --host http://localhost:8080 -u 10 + +Or headless: + + uv run locust --host http://localhost:8080 -u 10 -r 2 -t 5m --headless + +Select API version (default: v3): + + TOOLS_PERF_API_VERSION=v2 uv run locust --host http://localhost:8080 -u 10 + TOOLS_PERF_API_VERSION=all uv run locust --host http://localhost:8080 -u 10 + +Filter by tag: + + uv run locust --host http://localhost:8080 -u 10 --tags advisory + +Environment variables: + + TOOLS_PERF_API_VERSION Which API version to test: v2, v3 (default), or all. + TOOLS_PERF_WAIT_TIME_FROM Min seconds between requests per user (default: 1). + TOOLS_PERF_WAIT_TIME_TO Max seconds between requests per user (default: 3). + Set both to 0 for no delay (max throughput). + TOOLS_PERF_SCENARIO_FILE Path to a JSON5 scenario file with pre-computed IDs. + If unset, scenario-dependent tests are skipped. + +See scenario.py and the users/ modules for details. +""" + +import os + +TOOLS_PERF_API_VERSION = os.environ.get("TOOLS_PERF_API_VERSION", "v3") + +# Website tests are version-agnostic -- always loaded. +from users.website import WebsiteUser # noqa: F401, E402 + +if TOOLS_PERF_API_VERSION in ("v3", "all"): + from users.v3.analysis import AnalysisUserV3 # noqa: F401 + from users.v3.labels import ( # noqa: F401 + AdvisoryLabelUserV3, + SBOMLabelUserV3, + ) + from users.v3.rest_api import RestAPIUserV3 # noqa: F401 + from users.v3.rest_api_slow import RestAPIUserSlowV3 # noqa: F401 + +if TOOLS_PERF_API_VERSION in ("v2", "all"): + from users.v2.analysis import AnalysisUserV2 # noqa: F401 + from users.v2.rest_api import RestAPIUserV2 # noqa: F401 + from users.v2.rest_api_slow import RestAPIUserSlowV2 # noqa: F401 diff --git a/tools/perf/pyproject.toml b/tools/perf/pyproject.toml new file mode 100644 index 000000000..3a688ae9e --- /dev/null +++ b/tools/perf/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "trustify-perf" +version = "0.1.0" +description = "Locust-based performance tests for trustify" +requires-python = ">=3.13" +dependencies = [ + "locust==2.37.5", + "json5==0.12.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["users"] + +[tool.hatch.build.targets.wheel.force-include] +"config.py" = "config.py" +"scenario.py" = "scenario.py" \ No newline at end of file diff --git a/tools/perf/scenario.py b/tools/perf/scenario.py new file mode 100644 index 000000000..951b23826 --- /dev/null +++ b/tools/perf/scenario.py @@ -0,0 +1,116 @@ +"""Load scenario data from a JSON5 file. + +Scenario files provide pre-computed IDs (SBOMs, advisories, PURLs, etc.) +that parameterized tests use to hit detail endpoints. Fields set to null +disable the corresponding tests. List-valued fields (get_analysis_component, +get_recommendations, delete_sbom_pool) accept a single string or an array. + +Set TOOLS_PERF_SCENARIO_FILE to a path to enable. See etc/ for examples. +""" + +from __future__ import annotations + +import dataclasses +import json +import logging +import os +from dataclasses import dataclass, field +from typing import Any + +log = logging.getLogger(__name__) + +try: + import json5 as _json5 + + def _load(path: str) -> dict[str, Any]: + with open(path, encoding="utf-8") as fh: + return _json5.load(fh) # type: ignore[no-any-return] + +except ImportError: + _json5 = None # type: ignore[assignment] + + def _load(path: str) -> dict[str, Any]: + with open(path, encoding="utf-8") as fh: + return json.load(fh) # type: ignore[no-any-return] + + +@dataclass(frozen=True) +class Scenario: + """Pre-computed test data loaded from a scenario file.""" + + get_sbom: str | None = None + get_sbom_advisories: str | None = None + get_sbom_packages: str | None = None + get_sbom_related: str | None = None + get_vulnerability: str | None = None + sbom_by_package: str | None = None + sbom_license_ids: str | None = None + analyze_purl: str | None = None + get_purl_details: str | None = None + get_recommendations: list[str] = field(default_factory=list) + download_advisory: str | None = None + get_advisory: str | None = None + download_sbom: str | None = None + get_sbom_license_export: str | None = None + count_sbom_by_package: str | None = None + get_sbom_group: str | None = None + get_product: str | None = None + get_organization: str | None = None + get_base_purl: str | None = None + get_analysis_component: list[str] = field(default_factory=list) + render_sbom_graph: str | None = None + get_importer: str | None = None + get_weakness: str | None = None + get_spdx_license: str | None = None + delete_sbom_pool: list[str] = field(default_factory=list) + + +def load_scenario() -> Scenario: + """Load scenario from TOOLS_PERF_SCENARIO_FILE env var, or return empty.""" + path = os.environ.get("TOOLS_PERF_SCENARIO_FILE", "") + if not path: + log.warning( + "TOOLS_PERF_SCENARIO_FILE not set; " + "scenario-dependent tests will be skipped" + ) + return Scenario() + + log.info("Loading scenario from %s", path) + raw = _load(path) + + def _to_list(val: Any) -> list[str]: + """Coerce a scalar, list, or None into a list of strings.""" + if isinstance(val, list): + return [str(v) for v in val if v is not None] + if val is not None: + return [str(val)] + return [] + + recs_list = _to_list(raw.get("get_recommendations")) + pool_list = _to_list(raw.get("delete_sbom_pool")) + analysis_list = _to_list(raw.get("get_analysis_component")) + + list_fields = ( + "get_recommendations", + "delete_sbom_pool", + "get_analysis_component", + ) + + kwargs: dict[str, Any] = {} + for fld in dataclasses.fields(Scenario): + if fld.name in list_fields: + continue + val = raw.get(fld.name) + if val is not None: + kwargs[fld.name] = str(val) + + return Scenario( + get_recommendations=recs_list, + delete_sbom_pool=pool_list, + get_analysis_component=analysis_list, + **kwargs, + ) + + +# Singleton loaded once at import time. +SCENARIO = load_scenario() diff --git a/tools/perf/users/__init__.py b/tools/perf/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/perf/users/v2/__init__.py b/tools/perf/users/v2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/perf/users/v2/analysis.py b/tools/perf/users/v2/analysis.py new file mode 100644 index 000000000..8c2830f0c --- /dev/null +++ b/tools/perf/users/v2/analysis.py @@ -0,0 +1,62 @@ +"""AnalysisUserV2 -- analysis and graph rendering v2 endpoints (weight 1). + +Analysis status, scenario-driven component search, and SBOM graph +rendering. Component search accepts a list of keys in the scenario +file and picks one at random per request. +""" + +from __future__ import annotations + +import random +from urllib.parse import quote + +from locust import HttpUser, tag, task +from config import WAIT_TIME + +from scenario import SCENARIO + +# NOTE: v3 has analysis_by_cpe using a hardcoded CPE; this endpoint does +# not exist in v2, so there is no equivalent task here. + + +class AnalysisUserV2(HttpUser): + """Exercises trustify v2 analysis and graph endpoints.""" + + weight = 1 + wait_time = WAIT_TIME + + @tag("v2", "analysis") + @task + def analysis_status(self) -> None: + self.client.get( + "/api/v2/analysis/status", + name="/api/v2/analysis/status", + ) + + @tag("v2", "analysis", "detail") + @task + def get_analysis_component(self) -> None: + if not SCENARIO.get_analysis_component: + return + key = random.choice(SCENARIO.get_analysis_component) # noqa: S311 + with self.client.get( + f"/api/v2/analysis/component/{quote(key, safe='')}", + name=f"v2/get_analysis_component[{key[:20]}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "analysis", "detail") + @task + def render_sbom_graph_dot(self) -> None: + if not SCENARIO.render_sbom_graph: + return + sid = SCENARIO.render_sbom_graph + with self.client.get( + f"/api/v2/analysis/sbom/{quote(sid, safe='')}/render.dot", + name=f"v2/render_sbom_graph_dot[{sid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") diff --git a/tools/perf/users/v2/rest_api.py b/tools/perf/users/v2/rest_api.py new file mode 100644 index 000000000..4dcc509f5 --- /dev/null +++ b/tools/perf/users/v2/rest_api.py @@ -0,0 +1,722 @@ +"""RestAPIUserV2 -- main REST API v2 load test (weight 10). + +List, filter, sort, and detail endpoints for advisory, vulnerability, +SBOM, PURL, product, organization, importer, weakness, and license. +Scenario-driven detail lookups require TOOLS_PERF_SCENARIO_FILE. +""" + +from __future__ import annotations + +from urllib.parse import quote + +from locust import HttpUser, tag, task +from config import WAIT_TIME + +from scenario import SCENARIO + + +class RestAPIUserV2(HttpUser): + """Exercises the trustify v2 REST API with realistic query patterns.""" + + weight = 10 + wait_time = WAIT_TIME + + # -- Advisory list endpoints ---------------------------------------- + + @tag("v2", "advisory", "list") + @task + def list_advisory(self) -> None: + self.client.get("/api/v2/advisory", name="/api/v2/advisory") + + @tag("v2", "advisory", "list") + @task + def list_advisory_paginated(self) -> None: + self.client.get( + "/api/v2/advisory?offset=100&limit=10", + name="/api/v2/advisory?offset=100&limit=10", + ) + + @tag("v2", "advisory", "list") + @task + def list_advisory_by_identifier(self) -> None: + self.client.get( + "/api/v2/advisory?q=identifier%3dCVE-2022-0981", + name="/api/v2/advisory?q=identifier=CVE-2022-0981", + ) + + @tag("v2", "advisory", "list") + @task + def list_advisory_by_cve_prefix(self) -> None: + self.client.get( + "/api/v2/advisory?q=CVE-2021-", + name="/api/v2/advisory?q=CVE-2021-", + ) + + @tag("v2", "advisory", "list") + @task + def list_advisory_by_title(self) -> None: + self.client.get( + "/api/v2/advisory?q=title~openssl", + name="/api/v2/advisory?q=title~openssl", + ) + + @tag("v2", "advisory", "list") + @task + def list_advisory_by_modified(self) -> None: + self.client.get( + "/api/v2/advisory?q=modified>3 days ago", + name="/api/v2/advisory?q=modified>3 days ago", + ) + + @tag("v2", "advisory", "list") + @task + def list_advisory_sorted(self) -> None: + self.client.get( + "/api/v2/advisory?sort=modified:desc", + name="/api/v2/advisory?sort=modified:desc", + ) + + @tag("v2", "advisory", "list") + @task + def list_advisory_deprecated(self) -> None: + self.client.get( + "/api/v2/advisory?deprecated=Consider", + name="/api/v2/advisory?deprecated=Consider", + ) + + # -- Vulnerability list endpoints ----------------------------------- + + @tag("v2", "vulnerability", "list") + @task + def list_vulnerability(self) -> None: + self.client.get( + "/api/v2/vulnerability", + name="/api/v2/vulnerability", + ) + + @tag("v2", "vulnerability", "list") + @task + def list_vulnerability_paginated(self) -> None: + self.client.get( + "/api/v2/vulnerability?offset=100&limit=10", + name="/api/v2/vulnerability?offset=100&limit=10", + ) + + @tag("v2", "vulnerability", "list") + @task + def list_vulnerability_high(self) -> None: + self.client.get( + "/api/v2/vulnerability?q=base_severity=high", + name="/api/v2/vulnerability?q=base_severity=high", + ) + + @tag("v2", "vulnerability", "list") + @task + def list_vulnerability_score(self) -> None: + self.client.get( + "/api/v2/vulnerability?q=base_score>=7.0", + name="/api/v2/vulnerability?q=base_score>=7.0", + ) + + @tag("v2", "vulnerability", "list") + @task + def list_vulnerability_cwe(self) -> None: + self.client.get( + "/api/v2/vulnerability?q=cwes=CWE-79", + name="/api/v2/vulnerability?q=cwes=CWE-79", + ) + + @tag("v2", "vulnerability", "list") + @task + def list_vulnerability_sorted(self) -> None: + self.client.get( + "/api/v2/vulnerability?sort=base_score:desc", + name="/api/v2/vulnerability?sort=base_score:desc", + ) + + # -- SBOM list endpoints -------------------------------------------- + + @tag("v2", "sbom", "list") + @task(2) + def list_sbom(self) -> None: + self.client.get("/api/v2/sbom", name="/api/v2/sbom") + + @tag("v2", "sbom", "list") + @task(2) + def list_sbom_paginated(self) -> None: + self.client.get( + "/api/v2/sbom?offset=100&limit=10", + name="/api/v2/sbom?offset=100&limit=10", + ) + + @tag("v2", "sbom", "list") + @task + def list_sbom_by_name(self) -> None: + self.client.get( + "/api/v2/sbom?q=name~redhat", + name="/api/v2/sbom?q=name~redhat", + ) + + @tag("v2", "sbom", "list") + @task + def list_sbom_by_published(self) -> None: + self.client.get( + "/api/v2/sbom?q=published>2024-01-01", + name="/api/v2/sbom?q=published>2024-01-01", + ) + + @tag("v2", "sbom", "list") + @task + def list_sbom_sorted(self) -> None: + self.client.get( + "/api/v2/sbom?sort=ingested:desc", + name="/api/v2/sbom?sort=ingested:desc", + ) + + @tag("v2", "sbom", "list") + @task + def list_sbom_by_label(self) -> None: + self.client.get( + "/api/v2/sbom?q=label:type=product", + name="/api/v2/sbom?q=label:type=product", + ) + + @tag("v2", "sbom", "list") + @task + def list_sbom_labels(self) -> None: + self.client.get( + "/api/v2/sbom-labels", + name="/api/v2/sbom-labels", + ) + + # -- PURL list endpoints -------------------------------------------- + + @tag("v2", "purl", "list") + @task + def list_purl(self) -> None: + self.client.get("/api/v2/purl", name="/api/v2/purl") + + @tag("v2", "purl", "list") + @task + def list_purl_paginated(self) -> None: + self.client.get( + "/api/v2/purl?offset=100&limit=10", + name="/api/v2/purl?offset=100&limit=10", + ) + + @tag("v2", "purl", "list") + @task + def list_purl_by_name(self) -> None: + self.client.get( + "/api/v2/purl?q=curl", + name="/api/v2/purl?q=curl", + ) + + @tag("v2", "purl", "list") + @task + def list_purl_by_exact_name(self) -> None: + self.client.get( + "/api/v2/purl?q=name=curl", + name="/api/v2/purl?q=name=curl", + ) + + @tag("v2", "purl", "list") + @task + def list_purl_by_type(self) -> None: + self.client.get( + "/api/v2/purl?q=purl:ty=rpm", + name="/api/v2/purl?q=purl:ty=rpm", + ) + + @tag("v2", "purl", "list") + @task + def list_purl_by_namespace(self) -> None: + self.client.get( + "/api/v2/purl?q=purl:namespace=redhat", + name="/api/v2/purl?q=purl:namespace=redhat", + ) + + @tag("v2", "purl", "list") + @task + def list_purl_sorted(self) -> None: + self.client.get( + "/api/v2/purl?sort=purl:name:asc", + name="/api/v2/purl?sort=purl:name:asc", + ) + + # -- PURL base endpoints -------------------------------------------- + + @tag("v2", "purl", "list") + @task + def list_purl_base(self) -> None: + self.client.get("/api/v2/purl/base", name="/api/v2/purl/base") + + @tag("v2", "purl", "list") + @task + def list_purl_base_by_type(self) -> None: + self.client.get( + "/api/v2/purl/base?q=type=rpm", + name="/api/v2/purl/base?q=type=rpm", + ) + + @tag("v2", "purl", "list") + @task + def list_purl_base_by_ns(self) -> None: + self.client.get( + "/api/v2/purl/base?q=namespace=redhat", + name="/api/v2/purl/base?q=namespace=redhat", + ) + + @tag("v2", "purl", "list") + @task + def list_purl_base_sorted(self) -> None: + self.client.get( + "/api/v2/purl/base?sort=name:asc", + name="/api/v2/purl/base?sort=name:asc", + ) + + # -- Product / Organization ----------------------------------------- + + @tag("v2", "product", "list") + @task + def list_product(self) -> None: + self.client.get("/api/v2/product", name="/api/v2/product") + + @tag("v2", "product", "list") + @task + def list_product_by_name(self) -> None: + self.client.get( + "/api/v2/product?q=name~openshift", + name="/api/v2/product?q=name~openshift", + ) + + @tag("v2", "product", "list") + @task + def list_product_sorted(self) -> None: + self.client.get( + "/api/v2/product?sort=name:asc", + name="/api/v2/product?sort=name:asc", + ) + + @tag("v2", "organization", "list") + @task + def list_organization(self) -> None: + self.client.get( + "/api/v2/organization", + name="/api/v2/organization", + ) + + @tag("v2", "organization", "list") + @task + def list_organization_sorted(self) -> None: + self.client.get( + "/api/v2/organization?sort=name:asc", + name="/api/v2/organization?sort=name:asc", + ) + + # -- Importer / License / Weakness ---------------------------------- + # + # NOTE: v3 has these additional endpoints not present in v2: + # - /api/v3/license (list_license) -- v2 only has spdx/license + # - /api/v3/group/sbom (list_sbom_group, totals, parents) + # - /api/v3/ui/extract-sbom-purls (post_extract_sbom_purls) + # - /api/v3/vulnerability/analyze with hardcoded PURL + + @tag("v2", "importer", "list") + @task + def list_importer(self) -> None: + self.client.get("/api/v2/importer", name="/api/v2/importer") + + @tag("v2", "license", "list") + @task + def list_spdx_license(self) -> None: + self.client.get( + "/api/v2/license/spdx/license", + name="/api/v2/license/spdx/license", + ) + + @tag("v2", "weakness", "list") + @task + def list_weakness(self) -> None: + self.client.get("/api/v2/weakness", name="/api/v2/weakness") + + @tag("v2", "weakness", "list") + @task + def list_weakness_by_desc(self) -> None: + self.client.get( + "/api/v2/weakness?q=description~injection", + name="/api/v2/weakness?q=description~injection", + ) + + @tag("v2", "weakness", "list") + @task + def list_weakness_sorted(self) -> None: + self.client.get( + "/api/v2/weakness?sort=id:asc", + name="/api/v2/weakness?sort=id:asc", + ) + + # -- Well-known / misc ---------------------------------------------- + + @tag("v2", "misc") + @task + def well_known(self) -> None: + self.client.get( + "/.well-known/trustify", + name="/.well-known/trustify", + ) + + # -- Scenario-driven detail endpoints ------------------------------- + + @tag("v2", "advisory", "detail") + @task + def get_advisory(self) -> None: + if not SCENARIO.get_advisory: + return + uid = SCENARIO.get_advisory + with self.client.get( + f"/api/v2/advisory/urn:uuid:{uid}", + name=f"v2/get_advisory[{uid[:12]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "advisory", "detail") + @task + def download_advisory(self) -> None: + if not SCENARIO.download_advisory: + return + uid = SCENARIO.download_advisory + with self.client.get( + f"/api/v2/advisory/urn:uuid:{uid}/download", + name=f"v2/download_advisory[{uid[:12]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "sbom", "detail") + @task + def get_sbom(self) -> None: + if not SCENARIO.get_sbom: + return + key = SCENARIO.get_sbom + with self.client.get( + f"/api/v2/sbom/{quote(key, safe='')}", + name=f"v2/get_sbom[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "sbom", "detail") + @task + def get_sbom_advisories(self) -> None: + if not SCENARIO.get_sbom_advisories: + return + key = SCENARIO.get_sbom_advisories + with self.client.get( + f"/api/v2/sbom/{quote(key, safe='')}/advisory", + name=f"v2/get_sbom_advisories[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "sbom", "detail") + @task + def get_sbom_packages(self) -> None: + if not SCENARIO.get_sbom_packages: + return + key = SCENARIO.get_sbom_packages + with self.client.get( + f"/api/v2/sbom/{quote(key, safe='')}/packages", + name=f"v2/get_sbom_packages[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "sbom", "detail") + @task + def get_sbom_related(self) -> None: + if not SCENARIO.get_sbom_related: + return + key = SCENARIO.get_sbom_related + with self.client.get( + f"/api/v2/sbom/{quote(key, safe='')}/related", + name=f"v2/get_sbom_related[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "sbom", "detail") + @task + def get_sbom_license_ids(self) -> None: + if not SCENARIO.sbom_license_ids: + return + key = SCENARIO.sbom_license_ids + with self.client.get( + f"/api/v2/sbom/{quote(key, safe='')}/all-license-ids", + name=f"v2/get_sbom_license_ids[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "sbom", "detail") + @task + def download_sbom(self) -> None: + if not SCENARIO.download_sbom: + return + key = SCENARIO.download_sbom + with self.client.get( + f"/api/v2/sbom/{quote(key, safe='')}/download", + name=f"v2/download_sbom[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "sbom", "detail") + @task + def get_sbom_license_export(self) -> None: + if not SCENARIO.get_sbom_license_export: + return + sid = SCENARIO.get_sbom_license_export + with self.client.get( + f"/api/v2/sbom/{quote(sid, safe='')}/license-export", + name=f"v2/get_sbom_license_export[{sid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "sbom", "detail") + @task + def sbom_by_package(self) -> None: + if not SCENARIO.sbom_by_package: + return + purl = SCENARIO.sbom_by_package + with self.client.get( + f"/api/v2/sbom/by-package?purl={quote(purl, safe='')}", + name=f"v2/sbom_by_package[{purl[:20]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "sbom", "detail") + @task + def count_sbom_by_package(self) -> None: + if not SCENARIO.count_sbom_by_package: + return + purl = SCENARIO.count_sbom_by_package + with self.client.get( + "/api/v2/sbom/count-by-package", + json=[{"purl": purl}], + name=f"v2/count_sbom_by_package[{purl[:20]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "vulnerability", "detail") + @task + def get_vulnerability(self) -> None: + if not SCENARIO.get_vulnerability: + return + vid = SCENARIO.get_vulnerability + with self.client.get( + f"/api/v2/vulnerability/{quote(vid, safe='')}", + name=f"v2/get_vulnerability[{vid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "vulnerability", "detail") + @task + def get_vulnerability_scores(self) -> None: + if not SCENARIO.get_vulnerability: + return + vid = SCENARIO.get_vulnerability + with self.client.get( + f"/api/v2/vulnerability/{quote(vid, safe='')}?scores=true", + name=f"v2/get_vulnerability_scores[{vid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "vulnerability") + @task + def post_vulnerability_analyze(self) -> None: + if not SCENARIO.analyze_purl: + return + purl = SCENARIO.analyze_purl + with self.client.post( + "/api/v2/vulnerability/analyze", + json={"purls": [purl]}, + name=f"v2/post_vulnerability_analyze[{purl[:20]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "purl", "detail") + @task + def get_purl_details(self) -> None: + if not SCENARIO.get_purl_details: + return + pid = SCENARIO.get_purl_details + with self.client.get( + f"/api/v2/purl/{quote(pid, safe='')}", + name=f"v2/get_purl_details[{pid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "purl", "detail") + @task + def get_base_purl(self) -> None: + if not SCENARIO.get_base_purl: + return + key = SCENARIO.get_base_purl + with self.client.get( + f"/api/v2/purl/base/{quote(key, safe='')}", + name=f"v2/get_base_purl[{key[:20]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "purl") + @task(3) + def get_recommendations(self) -> None: + if not SCENARIO.get_recommendations: + return + batch = SCENARIO.get_recommendations[:25] + with self.client.post( + "/api/v2/purl/recommend", + json={"purls": batch}, + name=f"v2/get_recommendations[batch={len(batch)}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "product", "detail") + @task + def get_product(self) -> None: + if not SCENARIO.get_product: + return + pid = SCENARIO.get_product + with self.client.get( + f"/api/v2/product/{quote(pid, safe='')}", + name=f"v2/get_product[{pid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "organization", "detail") + @task + def get_organization(self) -> None: + if not SCENARIO.get_organization: + return + oid = SCENARIO.get_organization + with self.client.get( + f"/api/v2/organization/{quote(oid, safe='')}", + name=f"v2/get_organization[{oid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "importer", "detail") + @task + def get_importer(self) -> None: + if not SCENARIO.get_importer: + return + name = SCENARIO.get_importer + with self.client.get( + f"/api/v2/importer/{quote(name, safe='')}", + name=f"v2/get_importer[{name}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "importer", "detail") + @task + def get_importer_report(self) -> None: + if not SCENARIO.get_importer: + return + name = SCENARIO.get_importer + with self.client.get( + f"/api/v2/importer/{quote(name, safe='')}/report", + name=f"v2/get_importer_report[{name}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "license", "detail") + @task + def get_spdx_license(self) -> None: + if not SCENARIO.get_spdx_license: + return + lid = SCENARIO.get_spdx_license + with self.client.get( + f"/api/v2/license/spdx/license/{quote(lid, safe='')}", + name=f"v2/get_spdx_license[{lid}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "weakness", "detail") + @task + def get_weakness(self) -> None: + if not SCENARIO.get_weakness: + return + wid = SCENARIO.get_weakness + with self.client.get( + f"/api/v2/weakness/{quote(wid, safe='')}", + name=f"v2/get_weakness[{wid}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "group", "detail") + @task + def get_sbom_group(self) -> None: + if not SCENARIO.get_sbom_group: + return + gid = SCENARIO.get_sbom_group + with self.client.get( + f"/api/v2/group/sbom/{quote(gid, safe='')}", + name=f"v2/get_sbom_group[{gid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v2", "group", "detail") + @task + def get_sbom_group_assignments(self) -> None: + if not SCENARIO.get_sbom_group: + return + gid = SCENARIO.get_sbom_group + with self.client.get( + f"/api/v2/group/sbom-assignment/{quote(gid, safe='')}", + name=f"v2/get_sbom_group_assignments[{gid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") diff --git a/tools/perf/users/v2/rest_api_slow.py b/tools/perf/users/v2/rest_api_slow.py new file mode 100644 index 000000000..a9913a979 --- /dev/null +++ b/tools/perf/users/v2/rest_api_slow.py @@ -0,0 +1,77 @@ +"""RestAPIUserSlowV2 -- license-heavy v2 queries that tend to be slow (weight 1). + +These queries combine license filters and sorting across large result +sets, producing heavier database load. +""" + +from __future__ import annotations + +from locust import HttpUser, tag, task +from config import WAIT_TIME + + +class RestAPIUserSlowV2(HttpUser): + """Slow license-related queries against the trustify v2 REST API.""" + + weight = 1 + wait_time = WAIT_TIME + + @tag("v2", "license", "slow") + @task + def license_asl_sorted(self) -> None: + self.client.get( + "/api/v2/license?q=ASL&sort=license:desc", + name="/api/v2/license?q=ASL&sort=license:desc", + ) + + @tag("v2", "license", "slow") + @task + def license_apache(self) -> None: + self.client.get( + "/api/v2/license?q=license~Apache", + name="/api/v2/license?q=license~Apache", + ) + + @tag("v2", "license", "slow") + @task + def license_gpl(self) -> None: + self.client.get( + "/api/v2/license?q=license~GPL", + name="/api/v2/license?q=license~GPL", + ) + + @tag("v2", "license", "slow") + @task + def spdx_license_apache(self) -> None: + self.client.get( + "/api/v2/license/spdx/license?q=apache", + name="/api/v2/license/spdx/license?q=apache", + ) + + @tag("v2", "license", "slow") + @task + def spdx_license_gpl(self) -> None: + self.client.get( + "/api/v2/license/spdx/license?q=gpl", + name="/api/v2/license/spdx/license?q=gpl", + ) + + @tag("v2", "purl", "slow") + @task + def purl_license_filter(self) -> None: + self.client.get( + "/api/v2/purl", + params={ + "q": "license~GPLv3+ with exceptions|Apache", + "sort": "name:desc", + }, + name="/api/v2/purl?q=license~GPLv3+...&sort=name:desc", + ) + + @tag("v2", "sbom", "slow") + @task + def sbom_license_filter(self) -> None: + self.client.get( + "/api/v2/sbom?q=license~GPL&sort=name:desc", + name="/api/v2/sbom?q=license~GPL&sort=name:desc", + ) diff --git a/tools/perf/users/v3/__init__.py b/tools/perf/users/v3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/perf/users/v3/analysis.py b/tools/perf/users/v3/analysis.py new file mode 100644 index 000000000..fe83488fa --- /dev/null +++ b/tools/perf/users/v3/analysis.py @@ -0,0 +1,73 @@ +"""AnalysisUserV3 -- analysis and graph rendering v3 endpoints (weight 2). + +Analysis status, CPE lookup, scenario-driven component search, and +SBOM graph rendering. Component search accepts a list of keys in the +scenario file and cycles through them round-robin per request. +""" + +from __future__ import annotations + +import itertools +from urllib.parse import quote + +from locust import HttpUser, tag, task +from config import WAIT_TIME + +from scenario import SCENARIO + +_HARDCODED_CPE = "cpe:/a:redhat:openshift_container_platform:4.17::el9" + + +class AnalysisUserV3(HttpUser): + """Exercises trustify v3 analysis and graph endpoints.""" + + weight = 2 + wait_time = WAIT_TIME + _component_cycle = itertools.cycle( + SCENARIO.get_analysis_component + ) if SCENARIO.get_analysis_component else None + + @tag("v3", "analysis") + @task + def analysis_status(self) -> None: + self.client.get( + "/api/v3/analysis/status", + name="/api/v3/analysis/status", + ) + + @tag("v3", "analysis") + @task + def analysis_by_cpe(self) -> None: + self.client.get( + f"/api/v3/analysis/latest/component/{quote(_HARDCODED_CPE, safe='')}", + name="analysis_by_cpe", + ) + + @tag("v3", "analysis", "detail") + @task + def get_analysis_component(self) -> None: + + if not self._component_cycle: + return + key = next(self._component_cycle) + with self.client.get( + f"/api/v3/analysis/component/{quote(key, safe='')}", + name=f"get_analysis_component[{key[:20]}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "analysis", "detail") + @task + def render_sbom_graph_dot(self) -> None: + if not SCENARIO.render_sbom_graph: + return + sid = SCENARIO.render_sbom_graph + with self.client.get( + f"/api/v3/analysis/sbom/{quote(sid, safe='')}/render.dot", + name=f"render_sbom_graph_dot[{sid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") diff --git a/tools/perf/users/v3/labels.py b/tools/perf/users/v3/labels.py new file mode 100644 index 000000000..e2bb1b440 --- /dev/null +++ b/tools/perf/users/v3/labels.py @@ -0,0 +1,134 @@ +"""Label mutation users -- advisory and SBOM label PUT/PATCH (v3). + +AdvisoryLabelUserV3 discovers a random advisory then PUT/PATCH labels. +SBOMLabelUserV3 mutates labels on scenario-provided SBOM IDs. +""" + +from __future__ import annotations + +import random +from urllib.parse import quote + +from locust import HttpUser, tag, task +from config import WAIT_TIME + +from scenario import SCENARIO + + +class AdvisoryLabelUserV3(HttpUser): + """Finds random advisories and mutates their v3 labels.""" + + weight = 2 + wait_time = WAIT_TIME + + def on_start(self) -> None: + self._advisory_uuid: str | None = None + + def _find_random_advisory(self) -> str | None: + """Fetch a random advisory UUID from the list endpoint.""" + with self.client.get( + "/api/v3/advisory?limit=1", + name="find_random_advisory", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + return None + try: + data = resp.json() + total = data.get("total") or 0 + if total == 0: + resp.failure("no advisories in database") + return None + except Exception as exc: + resp.failure(str(exc)) + return None + + offset = random.randint(0, max(total - 1, 0)) # noqa: S311 + with self.client.get( + f"/api/v3/advisory?offset={offset}&limit=1", + name="find_random_advisory", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + return None + try: + items = resp.json().get("items", []) + if not items: + resp.failure("empty result") + return None + return items[0].get("uuid") or items[0].get("id") + except Exception as exc: + resp.failure(str(exc)) + return None + + @tag("v3", "advisory", "labels") + @task + def list_advisory_labels(self) -> None: + self.client.get( + "/api/v3/advisory-labels?filter_text=type&limit=1000", + name="list_advisory_labels", + ) + + @tag("v3", "advisory", "labels") + @task(3) + def put_and_patch_advisory_labels(self) -> None: + uid = self._find_random_advisory() + if not uid: + return + + put_body = { + "source": "load-test-put", + "load-test": "true", + "foo": "bar", + } + self.client.put( + f"/api/v3/advisory/urn:uuid:{uid}/label", + json=put_body, + name="put_advisory_labels", + ) + + patch_body = { + "source": "load-test-patch", + "load-test": "true", + "foo": "baz", + } + self.client.patch( + f"/api/v3/advisory/urn:uuid:{uid}/label", + json=patch_body, + name="patch_advisory_labels", + ) + + +class SBOMLabelUserV3(HttpUser): + """Mutates SBOM labels using scenario-provided SBOM IDs (v3).""" + + weight = 2 + wait_time = WAIT_TIME + + @tag("v3", "sbom", "labels") + @task + def put_sbom_labels(self) -> None: + if not SCENARIO.get_sbom: + return + key = SCENARIO.get_sbom + body = {"source": "load-test", "load-test": "true"} + self.client.put( + f"/api/v3/sbom/{quote(key, safe='')}/label", + json=body, + name="put_sbom_labels", + ) + + @tag("v3", "sbom", "labels") + @task + def patch_sbom_labels(self) -> None: + if not SCENARIO.get_sbom: + return + key = SCENARIO.get_sbom + body = {"source": "load-test-patch", "load-test": "true"} + self.client.patch( + f"/api/v3/sbom/{quote(key, safe='')}/label", + json=body, + name="patch_sbom_labels", + ) diff --git a/tools/perf/users/v3/rest_api.py b/tools/perf/users/v3/rest_api.py new file mode 100644 index 000000000..efa799702 --- /dev/null +++ b/tools/perf/users/v3/rest_api.py @@ -0,0 +1,798 @@ +"""RestAPIUserV3 -- main REST API v3 load test (weight 10). + +List, filter, sort, and detail endpoints for advisory, vulnerability, +SBOM, PURL, product, organization, importer, weakness, and license. +Scenario-driven detail lookups require TOOLS_PERF_SCENARIO_FILE. +""" + +from __future__ import annotations + +from urllib.parse import quote + +from locust import HttpUser, tag, task +from config import WAIT_TIME + +from scenario import SCENARIO + + +class RestAPIUserV3(HttpUser): + """Exercises the trustify v3 REST API with realistic query patterns.""" + + weight = 2 + wait_time = WAIT_TIME + + # -- Advisory list endpoints ---------------------------------------- + + @tag("v3", "advisory", "list") + @task + def list_advisory(self) -> None: + self.client.get( + "/api/v3/advisory?total=true", + name="/api/v3/advisory?total=true", + ) + + @tag("v3", "advisory", "list") + @task + def list_advisory_paginated(self) -> None: + self.client.get( + "/api/v3/advisory?offset=100&limit=10&total=true", + name="/api/v3/advisory?offset=100&limit=10&total=true", + ) + + @tag("v3", "advisory", "list") + @task + def list_advisory_by_identifier(self) -> None: + self.client.get( + "/api/v3/advisory?q=identifier%3dCVE-2022-0981&total=true", + name="/api/v3/advisory?q=identifier=CVE-2022-0981&total=true", + ) + + @tag("v3", "advisory", "list") + @task + def list_advisory_by_cve_prefix(self) -> None: + self.client.get( + "/api/v3/advisory?q=CVE-2021-&total=true", + name="/api/v3/advisory?q=CVE-2021-&total=true", + ) + + @tag("v3", "advisory", "list") + @task + def list_advisory_by_title(self) -> None: + self.client.get( + "/api/v3/advisory?q=title~openssl&total=true", + name="/api/v3/advisory?q=title~openssl&total=true", + ) + + @tag("v3", "advisory", "list") + @task + def list_advisory_by_modified(self) -> None: + self.client.get( + "/api/v3/advisory?q=modified>3 days ago&total=true", + name="/api/v3/advisory?q=modified>3 days ago&total=true", + ) + + @tag("v3", "advisory", "list") + @task + def list_advisory_sorted(self) -> None: + self.client.get( + "/api/v3/advisory?sort=modified:desc&total=true", + name="/api/v3/advisory?sort=modified:desc&total=true", + ) + + @tag("v3", "advisory", "list") + @task + def list_advisory_deprecated(self) -> None: + self.client.get( + "/api/v3/advisory?deprecated=Consider&total=true", + name="/api/v3/advisory?deprecated=Consider&total=true", + ) + + # -- Vulnerability list endpoints ----------------------------------- + + @tag("v3", "vulnerability", "list") + @task + def list_vulnerability(self) -> None: + self.client.get( + "/api/v3/vulnerability?total=true", + name="/api/v3/vulnerability?total=true", + ) + + @tag("v3", "vulnerability", "list") + @task + def list_vulnerability_paginated(self) -> None: + self.client.get( + "/api/v3/vulnerability?offset=100&limit=10&total=true", + name="/api/v3/vulnerability?offset=100&limit=10&total=true", + ) + + @tag("v3", "vulnerability", "list") + @task + def list_vulnerability_high(self) -> None: + self.client.get( + "/api/v3/vulnerability?q=base_severity=high&total=true", + name="/api/v3/vulnerability?q=base_severity=high&total=true", + ) + + @tag("v3", "vulnerability", "list") + @task + def list_vulnerability_score(self) -> None: + self.client.get( + "/api/v3/vulnerability?q=base_score>=7.0&total=true", + name="/api/v3/vulnerability?q=base_score>=7.0&total=true", + ) + + @tag("v3", "vulnerability", "list") + @task + def list_vulnerability_cwe(self) -> None: + self.client.get( + "/api/v3/vulnerability?q=cwes=CWE-79&total=true", + name="/api/v3/vulnerability?q=cwes=CWE-79&total=true", + ) + + @tag("v3", "vulnerability", "list") + @task + def list_vulnerability_sorted(self) -> None: + self.client.get( + "/api/v3/vulnerability?sort=base_score:desc&total=true", + name="/api/v3/vulnerability?sort=base_score:desc&total=true", + ) + + # -- SBOM list endpoints -------------------------------------------- + + @tag("v3", "sbom", "list") + @task(2) + def list_sbom(self) -> None: + self.client.get( + "/api/v3/sbom?total=true", + name="/api/v3/sbom?total=true", + ) + + @tag("v3", "sbom", "list") + @task(2) + def list_sbom_paginated(self) -> None: + self.client.get( + "/api/v3/sbom?offset=100&limit=10&total=true", + name="/api/v3/sbom?offset=100&limit=10&total=true", + ) + + @tag("v3", "sbom", "list") + @task + def list_sbom_by_name(self) -> None: + self.client.get( + "/api/v3/sbom?q=name~redhat&total=true", + name="/api/v3/sbom?q=name~redhat&total=true", + ) + + @tag("v3", "sbom", "list") + @task + def list_sbom_by_published(self) -> None: + self.client.get( + "/api/v3/sbom?q=published>2024-01-01&total=true", + name="/api/v3/sbom?q=published>2024-01-01&total=true", + ) + + @tag("v3", "sbom", "list") + @task + def list_sbom_sorted(self) -> None: + self.client.get( + "/api/v3/sbom?sort=ingested:desc&total=true", + name="/api/v3/sbom?sort=ingested:desc&total=true", + ) + + @tag("v3", "sbom", "list") + @task + def list_sbom_by_label(self) -> None: + self.client.get( + "/api/v3/sbom?q=label:type=product&total=true", + name="/api/v3/sbom?q=label:type=product&total=true", + ) + + @tag("v3", "sbom", "list") + @task + def list_sbom_labels(self) -> None: + self.client.get( + "/api/v3/sbom-labels?total=true", + name="/api/v3/sbom-labels?total=true", + ) + + # -- PURL list endpoints -------------------------------------------- + + @tag("v3", "purl", "list") + @task + def list_purl(self) -> None: + self.client.get( + "/api/v3/purl?total=true", + name="/api/v3/purl?total=true", + ) + + @tag("v3", "purl", "list") + @task + def list_purl_paginated(self) -> None: + self.client.get( + "/api/v3/purl?offset=100&limit=10&total=true", + name="/api/v3/purl?offset=100&limit=10&total=true", + ) + + @tag("v3", "purl", "list") + @task + def list_purl_by_name(self) -> None: + self.client.get( + "/api/v3/purl?q=curl&total=true", + name="/api/v3/purl?q=curl&total=true", + ) + + @tag("v3", "purl", "list") + @task + def list_purl_by_exact_name(self) -> None: + self.client.get( + "/api/v3/purl?q=name=curl&total=true", + name="/api/v3/purl?q=name=curl&total=true", + ) + + @tag("v3", "purl", "list") + @task + def list_purl_by_type(self) -> None: + self.client.get( + "/api/v3/purl?q=purl:ty=rpm&total=true", + name="/api/v3/purl?q=purl:ty=rpm&total=true", + ) + + @tag("v3", "purl", "list") + @task + def list_purl_by_namespace(self) -> None: + self.client.get( + "/api/v3/purl?q=purl:namespace=redhat&total=true", + name="/api/v3/purl?q=purl:namespace=redhat&total=true", + ) + + @tag("v3", "purl", "list") + @task + def list_purl_sorted(self) -> None: + self.client.get( + "/api/v3/purl?sort=purl:name:asc&total=true", + name="/api/v3/purl?sort=purl:name:asc&total=true", + ) + + # -- PURL base endpoints -------------------------------------------- + + @tag("v3", "purl", "list") + @task + def list_purl_base(self) -> None: + self.client.get( + "/api/v3/purl/base?total=true", + name="/api/v3/purl/base?total=true", + ) + + @tag("v3", "purl", "list") + @task + def list_purl_base_by_type(self) -> None: + self.client.get( + "/api/v3/purl/base?q=type=rpm&total=true", + name="/api/v3/purl/base?q=type=rpm&total=true", + ) + + @tag("v3", "purl", "list") + @task + def list_purl_base_by_ns(self) -> None: + self.client.get( + "/api/v3/purl/base?q=namespace=redhat&total=true", + name="/api/v3/purl/base?q=namespace=redhat&total=true", + ) + + @tag("v3", "purl", "list") + @task + def list_purl_base_sorted(self) -> None: + self.client.get( + "/api/v3/purl/base?sort=name:asc&total=true", + name="/api/v3/purl/base?sort=name:asc&total=true", + ) + + # -- Product / Organization ----------------------------------------- + + @tag("v3", "product", "list") + @task + def list_product(self) -> None: + self.client.get( + "/api/v3/product?total=true", + name="/api/v3/product?total=true", + ) + + @tag("v3", "product", "list") + @task + def list_product_by_name(self) -> None: + self.client.get( + "/api/v3/product?q=name~openshift&total=true", + name="/api/v3/product?q=name~openshift&total=true", + ) + + @tag("v3", "product", "list") + @task + def list_product_sorted(self) -> None: + self.client.get( + "/api/v3/product?sort=name:asc&total=true", + name="/api/v3/product?sort=name:asc&total=true", + ) + + @tag("v3", "organization", "list") + @task + def list_organization(self) -> None: + self.client.get( + "/api/v3/organization?total=true", + name="/api/v3/organization?total=true", + ) + + @tag("v3", "organization", "list") + @task + def list_organization_sorted(self) -> None: + self.client.get( + "/api/v3/organization?sort=name:asc&total=true", + name="/api/v3/organization?sort=name:asc&total=true", + ) + + # -- Importer / License / Weakness ---------------------------------- + + @tag("v3", "importer", "list") + @task + def list_importer(self) -> None: + self.client.get( + "/api/v3/importer?total=true", + name="/api/v3/importer?total=true", + ) + + @tag("v3", "license", "list") + @task + def list_license(self) -> None: + self.client.get( + "/api/v3/license?total=true", + name="/api/v3/license?total=true", + ) + + @tag("v3", "license", "list") + @task + def list_spdx_license(self) -> None: + self.client.get( + "/api/v3/license/spdx/license?total=true", + name="/api/v3/license/spdx/license?total=true", + ) + + @tag("v3", "weakness", "list") + @task + def list_weakness(self) -> None: + self.client.get( + "/api/v3/weakness?total=true", + name="/api/v3/weakness?total=true", + ) + + @tag("v3", "weakness", "list") + @task + def list_weakness_by_desc(self) -> None: + self.client.get( + "/api/v3/weakness?q=description~injection&total=true", + name="/api/v3/weakness?q=description~injection&total=true", + ) + + @tag("v3", "weakness", "list") + @task + def list_weakness_sorted(self) -> None: + self.client.get( + "/api/v3/weakness?sort=id:asc&total=true", + name="/api/v3/weakness?sort=id:asc&total=true", + ) + + # -- SBOM groups ---------------------------------------------------- + + @tag("v3", "group", "list") + @task + def list_sbom_group(self) -> None: + self.client.get( + "/api/v3/group/sbom?total=true", + name="/api/v3/group/sbom?total=true", + ) + + @tag("v3", "group", "list") + @task + def list_sbom_group_totals(self) -> None: + self.client.get( + "/api/v3/group/sbom?totals=true&total=true", + name="/api/v3/group/sbom?totals=true&total=true", + ) + + @tag("v3", "group", "list") + @task + def list_sbom_group_parents(self) -> None: + self.client.get( + "/api/v3/group/sbom?parents=resolve&total=true", + name="/api/v3/group/sbom?parents=resolve&total=true", + ) + + # -- Well-known / misc ---------------------------------------------- + + @tag("v3", "misc") + @task + def well_known(self) -> None: + self.client.get( + "/.well-known/trustify", + name="/.well-known/trustify", + ) + + @tag("v3", "misc") + @task + def post_extract_sbom_purls(self) -> None: + payload = { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "locust-test", + "documentNamespace": "https://example.com/locust", + "packages": [], + } + self.client.post( + "/api/v3/ui/extract-sbom-purls", + json=payload, + name="post_extract_sbom_purls", + ) + + @tag("v3", "vulnerability") + @task + def post_vulnerability_analyze_v3(self) -> None: + self.client.post( + "/api/v3/vulnerability/analyze", + json={"purls": ["pkg:rpm/redhat/openssl@3.0.0"]}, + name="post_vulnerability_analyze_v3", + ) + + # -- Scenario-driven detail endpoints ------------------------------- + + @tag("v3", "advisory", "detail") + @task + def get_advisory(self) -> None: + if not SCENARIO.get_advisory: + return + uid = SCENARIO.get_advisory + with self.client.get( + f"/api/v3/advisory/urn:uuid:{uid}", + name=f"get_advisory[{uid[:12]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "advisory", "detail") + @task + def download_advisory(self) -> None: + if not SCENARIO.download_advisory: + return + uid = SCENARIO.download_advisory + with self.client.get( + f"/api/v3/advisory/urn:uuid:{uid}/download", + name=f"download_advisory[{uid[:12]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "sbom", "detail") + @task + def get_sbom(self) -> None: + if not SCENARIO.get_sbom: + return + key = SCENARIO.get_sbom + with self.client.get( + f"/api/v3/sbom/{quote(key, safe='')}", + name=f"get_sbom[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "sbom", "detail") + @task + def get_sbom_advisories(self) -> None: + if not SCENARIO.get_sbom_advisories: + return + key = SCENARIO.get_sbom_advisories + with self.client.get( + f"/api/v3/sbom/{quote(key, safe='')}/advisory", + name=f"get_sbom_advisories[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "sbom", "detail") + @task + def get_sbom_packages(self) -> None: + if not SCENARIO.get_sbom_packages: + return + key = SCENARIO.get_sbom_packages + with self.client.get( + f"/api/v3/sbom/{quote(key, safe='')}/packages", + name=f"get_sbom_packages[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "sbom", "detail") + @task + def get_sbom_related(self) -> None: + if not SCENARIO.get_sbom_related: + return + key = SCENARIO.get_sbom_related + with self.client.get( + f"/api/v3/sbom/{quote(key, safe='')}/related", + name=f"get_sbom_related[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "sbom", "detail") + @task + def get_sbom_license_ids(self) -> None: + if not SCENARIO.sbom_license_ids: + return + key = SCENARIO.sbom_license_ids + with self.client.get( + f"/api/v3/sbom/{quote(key, safe='')}/all-license-ids", + name=f"get_sbom_license_ids[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "sbom", "detail") + @task + def download_sbom(self) -> None: + if not SCENARIO.download_sbom: + return + key = SCENARIO.download_sbom + with self.client.get( + f"/api/v3/sbom/{quote(key, safe='')}/download", + name=f"download_sbom[{key[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "sbom", "detail") + @task + def get_sbom_license_export(self) -> None: + if not SCENARIO.get_sbom_license_export: + return + sid = SCENARIO.get_sbom_license_export + with self.client.get( + f"/api/v3/sbom/{quote(sid, safe='')}/license-export", + name=f"get_sbom_license_export[{sid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "sbom", "detail") + @task + def sbom_by_package(self) -> None: + if not SCENARIO.sbom_by_package: + return + purl = SCENARIO.sbom_by_package + with self.client.get( + f"/api/v3/sbom/by-package?purl={quote(purl, safe='')}", + name=f"sbom_by_package[{purl[:20]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "sbom", "detail") + @task + def count_sbom_by_package(self) -> None: + if not SCENARIO.count_sbom_by_package: + return + purl = SCENARIO.count_sbom_by_package + with self.client.get( + "/api/v3/sbom/count-by-package", + json=[{"purl": purl}], + name=f"count_sbom_by_package[{purl[:20]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "vulnerability", "detail") + @task + def get_vulnerability(self) -> None: + if not SCENARIO.get_vulnerability: + return + vid = SCENARIO.get_vulnerability + with self.client.get( + f"/api/v3/vulnerability/{quote(vid, safe='')}", + name=f"get_vulnerability[{vid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "vulnerability", "detail") + @task + def get_vulnerability_scores(self) -> None: + if not SCENARIO.get_vulnerability: + return + vid = SCENARIO.get_vulnerability + with self.client.get( + f"/api/v3/vulnerability/{quote(vid, safe='')}?scores=true", + name=f"get_vulnerability_scores[{vid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "vulnerability") + @task + def post_vulnerability_analyze(self) -> None: + if not SCENARIO.analyze_purl: + return + purl = SCENARIO.analyze_purl + with self.client.post( + "/api/v3/vulnerability/analyze", + json={"purls": [purl]}, + name=f"post_vulnerability_analyze[{purl[:20]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "purl", "detail") + @task + def get_purl_details(self) -> None: + if not SCENARIO.get_purl_details: + return + pid = SCENARIO.get_purl_details + with self.client.get( + f"/api/v3/purl/{quote(pid, safe='')}", + name=f"get_purl_details[{pid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "purl", "detail") + @task + def get_base_purl(self) -> None: + if not SCENARIO.get_base_purl: + return + key = SCENARIO.get_base_purl + with self.client.get( + f"/api/v3/purl/base/{quote(key, safe='')}", + name=f"get_base_purl[{key[:20]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "purl") + @task(3) + def get_recommendations(self) -> None: + if not SCENARIO.get_recommendations: + return + batch = SCENARIO.get_recommendations[:25] + with self.client.post( + "/api/v3/purl/recommend", + json={"purls": batch}, + name=f"get_recommendations[batch={len(batch)}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "product", "detail") + @task + def get_product(self) -> None: + if not SCENARIO.get_product: + return + pid = SCENARIO.get_product + with self.client.get( + f"/api/v3/product/{quote(pid, safe='')}", + name=f"get_product[{pid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "organization", "detail") + @task + def get_organization(self) -> None: + if not SCENARIO.get_organization: + return + oid = SCENARIO.get_organization + with self.client.get( + f"/api/v3/organization/{quote(oid, safe='')}", + name=f"get_organization[{oid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "importer", "detail") + @task + def get_importer(self) -> None: + if not SCENARIO.get_importer: + return + name = SCENARIO.get_importer + with self.client.get( + f"/api/v3/importer/{quote(name, safe='')}", + name=f"get_importer[{name}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "importer", "detail") + @task + def get_importer_report(self) -> None: + if not SCENARIO.get_importer: + return + name = SCENARIO.get_importer + with self.client.get( + f"/api/v3/importer/{quote(name, safe='')}/report", + name=f"get_importer_report[{name}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "license", "detail") + @task + def get_spdx_license(self) -> None: + if not SCENARIO.get_spdx_license: + return + lid = SCENARIO.get_spdx_license + with self.client.get( + f"/api/v3/license/spdx/license/{quote(lid, safe='')}", + name=f"get_spdx_license[{lid}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "weakness", "detail") + @task + def get_weakness(self) -> None: + if not SCENARIO.get_weakness: + return + wid = SCENARIO.get_weakness + with self.client.get( + f"/api/v3/weakness/{quote(wid, safe='')}", + name=f"get_weakness[{wid}]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "group", "detail") + @task + def get_sbom_group(self) -> None: + if not SCENARIO.get_sbom_group: + return + gid = SCENARIO.get_sbom_group + with self.client.get( + f"/api/v3/group/sbom/{quote(gid, safe='')}", + name=f"get_sbom_group[{gid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + + @tag("v3", "group", "detail") + @task + def get_sbom_group_assignments(self) -> None: + if not SCENARIO.get_sbom_group: + return + gid = SCENARIO.get_sbom_group + with self.client.get( + f"/api/v3/group/sbom-assignment/{quote(gid, safe='')}", + name=f"get_sbom_group_assignments[{gid[:16]}...]", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"status {resp.status_code}") + diff --git a/tools/perf/users/v3/rest_api_slow.py b/tools/perf/users/v3/rest_api_slow.py new file mode 100644 index 000000000..e62a088f4 --- /dev/null +++ b/tools/perf/users/v3/rest_api_slow.py @@ -0,0 +1,78 @@ +"""RestAPIUserSlowV3 -- license-heavy v3 queries that tend to be slow (weight 1). + +These queries combine license filters and sorting across large result +sets, producing heavier database load. +""" + +from __future__ import annotations + +from locust import HttpUser, tag, task +from config import WAIT_TIME + + +class RestAPIUserSlowV3(HttpUser): + """Slow license-related queries against the trustify v3 REST API.""" + + weight = 2 + wait_time = WAIT_TIME + + @tag("v3", "license", "slow") + @task + def license_asl_sorted(self) -> None: + self.client.get( + "/api/v3/license?q=ASL&sort=license:desc&total=true", + name="/api/v3/license?q=ASL&sort=license:desc&total=true", + ) + + @tag("v3", "license", "slow") + @task + def license_apache(self) -> None: + self.client.get( + "/api/v3/license?q=license~Apache&total=true", + name="/api/v3/license?q=license~Apache&total=true", + ) + + @tag("v3", "license", "slow") + @task + def license_gpl(self) -> None: + self.client.get( + "/api/v3/license?q=license~GPL&total=true", + name="/api/v3/license?q=license~GPL&total=true", + ) + + @tag("v3", "license", "slow") + @task + def spdx_license_apache(self) -> None: + self.client.get( + "/api/v3/license/spdx/license?q=apache&total=true", + name="/api/v3/license/spdx/license?q=apache&total=true", + ) + + @tag("v3", "license", "slow") + @task + def spdx_license_gpl(self) -> None: + self.client.get( + "/api/v3/license/spdx/license?q=gpl&total=true", + name="/api/v3/license/spdx/license?q=gpl&total=true", + ) + + @tag("v3", "purl", "slow") + @task + def purl_license_filter(self) -> None: + self.client.get( + "/api/v3/purl", + params={ + "q": "license~GPLv3+ with exceptions|Apache", + "sort": "name:desc", + "total": "true", + }, + name="/api/v3/purl?q=license~GPLv3+...&sort=name:desc&total=true", + ) + + @tag("v3", "sbom", "slow") + @task + def sbom_license_filter(self) -> None: + self.client.get( + "/api/v3/sbom?q=license~GPL&sort=name:desc&total=true", + name="/api/v3/sbom?q=license~GPL&sort=name:desc&total=true", + ) diff --git a/tools/perf/users/website.py b/tools/perf/users/website.py new file mode 100644 index 000000000..7bd4d1bd7 --- /dev/null +++ b/tools/perf/users/website.py @@ -0,0 +1,46 @@ +"""WebsiteUser -- simulates UI page browsing (weight 1). + +Hits the main HTML pages a real user would navigate through. +""" + +from __future__ import annotations + +from locust import HttpUser, tag, task +from config import WAIT_TIME + + +class WebsiteUser(HttpUser): + """Simulates a user browsing the trustify web UI.""" + + weight = 1 + wait_time = WAIT_TIME + + @tag("website") + @task + def index(self) -> None: + self.client.get("/", name="website_index") + + @tag("website") + @task + def openapi(self) -> None: + self.client.get("/openapi/", name="website_openapi") + + @tag("website") + @task + def sboms(self) -> None: + self.client.get("/sboms", name="website_sboms") + + @tag("website") + @task + def packages(self) -> None: + self.client.get("/packages", name="website_packages") + + @tag("website") + @task + def advisories(self) -> None: + self.client.get("/advisories", name="website_advisories") + + @tag("website") + @task + def importers(self) -> None: + self.client.get("/importers", name="website_importers") diff --git a/tools/perf/uv.lock b/tools/perf/uv.lock new file mode 100644 index 000000000..332805fda --- /dev/null +++ b/tools/perf/uv.lock @@ -0,0 +1,807 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[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 = "configargparse" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/30328302903c55218ffc5199646d0e9d28348ff26c02ba77b2ffc58d294a/configargparse-1.7.5.tar.gz", hash = "sha256:e3f9a7bb6be34d66b2e3c4a2f58e3045f8dfae47b0dc039f87bcfaa0f193fb0f", size = 53548, upload-time = "2026-03-11T02:19:38.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/19/3ba5e1b0bcc7b91aeab6c258afd70e4907d220fed3972febe38feb40db30/configargparse-1.7.5-py3-none-any.whl", hash = "sha256:1e63fdffedf94da9cd435fc13a1cd24777e76879dd2343912c1f871d4ac8c592", size = 27692, upload-time = "2026-03-11T02:19:36.442Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-cors" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/03/4e464a50860f9adf08b5c1d3479cb8ea1f12af2aa69535c7042c6e628135/flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", size = 101386, upload-time = "2026-06-08T20:20:17.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/55/5bb1a2d918e9f02f131e47a59032bae70e48050e986e941511fd737a935c/flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8", size = 16692, upload-time = "2026-06-08T20:20:16.247Z" }, +] + +[[package]] +name = "flask-login" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" }, +] + +[[package]] +name = "gevent" +version = "24.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/75/a53f1cb732420f5e5d79b2563fc3504d22115e7ecfe7966e5cf9b3582ae7/gevent-24.11.1.tar.gz", hash = "sha256:8bd1419114e9e4a3ed33a5bad766afff9a3cf765cb440a582a1b3a9bc80c1aca", size = 5976624, upload-time = "2024-11-11T15:36:45.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8f/4958e70caeaf469c576ecc5b5f2cb49ddaad74336fa82363d89cddb3c284/gevent-24.11.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d618e118fdb7af1d6c1a96597a5cd6ac84a9f3732b5be8515c6a66e098d498b6", size = 2949601, upload-time = "2024-11-11T14:32:35.002Z" }, + { url = "https://files.pythonhosted.org/packages/3b/64/79892d250b7b2aa810688dfebe783aec02568e5cecacb1e100acbb9d95c6/gevent-24.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2142704c2adce9cd92f6600f371afb2860a446bfd0be5bd86cca5b3e12130766", size = 5107052, upload-time = "2024-11-11T15:20:07.219Z" }, + { url = "https://files.pythonhosted.org/packages/66/44/9ee0ed1909b4f41375e32bf10036d5d8624962afcbd901573afdecd2e36a/gevent-24.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92e0d7759de2450a501effd99374256b26359e801b2d8bf3eedd3751973e87f5", size = 5271736, upload-time = "2024-11-11T15:21:05.953Z" }, + { url = "https://files.pythonhosted.org/packages/e3/48/0184b2622a388a256199c5fadcad6b52b6455019c2a4b19edd6de58e30ba/gevent-24.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca845138965c8c56d1550499d6b923eb1a2331acfa9e13b817ad8305dde83d11", size = 5367782, upload-time = "2024-11-11T15:22:48.15Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b1/1a2704c346234d889d2e0042efb182534f7d294115f0e9f99d8079fa17eb/gevent-24.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:356b73d52a227d3313f8f828025b665deada57a43d02b1cf54e5d39028dbcf8d", size = 6757533, upload-time = "2024-11-11T14:57:15.142Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6e/b2eed8dec617264f0046d50a13a42d3f0a06c50071b9fc1eae00285a03f1/gevent-24.11.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:58851f23c4bdb70390f10fc020c973ffcf409eb1664086792c8b1e20f25eef43", size = 5449436, upload-time = "2024-11-11T15:37:08.143Z" }, + { url = "https://files.pythonhosted.org/packages/63/c2/eca6b95fbf9af287fa91c327494e4b74a8d5bfa0156cd87b233f63f118dc/gevent-24.11.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1ea50009ecb7f1327347c37e9eb6561bdbc7de290769ee1404107b9a9cba7cf1", size = 6866470, upload-time = "2024-11-11T15:03:48.724Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/51824bd1f2c1ce70aa01495aa6ffe04ab789fa819fa7e6f0ad2388fb03c6/gevent-24.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:ec68e270543ecd532c4c1d70fca020f90aa5486ad49c4f3b8b2e64a66f5c9274", size = 1540088, upload-time = "2024-11-11T14:46:23.849Z" }, +] + +[[package]] +name = "geventhttpclient" +version = "2.3.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "brotli" }, + { name = "certifi" }, + { name = "gevent" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/ff/cb3db11fca4223b2753ae170d1a09c9d32bfbfa3e8d4a6181324db686830/geventhttpclient-2.3.9.tar.gz", hash = "sha256:16807578dc4a175e8d97e6e39d65a10b04b5237a8c55f7a5ef39044e869baeb8", size = 84353, upload-time = "2026-03-03T08:09:03.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/0c/ec3e7926e5a24780ad0f2d422799966f2f13342c793ed9f37f0c03282f58/geventhttpclient-2.3.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9d0568d38cf74cecd37fd1ef65459f60ecd26dbc0d33bc2a1e0d8df4af24f07d", size = 70144, upload-time = "2026-03-03T08:08:19.932Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/d28f76482880f9233de07fb9422db26b983a901cad4670bba8bc1170f988/geventhttpclient-2.3.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:02e06a2f78a225b70e616b493317073f3e2fddd4e51ddfc44569d188f368bd8d", size = 51779, upload-time = "2026-03-03T08:08:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/35/ff/930be8f0e4f84d1b229b1ec394463ea36701991d888f4856904e292a6b0b/geventhttpclient-2.3.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eec8e442214d4086e40a3ae7fe1e1e3ecbc422157d8d2118059cf9977336d9f", size = 51516, upload-time = "2026-03-03T08:08:21.802Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ac/952c51392527f707c1f08401d0b477cdd1840a487dffa6e9fce444d54122/geventhttpclient-2.3.9-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a18b28d2f8bc7fcfc721227733bccb647602399db6b0fd093c00ff9699717b74", size = 115412, upload-time = "2026-03-03T08:08:22.615Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/1b61f8dae1efb670f7728cd727c35ff294b89af727db268f9e2d90102a97/geventhttpclient-2.3.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b16e30dbbc528453a4130210d83638444229357c073eb911421eb44e3367359", size = 116088, upload-time = "2026-03-03T08:08:23.474Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/941c05d483fe8a95672f8f39e7410292f4b617020d1d595b88da5660b132/geventhttpclient-2.3.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06df5597edf65d4c691052fce3e37620cbc037879a3b872bc16a7b2a0941d59a", size = 122068, upload-time = "2026-03-03T08:08:24.495Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/84400d58934f774cef259c8b49292542313c02224c2f11b1b116d720b464/geventhttpclient-2.3.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:47a303bcac3d69569f025d0c81781c5f0c1a48c9f225e43082d1b56e4c0440f8", size = 112054, upload-time = "2026-03-03T08:08:25.663Z" }, + { url = "https://files.pythonhosted.org/packages/12/ae/12821cad292235d4db8532f58c8bc93db4211862845bf76a4c06e6ed1416/geventhttpclient-2.3.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e73b25415e83064f5a334e83495d97b138e66f67a98cfcad154068c257733973", size = 118837, upload-time = "2026-03-03T08:08:26.816Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d3/34e80569f3563eb26f5d7bb971677de0b53b16d720f87373cc7aeee51c04/geventhttpclient-2.3.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98ff3350d8be75586076140bde565c35ccdd72a6840b88f94037ec6595407383", size = 112643, upload-time = "2026-03-03T08:08:27.666Z" }, + { url = "https://files.pythonhosted.org/packages/b1/79/94ace94281e40f7258ba4e7166ae846394d2a673dbe47a0a255eb0d53ca8/geventhttpclient-2.3.9-cp313-cp313-win32.whl", hash = "sha256:af7931f55522cddedf84e837769c66d9ceb130b29182ad1e2d0201f501df899f", size = 48741, upload-time = "2026-03-03T08:08:28.507Z" }, + { url = "https://files.pythonhosted.org/packages/89/67/15b1ba79dfbab515c0d42a01b6545adef7dad00968eaa89ec21cca030c2e/geventhttpclient-2.3.9-cp313-cp313-win_amd64.whl", hash = "sha256:14daf2f0361f19b0221f900d7e9d563c184bb7186676e61fe848495b1f2483d3", size = 49371, upload-time = "2026-03-03T08:08:29.319Z" }, + { url = "https://files.pythonhosted.org/packages/16/9f/57d5acd0d95417a29661dfa91a8657be8026a9df17cafc6ba4f20bc2a687/geventhttpclient-2.3.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c06e243de53f54942b098f81622917f4a33c16f44733c9371ea98a2cd5ce12e", size = 70423, upload-time = "2026-03-03T08:08:30.106Z" }, + { url = "https://files.pythonhosted.org/packages/29/8b/ad6eb43b136fdb2f4954dc21073911d7703ea95fd88a3cc7512714508ce3/geventhttpclient-2.3.9-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:549155d557de403612336ca36cd93a049e67acbf9a29e6b6b971d0f4cb56786d", size = 51902, upload-time = "2026-03-03T08:08:30.892Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/2d2cfd9dd9ae0a6d4138b8a88f0b4524657a48a7c81ead6986a3e955deda/geventhttpclient-2.3.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b463324d5fde983657247b2faea77f8f8a40f3f7ac0c2897a2fe3afa27d610", size = 51564, upload-time = "2026-03-03T08:08:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f3/4585fabea4f45c4c21cd128d61ebbf43a78c73d520c70471734d41177b1d/geventhttpclient-2.3.9-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e1ac3a39e3c4ae36024ddf1694eb82b0cc22c4516f176477f94f98bcd56ce6cf", size = 115449, upload-time = "2026-03-03T08:08:32.75Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e8/d7d82f527c632cbdeffa34858557db3da238f68f2fbb9bd80f2ec2c64510/geventhttpclient-2.3.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3d24480c3a2cc88311c41a042bc12ab8e4104dad6029591ecbf5a1e933e8a44", size = 116152, upload-time = "2026-03-03T08:08:33.941Z" }, + { url = "https://files.pythonhosted.org/packages/ea/63/25ee53c3efa9ded9976e4f5ac8c6f8e8cef941bbbc847290e7f5c0254c40/geventhttpclient-2.3.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b244adcbf5814a29d5cea8b2fc079f9242d92765191faa4dc5eccc0421840ae", size = 122145, upload-time = "2026-03-03T08:08:34.809Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8c/15c5d71e6011f317f7decb26fd15e1e6caf780b09297af3018599311e6df/geventhttpclient-2.3.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83dc6f037a50b7d2dc45af58a7e7978016a06320a5f823d1bd544c85d69f2058", size = 112134, upload-time = "2026-03-03T08:08:35.716Z" }, + { url = "https://files.pythonhosted.org/packages/02/5b/fd7b17c37a9f9002a5fd8d690c97ada372393fcfb9358dd62026e089ae96/geventhttpclient-2.3.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:caf8779ca686497e0fab1048b026b4e48fb14fb9e88ddbfd14ca1a1a4c4bfa89", size = 118879, upload-time = "2026-03-03T08:08:36.953Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/c1559f43ef56100d64bf1b227844bf229101fdb58b556e2336b4307bda0d/geventhttpclient-2.3.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cd4efebba798c7f585aa1ceb9aba9524b12ebc51b26ad62de5234b8264d9b94d", size = 112593, upload-time = "2026-03-03T08:08:37.842Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a61a9cb76feede0d4429154d391c275debde78b6602f52c95aeed6dce1ff/geventhttpclient-2.3.9-cp314-cp314-win32.whl", hash = "sha256:7b60c0b650c77d2644374149c38dfee34510e88e569ca85f38fe15f40ecaea1c", size = 49390, upload-time = "2026-03-03T08:08:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/fd/05/b8d71edd82c9b07b9be0c3e5d6faf94583c6a098e2e9e5ee2b14a6312c5e/geventhttpclient-2.3.9-cp314-cp314-win_amd64.whl", hash = "sha256:c4d5e1b9b1ac9baab42a1789bbfae7e97e40e8e83e09a32b353c6eb985f36071", size = 49881, upload-time = "2026-03-03T08:08:40.228Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/2f7f8f63968d26d7233fb9b9e5b1a5015989b90f95e997e9dc98283b0a86/geventhttpclient-2.3.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ae44cec808193bb70b634fabdfdd89f0850744ace5668dc98063d633cf50c417", size = 70812, upload-time = "2026-03-03T08:08:41.073Z" }, + { url = "https://files.pythonhosted.org/packages/5f/89/7887f802adee5990c10dd9c44b20a3205e046773061266ce5cffb99e30b9/geventhttpclient-2.3.9-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:53977ca41809eaef73cf38af170484baa53bde5f16bafbca7b77b670c343f48f", size = 52087, upload-time = "2026-03-03T08:08:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/5d/07/23cc505111abb65cb5a68e5cd123b1ffc1ad7893a1bc46945b9ed3d03245/geventhttpclient-2.3.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0f66a33c95e4d6d343fc6ace458b13c613684bf7cfd6832b61cc9c42eaf394f3", size = 51772, upload-time = "2026-03-03T08:08:42.926Z" }, + { url = "https://files.pythonhosted.org/packages/d9/97/461fd5c73858b2daaaba2ecefd2ff64aa8f2242c48c939e75caba9ec3cb2/geventhttpclient-2.3.9-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cfe23d419aa676492677374bdd37e364c921895d1090a180173be5d5f87f82b9", size = 118329, upload-time = "2026-03-03T08:08:44.07Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/0ede3d90ab92a105f24b758b0bfb2d5e7f34c017d22d76d87524e75e93cc/geventhttpclient-2.3.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e3b279da39ad3eee69a5df9e1b602f87bcd2cec7eb258d3cc801e2170682383", size = 119974, upload-time = "2026-03-03T08:08:45.231Z" }, + { url = "https://files.pythonhosted.org/packages/98/8f/0ef02946bbbbd91ba4c3da99657d90e250c00409710ed377e4e4540b90c3/geventhttpclient-2.3.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38535589a564822c64d1b4c2a5d6dcc27159d0d7d76500f2c8c8d21d9dd54880", size = 125764, upload-time = "2026-03-03T08:08:46.446Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/d8f385fd6a3538cf1fd57a3fd47b133fba2e32c6be86e75805117d96ff1f/geventhttpclient-2.3.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a6436cd77885a8ef7cdc6d225cddd732560a17e92969c74e997836cf3135baa0", size = 115599, upload-time = "2026-03-03T08:08:47.393Z" }, + { url = "https://files.pythonhosted.org/packages/cb/55/ec651647ee2f7fdfee8d7a75ba682064e0e5012696f9aa83c0392d54fdeb/geventhttpclient-2.3.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5000c9fb0553818c4e4c1de248ee4e9a56de0a245a30ef76b687542a935f4645", size = 122254, upload-time = "2026-03-03T08:08:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/77/7d/20606d1a4ae085eb3935e4d3625e7208911d0f1a0006c9fd962d88254d92/geventhttpclient-2.3.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:52516d5c153fcef0d3d2447e533244dc6360e8c2a190b958861137db6f227605", size = 115383, upload-time = "2026-03-03T08:08:49.454Z" }, + { url = "https://files.pythonhosted.org/packages/85/16/d20ac6ac73d63fe326ffe357a8e91c4f43b9e790faeeb9b15774eecf2550/geventhttpclient-2.3.9-cp314-cp314t-win32.whl", hash = "sha256:14eaa836bde26a70952e95ca462018f3a47c1c92642327315aa6502e54141016", size = 49749, upload-time = "2026-03-03T08:08:50.339Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/95d1ed1ace7902d8e1ce698db31931cb87d4abe621ec2df24e69daf49ae9/geventhttpclient-2.3.9-cp314-cp314t-win_amd64.whl", hash = "sha256:b9bbcbc7d5d875e5180f2b1f1c6fa8e092ef80d9debfb6ba22a4ec28f0565395", size = 50300, upload-time = "2026-03-03T08:08:51.482Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/8b/befc3cb36965f397d87e86fb3b00e3ec0dc67c1ecb0986d7f54ee528f018/greenlet-3.5.2.tar.gz", hash = "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", size = 199243, upload-time = "2026-06-17T20:19:01.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/3c/bb37b9d40d65b0741a8b040ca5c307034d0a9822994dff5f825c88dd7a6b/greenlet-3.5.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34", size = 287178, upload-time = "2026-06-17T17:35:25.132Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a6/0c5902393f492f8ceb19d0b5cf139284e3a11b333a049739643b1036b6f8/greenlet-3.5.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a", size = 606900, upload-time = "2026-06-17T18:07:21.692Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7c/42899c31d4b87148ae4e3f87f63e13398824be6241f4dde42ded95768a34/greenlet-3.5.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7", size = 619265, upload-time = "2026-06-17T18:29:44.837Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/28f991affb413b232b1e7d768db24c37b3f4d5daecc3f19b455d40bd2dea/greenlet-3.5.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e", size = 625044, upload-time = "2026-06-17T18:39:29.046Z" }, + { url = "https://files.pythonhosted.org/packages/d3/52/4ff8c98d3cfe62b4515f8584ae14510a58f35c549cc5292b78d9b7a40b70/greenlet-3.5.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473", size = 616187, upload-time = "2026-06-17T17:39:29.473Z" }, + { url = "https://files.pythonhosted.org/packages/29/05/0cc9ec660e7acff85f93b0a048b6654371c822c884add44c02a465cf70e0/greenlet-3.5.2-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5", size = 427322, upload-time = "2026-06-17T18:41:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a6/269c8bf9aefc13361ce1088f0e392b154cb21005de7862e42b5d782b81fd/greenlet-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5", size = 1573778, upload-time = "2026-06-17T18:22:13.497Z" }, + { url = "https://files.pythonhosted.org/packages/1f/9b/391d015cbc6323e81b14c02cf825fdca7e0049c9bb489bf4ac72883118ba/greenlet-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5", size = 1638092, upload-time = "2026-06-17T17:40:08.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/53/5b4df711f4356c62e85d9f819d87966d526d1cfb32bae49a8f7d6fc36ea4/greenlet-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c", size = 239352, upload-time = "2026-06-17T17:38:51.593Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/18efc3a329ec035c3f344b8f2b60356451950ddf9b7b64ff00023778a1dd/greenlet-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4", size = 237635, upload-time = "2026-06-17T17:35:36.632Z" }, + { url = "https://files.pythonhosted.org/packages/c7/89/aaafc8e14de4ac882e02ccb963225329b0e8578aba4365e71eb678e45722/greenlet-3.5.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb", size = 287676, upload-time = "2026-06-17T17:33:31.514Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/2308249206c12ac70de7b9a00970f84f07d10b3cd60e05d2fbcaa84124e8/greenlet-3.5.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39", size = 653552, upload-time = "2026-06-17T18:07:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/47730d1f8f1336b9b089237521ed7a26eee997065dcb4cab81cdca333abc/greenlet-3.5.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8", size = 665756, upload-time = "2026-06-17T18:29:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/2664d290cbd1fef9eb3f69b5d3bc5aa91b6fa907519298ca6af93a90c6cb/greenlet-3.5.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d", size = 669989, upload-time = "2026-06-17T18:39:30.79Z" }, + { url = "https://files.pythonhosted.org/packages/99/69/d6c99db15dc0b5e892ac3cc7b942c8b21f4a9cc3bd9ea0bc3b0f339ffbd4/greenlet-3.5.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163", size = 663228, upload-time = "2026-06-17T17:39:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/42/d4/fcb53fa9847d7fbd4723fbed9469c3869b9e3544c4e001d9d5aa2f66162d/greenlet-3.5.2-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef", size = 472888, upload-time = "2026-06-17T18:41:22.511Z" }, + { url = "https://files.pythonhosted.org/packages/4f/88/9e603f448e2bc107c883e95817b980fb9b45ba6aea0299b2e9978124bea2/greenlet-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95", size = 1620723, upload-time = "2026-06-17T18:22:14.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/91/26da17e3777858c16fdb8d020a4c68f3a03cb92f238de8f5351d5d5186e9/greenlet-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317", size = 1684227, upload-time = "2026-06-17T17:40:09.536Z" }, + { url = "https://files.pythonhosted.org/packages/2d/44/b3a11f7aa34cb38f1b7f3df8bcd9fcd09bac9d342c2a2c9b8686c804bcd2/greenlet-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73", size = 240257, upload-time = "2026-06-17T17:35:23.359Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/3b62145fe917311732041a258adb218248add00542e3131c48bd047fbed5/greenlet-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e", size = 239038, upload-time = "2026-06-17T17:37:56.792Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/d3bad483e9f6cd1848604fdffa32cac25846dd6dfcec0e6f81c790185518/greenlet-3.5.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0", size = 295668, upload-time = "2026-06-17T17:36:02.293Z" }, + { url = "https://files.pythonhosted.org/packages/00/e9/3a7e557b895fd0469b00cd0b2bd498ba950e8bfdf6d7adeecf2c5e4130a6/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c", size = 652820, upload-time = "2026-06-17T18:07:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/78/67/6225d5c5e4afc04be0fd161eec82e4b72017e8a100d222f25d7b42b0140d/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3", size = 658697, upload-time = "2026-06-17T18:29:48.365Z" }, + { url = "https://files.pythonhosted.org/packages/35/ad/9b3058f999b81750a9c6d9ec424f509462d232b58002086fe2ba63b66407/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c", size = 658945, upload-time = "2026-06-17T18:39:32.509Z" }, + { url = "https://files.pythonhosted.org/packages/fa/99/6324b8ef916dcaddccb340b304c992ca3f947614ce0f2685d438187300b8/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de", size = 656436, upload-time = "2026-06-17T17:39:32.509Z" }, + { url = "https://files.pythonhosted.org/packages/92/75/1b6ecd8c027b69ab1b6798a84094df79aab5e69ac7e249c78b9d361dd1fa/greenlet-3.5.2-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e", size = 490529, upload-time = "2026-06-17T18:41:23.954Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ee/f5bf9daac27c5e1b011965f64b5630a32b415daf7381b312943629e12c2a/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8", size = 1617193, upload-time = "2026-06-17T18:22:16.252Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/b05d5b12715bda92ce27c118d64971d21e9b8f3563ed959a7d271e2d4223/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a", size = 1677512, upload-time = "2026-06-17T17:40:10.771Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/1b8f1314b868041b327dc1051603e8142b826480cb0ecb8a7b7632aee9c4/greenlet-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32", size = 243145, upload-time = "2026-06-17T17:34:37.502Z" }, + { url = "https://files.pythonhosted.org/packages/36/07/1b5311775e04c718a118c504d7a3a312430e2a1bd1347226aff4774e4549/greenlet-3.5.2-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb", size = 288315, upload-time = "2026-06-17T17:34:34.04Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/6abcd2a486b58b9f77b7a93b690d59cb2c11a5906ed2ad4c63c7b9c1113d/greenlet-3.5.2-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682", size = 659130, upload-time = "2026-06-17T18:07:26.354Z" }, + { url = "https://files.pythonhosted.org/packages/f2/12/f4aaad6d3d383233f700ab322568a4f29f2c701a4861d85f4811d99689b2/greenlet-3.5.2-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce", size = 669724, upload-time = "2026-06-17T18:29:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/53/e0/4ce3a046b51e53934eae93d7f9c13975a97285741e9e1fcadf8751314c37/greenlet-3.5.2-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f", size = 673494, upload-time = "2026-06-17T18:39:34.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/2a/a089811fc31c6bf8742f40a4e73470d6d401cef18e4314eb20dc399b377c/greenlet-3.5.2-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9", size = 668089, upload-time = "2026-06-17T17:39:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/52/e0/9c18721e63445dce02ee67e4c81c0f281626604ff55ae6f7b7f4354d7129/greenlet-3.5.2-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3", size = 479721, upload-time = "2026-06-17T18:41:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/2f47c7d5fcfa98a62b705bf9a0505d86f4563c0d81cab1f7159ff1e743b7/greenlet-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32", size = 1625684, upload-time = "2026-06-17T18:22:17.664Z" }, + { url = "https://files.pythonhosted.org/packages/b9/bf/661dd24624f70b7b32972d7693d0344ecde10278f647d7b828baf739899c/greenlet-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74", size = 1688043, upload-time = "2026-06-17T17:40:12.403Z" }, + { url = "https://files.pythonhosted.org/packages/60/49/d9bde1d15a21296b3b521fe083eb8aabd54ac05d15de9832918f3d639543/greenlet-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a", size = 240531, upload-time = "2026-06-17T17:35:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4d/86d7768bd53e9907de0333df215c2018cd01a593b3715cbd79aa82dd94b7/greenlet-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9", size = 239579, upload-time = "2026-06-17T17:39:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/92/15/907be5e8900901039bae752fa9a31c03a3c1e064833f35a4e49449184581/greenlet-3.5.2-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c", size = 296697, upload-time = "2026-06-17T17:37:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/08c57be575c3d6a3c023bbf22144a1c7dc6ed4d134527bb36ded4dbf04a8/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4", size = 656710, upload-time = "2026-06-17T18:07:28.046Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/749f917bdc9fc90fceea4aa65fbf6556e617a50714d1496bdc8ad190bb36/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c", size = 662629, upload-time = "2026-06-17T18:29:51.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/87/10776cd88df54d0f563e9e21e98363f2d6af94bedc553b1da0972fa87f80/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014", size = 663191, upload-time = "2026-06-17T18:39:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a5/68cefae3a07f6d0093a490cf28ab604f14578f3e60205a2a2b2d5cd70af2/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00", size = 660147, upload-time = "2026-06-17T17:39:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/02/aa/26ddf92826a99d87bfb8fdb8f3a262a6f16495a5d8e579737baa92fb4543/greenlet-3.5.2-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b", size = 498199, upload-time = "2026-06-17T18:41:27.464Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/b9156d8397e4750220f54c7c5c34650f1e740a8d2f66eab9cfd1b7b53b69/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1", size = 1621675, upload-time = "2026-06-17T18:22:18.873Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e3/d3250f4fa01c211a93d04e34fded63187e648dbec17b9b1a14d388040593/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f", size = 1680577, upload-time = "2026-06-17T17:40:14.055Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eaee8bda4419770d7096b5a009ebff0ab20a2a28cdd83c4b591bfdf36fa9/greenlet-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904", size = 243482, upload-time = "2026-06-17T17:37:34.741Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/f794a81c91e9942c61f9110bd1f9a38a0ea565eab57f8b08cd53d3131e48/greenlet-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f", size = 242062, upload-time = "2026-06-17T17:35:39.814Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[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 = "json5" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/be/c6c745ec4c4539b25a278b70e29793f10382947df0d9efba2fa09120895d/json5-0.12.0.tar.gz", hash = "sha256:0b4b6ff56801a1c7dc817b0241bca4ce474a0e6a163bfef3fc594d3fd263ff3a", size = 51907, upload-time = "2025-04-03T16:33:13.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9f/3500910d5a98549e3098807493851eeef2b89cdd3032227558a104dfe926/json5-0.12.0-py3-none-any.whl", hash = "sha256:6d37aa6c08b0609f16e1ec5ff94697e2cbbfbad5ac112afa05794da9ab7810db", size = 36079, upload-time = "2025-04-03T16:33:11.927Z" }, +] + +[[package]] +name = "locust" +version = "2.37.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configargparse" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-login" }, + { name = "gevent" }, + { name = "geventhttpclient" }, + { name = "locust-cloud" }, + { name = "msgpack" }, + { name = "psutil" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "pyzmq" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/d1/60d5fddac2baa47314c091636868b50178a38fc71ce39d68afd847448028/locust-2.37.5.tar.gz", hash = "sha256:c90824c4cb6a01cdede220684c7c8381253fcca47fc689dbca4f6c46d740c99f", size = 2252000, upload-time = "2025-05-22T08:54:58.676Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a0/32a51fb48f96b0de6bb6ea7308f68b7ae1bae53e6b975672f8c4ef7f8c08/locust-2.37.5-py3-none-any.whl", hash = "sha256:9922a2718b42f1c57a05c822e47b66555b3c61292694ec5edaf7a166fac6d112", size = 2268626, upload-time = "2025-05-22T08:54:55.938Z" }, +] + +[[package]] +name = "locust-cloud" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configargparse" }, + { name = "gevent" }, + { name = "platformdirs" }, + { name = "python-engineio" }, + { name = "python-socketio", extra = ["client"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/86/cd6b611f008387ffce5bcb6132ba7431aec7d1b09d8ce27e152e96d94315/locust_cloud-1.30.0.tar.gz", hash = "sha256:324ae23754d49816df96d3f7472357a61cd10e56cebcb26e2def836675cb3c68", size = 457297, upload-time = "2025-12-15T13:35:50.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/db/35c1cc8e01dfa570913255c55eb983a7e2e532060b4d1ee5f1fb543a6a0b/locust_cloud-1.30.0-py3-none-any.whl", hash = "sha256:2324b690efa1bfc8d1871340276953cf265328bd6333e07a5ba8ff7dc5e99e6c", size = 413446, upload-time = "2025-12-15T13:35:48.75Z" }, +] + +[[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 = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "python-engineio" +version = "4.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/a0/f75491f942184d9960b15e763270f765fe9f239745ca5f9e16289011aed4/python_engineio-4.13.3.tar.gz", hash = "sha256:572b7783e341fed21edbc7cea297ccd378dad79265fdde96aa4664420a7c06c9", size = 79734, upload-time = "2026-06-20T22:53:52.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl", hash = "sha256:1f60ecaf1358190f0e26c48c578a60428dc02a8f1295bc3dbf53d1b31116821f", size = 59993, upload-time = "2026-06-20T22:53:50.775Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "requests" }, + { name = "websocket-client" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + +[[package]] +name = "trustify-perf" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "json5" }, + { name = "locust" }, +] + +[package.metadata] +requires-dist = [ + { name = "json5", specifier = "==0.12.0" }, + { name = "locust", specifier = "==2.37.5" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "zope-event" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/41/faa10af34d48d9cd6fa0249a1162943ad84a9590bd1a06939981e6640416/zope_event-6.2.tar.gz", hash = "sha256:b97d5d6327067ee6b9dfcbdf606ade9ade70991e19c162e808ea39e5fcf0f8d3", size = 18958, upload-time = "2026-04-28T06:24:10.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/33/848922889e946d4befc415c219fe516af75c49555d8e736e183bfd30db42/zope_event-6.2-py3-none-any.whl", hash = "sha256:5e755153ac4faf64c10a4b6dd3307680166a3edf65b38df22df592610f8fa874", size = 6525, upload-time = "2026-04-28T06:24:09.176Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957, upload-time = "2026-05-26T06:50:14.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/8c/4c15755d701f2ec0e80d64a18e1ebaf5be2c584c0ec153fd516f5d13eada/zope_interface-8.5-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:28e80457c134d1fa57a7d758004dece348654e1b1467ac22dcdc20fc1d127c52", size = 212512, upload-time = "2026-05-26T06:49:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/4360c54c465db042cc8fbeeec92abac28b4cedbf6ba63c1f092fd08a190f/zope_interface-8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09495ce9d559c06b70f2d4855b3e4f48a822a9ddc8be1d30c5b4e5be14ae1ace", size = 212541, upload-time = "2026-05-26T06:49:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a5/692a2b8d70f78e848793231d5fae5fecbf8d0cccd73430fdc34802a6d3c1/zope_interface-8.5-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7849ad8fa90763cc1087f4dda78ca3a233e950b3e08fac7079297c9cafbbd7bb", size = 265191, upload-time = "2026-05-26T06:49:43.449Z" }, + { url = "https://files.pythonhosted.org/packages/70/8d/454a9cfc7a050c394ab4f11b3371f7897828b7415e096afff724637e65e0/zope_interface-8.5-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5578c9421ca409a1f39f153d6f7803e4cde01da592ec75a9ac5e1b777d18d33b", size = 270626, upload-time = "2026-05-26T06:49:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/db8409cfa3575b8e9b4800babd7d49f8228433cd1f0c56814bd0ada49c33/zope_interface-8.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1bd7d96b4ca5fa311f54c9eac16dce4886b428c1531dbe06067763ccdf123b4", size = 270444, upload-time = "2026-05-26T06:49:47.025Z" }, + { url = "https://files.pythonhosted.org/packages/4a/df/a386940e41469ef615e100a216d8b386521e9e598817147f87932ca203c4/zope_interface-8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0c8123d2a4dfde2a613c7cb772605477724782c20bc2e0ad1d9435376a6a44a3", size = 215021, upload-time = "2026-05-26T06:49:48.478Z" }, + { url = "https://files.pythonhosted.org/packages/89/75/477eb5669b6b2a7a843decd1a075e9b1971a8720017654143a7183abd3d9/zope_interface-8.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d02be14f3173c6c7288bc2fdf530090c01c3cf8764ad46c68024686f364278e", size = 213610, upload-time = "2026-05-26T06:49:50.01Z" }, + { url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597, upload-time = "2026-05-26T06:49:51.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626, upload-time = "2026-05-26T06:49:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827, upload-time = "2026-05-26T06:49:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139, upload-time = "2026-05-26T06:49:57.116Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338, upload-time = "2026-05-26T06:49:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789, upload-time = "2026-05-26T06:50:00.405Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757, upload-time = "2026-05-26T06:50:01.973Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591, upload-time = "2026-05-26T06:50:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733, upload-time = "2026-05-26T06:50:05.101Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905, upload-time = "2026-05-26T06:50:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885, upload-time = "2026-05-26T06:50:08.997Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672, upload-time = "2026-05-26T06:50:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241, upload-time = "2026-05-26T06:50:12.171Z" }, +]