mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat(cli,mcp): complete S3 control surface — hanzo s3 + s3 MCP tool
Closes the last gap in the one-way Hanzo control surface. IAM/KMS/PaaS already had both CLI subcommands and MCP tools; S3 had neither. - hanzo s3: buckets/mb/rb/ls/stat/rm/presign over hanzo_s3 (MinIO) client, creds from env (inject from KMS, never plaintext). Registered in the unified hanzo CLI alongside iam/kms/paas. - hanzo-tools-s3: S3Tool MCP tool (same action-dispatch pattern as PaaSTool), exported via the hanzo.tools entry point; s3 added to loader prefix map. - uv workspace: map hanzo-cli/iam/kms/s3 + tools-s3 to local sources so the unified CLI resolves on-disk packages (fixes hanzo_iam ImportError from a stale published 1.29.0 wheel shadowing local 1.30.0). Tests: 7 CLI + 14 MCP, all green.
This commit is contained in:
@@ -8,6 +8,7 @@ Usage:
|
||||
hanzo iam <subcommand>
|
||||
hanzo kms <subcommand>
|
||||
hanzo paas <subcommand>
|
||||
hanzo s3 <subcommand>
|
||||
hanzo k8s <subcommand | kubectl args>
|
||||
"""
|
||||
|
||||
@@ -124,12 +125,14 @@ from hanzo_cli.iam.commands import iam # noqa: E402
|
||||
from hanzo_cli.k8s.commands import k8s # noqa: E402
|
||||
from hanzo_cli.kms.commands import kms # noqa: E402
|
||||
from hanzo_cli.paas.commands import deploy, paas # noqa: E402
|
||||
from hanzo_cli.s3.commands import s3 # noqa: E402
|
||||
|
||||
main.add_command(bot)
|
||||
main.add_command(iam)
|
||||
main.add_command(k8s)
|
||||
main.add_command(kms)
|
||||
main.add_command(paas)
|
||||
main.add_command(s3)
|
||||
|
||||
# Top-level aliases — `hanzo deploy` = `hanzo paas deploy`
|
||||
main.add_command(deploy)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Hanzo CLI — S3 object-storage subcommands."""
|
||||
|
||||
from hanzo_cli.s3.commands import s3
|
||||
|
||||
__all__ = ["s3"]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Hanzo CLI — S3 subcommands for object storage.
|
||||
|
||||
Wraps the ``hanzo_s3`` client (S3-compatible / MinIO) at s3.hanzo.ai.
|
||||
Credentials come from the environment — inject them from KMS, never store
|
||||
them in plaintext:
|
||||
|
||||
eval "$(hanzo kms inject myproject production)" # exports HANZO_S3_*
|
||||
|
||||
Usage:
|
||||
hanzo s3 buckets — List buckets
|
||||
hanzo s3 mb <bucket> — Make a bucket
|
||||
hanzo s3 rb <bucket> [--yes] — Remove a bucket
|
||||
hanzo s3 ls <bucket> [--prefix p] — List objects
|
||||
hanzo s3 stat <bucket> <object> — Object metadata
|
||||
hanzo s3 rm <bucket> <object> [--yes] — Remove an object
|
||||
hanzo s3 presign <bucket> <object> — Presigned GET URL
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _get_s3_client():
|
||||
"""Build an S3 client from environment credentials."""
|
||||
from hanzo_s3 import S3Client
|
||||
|
||||
endpoint = os.getenv("HANZO_S3_ENDPOINT", "s3.hanzo.ai")
|
||||
access_key = os.getenv("HANZO_S3_ACCESS_KEY") or os.getenv("AWS_ACCESS_KEY_ID")
|
||||
secret_key = os.getenv("HANZO_S3_SECRET_KEY") or os.getenv("AWS_SECRET_ACCESS_KEY")
|
||||
secure = os.getenv("HANZO_S3_SECURE", "true").lower() not in ("0", "false", "no")
|
||||
|
||||
if not access_key or not secret_key:
|
||||
raise click.ClickException(
|
||||
"Missing S3 credentials. Set HANZO_S3_ACCESS_KEY and HANZO_S3_SECRET_KEY "
|
||||
"(inject from KMS: eval \"$(hanzo kms inject <project> <env>)\")."
|
||||
)
|
||||
|
||||
return S3Client(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
|
||||
|
||||
|
||||
@click.group()
|
||||
def s3() -> None:
|
||||
"""Manage S3-compatible object storage."""
|
||||
|
||||
|
||||
@s3.command("buckets")
|
||||
def list_buckets() -> None:
|
||||
"""List all buckets."""
|
||||
client = _get_s3_client()
|
||||
buckets = list(client.list_buckets())
|
||||
if not buckets:
|
||||
console.print("[yellow]No buckets found.[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title="Buckets")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Created", style="white")
|
||||
for b in buckets:
|
||||
table.add_row(b.name, str(b.creation_date)[:19] if b.creation_date else "—")
|
||||
console.print(table)
|
||||
|
||||
|
||||
@s3.command("mb")
|
||||
@click.argument("bucket")
|
||||
def make_bucket(bucket: str) -> None:
|
||||
"""Make a bucket."""
|
||||
client = _get_s3_client()
|
||||
client.make_bucket(bucket)
|
||||
console.print(f"[green]Created[/green] bucket {bucket}")
|
||||
|
||||
|
||||
@s3.command("rb")
|
||||
@click.argument("bucket")
|
||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
|
||||
def remove_bucket(bucket: str, yes: bool) -> None:
|
||||
"""Remove a bucket (must be empty)."""
|
||||
if not yes:
|
||||
click.confirm(f"Remove bucket '{bucket}'?", abort=True)
|
||||
client = _get_s3_client()
|
||||
client.remove_bucket(bucket)
|
||||
console.print(f"[green]Removed[/green] bucket {bucket}")
|
||||
|
||||
|
||||
@s3.command("ls")
|
||||
@click.argument("bucket")
|
||||
@click.option("--prefix", default="", help="Object key prefix.")
|
||||
@click.option("--recursive", "-r", is_flag=True, help="Recurse into prefixes.")
|
||||
def list_objects(bucket: str, prefix: str, recursive: bool) -> None:
|
||||
"""List objects in a bucket."""
|
||||
client = _get_s3_client()
|
||||
objects = list(client.list_objects(bucket, prefix=prefix, recursive=recursive))
|
||||
if not objects:
|
||||
console.print("[yellow]No objects found.[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title=f"Objects: {bucket}/{prefix}")
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Size", style="white", justify="right")
|
||||
table.add_column("Modified", style="white")
|
||||
for o in objects:
|
||||
size = "—" if o.is_dir else str(o.size)
|
||||
modified = str(o.last_modified)[:19] if o.last_modified else "—"
|
||||
table.add_row(o.object_name, size, modified)
|
||||
console.print(table)
|
||||
|
||||
|
||||
@s3.command("stat")
|
||||
@click.argument("bucket")
|
||||
@click.argument("object_name")
|
||||
def stat_object(bucket: str, object_name: str) -> None:
|
||||
"""Show object metadata."""
|
||||
client = _get_s3_client()
|
||||
obj = client.stat_object(bucket, object_name)
|
||||
table = Table(title=f"{bucket}/{object_name}")
|
||||
table.add_column("Field", style="cyan")
|
||||
table.add_column("Value", style="white")
|
||||
table.add_row("Size", str(obj.size))
|
||||
table.add_row("ETag", obj.etag or "—")
|
||||
table.add_row("Content-Type", obj.content_type or "—")
|
||||
table.add_row("Modified", str(obj.last_modified)[:19] if obj.last_modified else "—")
|
||||
console.print(table)
|
||||
|
||||
|
||||
@s3.command("rm")
|
||||
@click.argument("bucket")
|
||||
@click.argument("object_name")
|
||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
|
||||
def remove_object(bucket: str, object_name: str, yes: bool) -> None:
|
||||
"""Remove an object."""
|
||||
if not yes:
|
||||
click.confirm(f"Remove '{bucket}/{object_name}'?", abort=True)
|
||||
client = _get_s3_client()
|
||||
client.remove_object(bucket, object_name)
|
||||
console.print(f"[green]Removed[/green] {bucket}/{object_name}")
|
||||
|
||||
|
||||
@s3.command("presign")
|
||||
@click.argument("bucket")
|
||||
@click.argument("object_name")
|
||||
@click.option("--expires", default=3600, help="URL lifetime in seconds.")
|
||||
def presign(bucket: str, object_name: str, expires: int) -> None:
|
||||
"""Generate a presigned GET URL for an object."""
|
||||
from datetime import timedelta
|
||||
|
||||
client = _get_s3_client()
|
||||
url = client.presigned_get_object(
|
||||
bucket, object_name, expires=timedelta(seconds=expires)
|
||||
)
|
||||
click.echo(url)
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "hanzo-cli"
|
||||
version = "0.2.1"
|
||||
description = "Hanzo unified CLI — IAM, KMS, Deploy"
|
||||
description = "Hanzo unified CLI — IAM, KMS, PaaS, S3, Deploy"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.12"
|
||||
@@ -26,6 +26,7 @@ dependencies = [
|
||||
"pyjwt>=2.8.0",
|
||||
"hanzo-iam>=1.30.0",
|
||||
"hanzo-kms>=1.0.0",
|
||||
"hanzo-s3>=1.0.0",
|
||||
"tomli>=2.0.0; python_version < '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Unit tests for the `hanzo s3` CLI group (mocked S3 client)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from hanzo_cli.s3.commands import s3
|
||||
|
||||
|
||||
def _patch_client(client):
|
||||
return patch("hanzo_cli.s3.commands._get_s3_client", return_value=client)
|
||||
|
||||
|
||||
def test_s3_group_help():
|
||||
result = CliRunner().invoke(s3, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
for cmd in ("buckets", "mb", "rb", "ls", "stat", "rm", "presign"):
|
||||
assert cmd in result.output
|
||||
|
||||
|
||||
def test_buckets_lists():
|
||||
client = MagicMock()
|
||||
b = MagicMock(creation_date=datetime(2026, 1, 1))
|
||||
b.name = "hanzo-space"
|
||||
client.list_buckets.return_value = [b]
|
||||
with _patch_client(client):
|
||||
result = CliRunner().invoke(s3, ["buckets"])
|
||||
assert result.exit_code == 0
|
||||
assert "hanzo-space" in result.output
|
||||
|
||||
|
||||
def test_make_bucket():
|
||||
client = MagicMock()
|
||||
with _patch_client(client):
|
||||
result = CliRunner().invoke(s3, ["mb", "b1"])
|
||||
assert result.exit_code == 0
|
||||
client.make_bucket.assert_called_once_with("b1")
|
||||
|
||||
|
||||
def test_ls_objects():
|
||||
client = MagicMock()
|
||||
obj = MagicMock(object_name="a.txt", size=12, is_dir=False,
|
||||
last_modified=datetime(2026, 1, 1))
|
||||
client.list_objects.return_value = [obj]
|
||||
with _patch_client(client):
|
||||
result = CliRunner().invoke(s3, ["ls", "b1", "--prefix", "a"])
|
||||
assert result.exit_code == 0
|
||||
assert "a.txt" in result.output
|
||||
|
||||
|
||||
def test_presign_outputs_url():
|
||||
client = MagicMock()
|
||||
client.presigned_get_object.return_value = "https://s3.hanzo.ai/b1/a.txt?sig=x"
|
||||
with _patch_client(client):
|
||||
result = CliRunner().invoke(s3, ["presign", "b1", "a.txt"])
|
||||
assert result.exit_code == 0
|
||||
assert "https://s3.hanzo.ai/" in result.output
|
||||
|
||||
|
||||
def test_rm_confirms(monkeypatch):
|
||||
client = MagicMock()
|
||||
with _patch_client(client):
|
||||
# Decline confirmation → aborts, client not called.
|
||||
result = CliRunner().invoke(s3, ["rm", "b1", "a.txt"], input="n\n")
|
||||
assert result.exit_code != 0
|
||||
client.remove_object.assert_not_called()
|
||||
|
||||
|
||||
def test_missing_credentials():
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
result = runner.invoke(s3, ["buckets"])
|
||||
assert result.exit_code != 0
|
||||
assert "credentials" in result.output.lower()
|
||||
@@ -60,6 +60,7 @@ PACKAGE_TOOL_PREFIXES: dict[str, list[str]] = {
|
||||
"auth": ["auth"],
|
||||
"kms": ["kms"],
|
||||
"paas": ["paas"],
|
||||
"s3": ["s3"],
|
||||
"billing": ["billing"],
|
||||
"commerce": ["commerce"],
|
||||
"iam_tools": ["iam"],
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# hanzo-tools-s3
|
||||
|
||||
MCP tool for Hanzo S3 — S3-compatible object storage (s3.hanzo.ai).
|
||||
|
||||
Exposes a single `s3` tool with bucket and object actions: `buckets`,
|
||||
`make_bucket`, `remove_bucket`, `objects`, `stat`, `remove_object`, `presign`.
|
||||
|
||||
Credentials come from the environment — inject them from KMS, never plaintext:
|
||||
|
||||
```bash
|
||||
eval "$(hanzo kms inject <project> <env>)" # exports HANZO_S3_*
|
||||
```
|
||||
|
||||
Env: `HANZO_S3_ENDPOINT` (default `s3.hanzo.ai`), `HANZO_S3_ACCESS_KEY`,
|
||||
`HANZO_S3_SECRET_KEY`, `HANZO_S3_SECURE` (default `true`).
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Hanzo S3 Tools — object storage via MCP."""
|
||||
|
||||
from .s3_tool import S3Tool
|
||||
|
||||
TOOLS = [S3Tool]
|
||||
|
||||
__all__ = ["S3Tool", "TOOLS"]
|
||||
@@ -0,0 +1,244 @@
|
||||
"""MCP tool for Hanzo S3 — S3-compatible object storage.
|
||||
|
||||
Single tool over the ``hanzo_s3`` client (MinIO-compatible) at s3.hanzo.ai.
|
||||
Buckets and objects: list, create, remove, stat, and presign.
|
||||
|
||||
Auth: credentials from the environment — inject them from KMS, never store
|
||||
plaintext:
|
||||
|
||||
eval "$(hanzo kms inject <project> <env>)" # exports HANZO_S3_*
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Annotated, final
|
||||
from datetime import timedelta
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server import FastMCP
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DESCRIPTION = """Hanzo S3 — S3-compatible object storage (s3.hanzo.ai).
|
||||
|
||||
Credentials from env: HANZO_S3_ENDPOINT, HANZO_S3_ACCESS_KEY, HANZO_S3_SECRET_KEY
|
||||
(inject from KMS — never plaintext).
|
||||
|
||||
Bucket actions:
|
||||
- buckets: List buckets
|
||||
- make_bucket: Create a bucket (params: bucket)
|
||||
- remove_bucket: Remove an empty bucket (params: bucket)
|
||||
|
||||
Object actions:
|
||||
- objects: List objects (params: bucket, prefix, recursive)
|
||||
- stat: Object metadata (params: bucket, object_name)
|
||||
- remove_object: Delete an object (params: bucket, object_name)
|
||||
- presign: Presigned GET URL (params: bucket, object_name, expires)
|
||||
"""
|
||||
|
||||
|
||||
def _get_client():
|
||||
"""Build an S3 client from environment credentials."""
|
||||
from hanzo_s3 import S3Client
|
||||
|
||||
endpoint = os.getenv("HANZO_S3_ENDPOINT", "s3.hanzo.ai")
|
||||
access_key = os.getenv("HANZO_S3_ACCESS_KEY") or os.getenv("AWS_ACCESS_KEY_ID")
|
||||
secret_key = os.getenv("HANZO_S3_SECRET_KEY") or os.getenv("AWS_SECRET_ACCESS_KEY")
|
||||
secure = os.getenv("HANZO_S3_SECURE", "true").lower() not in ("0", "false", "no")
|
||||
|
||||
if not access_key or not secret_key:
|
||||
raise RuntimeError(
|
||||
"Missing S3 credentials. Set HANZO_S3_ACCESS_KEY and HANZO_S3_SECRET_KEY "
|
||||
"(inject from KMS)."
|
||||
)
|
||||
|
||||
return S3Client(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
|
||||
|
||||
|
||||
@final
|
||||
class S3Tool(BaseTool):
|
||||
"""MCP tool for Hanzo S3 object storage."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "s3"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return DESCRIPTION
|
||||
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
action: str = "buckets",
|
||||
bucket: str | None = None,
|
||||
object_name: str | None = None,
|
||||
prefix: str = "",
|
||||
recursive: bool = False,
|
||||
expires: int = 3600,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
try:
|
||||
if action == "buckets":
|
||||
return self._buckets()
|
||||
elif action == "make_bucket":
|
||||
return self._make_bucket(bucket)
|
||||
elif action == "remove_bucket":
|
||||
return self._remove_bucket(bucket)
|
||||
elif action == "objects":
|
||||
return self._objects(bucket, prefix, recursive)
|
||||
elif action == "stat":
|
||||
return self._stat(bucket, object_name)
|
||||
elif action == "remove_object":
|
||||
return self._remove_object(bucket, object_name)
|
||||
elif action == "presign":
|
||||
return self._presign(bucket, object_name, expires)
|
||||
else:
|
||||
return json.dumps({
|
||||
"error": f"Unknown action: {action}",
|
||||
"available": [
|
||||
"buckets", "make_bucket", "remove_bucket",
|
||||
"objects", "stat", "remove_object", "presign",
|
||||
],
|
||||
})
|
||||
except RuntimeError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.exception(f"S3 tool error: {e}")
|
||||
return json.dumps({"error": f"S3 error: {e}"})
|
||||
|
||||
# -- Bucket actions ------------------------------------------------------
|
||||
|
||||
def _buckets(self) -> str:
|
||||
client = _get_client()
|
||||
result = [
|
||||
{
|
||||
"name": b.name,
|
||||
"creation_date": str(b.creation_date) if b.creation_date else None,
|
||||
}
|
||||
for b in client.list_buckets()
|
||||
]
|
||||
return json.dumps({"count": len(result), "buckets": result}, indent=2)
|
||||
|
||||
def _make_bucket(self, bucket: str | None) -> str:
|
||||
if not bucket:
|
||||
return json.dumps({"error": "Required: bucket"})
|
||||
client = _get_client()
|
||||
client.make_bucket(bucket)
|
||||
return json.dumps({"action": "created", "bucket": bucket}, indent=2)
|
||||
|
||||
def _remove_bucket(self, bucket: str | None) -> str:
|
||||
if not bucket:
|
||||
return json.dumps({"error": "Required: bucket"})
|
||||
client = _get_client()
|
||||
client.remove_bucket(bucket)
|
||||
return json.dumps({"action": "removed", "bucket": bucket}, indent=2)
|
||||
|
||||
# -- Object actions ------------------------------------------------------
|
||||
|
||||
def _objects(self, bucket: str | None, prefix: str, recursive: bool) -> str:
|
||||
if not bucket:
|
||||
return json.dumps({"error": "Required: bucket"})
|
||||
client = _get_client()
|
||||
result = [
|
||||
{
|
||||
"key": o.object_name,
|
||||
"size": None if o.is_dir else o.size,
|
||||
"last_modified": str(o.last_modified) if o.last_modified else None,
|
||||
"is_dir": o.is_dir,
|
||||
}
|
||||
for o in client.list_objects(bucket, prefix=prefix, recursive=recursive)
|
||||
]
|
||||
return json.dumps(
|
||||
{"bucket": bucket, "prefix": prefix, "count": len(result), "objects": result},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
def _stat(self, bucket: str | None, object_name: str | None) -> str:
|
||||
if not bucket or not object_name:
|
||||
return json.dumps({"error": "Required: bucket, object_name"})
|
||||
client = _get_client()
|
||||
obj = client.stat_object(bucket, object_name)
|
||||
return json.dumps({
|
||||
"bucket": bucket,
|
||||
"object_name": object_name,
|
||||
"size": obj.size,
|
||||
"etag": obj.etag,
|
||||
"content_type": obj.content_type,
|
||||
"last_modified": str(obj.last_modified) if obj.last_modified else None,
|
||||
}, indent=2)
|
||||
|
||||
def _remove_object(self, bucket: str | None, object_name: str | None) -> str:
|
||||
if not bucket or not object_name:
|
||||
return json.dumps({"error": "Required: bucket, object_name"})
|
||||
client = _get_client()
|
||||
client.remove_object(bucket, object_name)
|
||||
return json.dumps({"action": "removed", "bucket": bucket, "object_name": object_name}, indent=2)
|
||||
|
||||
def _presign(self, bucket: str | None, object_name: str | None, expires: int) -> str:
|
||||
if not bucket or not object_name:
|
||||
return json.dumps({"error": "Required: bucket, object_name"})
|
||||
client = _get_client()
|
||||
url = client.presigned_get_object(
|
||||
bucket, object_name, expires=timedelta(seconds=expires)
|
||||
)
|
||||
return json.dumps({"bucket": bucket, "object_name": object_name, "url": url}, indent=2)
|
||||
|
||||
# -- Registration --------------------------------------------------------
|
||||
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register S3 tool with explicit parameters."""
|
||||
tool_instance = self
|
||||
|
||||
@mcp_server.tool(
|
||||
name="s3",
|
||||
description=DESCRIPTION,
|
||||
)
|
||||
async def s3(
|
||||
action: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=(
|
||||
"Action to perform. "
|
||||
"Buckets: buckets, make_bucket, remove_bucket. "
|
||||
"Objects: objects, stat, remove_object, presign."
|
||||
),
|
||||
),
|
||||
] = "buckets",
|
||||
bucket: Annotated[
|
||||
str | None,
|
||||
Field(description="Bucket name"),
|
||||
] = None,
|
||||
object_name: Annotated[
|
||||
str | None,
|
||||
Field(description="Object key (for stat, remove_object, presign)"),
|
||||
] = None,
|
||||
prefix: Annotated[
|
||||
str,
|
||||
Field(description="Object key prefix (for objects)"),
|
||||
] = "",
|
||||
recursive: Annotated[
|
||||
bool,
|
||||
Field(description="Recurse into prefixes (for objects)"),
|
||||
] = False,
|
||||
expires: Annotated[
|
||||
int,
|
||||
Field(description="Presigned URL lifetime in seconds (for presign)"),
|
||||
] = 3600,
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
return await tool_instance.call(
|
||||
ctx,
|
||||
action=action,
|
||||
bucket=bucket,
|
||||
object_name=object_name,
|
||||
prefix=prefix,
|
||||
recursive=recursive,
|
||||
expires=expires,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
[project]
|
||||
name = "hanzo-tools-s3"
|
||||
version = "0.1.0"
|
||||
description = "Hanzo S3 MCP tool — buckets and objects on S3-compatible storage"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Hanzo AI", email = "[email protected]" }]
|
||||
keywords = ["hanzo", "mcp", "s3", "minio", "object-storage", "tools"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"hanzo-tools-core>=0.1.0",
|
||||
"hanzo-s3>=1.0.0",
|
||||
]
|
||||
|
||||
[project.entry-points."hanzo.tools"]
|
||||
s3 = "hanzo_tools.s3:TOOLS"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["hanzo_tools"]
|
||||
@@ -0,0 +1,138 @@
|
||||
"""S3Tool test suite — action routing, validation, client wiring."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hanzo_tools.s3.s3_tool import S3Tool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool():
|
||||
return S3Tool()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
def _patch_client(client):
|
||||
return patch("hanzo_tools.s3.s3_tool._get_client", return_value=client)
|
||||
|
||||
|
||||
class TestProperties:
|
||||
def test_name(self, tool):
|
||||
assert tool.name == "s3"
|
||||
|
||||
def test_description_sections(self, tool):
|
||||
for section in ("Bucket actions", "Object actions"):
|
||||
assert section in tool.description
|
||||
|
||||
|
||||
class TestActionRouting:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_action_returns_error(self, tool, ctx):
|
||||
result = json.loads(await tool.call(ctx, action="bogus"))
|
||||
assert "Unknown action" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_action_lists_available(self, tool, ctx):
|
||||
result = json.loads(await tool.call(ctx, action="bogus"))
|
||||
assert set(result["available"]) == {
|
||||
"buckets", "make_bucket", "remove_bucket",
|
||||
"objects", "stat", "remove_object", "presign",
|
||||
}
|
||||
|
||||
|
||||
class TestBuckets:
|
||||
@pytest.mark.asyncio
|
||||
async def test_buckets(self, tool, ctx):
|
||||
client = MagicMock()
|
||||
bucket = MagicMock(name="hanzo-space", creation_date=datetime(2026, 1, 1))
|
||||
bucket.name = "hanzo-space"
|
||||
client.list_buckets.return_value = [bucket]
|
||||
with _patch_client(client):
|
||||
result = json.loads(await tool.call(ctx, action="buckets"))
|
||||
assert result["count"] == 1
|
||||
assert result["buckets"][0]["name"] == "hanzo-space"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_bucket_requires_name(self, tool, ctx):
|
||||
result = json.loads(await tool.call(ctx, action="make_bucket"))
|
||||
assert "Required" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_bucket(self, tool, ctx):
|
||||
client = MagicMock()
|
||||
with _patch_client(client):
|
||||
result = json.loads(await tool.call(ctx, action="make_bucket", bucket="b1"))
|
||||
client.make_bucket.assert_called_once_with("b1")
|
||||
assert result["action"] == "created"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_bucket(self, tool, ctx):
|
||||
client = MagicMock()
|
||||
with _patch_client(client):
|
||||
result = json.loads(await tool.call(ctx, action="remove_bucket", bucket="b1"))
|
||||
client.remove_bucket.assert_called_once_with("b1")
|
||||
assert result["action"] == "removed"
|
||||
|
||||
|
||||
class TestObjects:
|
||||
@pytest.mark.asyncio
|
||||
async def test_objects_requires_bucket(self, tool, ctx):
|
||||
result = json.loads(await tool.call(ctx, action="objects"))
|
||||
assert "Required" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_objects(self, tool, ctx):
|
||||
client = MagicMock()
|
||||
obj = MagicMock(object_name="a.txt", size=12, is_dir=False,
|
||||
last_modified=datetime(2026, 1, 1))
|
||||
client.list_objects.return_value = [obj]
|
||||
with _patch_client(client):
|
||||
result = json.loads(await tool.call(ctx, action="objects", bucket="b1", prefix="a"))
|
||||
assert result["count"] == 1
|
||||
assert result["objects"][0]["key"] == "a.txt"
|
||||
client.list_objects.assert_called_once_with("b1", prefix="a", recursive=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat(self, tool, ctx):
|
||||
client = MagicMock()
|
||||
client.stat_object.return_value = MagicMock(
|
||||
size=99, etag="abc", content_type="text/plain",
|
||||
last_modified=datetime(2026, 1, 1),
|
||||
)
|
||||
with _patch_client(client):
|
||||
result = json.loads(await tool.call(ctx, action="stat", bucket="b1", object_name="a.txt"))
|
||||
assert result["size"] == 99
|
||||
assert result["etag"] == "abc"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_object(self, tool, ctx):
|
||||
client = MagicMock()
|
||||
with _patch_client(client):
|
||||
result = json.loads(await tool.call(ctx, action="remove_object", bucket="b1", object_name="a.txt"))
|
||||
client.remove_object.assert_called_once_with("b1", "a.txt")
|
||||
assert result["action"] == "removed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_presign(self, tool, ctx):
|
||||
client = MagicMock()
|
||||
client.presigned_get_object.return_value = "https://s3.hanzo.ai/b1/a.txt?sig=x"
|
||||
with _patch_client(client):
|
||||
result = json.loads(await tool.call(ctx, action="presign", bucket="b1", object_name="a.txt"))
|
||||
assert result["url"].startswith("https://s3.hanzo.ai/")
|
||||
|
||||
|
||||
class TestCredentials:
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_creds_errors(self, tool, ctx, monkeypatch):
|
||||
for var in ("HANZO_S3_ACCESS_KEY", "HANZO_S3_SECRET_KEY",
|
||||
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
result = json.loads(await tool.call(ctx, action="buckets"))
|
||||
assert "credentials" in result["error"].lower()
|
||||
@@ -486,6 +486,10 @@ members = [
|
||||
"pkg/hanzo-s3",
|
||||
"pkg/hanzo-tools-ui",
|
||||
"pkg/hanzo-tools-browser",
|
||||
"pkg/hanzo-cli",
|
||||
"pkg/hanzo-iam",
|
||||
"pkg/hanzo-kms",
|
||||
"pkg/hanzo-tools-s3",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
@@ -494,6 +498,13 @@ hanzo-tools-ui = { workspace = true }
|
||||
# Browser tool routes through the local zapd router; the on-disk source is the
|
||||
# single truth (not a published wheel) so the MCP and the repo never diverge.
|
||||
hanzo-tools-browser = { workspace = true }
|
||||
# Unified CLI + its service clients resolve to the on-disk source, never a
|
||||
# stale published wheel — one truth for the control surface.
|
||||
hanzo-cli = { workspace = true }
|
||||
hanzo-iam = { workspace = true }
|
||||
hanzo-kms = { workspace = true }
|
||||
hanzo-s3 = { workspace = true }
|
||||
hanzo-tools-s3 = { workspace = true }
|
||||
|
||||
[tool.ruff.lint.extend-per-file-ignores]
|
||||
# Allow print statements in scripts and examples
|
||||
|
||||
@@ -9,9 +9,13 @@ resolution-markers = [
|
||||
|
||||
[manifest]
|
||||
members = [
|
||||
"hanzo-cli",
|
||||
"hanzo-iam",
|
||||
"hanzo-kms",
|
||||
"hanzo-memory",
|
||||
"hanzo-s3",
|
||||
"hanzo-tools-browser",
|
||||
"hanzo-tools-s3",
|
||||
"hanzo-tools-ui",
|
||||
"hanzoai",
|
||||
]
|
||||
@@ -1221,6 +1225,124 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "hpack" },
|
||||
{ name = "hyperframe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hanzo-cli"
|
||||
version = "0.2.1"
|
||||
source = { editable = "pkg/hanzo-cli" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "hanzo-iam" },
|
||||
{ name = "hanzo-kms" },
|
||||
{ name = "hanzo-s3" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.0" },
|
||||
{ name = "hanzo-iam", editable = "pkg/hanzo-iam" },
|
||||
{ name = "hanzo-kms", editable = "pkg/hanzo-kms" },
|
||||
{ name = "hanzo-s3", editable = "pkg/hanzo-s3" },
|
||||
{ name = "httpx", specifier = ">=0.25.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.8.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "rich", specifier = ">=13.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "hanzo-iam"
|
||||
version = "1.30.0"
|
||||
source = { editable = "pkg/hanzo-iam" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
fastapi = [
|
||||
{ name = "fastapi" },
|
||||
]
|
||||
kms = [
|
||||
{ name = "hanzo-kms" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0" },
|
||||
{ name = "hanzo-kms", marker = "extra == 'kms'", editable = "pkg/hanzo-kms" },
|
||||
{ name = "httpx", specifier = ">=0.25.0" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.8.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" },
|
||||
]
|
||||
provides-extras = ["fastapi", "kms", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "hanzo-kms"
|
||||
version = "1.1.0"
|
||||
source = { editable = "pkg/hanzo-kms" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
async = [
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.25.0" },
|
||||
{ name = "httpx", extras = ["http2"], marker = "extra == 'async'", specifier = ">=0.25.0" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" },
|
||||
]
|
||||
provides-extras = ["async", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "hanzo-llm"
|
||||
version = "1.81.15"
|
||||
@@ -1375,6 +1497,7 @@ dependencies = [
|
||||
{ name = "hanzo-tools" },
|
||||
{ name = "mcp" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "zap-proto" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1388,6 +1511,7 @@ requires-dist = [
|
||||
{ name = "mcp", specifier = ">=1.25.0" },
|
||||
{ name = "playwright", marker = "extra == 'playwright'", specifier = ">=1.49.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||
{ name = "zap-proto", specifier = ">=1.2.0" },
|
||||
]
|
||||
provides-extras = ["playwright"]
|
||||
|
||||
@@ -1403,6 +1527,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f2/a0bdbc43b160a32dae34eac683779a5aa70d326665b758a4972101fb0b44/hanzo_tools_core-0.3.0-py3-none-any.whl", hash = "sha256:75bba9a3fb6f9203e9bd6c14fff86fdebbd50b0b7b44f6fb663ddfccbba25bf3", size = 18252, upload-time = "2026-02-21T19:58:29.008Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hanzo-tools-s3"
|
||||
version = "0.1.0"
|
||||
source = { editable = "pkg/hanzo-tools-s3" }
|
||||
dependencies = [
|
||||
{ name = "hanzo-s3" },
|
||||
{ name = "hanzo-tools-core" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "hanzo-s3", editable = "pkg/hanzo-s3" },
|
||||
{ name = "hanzo-tools-core", specifier = ">=0.1.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hanzo-tools-ui"
|
||||
version = "0.2.0"
|
||||
@@ -1549,6 +1688,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hpack"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
@@ -1606,6 +1754,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
http2 = [
|
||||
{ name = "h2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
@@ -1644,6 +1797,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyperframe"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id"
|
||||
version = "1.6.1"
|
||||
@@ -4537,6 +4699,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zap-proto"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/7b/37543decb0ec4d55781307a5a00a52e974f0a114cc9b042dfd42b0a57154/zap_proto-1.4.1.tar.gz", hash = "sha256:32965a0c1d10c6532b294e531497e519292bf63638c00f1d83211268330777c7", size = 99806, upload-time = "2026-06-21T15:04:23.967Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/eb/bec558b662a24149d8a266b34bb655e6ce08c08118cd2bb2aa866b4e5421/zap_proto-1.4.1-py3-none-any.whl", hash = "sha256:21b440ed1c5d265edc5fa267dc88b042f240b7a2e8b8509aaec1d8a3fb41fea6", size = 61625, upload-time = "2026-06-21T15:04:22.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.23.0"
|
||||
|
||||
Reference in New Issue
Block a user