This is an automated email from the ASF dual-hosted git repository. akm pushed a commit to branch ssh-invalidate-737 in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 59f30984fd3eef98475f43e6187b1d44d6505964 Author: Andrew K. Musselman <[email protected]> AuthorDate: Thu Mar 19 16:45:31 2026 -0700 Invalidate SSH keys; fixes #737 --- atr/admin/__init__.py | 84 +++++++++++++++++++++++++ atr/admin/templates/revoke-user-ssh-keys.html | 76 ++++++++++++++++++++++ atr/docs/authentication-security.md | 9 +++ atr/docs/authorization-security.md | 6 ++ atr/models/sql.py | 1 + atr/ssh.py | 23 ++++++- atr/storage/__init__.py | 1 + atr/storage/writers/ssh.py | 52 +++++++++++++++ atr/templates/includes/topnav.html | 5 ++ migrations/versions/0062_2026.03.19_ef59ffaf.py | 33 ++++++++++ pip-audit.requirements | 10 +-- tests/e2e/admin/conftest.py | 7 +++ tests/e2e/admin/helpers.py | 1 + tests/e2e/admin/test_revoke_ssh_keys.py | 69 ++++++++++++++++++++ uv.lock | 76 +++++++++++----------- 15 files changed, 409 insertions(+), 44 deletions(-) diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py index 1ce05888..6e8be873 100644 --- a/atr/admin/__init__.py +++ b/atr/admin/__init__.py @@ -100,6 +100,11 @@ class RevokeUserTokensForm(form.Form): confirm_revoke: Literal["REVOKE"] = form.label("Confirmation", "Type REVOKE to confirm.") +class RevokeUserSSHKeysForm(form.Form): + asf_uid: str = form.label("ASF UID", "Enter the ASF UID whose SSH keys should be revoked.") + confirm_revoke: Literal["REVOKE"] = form.label("Confirmation", "Type REVOKE to confirm.") + + class RotateJwtKeyForm(form.Form): confirm_rotate: Literal["ROTATE"] = form.label("Confirmation", "Type ROTATE to confirm.") @@ -895,6 +900,85 @@ async def revoke_user_tokens_post( return await session.redirect(revoke_user_tokens_get) [email protected] +async def revoke_user_ssh_keys_get( + _session: web.Committer, _revoke_user_ssh_keys: Literal["revoke-user-ssh-keys"] +) -> str: + """ + URL: GET /revoke-user-ssh-keys + + Revoke all SSH keys for a specified user. + """ + via = sql.validate_instrumented_attribute + ssh_key_counts: list[tuple[str, int]] = [] + workflow_key_counts: list[tuple[str, int]] = [] + async with db.session() as data: + ssh_stmt = ( + sqlmodel.select( + sql.SSHKey.asf_uid, + sqlmodel.func.count(), + ) + .group_by(sql.SSHKey.asf_uid) + .order_by(sql.SSHKey.asf_uid) + ) + ssh_rows = await data.execute_query(ssh_stmt) + ssh_key_counts = [(row[0], row[1]) for row in ssh_rows] + + workflow_stmt = ( + sqlmodel.select( + sql.WorkflowSSHKey.asf_uid, + sqlmodel.func.count(), + ) + .where(via(sql.WorkflowSSHKey.revoked).is_(False)) + .group_by(sql.WorkflowSSHKey.asf_uid) + .order_by(sql.WorkflowSSHKey.asf_uid) + ) + workflow_rows = await data.execute_query(workflow_stmt) + workflow_key_counts = [(row[0], row[1]) for row in workflow_rows] + + rendered_form = form.render( + model_cls=RevokeUserSSHKeysForm, + submit_label="Revoke all SSH keys", + ) + return await template.render( + "revoke-user-ssh-keys.html", + form=rendered_form, + ssh_key_counts=ssh_key_counts, + workflow_key_counts=workflow_key_counts, + ) + + [email protected] +async def revoke_user_ssh_keys_post( + session: web.Committer, + _revoke_user_ssh_keys: Literal["revoke-user-ssh-keys"], + revoke_form: RevokeUserSSHKeysForm, +) -> str | web.WerkzeugResponse: + """ + URL: POST /revoke-user-ssh-keys + + Revoke all SSH keys for a specified user. + """ + target_uid = revoke_form.asf_uid + + async with storage.write(session) as write: + wafa = write.as_foundation_admin() + persistent_count, workflow_count = await wafa.ssh.revoke_all_user_keys(target_uid) + + total = persistent_count + workflow_count + if total > 0: + parts = [] + if persistent_count > 0: + parts.append(util.plural(persistent_count, "persistent key")) + if workflow_count > 0: + parts.append(util.plural(workflow_count, "workflow key")) + await quart.flash(f"Revoked {' and '.join(parts)} for {target_uid}.", "success") + else: + await quart.flash(f"No SSH keys found for {target_uid}.", "info") + + return await session.redirect(revoke_user_ssh_keys_get) + + @admin.typed async def rotate_jwt_key_get(_session: web.Committer, _rotate_jwt_key: Literal["rotate-jwt-key"]) -> str: """ diff --git a/atr/admin/templates/revoke-user-ssh-keys.html b/atr/admin/templates/revoke-user-ssh-keys.html new file mode 100644 index 00000000..d7b37c23 --- /dev/null +++ b/atr/admin/templates/revoke-user-ssh-keys.html @@ -0,0 +1,76 @@ +{% extends "layouts/base-admin.html" %} + +{%- block title -%}Revoke user SSH keys ~ ATR{%- endblock title -%} + +{%- block description -%}Revoke all SSH keys for a user.{%- endblock description -%} + +{% block content %} + <h1>Revoke user SSH keys</h1> + <p>Revoke all SSH keys (persistent and workflow) for a user account. Use this when an + account is being disabled or when immediate SSH key revocation is needed.</p> + + <div class="card mb-4"> + <div class="card-header"> + <h5 class="mb-0">Revoke SSH keys</h5> + </div> + <div class="card-body"> + {{ form }} + </div> + </div> + + {% if ssh_key_counts %} + <div class="card mb-4"> + <div class="card-header"> + <h5 class="mb-0">Users with persistent SSH keys</h5> + </div> + <div class="card-body"> + <table class="table table-sm table-striped table-bordered"> + <thead> + <tr> + <th>ASF UID</th> + <th>Key count</th> + </tr> + </thead> + <tbody> + {% for uid, count in ssh_key_counts %} + <tr> + <td><code>{{ uid }}</code></td> + <td>{{ count }}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + {% else %} + <div class="alert alert-info" role="alert">No users currently have persistent SSH keys.</div> + {% endif %} + + {% if workflow_key_counts %} + <div class="card"> + <div class="card-header"> + <h5 class="mb-0">Users with active workflow SSH keys</h5> + </div> + <div class="card-body"> + <table class="table table-sm table-striped table-bordered"> + <thead> + <tr> + <th>ASF UID</th> + <th>Key count</th> + </tr> + </thead> + <tbody> + {% for uid, count in workflow_key_counts %} + <tr> + <td><code>{{ uid }}</code></td> + <td>{{ count }}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + {% else %} + <div class="alert alert-info" role="alert">No users currently have active workflow SSH keys.</div> + {% endif %} +{% endblock content %} diff --git a/atr/docs/authentication-security.md b/atr/docs/authentication-security.md index 0a1b6efa..555a30ca 100644 --- a/atr/docs/authentication-security.md +++ b/atr/docs/authentication-security.md @@ -180,6 +180,13 @@ For web users, authentication happens once via ASF OAuth, and the session persis * Signed with a server secret initialized at startup * Stateless design means no database lookup required for verification +### SSH keys + +* Persistent SSH keys are deleted when an admin revokes keys for a user +* Workflow SSH keys (20-minute TTL) can be immediately revoked via a `revoked` flag +* LDAP account status is checked during SSH authentication, rejecting banned or deleted accounts +* Administrators can revoke all SSH keys for a user via the admin interface + ### Credential protection Tokens must be protected by the user at all times: @@ -194,3 +201,5 @@ Tokens must be protected by the user at all times: * [`principal.py`](/ref/atr/principal.py) - Session caching and authorization data * [`jwtoken.py`](/ref/atr/jwtoken.py) - JWT creation, verification, and decorators * [`storage/writers/tokens.py`](/ref/atr/storage/writers/tokens.py) - Token creation, deletion, and admin revocation +* [`ssh.py`](/ref/atr/ssh.py) - SSH server with LDAP account status checks +* [`storage/writers/ssh.py`](/ref/atr/storage/writers/ssh.py) - SSH key management and admin revocation diff --git a/atr/docs/authorization-security.md b/atr/docs/authorization-security.md index fcb51ff3..33484d7f 100644 --- a/atr/docs/authorization-security.md +++ b/atr/docs/authorization-security.md @@ -136,6 +136,12 @@ Token operations apply to the authenticated user: * Interface: Admin "Revoke user tokens" page * Constraint: Requires typing "REVOKE" as confirmation +**Revoke all SSH keys for a user (admin)**: + +* Allowed for: ATR administrators only +* Interface: Admin "Revoke user SSH keys" page +* Constraint: Requires typing "REVOKE" as confirmation + **Exchange PAT for JWT**: * Allowed for: Anyone with a valid PAT diff --git a/atr/models/sql.py b/atr/models/sql.py index d0a1a817..a19a7a1d 100644 --- a/atr/models/sql.py +++ b/atr/models/sql.py @@ -492,6 +492,7 @@ class WorkflowSSHKey(sqlmodel.SQLModel, table=True): default_factory=dict, sa_column=sqlalchemy.Column(sqlalchemy.JSON, nullable=False) ) expires: int = sqlmodel.Field() + revoked: bool = sqlmodel.Field(default=False) # SQL core models diff --git a/atr/ssh.py b/atr/ssh.py index 533fc86f..5cb55678 100644 --- a/atr/ssh.py +++ b/atr/ssh.py @@ -39,6 +39,7 @@ import ssh_audit.builtin_policies as builtin_policies import atr.attestable as attestable import atr.config as config import atr.db as db +import atr.ldap as ldap import atr.log as log import atr.models.github as github import atr.models.safe as safe @@ -101,7 +102,14 @@ class SSHServer(asyncssh.SSHServer): if username == "github": log.info("GitHub authentication will use validate_public_key") return True - + try: + account = await ldap.account_lookup(username) + if account is None or ldap.is_banned(account): + log.warning(f"SSH auth rejected: account {username} is banned or deleted") + return True + except Exception as e: + log.error(f"LDAP lookup failed for SSH user {username}: {e}") + return True try: # Load SSH keys for this user from the database async with db.session() as data: @@ -154,10 +162,23 @@ class SSHServer(asyncssh.SSHServer): if workflow_key is None: return False + if workflow_key.revoked: + log.failed_authentication("workflow_key_revoked") + return False + # In some cases this will be a service account self._github_asf_uid = workflow_key.asf_uid log.set_asf_uid(self._github_asf_uid) + try: + account = await ldap.account_lookup(self._github_asf_uid) + if account is None or ldap.is_banned(account): + log.failed_authentication("account_banned_or_deleted") + return False + except Exception as e: + log.error(f"LDAP lookup failed for workflow key user {self._github_asf_uid}: {e}") + return False + now = int(time.time()) # audit_guidance this application is not concerned with checking for a not_before flag on the workflow_key if workflow_key.expires < now: diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py index d9465078..978f4010 100644 --- a/atr/storage/__init__.py +++ b/atr/storage/__init__.py @@ -226,6 +226,7 @@ class WriteAsFoundationAdmin(WriteAsFoundationCommitter): self.__asf_uid = write.authorisation.asf_uid self.release = writers.release.FoundationAdmin(write, self, data) self.tokens = writers.tokens.FoundationAdmin(write, self, data) + self.ssh = writers.ssh.FoundationAdmin(write, self, data) @property def asf_uid(self) -> str: diff --git a/atr/storage/writers/ssh.py b/atr/storage/writers/ssh.py index fb776e4a..7c7d3eae 100644 --- a/atr/storage/writers/ssh.py +++ b/atr/storage/writers/ssh.py @@ -20,6 +20,8 @@ from __future__ import annotations import time +import sqlmodel + import atr.db as db import atr.models.github as github import atr.models.safe as safe @@ -140,3 +142,53 @@ class CommitteeMember(CommitteeParticipant): raise storage.AccessError("Not authorized") self.__asf_uid = asf_uid self.__committee_key = committee_key + + +class FoundationAdmin(FoundationCommitter): + def __init__( + self, + write: storage.Write, + write_as: storage.WriteAsFoundationAdmin, + data: db.Session, + ): + super().__init__(write, write_as, data) + self.__write = write + self.__write_as = write_as + self.__data = data + asf_uid = write.authorisation.asf_uid + if asf_uid is None: + raise storage.AccessError("Not authorized") + self.__asf_uid = asf_uid + + async def revoke_all_user_keys(self, target_asf_uid: str) -> tuple[int, int]: + """Revoke all SSH keys for a specified user. + + Returns (persistent_count, workflow_count) of keys affected. + """ + via = sql.validate_instrumented_attribute + persistent_keys = await self.__data.query_all( + sqlmodel.select(sql.SSHKey).where(sql.SSHKey.asf_uid == target_asf_uid) + ) + persistent_count = len(persistent_keys) + for key in persistent_keys: + await self.__data.delete(key) + + workflow_keys = await self.__data.query_all( + sqlmodel.select(sql.WorkflowSSHKey).where( + sql.WorkflowSSHKey.asf_uid == target_asf_uid, + via(sql.WorkflowSSHKey.revoked).is_(False), + ) + ) + workflow_count = len(workflow_keys) + for key in workflow_keys: + key.revoked = True + + total = persistent_count + workflow_count + if total > 0: + await self.__data.commit() + self.__write_as.append_to_audit_log( + target_asf_uid=target_asf_uid, + persistent_keys_deleted=persistent_count, + workflow_keys_revoked=workflow_count, + ) + return persistent_count, workflow_count diff --git a/atr/templates/includes/topnav.html b/atr/templates/includes/topnav.html index 62e733c3..9865be04 100644 --- a/atr/templates/includes/topnav.html +++ b/atr/templates/includes/topnav.html @@ -222,6 +222,11 @@ href="{{ as_url(admin.revoke_user_tokens_get) }}" {% if request.endpoint == 'atr_admin_revoke_user_tokens_get' %}class="active"{% endif %}><i class="bi bi-shield-x"></i> Revoke user tokens</a> </li> + <li> + <a class="dropdown-item" + href="{{ as_url(admin.revoke_user_ssh_keys_get) }}" + {% if request.endpoint == 'atr_admin_revoke_user_ssh_keys_get' %}class="active"{% endif %}><i class="bi bi-shield-x"></i> Revoke user SSH keys</a> + </li> <li> <a class="dropdown-item" href="{{ as_url(admin.rotate_jwt_key_get) }}" diff --git a/migrations/versions/0062_2026.03.19_ef59ffaf.py b/migrations/versions/0062_2026.03.19_ef59ffaf.py new file mode 100644 index 00000000..f39897fd --- /dev/null +++ b/migrations/versions/0062_2026.03.19_ef59ffaf.py @@ -0,0 +1,33 @@ +"""add revoked field to workflowsshkey + +Revision ID: 0062_2026.03.19_ef59ffaf +Revises: 0061_2026.03.18_7838cfcc +Create Date: 2026-03-19 22:57:29.018312+00:00 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# Revision identifiers, used by Alembic +revision: str = "0062_2026.03.19_ef59ffaf" +down_revision: str | None = "0061_2026.03.18_7838cfcc" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("workflowsshkey", schema=None) as batch_op: + batch_op.add_column(sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.text("0"))) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("workflowsshkey", schema=None) as batch_op: + batch_op.drop_column("revoked") + + # ### end Alembic commands ### diff --git a/pip-audit.requirements b/pip-audit.requirements index ee0180c4..aef6ef78 100644 --- a/pip-audit.requirements +++ b/pip-audit.requirements @@ -30,11 +30,11 @@ arrow==1.4.0 # via isoduration asfpy==0.58 # via asfquart -asfquart @ git+https://github.com/apache/infrastructure-asfquart.git@ae3b020002b63688c65715f4f45bd351d868da3d +asfquart @ git+https://github.com/apache/infrastructure-asfquart.git@7ff9f50dcfca338bc935646323c1942275b85195 # via tooling-trusted-releases asyncssh==2.22.0 # via tooling-trusted-releases -attrs==25.4.0 +attrs==26.1.0 # via # aiohttp # jsonschema @@ -150,7 +150,7 @@ hypercorn==0.18.0 # tooling-trusted-releases hyperframe==6.1.0 # via h2 -hyperscan==0.8.1 +hyperscan==0.8.2 # via tooling-trusted-releases identify==2.6.18 # via pre-commit @@ -271,7 +271,7 @@ python-dateutil==2.9.0.post0 # strictyaml python-decouple==3.8 # via tooling-trusted-releases -python-discovery==1.1.3 +python-discovery==1.2.0 # via virtualenv python-gnupg==0.5.6 # via tooling-trusted-releases @@ -317,7 +317,7 @@ rpds-py==0.30.0 # via # jsonschema # referencing -ruff==0.15.6 +ruff==0.15.7 semver==3.0.4 # via tooling-trusted-releases six==1.17.0 diff --git a/tests/e2e/admin/conftest.py b/tests/e2e/admin/conftest.py index 7eecb75d..666ea544 100644 --- a/tests/e2e/admin/conftest.py +++ b/tests/e2e/admin/conftest.py @@ -29,6 +29,13 @@ if TYPE_CHECKING: from playwright.sync_api import Page [email protected] +def page_revoke_ssh_keys(page: Page) -> Generator[Page]: + helpers.log_in(page) + helpers.visit(page, admin_helpers.REVOKE_SSH_KEYS_PATH) + yield page + + @pytest.fixture def page_revoke_tokens(page: Page) -> Generator[Page]: helpers.log_in(page) diff --git a/tests/e2e/admin/helpers.py b/tests/e2e/admin/helpers.py index 13c17d25..006630c9 100644 --- a/tests/e2e/admin/helpers.py +++ b/tests/e2e/admin/helpers.py @@ -20,6 +20,7 @@ from typing import Final from playwright.sync_api import Page REVOKE_TOKENS_PATH: Final[str] = "/admin/revoke-user-tokens" +REVOKE_SSH_KEYS_PATH: Final[str] = "/admin/revoke-user-ssh-keys" TOKENS_PATH: Final[str] = "/tokens" TOKEN_LABEL_FOR_TESTING: Final[str] = "e2e-revoke-test-token" diff --git a/tests/e2e/admin/test_revoke_ssh_keys.py b/tests/e2e/admin/test_revoke_ssh_keys.py new file mode 100644 index 00000000..72f4bedb --- /dev/null +++ b/tests/e2e/admin/test_revoke_ssh_keys.py @@ -0,0 +1,69 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from playwright.sync_api import Page, expect + + +def test_revoke_ssh_keys_page_loads(page_revoke_ssh_keys: Page) -> None: + expect(page_revoke_ssh_keys).to_have_title("Revoke user SSH keys ~ ATR") + + +def test_revoke_ssh_keys_page_has_heading(page_revoke_ssh_keys: Page) -> None: + heading = page_revoke_ssh_keys.get_by_role("heading", name="Revoke user SSH keys") + expect(heading).to_be_visible() + + +def test_revoke_ssh_keys_page_has_uid_input(page_revoke_ssh_keys: Page) -> None: + uid_input = page_revoke_ssh_keys.locator('input[name="asf_uid"]') + expect(uid_input).to_be_visible() + + +def test_revoke_ssh_keys_page_has_confirmation_input(page_revoke_ssh_keys: Page) -> None: + confirm_input = page_revoke_ssh_keys.locator('input[name="confirm_revoke"]') + expect(confirm_input).to_be_visible() + + +def test_revoke_ssh_keys_page_has_submit_button(page_revoke_ssh_keys: Page) -> None: + button = page_revoke_ssh_keys.get_by_role("button", name="Revoke all SSH keys") + expect(button).to_be_visible() + + +def test_revoke_ssh_keys_shows_error_for_wrong_confirmation(page_revoke_ssh_keys: Page) -> None: + page = page_revoke_ssh_keys + page.locator('input[name="asf_uid"]').fill("test") + page.locator('input[name="confirm_revoke"]').fill("WRONG") + page.get_by_role("button", name="Revoke all SSH keys").click() + page.wait_for_load_state() + + error_message = page.locator(".flash-message.flash-error") + expect(error_message).to_be_visible() + + +def test_revoke_ssh_keys_nonexistent_user_shows_info(page_revoke_ssh_keys: Page) -> None: + page = page_revoke_ssh_keys + page.locator('input[name="asf_uid"]').fill("nonexistent_user_abc123") + page.locator('input[name="confirm_revoke"]').fill("REVOKE") + page.get_by_role("button", name="Revoke all SSH keys").click() + page.wait_for_load_state() + + info_message = page.locator('.flash-message:has-text("No SSH keys found")') + expect(info_message).to_be_visible() + + +def test_revoke_ssh_keys_nav_link_exists(page_revoke_ssh_keys: Page) -> None: + nav_link = page_revoke_ssh_keys.locator('a.dropdown-item:has-text("Revoke user SSH keys")') + expect(nav_link).to_have_count(1) diff --git a/uv.lock b/uv.lock index 7a28b2e0..01607277 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = "==3.13.*" [options] -exclude-newer = "2026-03-17T17:33:15Z" +exclude-newer = "2026-03-19T23:31:09Z" [[package]] name = "aiofiles" @@ -175,7 +175,7 @@ wheels = [ [[package]] name = "asfquart" version = "0.1.13" -source = { git = "https://github.com/apache/infrastructure-asfquart.git?rev=main#ae3b020002b63688c65715f4f45bd351d868da3d" } +source = { git = "https://github.com/apache/infrastructure-asfquart.git?rev=main#7ff9f50dcfca338bc935646323c1942275b85195" } dependencies = [ { name = "aiohttp" }, { name = "asfpy" }, @@ -201,11 +201,11 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] @@ -779,17 +779,17 @@ wheels = [ [[package]] name = "hyperscan" -version = "0.8.1" +version = "0.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/56/6359bfa86a3bfeeecbeeb1ffcfc9484058c8a8ae153c8bda2181c3511e6b/hyperscan-0.8.1.tar.gz", hash = "sha256:d50bf70b0110817a308bfb1855055dc4d649934857b958498a1791164f512779", size = 125303, upload-time = "2026-02-11T19:19:46.323Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/26/21daad311299a416059cf1919c51410573180cf7133b42927693f19c0af7/hyperscan-0.8.2.tar.gz", hash = "sha256:1724e87e8f77f033a4592dc2cda7aecd10c91dfc718b55fa5379d0c95cff28e8", size = 125600, upload-time = "2026-03-19T01:47:34.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/b2/6060a2a84a2dae7024d03719ecf6b0438b6f40aeba11a34ede6ffdea7b91/hyperscan-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee39b88a85fbd0b262e2a458985f878f6cf726e41099c601e0c2ce681aa16d98", size = 2044166, upload-time = "2026-02-11T19:19:09.761Z" }, - { url = "https://files.pythonhosted.org/packages/ad/44/6a727f676c0cf86efed79320dfe968d53d66f8f2df4f9ebab33cabd648c9/hyperscan-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6ce5d67e90ad18800f68a7ef52fdba60223ff5ebfa19b945d6abbc0a6163e69f", size = 2033045, upload-time = "2026-02-11T19:19:11.156Z" }, - { url = "https://files.pythonhosted.org/packages/a0/32/6edc476f9623ef7f87dc851e28803ad0b765202f129f399223a7b917fb32/hyperscan-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d504b8fda00678d86cd1ea63ca15ef19e553da411636eb36acc1c85d1d0ee2c", size = 2763694, upload-time = "2026-02-11T19:19:12.522Z" }, - { url = "https://files.pythonhosted.org/packages/d6/83/e05ec0da2f856925dae6c978fc67c354d9a48712626cc455c891995b236f/hyperscan-0.8.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f25ec48d0a8d3d97ebc758fdfa601c89db26039004c7f1ce2823249adbc7960", size = 2567752, upload-time = "2026-02-11T19:19:13.932Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b0/44679375c66a7ee30c05e31f92cbeeade4bd9efe225b0e4080657a2258de/hyperscan-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4952a74ab75196ce937d3d60d37fe7157e6b570b87726366f6ec7a22f204519d", size = 2389687, upload-time = "2026-02-11T19:19:15.485Z" }, - { url = "https://files.pythonhosted.org/packages/af/32/e056369242414849e8ea4ea6efd03fdccf05b953623baca77b5ac6a33640/hyperscan-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f22cfcba25ff91ecaa655eb5e666760e2f5fde1af6c91548b71a7336cb0531e", size = 2429033, upload-time = "2026-02-11T19:19:16.843Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ac/2e53b22605671a872fb2a8ef8ff40a051ced0caae71d91a690af4f08fffe/hyperscan-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:773fc1373a6a12b09e3b1580dcb238dc5e2ebc17482284a436958cbb432e439f", size = 1956074, upload-time = "2026-02-11T19:19:18.571Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fd/34ed5d1ddb1b0ad384a05b5afdb1f302c145cb4bb885a1cd91266be04740/hyperscan-0.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4fee39d8af5738e51dd6aa3684ffcb1c782dfa907a7a64f50c599635e80606dc", size = 2044020, upload-time = "2026-03-19T01:46:56.576Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2b/a222d1cce1d203ef9c14ab48d6b5d8c9e3c457a7ebf29ed8dcd9b5ff9193/hyperscan-0.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7bdac73df001759538f9beee957ac2224739b5ac49814f96a6c3cd2a1fcdafa0", size = 2032948, upload-time = "2026-03-19T01:46:58.688Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/44b8879c6e6e5c32f3d47f6be425778bd4124a5f19d0d30610f60a61f817/hyperscan-0.8.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:177692a7688e64e1c77f0af5f23eaad937c452798cd15c0db86bf98b5dce4671", size = 2763696, upload-time = "2026-03-19T01:47:00.159Z" }, + { url = "https://files.pythonhosted.org/packages/48/0f/d0014ef543ef7327c437337905acbba271632698bd755673126d698bb1fe/hyperscan-0.8.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ec49927002a38ac767d0f18e17135602e493bf2f720548bf7d43a3af2f810a0", size = 2567752, upload-time = "2026-03-19T01:47:01.97Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/e25ce2c7b76d758e3ca8013e1df3c7388240e9f72e07f003ce55f0fef628/hyperscan-0.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1055fac1eec046bfc67254d4ea900852597b2eca8e7219e3e558fb869c48100e", size = 2389688, upload-time = "2026-03-19T01:47:03.482Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bd/b0afe3df17a843a9df3cd60e6a63b31b6c3d5a672f5641eb64eeb91a1707/hyperscan-0.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d94495f8be1c0efe9e24ca3f10796c23921f8556a53b20d5619d4e96861d2f59", size = 2429031, upload-time = "2026-03-19T01:47:05.088Z" }, + { url = "https://files.pythonhosted.org/packages/e8/62/9e62e22214b47fbd42c58397691d119cb73c0e60ca6a932cf597aaf65f30/hyperscan-0.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d5a6ac08dab6c9879c87221858371d63545c08920e09bffa258a555843f6ef3", size = 1956255, upload-time = "2026-03-19T01:47:06.645Z" }, ] [[package]] @@ -1493,15 +1493,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.1.3" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/90/bcce6b46823c9bec1757c964dc37ed332579be512e17a30e9698095dcae4/python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1", size = 58055, upload-time = "2026-03-19T01:43:08.248Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3c/2005227cb951df502412de2fa781f800663cccbef8d90ec6f1b371ac2c0d/python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", size = 31524, upload-time = "2026-03-19T01:43:07.045Z" }, ] [[package]] @@ -1774,27 +1774,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, - { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, - { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, - { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, - { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, - { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, - { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, - { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] [[package]] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
