From 4e89e51901d9b3726a56d15980a0845b8e2a36b0 Mon Sep 17 00:00:00 2001 From: Dawid Rycerz Date: Tue, 15 Jul 2025 16:54:54 +0300 Subject: feat: add epics functionality --- .cursorrules | 3 +- pyproject.toml | 1 + servers/gitlab_python/pyproject.toml | 5 +- .../src/mcp_server_gitlab_python/cli.py | 2 +- .../src/mcp_server_gitlab_python/server.py | 245 ++++++++++++++++----- servers/gitlab_python/tests/test_server.py | 107 +++++++-- servers/gitlab_python/uv.lock | 37 +++- 7 files changed, 328 insertions(+), 72 deletions(-) diff --git a/.cursorrules b/.cursorrules index afd9955..a4d098d 100644 --- a/.cursorrules +++ b/.cursorrules @@ -80,4 +80,5 @@ - **When providing solutions or examples, ensure they are self-contained and executable without requiring extensive modifications.** - **If a request is unclear or lacks sufficient information, ask clarifying questions before proceeding.** - **Always consider the security implications of your code, especially when dealing with user inputs and external data.** -- **Actively use and promote best practices for the specific tasks at hand (LLM app development, data cleaning, demo creation, etc.).** \ No newline at end of file +- **Actively use and promote best practices for the specific tasks at hand (LLM app development, data cleaning, demo creation, etc.).** +- **Run all tests by changing directory to subproject and run `uv run pytest` for dependency context** \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 90c29bf..1e8b0d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dev = [ target-version = "py310" line-length = 88 select = ["E", "F", "B", "I", "N", "UP", "ANN", "S", "A"] +exclude = ["**/test_*.py"] [tool.ruff.isort] known-first-party = ["shared"] diff --git a/servers/gitlab_python/pyproject.toml b/servers/gitlab_python/pyproject.toml index 30ad43d..1b7bd1d 100644 --- a/servers/gitlab_python/pyproject.toml +++ b/servers/gitlab_python/pyproject.toml @@ -11,8 +11,9 @@ authors = [ dependencies = [ "mcp>=1.6.0", "pydantic>=2.11.0", - "python-gitlab>=4.4.0", - "PyYAML>=6.0.0" + "python-gitlab>=6.1.0", + "PyYAML>=6.0.0", + "GitPython>=3.1.43", ] [tool.uv] diff --git a/servers/gitlab_python/src/mcp_server_gitlab_python/cli.py b/servers/gitlab_python/src/mcp_server_gitlab_python/cli.py index 1b4db35..323c46a 100644 --- a/servers/gitlab_python/src/mcp_server_gitlab_python/cli.py +++ b/servers/gitlab_python/src/mcp_server_gitlab_python/cli.py @@ -6,8 +6,8 @@ Common CLI functionality for the GitLab Python MCP server. import argparse import asyncio import logging -import sys import os +import sys from .server import main diff --git a/servers/gitlab_python/src/mcp_server_gitlab_python/server.py b/servers/gitlab_python/src/mcp_server_gitlab_python/server.py index 1b6ba76..05e84c5 100644 --- a/servers/gitlab_python/src/mcp_server_gitlab_python/server.py +++ b/servers/gitlab_python/src/mcp_server_gitlab_python/server.py @@ -1,26 +1,23 @@ import logging import os -import sys -import yaml +from typing import Any + import gitlab -import subprocess -from typing import Any, Optional +import yaml from mcp.server.fastmcp import FastMCP logger = logging.getLogger("mcp_gitlab_python_server") -def get_git_remote_url(working_directory: str) -> Optional[str]: +def get_git_remote_url(working_directory: str) -> str | None: try: - result = subprocess.run([ - "git", "remote", "get-url", "origin" - ], capture_output=True, text=True, check=False, cwd=working_directory) - if result.returncode == 0: - return result.stdout.strip() + from git import Repo + repo = Repo(working_directory) + return repo.remotes.origin.url except Exception as e: logger.warning(f"Could not get git remote url: {e}") return None -def parse_gitlab_url_from_remote(remote_url: str) -> Optional[str]: +def parse_gitlab_url_from_remote(remote_url: str) -> str | None: # Handles both SSH and HTTPS remotes if remote_url.startswith("git@"): # git@gitlab.com:namespace/project.git @@ -33,28 +30,22 @@ def parse_gitlab_url_from_remote(remote_url: str) -> Optional[str]: return f"{parts[0]}//{parts[2]}" return None -def get_token_from_glab_config(host: str) -> Optional[str]: +def get_token_from_glab_config(host: str) -> str | None: """ Retrieve the GitLab token for a specific host from the glab-cli config file. - - Args: - host (str): The GitLab host (e.g., 'gitlab.com'). - - Returns: - Optional[str]: The token if found, otherwise None. """ config_path = os.path.expanduser("~/.config/glab-cli/config.yml") if not os.path.exists(config_path): return None try: - with open(config_path, "r") as f: + with open(config_path) as f: config = yaml.safe_load(f) hosts = config.get('hosts', {}) # Try direct match if host in hosts and 'token' in hosts[host]: return hosts[host]['token'] # Try matching by api_host if present - for h, data in hosts.items(): + for _h, data in hosts.items(): # Renamed h to _h for Ruff B007 if data.get('api_host') == host and 'token' in data: return data['token'] except Exception as e: @@ -86,18 +77,25 @@ def get_gitlab_settings(working_directory: str) -> tuple[str, str]: # Extract host from URL from urllib.parse import urlparse parsed = urlparse(url) - host = parsed.hostname or url.replace("https://", "").replace("http://", "").split("/")[0] + host = ( + parsed.hostname + or url.replace("https://", "").replace("http://", "").split("/")[0] + ) # Token token = os.environ.get("GITLAB_TOKEN") if not token: token = get_token_from_glab_config(host) if not token: - logger.error(f"No GitLab token found for host '{host}' in env or glab-cli config.") - raise RuntimeError(f"No GitLab token found for host '{host}' in env or glab-cli config.") + logger.error( + f"No GitLab token found for host '{host}' in env or glab-cli config." + ) + raise RuntimeError( + f"No GitLab token found for host '{host}' in env or glab-cli config." + ) return url, token class GitLabPythonServer: - def __init__(self, working_directory: str): + def __init__(self, working_directory: str) -> None: url, token = get_gitlab_settings(working_directory) self.gl = gitlab.Gitlab(url, private_token=token) self.gl.auth() @@ -115,7 +113,7 @@ class GitLabPythonServer: for p in projects ] - def search_issues(self, project: str, **filters) -> dict[str, Any]: + def search_issues(self, project: str, **filters: object) -> dict[str, Any]: try: proj = self.gl.projects.get(project) issues = proj.issues.list(**filters, all=True) @@ -136,10 +134,20 @@ class GitLabPythonServer: except Exception as e: return {"error": str(e)} - def create_issue(self, project: str, title: str, description: str, **kwargs) -> dict[str, Any]: + def create_issue( + self, + project: str, + title: str, + description: str, + **kwargs: object, + ) -> dict[str, Any]: try: proj = self.gl.projects.get(project) - issue = proj.issues.create({"title": title, "description": description, **kwargs}) + issue = proj.issues.create({ + "title": title, + "description": description, + **kwargs, + }) return {"url": issue.web_url} except Exception as e: return {"error": str(e)} @@ -149,7 +157,7 @@ class GitLabPythonServer: project: str, mr_iid: int, max_size_kb: int = 100, - filter_extensions: Optional[list[str]] = None, + filter_extensions: list[str] | None = None, ) -> dict[str, Any]: import tempfile if filter_extensions is None: @@ -214,22 +222,19 @@ class GitLabPythonServer: self, project: str, branch: str = None, - variables: Optional[dict] = None, + variables: dict | None = None, web_mode: bool = False, working_directory: str = None, ) -> dict[str, Any]: - import subprocess try: proj = self.gl.projects.get(project) ref = branch if not ref: - # Try to detect current branch + # Try to detect current branch using GitPython try: - result = subprocess.run([ - "git", "branch", "--show-current" - ], capture_output=True, text=True, check=False, cwd=working_directory) - if result.returncode == 0 and result.stdout.strip(): - ref = result.stdout.strip() + from git import Repo + repo = Repo(working_directory) + ref = repo.active_branch.name except Exception: ref = None if not ref: @@ -256,6 +261,80 @@ class GitLabPythonServer: except Exception as e: return {"error": str(e)} + def find_group(self, group_name: str) -> list[dict[str, Any]]: + """Find GitLab groups by name or path.""" + groups = self.gl.groups.list(search=group_name, all=True) + return [ + { + "id": g.id, + "name": g.name, + "full_path": g.full_path, + "web_url": g.web_url, + "description": g.description, + } + for g in groups + ] + + def search_epics(self, group: str, **filters: object) -> dict[str, Any]: + """Search for GitLab epics in a group with various filters. + + Args: + group (str): The group full path or ID. + **filters: Additional filters for the search. + + Returns: + dict[str, Any]: Dictionary with a list of epics or error message. + """ + try: + grp = self.gl.groups.get(group) + epics = grp.epics.list(**filters, all=True) + return { + "epics": [ + { + "id": e.id, + "iid": e.iid, + "title": e.title, + "web_url": e.web_url, + "state": e.state, + "created_at": e.created_at, + "updated_at": e.updated_at, + "author": getattr(e, "author", None), + "labels": getattr(e, "labels", []), + "description": getattr(e, "description", None), + } + for e in epics + ] + } + except Exception as e: + return {"error": str(e)} + + def create_epic( + self, + group: str, + title: str, + description: str = "", + **kwargs: object, + ) -> dict[str, Any]: + """Create a new GitLab epic in a group. + + Args: + group (str): The group full path or ID. + title (str): The title of the epic. + description (str): The description of the epic. + **kwargs: Additional fields for the epic. + + Returns: + dict[str, Any]: Dictionary with the epic URL or error message. + """ + try: + grp = self.gl.groups.get(group) + data = {"title": title, "description": description} + data.update(kwargs) + epic = grp.epics.create(data) + return {"url": epic.web_url} + except Exception as e: + return {"error": str(e)} + def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: mcp = FastMCP("GitLab Python", host=host, port=port) @@ -269,16 +348,20 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: def search_issues( project: str, working_directory: str, - author_id: Optional[int] = None, - assignee_id: Optional[int] = None, - state: Optional[str] = None, - labels: Optional[list[str]] = None, - milestone: Optional[str] = None, - **kwargs + author_id: int | None = None, + assignee_id: int | None = None, + state: str | None = None, + labels: list[str] | None = None, + milestone: str | None = None, + **kwargs: object ) -> dict[str, Any]: """Search for GitLab issues with various filters.""" server = GitLabPythonServer(working_directory) - filters = {k: v for k, v in locals().items() if v is not None and k not in ["project", "working_directory", "kwargs"]} + filters = { + k: v + for k, v in locals().items() + if v is not None and k not in ["project", "working_directory", "kwargs"] + } if labels: filters["labels"] = ",".join(labels) filters.update(kwargs) @@ -290,10 +373,10 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: title: str, description: str, working_directory: str, - labels: Optional[list[str]] = None, - assignee_ids: Optional[list[int]] = None, - milestone_id: Optional[int] = None, - **kwargs + labels: list[str] | None = None, + assignee_ids: list[int] | None = None, + milestone_id: int | None = None, + **kwargs: object ) -> dict[str, Any]: """Create a new GitLab issue.""" server = GitLabPythonServer(working_directory) @@ -313,7 +396,7 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: mr_iid: int, working_directory: str, max_size_kb: int = 100, - filter_extensions: Optional[list[str]] = None, + filter_extensions: list[str] | None = None, ) -> dict[str, Any]: """Get the diff for a merge request.""" server = GitLabPythonServer(working_directory) @@ -329,7 +412,7 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: project: str, working_directory: str, branch: str = None, - variables: Optional[dict] = None, + variables: dict | None = None, web_mode: bool = False, ) -> dict[str, Any]: """Run a CI/CD pipeline on GitLab.""" @@ -342,6 +425,70 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: working_directory=working_directory, ) + @mcp.tool() + def find_group(group_name: str, working_directory: str) -> list[dict[str, Any]]: + """Find GitLab groups by name or path.""" + server = GitLabPythonServer(working_directory) + return server.find_group(group_name) + + @mcp.tool() + def search_epics( + group: str, + working_directory: str, + author_id: int | None = None, + state: str | None = None, + labels: list[str] | None = None, + order_by: str | None = None, + sort: str | None = None, + search: str | None = None, + **kwargs: object + ) -> dict[str, Any]: + """Search for GitLab epics in a group with various filters.""" + server = GitLabPythonServer(working_directory) + filters = { + k: v + for k, v in locals().items() + if v is not None and k not in ["group", "working_directory", "kwargs"] + } + if labels: + filters["labels"] = ",".join(labels) + filters.update(kwargs) + return server.search_epics(group, **filters) + + @mcp.tool() + def create_epic( + group: str, + title: str, + description: str, + working_directory: str, + labels: list[str] | None = None, + parent_id: int | None = None, + color: str | None = None, + confidential: bool | None = None, + start_date_fixed: str | None = None, + due_date_fixed: str | None = None, + **kwargs: object + ) -> dict[str, Any]: + """Create a new GitLab epic in a group.""" + server = GitLabPythonServer(working_directory) + data = {} + if labels: + data["labels"] = labels + if parent_id is not None: + data["parent_id"] = parent_id + if color is not None: + data["color"] = color + if confidential is not None: + data["confidential"] = confidential + if start_date_fixed is not None: + data["start_date_fixed"] = start_date_fixed + data["start_date_is_fixed"] = True + if due_date_fixed is not None: + data["due_date_fixed"] = due_date_fixed + data["due_date_is_fixed"] = True + data.update(kwargs) + return server.create_epic(group, title, description, **data) + return mcp async def main(transport_type: str, host: str, port: int) -> None: diff --git a/servers/gitlab_python/tests/test_server.py b/servers/gitlab_python/tests/test_server.py index 99cb788..7c0e69f 100644 --- a/servers/gitlab_python/tests/test_server.py +++ b/servers/gitlab_python/tests/test_server.py @@ -111,8 +111,8 @@ def test_run_ci_pipeline_success_with_branch(mock_gitlab, mock_settings): @patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token")) @patch("gitlab.Gitlab") -@patch("subprocess.run") -def test_run_ci_pipeline_success_current_branch(mock_subprocess, mock_gitlab, mock_settings): +@patch("git.Repo") +def test_run_ci_pipeline_success_current_branch(mock_repo, mock_gitlab, mock_settings): server = GitLabPythonServer(working_directory="/tmp") proj = MagicMock() pipeline = MagicMock() @@ -121,11 +121,10 @@ def test_run_ci_pipeline_success_current_branch(mock_subprocess, mock_gitlab, mo proj.pipelines.create.return_value = pipeline proj.default_branch = "main" mock_gitlab.return_value.projects.get.return_value = proj - # Simulate git branch detection - mock_git = MagicMock() - mock_git.returncode = 0 - mock_git.stdout = "feature-branch" - mock_subprocess.return_value = mock_git + # Simulate git branch detection using GitPython + mock_branch = MagicMock() + mock_branch.name = "feature-branch" + mock_repo.return_value.active_branch = mock_branch result = server.run_ci_pipeline( project="project/path", branch=None, @@ -137,15 +136,12 @@ def test_run_ci_pipeline_success_current_branch(mock_subprocess, mock_gitlab, mo assert result["pipeline_id"] == 456 assert result["pipeline_url"] == "https://gitlab.com/project/-/pipelines/456" assert result["branch"] == "feature-branch" - mock_subprocess.assert_called_once_with([ - "git", "branch", "--show-current" - ], capture_output=True, text=True, check=False, cwd="/tmp") proj.pipelines.create.assert_called_once() @patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token")) @patch("gitlab.Gitlab") -@patch("subprocess.run") -def test_run_ci_pipeline_fallback_to_default_branch(mock_subprocess, mock_gitlab, mock_settings): +@patch("git.Repo") +def test_run_ci_pipeline_fallback_to_default_branch(mock_repo, mock_gitlab, mock_settings): server = GitLabPythonServer(working_directory="/tmp") proj = MagicMock() pipeline = MagicMock() @@ -154,11 +150,8 @@ def test_run_ci_pipeline_fallback_to_default_branch(mock_subprocess, mock_gitlab proj.pipelines.create.return_value = pipeline proj.default_branch = "main" mock_gitlab.return_value.projects.get.return_value = proj - # Simulate git branch detection failure - mock_git = MagicMock() - mock_git.returncode = 1 - mock_git.stdout = "" - mock_subprocess.return_value = mock_git + # Simulate git branch detection failure by raising an exception + mock_repo.return_value.active_branch = property(lambda self: (_ for _ in ()).throw(Exception("No branch"))) result = server.run_ci_pipeline( project="project/path", branch=None, @@ -167,6 +160,8 @@ def test_run_ci_pipeline_fallback_to_default_branch(mock_subprocess, mock_gitlab working_directory="/tmp" ) assert result["success"] is True + assert result["pipeline_id"] == 789 + assert result["pipeline_url"] == "https://gitlab.com/project/-/pipelines/789" assert result["branch"] == "main" proj.pipelines.create.assert_called_once() @@ -206,4 +201,80 @@ def test_run_ci_pipeline_error_handling(mock_gitlab, mock_settings): working_directory="/tmp" ) assert "error" in result - assert "Not found" in result["error"] \ No newline at end of file + assert "Not found" in result["error"] + +@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token")) +@patch("gitlab.Gitlab") +def test_find_group(mock_gitlab, mock_settings): + server = GitLabPythonServer(working_directory="/tmp") + group1 = MagicMock() + group1.id = 1 + group1.name = "Test Group" + group1.full_path = "test-group" + group1.web_url = "https://gitlab.com/groups/test-group" + group1.description = "A test group" + mock_gitlab.return_value.groups.list.return_value = [group1] + result = server.find_group("test-group") + assert isinstance(result, list) + assert result[0]["name"] == "Test Group" + assert result[0]["full_path"] == "test-group" + +@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token")) +@patch("gitlab.Gitlab") +def test_search_epics_success(mock_gitlab, mock_settings): + server = GitLabPythonServer(working_directory="/tmp") + group = MagicMock() + epic = MagicMock() + epic.id = 1 + epic.iid = 101 + epic.title = "Epic Title" + epic.web_url = "https://gitlab.com/groups/test-group/-/epics/101" + epic.state = "opened" + epic.created_at = "2024-01-01T00:00:00Z" + epic.updated_at = "2024-01-02T00:00:00Z" + epic.author = {"id": 2, "name": "Author"} + epic.labels = ["label1"] + epic.description = "Epic description" + group.epics.list.return_value = [epic] + mock_gitlab.return_value.groups.get.return_value = group + result = server.search_epics("test-group", state="opened") + assert "epics" in result + assert result["epics"][0]["title"] == "Epic Title" + assert result["epics"][0]["state"] == "opened" + assert result["epics"][0]["author"]["name"] == "Author" + +@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token")) +@patch("gitlab.Gitlab") +def test_search_epics_error(mock_gitlab, mock_settings): + server = GitLabPythonServer(working_directory="/tmp") + mock_gitlab.return_value.groups.get.side_effect = Exception("Group not found") + result = server.search_epics("bad-group") + assert "error" in result + assert "Group not found" in result["error"] + +@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token")) +@patch("gitlab.Gitlab") +def test_create_epic_success(mock_gitlab, mock_settings): + server = GitLabPythonServer(working_directory="/tmp") + group = MagicMock() + epic = MagicMock() + epic.web_url = "https://gitlab.com/groups/test-group/-/epics/101" + group.epics.create.return_value = epic + mock_gitlab.return_value.groups.get.return_value = group + result = server.create_epic("test-group", "Epic Title", "Epic description", labels=["label1"]) + assert "url" in result + assert result["url"] == "https://gitlab.com/groups/test-group/-/epics/101" + group.epics.create.assert_called_once() + args = group.epics.create.call_args[0][0] + assert args["title"] == "Epic Title" + assert args["description"] == "Epic description" + assert args["labels"] == ["label1"] + +@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token")) +@patch("gitlab.Gitlab") +def test_create_epic_error(mock_gitlab, mock_settings): + server = GitLabPythonServer(working_directory="/tmp") + mock_gitlab.return_value.groups.get.side_effect = Exception("Group not found") + result = server.create_epic("bad-group", "Epic Title", "Epic description") + assert "error" in result + assert "Group not found" in result["error"] \ No newline at end of file diff --git a/servers/gitlab_python/uv.lock b/servers/gitlab_python/uv.lock index b1c296e..5601b46 100644 --- a/servers/gitlab_python/uv.lock +++ b/servers/gitlab_python/uv.lock @@ -171,6 +171,30 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[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 = "h11" version = "0.16.0" @@ -289,6 +313,7 @@ name = "mcp-server-gitlab-python" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "gitpython" }, { name = "mcp" }, { name = "pydantic" }, { name = "python-gitlab" }, @@ -304,9 +329,10 @@ dev = [ [package.metadata] requires-dist = [ + { name = "gitpython", specifier = ">=3.1.43" }, { name = "mcp", specifier = ">=1.6.0" }, { name = "pydantic", specifier = ">=2.11.0" }, - { name = "python-gitlab", specifier = ">=4.4.0" }, + { name = "python-gitlab", specifier = ">=6.1.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, ] @@ -707,6 +733,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/ed/9de62c2150ca8e2e5858acf3f4f4d0d180a38feef9fdab4078bea63d8dba/rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e99685fc95d386da368013e7fb4269dd39c30d99f812a8372d62f244f662709c", size = 555334 }, ] +[[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" -- cgit v1.2.3