From 3ccd42fda358d2bbad5b0a4b92e1fce0effeb6e4 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 29 Mar 2025 20:48:07 +0100 Subject: [PATCH 1/8] Add uv entrypoint --- pyproject.toml | 3 +++ scripts/mcp_server.py | 5 ++--- src/serena/mcp.py | 4 ++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b8d56651..9a9533ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,9 @@ dependencies = [ "types-pyyaml>=6.0.12.20241230", ] +[project.scripts] +serena = "serena.mcp:start_mcp_server" + [project.license] text = "MIT" diff --git a/scripts/mcp_server.py b/scripts/mcp_server.py index c3755751..dbc82cce 100644 --- a/scripts/mcp_server.py +++ b/scripts/mcp_server.py @@ -1,10 +1,9 @@ import logging -from serena.mcp import mcp +from serena.mcp import start_mcp_server log = logging.getLogger(__name__) if __name__ == "__main__": - log.info("Starting server") - mcp.run() + start_mcp_server() diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 1e9ba038..e9c1020f 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -134,6 +134,10 @@ class MemoriesManager: mcp_settings = Settings(lifespan=server_lifespan) mcp = FastMCP(**mcp_settings.model_dump()) +def start_mcp_server(): + log.info("Starting server") + mcp.run() + class Component(ABC): def __init__(self, ctx: Context): From 6ca0a7c4a63449cbe3a4cd2cf64ff7c64350b0bc Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 29 Mar 2025 21:21:53 +0100 Subject: [PATCH 2/8] Add uv entrypoint --- README.md | 4 ++-- pyproject.toml | 2 ++ uv.lock | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1bde986c..c216181e 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,8 @@ This is typically done by providing a configuration file in JSON format as follo { "mcpServers": { "serena": { - "command": "uv", - "args": ["run", "--directory", "/path/to/serena", "python", "scripts/mcp_server.py", "myproject.yml"] + "command": "/path/to/uv", + "args": ["run", "--directory", "/path/to/serena", "serena", "/path/to/myproject.yml"] } } } diff --git a/pyproject.toml b/pyproject.toml index 9a9533ed..1f249155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,8 @@ dependencies = [ "sensai-utils>=1.4.0", "pydantic>=2.10.6", "types-pyyaml>=6.0.12.20241230", + "pyyaml>=6.0.2", + "jinja2>=3.1.6", ] [project.scripts] diff --git a/uv.lock b/uv.lock index 2cb6479a..d980aef3 100644 --- a/uv.lock +++ b/uv.lock @@ -2008,11 +2008,13 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastmcp" }, + { name = "jinja2" }, { name = "mcp" }, { name = "overrides" }, { name = "pydantic" }, { name = "pyright" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "requests" }, { name = "sensai-utils" }, { name = "types-pyyaml" }, @@ -2051,6 +2053,7 @@ requires-dist = [ { name = "black", extras = ["jupyter"], marker = "extra == 'dev'", specifier = ">=23.7.0" }, { name = "docutils", marker = "extra == 'dev'", specifier = ">=0.20.1" }, { name = "fastmcp", specifier = ">=0.4.1" }, + { name = "jinja2", specifier = ">=3.1.6" }, { name = "jinja2", marker = "extra == 'dev'" }, { name = "jupyter", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "jupyter-book", marker = "extra == 'dev'", specifier = "==1.0.0" }, @@ -2066,6 +2069,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.2" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "python-dotenv", specifier = ">=1.0.0,<2" }, + { name = "pyyaml", specifier = ">=6.0.2" }, { name = "requests", specifier = ">=2.32.3,<3" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.0.285" }, { name = "sensai-utils", specifier = ">=1.4.0" }, From 8e5973e646714719103e91b9e732f343c6ba65df Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 29 Mar 2025 22:02:10 +0100 Subject: [PATCH 3/8] Readme, WIP --- README.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c216181e..f284fd59 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,82 @@ -# serena +# Serena -Welcome to the serena library! +> A powerful, fully-featured coding agent that you can use for free. No API key and no subscription required! + +Serena answers the following important question: +Can I have a state-of-the-art coding agent without paying (enormous) API costs +or constantly purchasing tokens? The answer is: yes, you can! + +We enable developers to harness the full coding power of Claude +(and soon also ChatGPT, see [here](https://x.com/OpenAIDevs/status/1904957755829481737)) +for free by leveraging two key technologies: +**MCP Servers** and **Language Servers**. + +The former allows MCP clients like Claude Desktop to gain access to your codebase and developer +tools. Using the latter, we built the core logic of serena - a set of versatile code querying +and editing functionalities based on symbolic understanding of the code. Serena discovers and +edits code just like a seasoned developer would do it by navigating the codebase inside an IDE. +Because of this serena can find the right context and do the right thing even in very large and +complex projects! So not only is it free and open source, it is also often better than other existing +solutions. + + +## Is it Really Free? + +Yes! Even the free tier of Anthropic has support for MCP Servers, so you can use serena there. +Presumably, the same will be true for ChatGPT Desktop once they add support for MCP servers. +But we do recommend to buy the Claude Pro subscription for 20$ per month as this way the rate +limits are much higher. + +Serena is Oraios AI's contribution to the developer community. We use it ourselves every day. + +We got tired of having to pay multiple +subscriptions (Cursor, Windsurf) that forced us to keep purchasing tokens on top of the subscription costs. +We also got tired of paying the huge API costs from Claude Code, Cline, Aider and other API-based tools. +This is why we built serena and cancelled most subscriptions. +See below for a more detailed comparison to existing technologies. + +## What if I do Want to Pay? + +If you want to use your own API key, or use serena within a paid tool, you can do so. +We decoupled the tools offered by serena from the MCP server implementation, so they can also +be used in any agent framework and with any model! See the section [Serena as Agent](#serena-as-agent) + +Serena stands for Semantic Retrieval and Editing Noetic Agent (but). + +## Quick Start + +1. Install `uv` if not done yet (instructions [here](https://docs.astral.sh/uv/getting-started/installation/)) +2. Clone the repository to `/path/to/serena`. +3. Create a configuration file for your project, say `myproject.yml`. See `myproject.demo.yml` for the structure. + You can just copy it and edit the entries. +4. Configure the mcp server in your client. For example, for Claude Desktop, you need to go to `Settings->Developer->MCP Servers`, + which will let you open a json file. There add the following to enable serena: + +```json +{ + "mcpServers": { + "serena": { + "command": "/abs/path/to/uv", + "args": ["run", "--directory", "/abs/path/to/serena", "serena", "/abs/path/to/myproject.yml"] + } + } +} +``` + +That's it! Save the config, restart Claude (you will have actually terminate the process on windows, just closing the window is not enough), +and you should see the serena mcp tools in your chat interface (the small hammer icon). + +Note that serena is always configured **for a single project**. To use it for another, you will have to +write a new configuration file and adjust the config in the MCP client (don't forget to restart after). + +For more info on MCP servers with Claude Desktop see [here](https://modelcontextprotocol.io/quickstart/user). + +## Serena as Agent + +In the agent mode, serena can be used with any model, including the currently popular (and SOTA in coding) +Gemini-2.5-pro. + +... ## Getting Started From af3405377049e796c390bc0e1782f16edf21baf2 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 29 Mar 2025 22:05:35 +0100 Subject: [PATCH 4/8] Readme, WIP --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f284fd59..6fad2ae0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Serena -> A powerful, fully-featured coding agent that you can use for free. No API key and no subscription required! +> **🚀 A powerful, fully-featured coding agent that you can use for free. No API key and no subscription required!** + Serena answers the following important question: Can I have a state-of-the-art coding agent without paying (enormous) API costs @@ -15,7 +16,7 @@ The former allows MCP clients like Claude Desktop to gain access to your codebas tools. Using the latter, we built the core logic of serena - a set of versatile code querying and editing functionalities based on symbolic understanding of the code. Serena discovers and edits code just like a seasoned developer would do it by navigating the codebase inside an IDE. -Because of this serena can find the right context and do the right thing even in very large and +Because of this, serena can find the right context and do the right thing even in very large and complex projects! So not only is it free and open source, it is also often better than other existing solutions. From 519fd83847fd3a451700d33453d47dadbdf6af64 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 29 Mar 2025 22:08:43 +0100 Subject: [PATCH 5/8] Readme, WIP --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6fad2ae0..16e1ebc3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ We also got tired of paying the huge API costs from Claude Code, Cline, Aider an This is why we built serena and cancelled most subscriptions. See below for a more detailed comparison to existing technologies. -## What if I do Want to Pay? +## What if I Do Want to Pay? If you want to use your own API key, or use serena within a paid tool, you can do so. We decoupled the tools offered by serena from the MCP server implementation, so they can also From 80d0a53f72a85dda1859bd0f59bc9f52b83d098c Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 29 Mar 2025 22:14:03 +0100 Subject: [PATCH 6/8] Readme, WIP --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 16e1ebc3..063f0971 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Because of this, serena can find the right context and do the right thing even i complex projects! So not only is it free and open source, it is also often better than other existing solutions. +Serena stands for *Semantic Retrieval and Editing Noetic Agent*, but we also just liked the name. + ## Is it Really Free? @@ -28,7 +30,7 @@ Presumably, the same will be true for ChatGPT Desktop once they add support for But we do recommend to buy the Claude Pro subscription for 20$ per month as this way the rate limits are much higher. -Serena is Oraios AI's contribution to the developer community. We use it ourselves every day. +Serena is [Oraios AI](https://oraios-ai.de/)'s contribution to the developer community. We use it ourselves every day. We got tired of having to pay multiple subscriptions (Cursor, Windsurf) that forced us to keep purchasing tokens on top of the subscription costs. @@ -38,11 +40,11 @@ See below for a more detailed comparison to existing technologies. ## What if I Do Want to Pay? -If you want to use your own API key, or use serena within a paid tool, you can do so. +If you want to use your own API key, connect serena to a custom model, +or use it within a paid tool, you can do so. We decoupled the tools offered by serena from the MCP server implementation, so they can also -be used in any agent framework and with any model! See the section [Serena as Agent](#serena-as-agent) - -Serena stands for Semantic Retrieval and Editing Noetic Agent (but). +be used in any agent framework and with any model. See the section [Serena as Agent](#serena-as-agent). +This way, you can run serena as a command line tool or within a UI of your choice. ## Quick Start From ae5aeee3b40636bebfa2ef0847f9d538f1eaf707 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 29 Mar 2025 22:19:11 +0100 Subject: [PATCH 7/8] Deleted boilerplate --- scripts/run_sample.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 scripts/run_sample.py diff --git a/scripts/run_sample.py b/scripts/run_sample.py deleted file mode 100644 index 1523ba58..00000000 --- a/scripts/run_sample.py +++ /dev/null @@ -1,5 +0,0 @@ -from serena.sample_module import SampleClass - -if __name__ == "__main__": - print(SampleClass().sample_method("Oraios AI")) - print("Your new library project serena is waiting for you!") From 640b4523d54217474ff5b30cb5e19b0d53210be5 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 29 Mar 2025 22:57:58 +0100 Subject: [PATCH 8/8] Agno agent, WIP --- prompts/mcp_prompts.yml | 8 +++- pyproject.toml | 3 ++ scripts/agno_agent.py | 13 +++++ src/serena/llm/prompt_factory.py | 3 ++ uv.lock | 82 ++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 scripts/agno_agent.py diff --git a/prompts/mcp_prompts.yml b/prompts/mcp_prompts.yml index a76c108e..450aba90 100644 --- a/prompts/mcp_prompts.yml +++ b/prompts/mcp_prompts.yml @@ -65,4 +65,10 @@ prompts: Recommend which tools to use and the next steps to take. Imagine that you are handing over the task to another person who has access to the same tools and memory files as you do, but has not been part of the conversation so far. If you have discovered anything general about the project in the course of the conversation, consider writing a new memory file to store that information. - + + system_prompt: | + You are a professional coding agent who is concerned with one particular codebase. You have tools to explore the codebase + iteratively, to edit files and to execute arbitrary commands. You heavily rely on these tools to solve the tasks give to you, + but you also operate in a frugal and intelligent manner, always + keeping in mind to not load content that is not needed for the task at hand. If you cannot find everything you need in the codebase + and generally when in doubt, you ask the user clarifying questions. diff --git a/pyproject.toml b/pyproject.toml index 1f249155..fe11ebd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,9 @@ dev = [ "sphinxcontrib-spelling>=8.0.0", "types-pyyaml>=6.0.12.20241230", ] +agno = [ + "agno>=1.2.6", +] [project.urls] Homepage = "https://github.com/oraios/serena" diff --git a/scripts/agno_agent.py b/scripts/agno_agent.py new file mode 100644 index 00000000..dcbb59d0 --- /dev/null +++ b/scripts/agno_agent.py @@ -0,0 +1,13 @@ +from agno.agent import Agent +from agno.models.google import Gemini +from agno.tools.duckduckgo import DuckDuckGoTools + +agent = Agent( + model=Gemini(id="gemini-2.5-pro-exp-03-25"), + description="", + tools=[], # Add serena tools here + show_tool_calls=True, + markdown=True, + context= +) +agent.print_response("Tell me about a breaking news story from New York.", stream=True) \ No newline at end of file diff --git a/src/serena/llm/prompt_factory.py b/src/serena/llm/prompt_factory.py index 1eb05bfa..74dc4f26 100644 --- a/src/serena/llm/prompt_factory.py +++ b/src/serena/llm/prompt_factory.py @@ -40,4 +40,7 @@ class PromptFactory: def create_prepare_for_new_conversation(self) -> str: return self._format_prompt('prepare_for_new_conversation', locals()) + def create_system_prompt(self) -> str: + return self._format_prompt('system_prompt', locals()) + diff --git a/uv.lock b/uv.lock index d980aef3..82554dc8 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903 }, ] +[[package]] +name = "agno" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "gitpython" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tomli" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/56/52971b8f6d17af618a3f88aaa55a06b388f1c2871db509efe6f792d1e662/agno-1.2.6.tar.gz", hash = "sha256:0e83cf44e2957823814b47a8321be23e7faba0ff144845e60eea585fb86fdf57", size = 441431 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ca/c0bf8045f6ca7cf8e99fe2f60c1162eca7f056c6b447fad27e0fd81e88ec/agno-1.2.6-py3-none-any.whl", hash = "sha256:b008416f01f4a6b2ff106777586ccc0d93d50f114944140dd95e2ff8adf7802a", size = 586946 }, +] + [[package]] name = "alabaster" version = "0.7.16" @@ -449,6 +472,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, ] +[[package]] +name = "docstring-parser" +version = "0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/12/9c22a58c0b1e29271051222d8906257616da84135af9ed167c9e28f85cb3/docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e", size = 26565 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533 }, +] + [[package]] name = "docutils" version = "0.20.1" @@ -524,6 +556,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121 }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794 }, +] + +[[package]] +name = "gitpython" +version = "3.1.44" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599 }, +] + [[package]] name = "greenlet" version = "3.1.1" @@ -1780,6 +1836,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/20/0f2523b9e50a8052bc6a8b732dfc8568abbdc42010aef03a2d750bdab3b2/python_json_logger-3.3.0-py3-none-any.whl", hash = "sha256:dd980fae8cffb24c13caf6e158d3d61c0d6d22342f932cb6e9deedab3d35eec7", size = 15163 }, ] +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, +] + [[package]] name = "pywin32" version = "310" @@ -2048,6 +2113,11 @@ dev = [ { name = "types-pyyaml" }, ] +[package.dev-dependencies] +agno = [ + { name = "agno" }, +] + [package.metadata] requires-dist = [ { name = "black", extras = ["jupyter"], marker = "extra == 'dev'", specifier = ">=23.7.0" }, @@ -2088,6 +2158,9 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +agno = [{ name = "agno", specifier = ">=1.2.6" }] + [[package]] name = "setuptools" version = "78.0.1" @@ -2115,6 +2188,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303 }, +] + [[package]] name = "sniffio" version = "1.3.1"