Compare commits

...

3 commits

14 changed files with 137 additions and 58 deletions

8
.envrc Normal file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env bash
if [[ $(type -t use_flake) != function ]]; then
echo "ERROR: direnv's use_flake function missing. update direnv to v2.30.0 or later." && exit 1
fi
#export BOT_ENV_FILE=./dev.env
export BOT_CONFIG_FILE=config.json
use flake
dotenv

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ __pycache__
.venv .venv
devstate devstate
.env* .env*
!.envrc
config.json config.json
dev.data dev.data
data data

View file

@ -1,15 +0,0 @@
---
image: python:3.11
stages:
- check
check:
image: python:3.11-bookworm
stage: check
script:
- apt-get update
- apt-get install -y make libolm-dev
- pip install uv
- uv sync --frozen --group dev
- uv run make check

View file

@ -2,6 +2,8 @@
> a bot for ops in matrix > a bot for ops in matrix
Repository: https://guardianproject.dev/ops/matrix-ops-bot
This bot catches webhooks and forwards them as messages to matrix rooms. This bot catches webhooks and forwards them as messages to matrix rooms.
Current supported webhooks: Current supported webhooks:

View file

@ -172,10 +172,53 @@
let let
system = pkgs.stdenv.hostPlatform.system; system = pkgs.stdenv.hostPlatform.system;
exportedPackage = self.packages.${system}.default; exportedPackage = self.packages.${system}.default;
testVenv = exportedPackage.testVenv;
src = ./.;
ruffCheck = pkgs.stdenv.mkDerivation {
name = "matrix-ops-bot-ruff";
inherit src;
dontConfigure = true;
dontBuild = true;
nativeBuildInputs = [ testVenv ];
checkPhase = ''
runHook preCheck
ruff check ops_bot/ tests/
ruff format --check ops_bot/ tests/
runHook postCheck
'';
doCheck = true;
installPhase = ''
mkdir -p "$out"
touch "$out/passed"
'';
};
pyrightCheck = pkgs.stdenv.mkDerivation {
name = "matrix-ops-bot-pyright";
inherit src;
dontConfigure = true;
dontBuild = true;
nativeBuildInputs = [
testVenv
pkgs.nodejs
];
checkPhase = ''
runHook preCheck
export HOME="$(mktemp -d)"
pyright ops_bot/
runHook postCheck
'';
doCheck = true;
installPhase = ''
mkdir -p "$out"
touch "$out/passed"
'';
};
in in
{ {
package-default = exportedPackage; package-default = exportedPackage;
tests = exportedPackage.tests; tests = exportedPackage.tests;
ruff = ruffCheck;
pyright = pyrightCheck;
} }
); );
@ -184,6 +227,8 @@
packages = [ packages = [
pkgs.python311 pkgs.python311
pkgs.uv pkgs.uv
pkgs.ruff
pkgs.pyright
]; ];
env.LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ env.LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
pkgs.stdenv.cc.cc pkgs.stdenv.cc.cc

View file

@ -92,7 +92,7 @@ def handle_cloudtrail_sts(payload: Any) -> List[Tuple[str, str]]:
for x in [ for x in [
f"<font color={color}>**🚨 ALERT[{subject}]** </font>: {title}", f"<font color={color}>**🚨 ALERT[{subject}]** </font>: {title}",
f"- **Region**: {region}", f"- **Region**: {region}",
f"- **Assumed Role**: {assumed_role}", (f"- **Assumed Role**: {assumed_role}" if assumed_role else None),
f"- **Event Time**: {event_time}", f"- **Event Time**: {event_time}",
f"- **Account ID**: {account_id}", f"- **Account ID**: {account_id}",
] ]

View file

@ -1,6 +1,6 @@
import logging import logging
import re import re
from typing import Any, List, Optional, Tuple from typing import Any, List, Optional, Tuple, cast
import attr import attr
from fastapi import Request from fastapi import Request
@ -47,8 +47,9 @@ async def handle_event(x_gitlab_event: str, payload: Any) -> List[Tuple[str, str
nonlocal aborted nonlocal aborted
aborted = True aborted = True
action_fields = cast(Any, Action)
base_args = { base_args = {
**{field.key: field for field in Action if field.key.isupper()}, **{field.key: field for field in action_fields if field.key.isupper()},
**OTHER_ENUMS, **OTHER_ENUMS,
"util": TemplateUtil, "util": TemplateUtil,
} }

View file

@ -717,7 +717,7 @@ class GitlabPushEvent(SerializableAttrs, GitlabEvent):
def split_updates( def split_updates(
evt: Union["GitlabIssueEvent", "GitlabMergeRequestEvent"] evt: Union["GitlabIssueEvent", "GitlabMergeRequestEvent"],
) -> List[GitlabEvent]: ) -> List[GitlabEvent]:
if not evt.changes: if not evt.changes:
return [evt] return [evt]

View file

@ -14,7 +14,6 @@ from fastapi.security import (
HTTPBasicCredentials, HTTPBasicCredentials,
HTTPBearer, HTTPBearer,
) )
from prometheus_client import start_http_server
from prometheus_fastapi_instrumentator import Instrumentator from prometheus_fastapi_instrumentator import Instrumentator
from ops_bot import alertmanager, aws, pagerduty from ops_bot import alertmanager, aws, pagerduty
@ -101,8 +100,7 @@ class Authorizer(Protocol):
request: Request, request: Request,
basic_credentials: Optional[HTTPBasicCredentials], basic_credentials: Optional[HTTPBasicCredentials],
bearer_credentials: Optional[HTTPAuthorizationCredentials], bearer_credentials: Optional[HTTPAuthorizationCredentials],
) -> bool: ) -> bool: ...
...
class ParseHandler(Protocol): class ParseHandler(Protocol):
@ -111,8 +109,7 @@ class ParseHandler(Protocol):
route: RoutingKey, route: RoutingKey,
payload: Any, payload: Any,
request: Request, request: Request,
) -> List[Tuple[str, str]]: ) -> List[Tuple[str, str]]: ...
...
handlers: Dict[str, Tuple[Authorizer, ParseHandler]] = { handlers: Dict[str, Tuple[Authorizer, ParseHandler]] = {

View file

@ -21,9 +21,11 @@ class ClientCredentials(BaseModel):
class CredentialStorage(Protocol): class CredentialStorage(Protocol):
def save_config(self, config: ClientCredentials) -> None: def save_config(self, config: ClientCredentials) -> None:
"""Save config""" """Save config"""
...
def read_config(self) -> ClientCredentials: def read_config(self) -> ClientCredentials:
"""Load config""" """Load config"""
...
class LocalCredentialStore: class LocalCredentialStore:
@ -64,7 +66,7 @@ class MatrixClient:
self.store_path.joinpath("credentials.json") self.store_path.joinpath("credentials.json")
) )
self.client: AsyncClient = None self.client: Optional[AsyncClient] = None
self.client_config = AsyncClientConfig( self.client_config = AsyncClientConfig(
max_limit_exceeded=0, max_limit_exceeded=0,
max_timeouts=0, max_timeouts=0,
@ -79,15 +81,19 @@ class MatrixClient:
async def start(self) -> None: async def start(self) -> None:
await self.login() await self.login()
if self.client is None:
raise RuntimeError("Matrix client failed to initialize")
if self.client.should_upload_keys: client = self.client
await self.client.keys_upload()
if client.should_upload_keys:
await client.keys_upload()
for room in self.join_rooms: for room in self.join_rooms:
await self.client.join(room) await client.join(room)
await self.client.joined_rooms() await client.joined_rooms()
await self.client.sync_forever(timeout=300000, full_state=True) await client.sync_forever(timeout=300000, full_state=True)
def save_credentials(self, resp: LoginResponse, homeserver: str) -> None: def save_credentials(self, resp: LoginResponse, homeserver: str) -> None:
credentials = ClientCredentials( credentials = ClientCredentials(
@ -150,6 +156,9 @@ class MatrixClient:
message: str, message: str,
message_formatted: Optional[str] = None, message_formatted: Optional[str] = None,
) -> None: ) -> None:
if self.client is None:
raise RuntimeError("Matrix client failed to initialize")
content = { content = {
"msgtype": "m.text", "msgtype": "m.text",
"body": f"{message}", "body": f"{message}",
@ -168,4 +177,5 @@ class MatrixClient:
) )
async def shutdown(self) -> None: async def shutdown(self) -> None:
if self.client is not None:
await self.client.close() await self.client.close()

View file

@ -123,14 +123,14 @@ class PluginTemplateLoader(BaseLoader):
self.macros = sync_read_file(base_path / "macros.html") self.macros = sync_read_file(base_path / "macros.html")
def get_source( def get_source(
self, environment: Any, name: str self, environment: Any, template: str
) -> Tuple[str, str, Callable[[], bool]]: ) -> Tuple[str, str, Callable[[], bool]]:
path = self.directory / f"{name}.html" path = self.directory / f"{template}.html"
try: try:
tpl = sync_read_file(path) tpl = sync_read_file(path)
except KeyError: except KeyError:
raise TemplateNotFound(name) raise TemplateNotFound(template)
return self.macros + tpl, name, lambda: True return self.macros + tpl, template, lambda: True
def list_templates(self) -> List[str]: def list_templates(self) -> List[str]:
return [ return [

View file

@ -30,6 +30,8 @@ matrix-ops-bot = "ops_bot.main:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=7.2.0,<8.0.0", "pytest>=7.2.0,<8.0.0",
"ruff>=0.9.0,<1.0.0",
"pyright>=1.1.390,<2.0.0",
"black>=22.10.0,<23.0.0", "black>=22.10.0,<23.0.0",
"isort>=5.10.1,<6.0.0", "isort>=5.10.1,<6.0.0",
"mypy>=1.2.0,<2.0.0", "mypy>=1.2.0,<2.0.0",

View file

@ -1,23 +0,0 @@
{ system ? "x86_64-linux", pkgs ? import <nixpkgs> { inherit system; } }:
let
packages = [
pkgs.python313
pkgs.uv
pkgs.zsh
pkgs.olm
];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
pkgs.stdenv.cc.cc
];
in
pkgs.mkShell {
buildInputs = packages;
shellHook = ''
export SHELL=${pkgs.zsh}
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH}"
export UV_PROJECT_ENVIRONMENT=".venv"
export PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring
'';
}

51
uv.lock generated
View file

@ -813,8 +813,10 @@ dev = [
{ name = "flake8-black" }, { name = "flake8-black" },
{ name = "isort" }, { name = "isort" },
{ name = "mypy" }, { name = "mypy" },
{ name = "pyright" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "types-commonmark" }, { name = "types-commonmark" },
{ name = "types-markdown" }, { name = "types-markdown" },
{ name = "types-termcolor" }, { name = "types-termcolor" },
@ -846,8 +848,10 @@ dev = [
{ name = "flake8-black", specifier = ">=0.3.5,<0.4.0" }, { name = "flake8-black", specifier = ">=0.3.5,<0.4.0" },
{ name = "isort", specifier = ">=5.10.1,<6.0.0" }, { name = "isort", specifier = ">=5.10.1,<6.0.0" },
{ name = "mypy", specifier = ">=1.2.0,<2.0.0" }, { name = "mypy", specifier = ">=1.2.0,<2.0.0" },
{ name = "pyright", specifier = ">=1.1.390,<2.0.0" },
{ name = "pytest", specifier = ">=7.2.0,<8.0.0" }, { name = "pytest", specifier = ">=7.2.0,<8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.20.2,<0.21.0" }, { name = "pytest-asyncio", specifier = ">=0.20.2,<0.21.0" },
{ name = "ruff", specifier = ">=0.9.0,<1.0.0" },
{ name = "types-commonmark", specifier = ">=0.9.2,<0.10.0" }, { name = "types-commonmark", specifier = ">=0.9.2,<0.10.0" },
{ name = "types-markdown", specifier = ">=3.4.0,<4.0.0" }, { name = "types-markdown", specifier = ">=3.4.0,<4.0.0" },
{ name = "types-termcolor", specifier = ">=1.1.5,<2.0.0" }, { name = "types-termcolor", specifier = ">=1.1.5,<2.0.0" },
@ -1050,6 +1054,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
] ]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" version = "26.0"
@ -1408,6 +1421,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
] ]
[[package]]
name = "pyright"
version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "7.4.4" version = "7.4.4"
@ -1660,6 +1686,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
] ]
[[package]]
name = "ruff"
version = "0.15.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"