From c45d0fae883cf25e32e3a899bb9419a2e5738be7 Mon Sep 17 00:00:00 2001 From: Suhaib Mujahid Date: Fri, 15 May 2026 16:31:15 -0400 Subject: [PATCH 1/2] Support load code review skills from URLs on demand --- bugbug/tools/code_review/agent.py | 11 ++ bugbug/tools/code_review/data_types.py | 47 +++++++- bugbug/tools/code_review/langchain_tools.py | 35 ++++++ bugbug/tools/code_review/prompts.py | 9 ++ tests/test_code_review.py | 126 ++++++++++++++++++++ 5 files changed, 227 insertions(+), 1 deletion(-) diff --git a/bugbug/tools/code_review/agent.py b/bugbug/tools/code_review/agent.py index 48611a1517..e6f89d3871 100644 --- a/bugbug/tools/code_review/agent.py +++ b/bugbug/tools/code_review/agent.py @@ -25,11 +25,13 @@ AgentResponse, CodeReviewToolResponse, GeneratedReviewComment, + Skill, ) from bugbug.tools.code_review.database import ReviewCommentsDB from bugbug.tools.code_review.langchain_tools import ( CodeReviewContext, create_find_function_definition_tool, + create_load_skill_tool, expand_context, ) from bugbug.tools.code_review.prompts import ( @@ -70,6 +72,7 @@ def __init__( verbose: bool = True, target_software: str = "Mozilla Firefox", todo_enabled: bool = True, + skills: Optional[list[Skill]] = None, ) -> None: super().__init__() @@ -98,6 +101,9 @@ def __init__( if function_search: tools.append(create_find_function_definition_tool(function_search)) + if skills: + tools.append(create_load_skill_tool(skills)) + self._agent_model = llm middleware = [] @@ -176,6 +182,11 @@ def create(cls, **kwargs): kwargs["suggestion_filterer"] = SuggestionFilteringTool.create() + if "skills" not in kwargs: + from bugbug.tools.code_review.prompts import REVIEW_SKILLS + + kwargs["skills"] = REVIEW_SKILLS + return cls(**kwargs) def count_tokens(self, text): diff --git a/bugbug/tools/code_review/data_types.py b/bugbug/tools/code_review/data_types.py index 1c3c1c1788..d7ecd6938d 100644 --- a/bugbug/tools/code_review/data_types.py +++ b/bugbug/tools/code_review/data_types.py @@ -1,5 +1,9 @@ -from pydantic import BaseModel, Field +import re +import httpx +from pydantic import BaseModel, Field, PrivateAttr + +from bugbug.tools.core.connection import get_http_client from bugbug.tools.core.data_types import InlineComment @@ -37,3 +41,44 @@ class CodeReviewToolResponse(BaseModel): details: dict = Field( description="Additional details about the tool's execution, such as which models were used and any relevant metadata." ) + + +class SkillLoadError(Exception): + """Raised when a Skill's body cannot be loaded.""" + + +_FRONTMATTER_RE = re.compile(r"\A---\s*\n.*?\n---\s*\n", re.DOTALL) + + +def _strip_frontmatter(text: str) -> str: + return _FRONTMATTER_RE.sub("", text, count=1) + + +class Skill(BaseModel): + """A reusable instruction set the agent can load on demand.""" + + name: str = Field( + description="A unique identifier the agent uses to load the skill." + ) + url: str = Field(description="HTTPS URL of the skill.md file.") + description: str = Field( + description="Short summary shown to the agent so it can decide when to load this skill." + ) + + _cached_body: str | None = PrivateAttr(default=None) + + async def load(self) -> str: + """Return the skill body, fetching and caching it on first use.""" + if self._cached_body is not None: + return self._cached_body + + try: + response = await get_http_client().get(self.url, timeout=30) + response.raise_for_status() + except httpx.HTTPError as e: + raise SkillLoadError( + f"Could not load skill '{self.name}' from {self.url}" + ) from e + + self._cached_body = _strip_frontmatter(response.text) + return self._cached_body diff --git a/bugbug/tools/code_review/langchain_tools.py b/bugbug/tools/code_review/langchain_tools.py index 457bc9c70d..98d6d0cbc9 100644 --- a/bugbug/tools/code_review/langchain_tools.py +++ b/bugbug/tools/code_review/langchain_tools.py @@ -13,6 +13,7 @@ from requests import HTTPError from bugbug.code_search.function_search import FunctionSearch +from bugbug.tools.code_review.data_types import Skill, SkillLoadError from bugbug.tools.core.platforms.base import Patch logger = getLogger(__name__) @@ -95,3 +96,37 @@ def find_function_definition( return functions[0].source return find_function_definition + + +def create_load_skill_tool(skills: list[Skill]): + skills_by_name = {skill.name: skill for skill in skills} + available_names = ", ".join(skills_by_name) + + catalog_lines = "\n".join( + f"- **{skill.name}**: {skill.description}" for skill in skills + ) + description = ( + "Load the contents of a named skill to guide the review. Use this when " + "the patch touches an area covered by one of the skills below; otherwise, " + "do not call it.\n\n" + "Available skills:\n" + f"{catalog_lines}\n\n" + "Args:\n" + " name: The name of the skill to load (must match one of the names above).\n\n" + "Returns:\n" + " The skill content as Markdown." + ) + + @tool(description=description) + async def load_skill(name: str) -> str: + skill = skills_by_name.get(name) + if skill is None: + return f"Unknown skill '{name}'. Available: {available_names}." + + try: + return await skill.load() + except SkillLoadError: + logger.exception("Failed to load skill '%s'", name) + return f"Failed to load skill '{name}'. Please proceed without it." + + return load_skill diff --git a/bugbug/tools/code_review/prompts.py b/bugbug/tools/code_review/prompts.py index 639b36abc9..bf4073d474 100644 --- a/bugbug/tools/code_review/prompts.py +++ b/bugbug/tools/code_review/prompts.py @@ -5,6 +5,8 @@ """Prompt templates for code review agent.""" +from bugbug.tools.code_review.data_types import Skill + TARGET_SOFTWARE: str | None = None @@ -213,3 +215,10 @@ +++ b/{filename} {raw_hunk} """ + + +# The agent exposes these to the model via a load_skill tool that fetches +# the URL on demand, strips YAML frontmatter, and caches the result. +REVIEW_SKILLS: list[Skill] = [ + # Skill(name="...", url="https://...", description="..."), +] diff --git a/tests/test_code_review.py b/tests/test_code_review.py index f09c0fd9cb..369c195aa1 100644 --- a/tests/test_code_review.py +++ b/tests/test_code_review.py @@ -1,7 +1,14 @@ +import logging import os +from unittest.mock import AsyncMock, MagicMock, patch +import httpx +import pytest from unidiff import PatchSet +from bugbug.tools.code_review import data_types, langchain_tools +from bugbug.tools.code_review.data_types import Skill, _strip_frontmatter +from bugbug.tools.code_review.langchain_tools import create_load_skill_tool from bugbug.tools.code_review.utils import find_comment_scope FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures/phabricator") @@ -56,3 +63,122 @@ def test_find_comment_scope(): for line_number, expected_scope in target_hunks.items(): assert find_comment_scope(patched_file, line_number) == expected_scope + + +def _mock_client_returning(text: str) -> MagicMock: + response = MagicMock() + response.text = text + response.raise_for_status = MagicMock() + client = MagicMock() + client.get = AsyncMock(return_value=response) + return client + + +def test_strip_frontmatter_present(): + text = "---\nname: mozilla\ndescription: foo\n---\nThe body.\n" + assert _strip_frontmatter(text) == "The body.\n" + + +def test_strip_frontmatter_absent(): + text = "Just a body, no frontmatter.\n" + assert _strip_frontmatter(text) == text + + +def test_strip_frontmatter_unterminated(): + text = "---\nname: mozilla\nstill no closing marker\n" + assert _strip_frontmatter(text) == text + + +@pytest.mark.asyncio +async def test_skill_load_caches(): + skill = Skill( + name="a", + url="https://example.com/a.md", + description="example", + ) + client = _mock_client_returning("---\nname: a\n---\nbody\n") + with patch.object(data_types, "get_http_client", return_value=client): + first = await skill.load() + second = await skill.load() + assert first == "body\n" + assert second == "body\n" + assert client.get.await_count == 1 + + +@pytest.mark.asyncio +async def test_load_skill_unknown_name(): + skills = [ + Skill( + name="mozilla-style", + url="https://example.com/mozilla-style.md", + description="Mozilla style guide", + ) + ] + tool = create_load_skill_tool(skills) + client = _mock_client_returning("ignored") + with patch.object(data_types, "get_http_client", return_value=client): + result = await tool.ainvoke({"name": "nonexistent"}) + assert "Unknown skill 'nonexistent'" in result + assert "mozilla-style" in result + assert client.get.await_count == 0 + + +@pytest.mark.asyncio +async def test_load_skill_happy_path(): + skills = [ + Skill( + name="mozilla-style", + url="https://example.com/mozilla-style.md", + description="Mozilla style guide", + ) + ] + tool = create_load_skill_tool(skills) + client = _mock_client_returning( + "---\nname: mozilla-style\ndescription: ignored\n---\nUse 4-space indents.\n" + ) + with patch.object(data_types, "get_http_client", return_value=client): + result = await tool.ainvoke({"name": "mozilla-style"}) + assert result == "Use 4-space indents.\n" + + +@pytest.mark.asyncio +async def test_load_skill_fetch_failure(caplog): + skills = [ + Skill( + name="mozilla-style", + url="https://example.com/mozilla-style.md", + description="Mozilla style guide", + ) + ] + tool = create_load_skill_tool(skills) + client = MagicMock() + client.get = AsyncMock(side_effect=httpx.ConnectError("boom")) + with ( + patch.object(data_types, "get_http_client", return_value=client), + caplog.at_level(logging.ERROR, logger=langchain_tools.logger.name), + ): + result = await tool.ainvoke({"name": "mozilla-style"}) + assert "Failed to load skill 'mozilla-style'" in result + assert any( + "Failed to load skill 'mozilla-style'" in record.message + for record in caplog.records + ) + + +def test_load_skill_tool_description_lists_skills(): + skills = [ + Skill( + name="mozilla-style", + url="https://example.com/mozilla-style.md", + description="Mozilla style guide", + ), + Skill( + name="security-checklist", + url="https://example.com/sec.md", + description="Common security pitfalls", + ), + ] + tool = create_load_skill_tool(skills) + for skill in skills: + assert skill.name in tool.description + assert skill.description in tool.description From 6d27be8e66950e65b4841bc56d90dca0181dd751 Mon Sep 17 00:00:00 2001 From: Suhaib Mujahid Date: Fri, 15 May 2026 18:43:18 -0400 Subject: [PATCH 2/2] Add pytest-asyncio the test dependencies --- pyproject.toml | 1 + uv.lock | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 7bc9df97b2..6c4e9f55ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ test = [ "jsonschema==4.26.0", "pre-commit==4.5.1", "pytest==9.0.3", + "pytest-asyncio==1.3.0", "pytest-cov==7.1.0", "pytest-responses==0.5.1", "responses==0.21.0", diff --git a/uv.lock b/uv.lock index fd17b0da4b..26960fc644 100644 --- a/uv.lock +++ b/uv.lock @@ -485,6 +485,7 @@ test = [ { name = "jsonschema" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-responses" }, { name = "responses" }, @@ -559,6 +560,7 @@ test = [ { name = "jsonschema", specifier = "==4.26.0" }, { name = "pre-commit", specifier = "==4.5.1" }, { name = "pytest", specifier = "==9.0.3" }, + { name = "pytest-asyncio", specifier = "==1.3.0" }, { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-responses", specifier = "==0.5.1" }, { name = "responses", specifier = "==0.21.0" }, @@ -1594,6 +1596,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -1602,6 +1605,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -1610,6 +1614,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -1618,6 +1623,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -4201,6 +4207,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0"