hanzo-tools-s3 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hanzo_tools_s3-0.1.0/.gitignore +76 -0
- hanzo_tools_s3-0.1.0/PKG-INFO +32 -0
- hanzo_tools_s3-0.1.0/README.md +15 -0
- hanzo_tools_s3-0.1.0/hanzo_tools/__init__.py +0 -0
- hanzo_tools_s3-0.1.0/hanzo_tools/s3/__init__.py +7 -0
- hanzo_tools_s3-0.1.0/hanzo_tools/s3/s3_tool.py +245 -0
- hanzo_tools_s3-0.1.0/pyproject.toml +31 -0
- hanzo_tools_s3-0.1.0/tests/test_s3_tool.py +138 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# IDE and editor
|
|
2
|
+
.vscode
|
|
3
|
+
.idea
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
*.egg-info
|
|
7
|
+
__pycache__
|
|
8
|
+
.mypy_cache
|
|
9
|
+
.pytest_cache
|
|
10
|
+
*.pyc
|
|
11
|
+
.venv
|
|
12
|
+
|
|
13
|
+
# Build
|
|
14
|
+
build/
|
|
15
|
+
dist
|
|
16
|
+
_dev
|
|
17
|
+
|
|
18
|
+
# Environment
|
|
19
|
+
.env
|
|
20
|
+
.envrc
|
|
21
|
+
|
|
22
|
+
# Logs
|
|
23
|
+
.prism.log
|
|
24
|
+
codegen.log
|
|
25
|
+
*.log
|
|
26
|
+
|
|
27
|
+
# Package manager
|
|
28
|
+
Brewfile.lock.json
|
|
29
|
+
|
|
30
|
+
# Agent config files (symlinked from user home)
|
|
31
|
+
LLM.md
|
|
32
|
+
AGENTS.md
|
|
33
|
+
CLAUDE.md
|
|
34
|
+
GEMINI.md
|
|
35
|
+
GROK.md
|
|
36
|
+
QWEN.md
|
|
37
|
+
|
|
38
|
+
# Local databases and state
|
|
39
|
+
.hanzo/
|
|
40
|
+
.grok/
|
|
41
|
+
|
|
42
|
+
# Documentation build
|
|
43
|
+
docs/.next/
|
|
44
|
+
docs/out/
|
|
45
|
+
docs/node_modules/
|
|
46
|
+
|
|
47
|
+
# Training data and scripts (DO NOT COMMIT)
|
|
48
|
+
training_dataset.jsonl
|
|
49
|
+
scripts/full_extractor.py
|
|
50
|
+
scripts/mega_extractor.py
|
|
51
|
+
scripts/mega_full_extractor.py
|
|
52
|
+
scripts/streaming_extractor.py
|
|
53
|
+
scripts/supplement_extractor.py
|
|
54
|
+
|
|
55
|
+
# Test files at root (experimental)
|
|
56
|
+
test_post_quantum_*.py
|
|
57
|
+
|
|
58
|
+
# Analysis documents (internal)
|
|
59
|
+
HANZO_INNOVATION_OPPORTUNITIES.md
|
|
60
|
+
POST_QUANTUM_CRYPTOGRAPHY_IMPLEMENTATION.md
|
|
61
|
+
|
|
62
|
+
# Experimental cryptography (WIP)
|
|
63
|
+
pkg/hanzo/src/hanzo/cryptography/
|
|
64
|
+
site/
|
|
65
|
+
|
|
66
|
+
# hygiene (untrack node_modules, block common build output)
|
|
67
|
+
node_modules/
|
|
68
|
+
**/node_modules/
|
|
69
|
+
.pnpm-store/
|
|
70
|
+
dist/
|
|
71
|
+
.next/
|
|
72
|
+
coverage/
|
|
73
|
+
playwright-report/
|
|
74
|
+
test-results/
|
|
75
|
+
tmp/
|
|
76
|
+
.DS_Store
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hanzo-tools-s3
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hanzo S3 MCP tool — buckets and objects on S3-compatible storage
|
|
5
|
+
Author-email: Hanzo AI <dev@hanzo.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: hanzo,mcp,object-storage,s3,seaweedfs,tools
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: hanzo-s3>=1.0.0
|
|
15
|
+
Requires-Dist: hanzo-tools-core>=0.1.0
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# hanzo-tools-s3
|
|
19
|
+
|
|
20
|
+
MCP tool for Hanzo S3 — S3-compatible object storage (s3.hanzo.ai).
|
|
21
|
+
|
|
22
|
+
Exposes a single `s3` tool with bucket and object actions: `buckets`,
|
|
23
|
+
`make_bucket`, `remove_bucket`, `objects`, `stat`, `remove_object`, `presign`.
|
|
24
|
+
|
|
25
|
+
Credentials come from the environment — inject them from KMS, never plaintext:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
eval "$(hanzo kms inject <project> <env>)" # exports HANZO_S3_*
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Env: `HANZO_S3_ENDPOINT` (default `s3.hanzo.ai`), `HANZO_S3_ACCESS_KEY`,
|
|
32
|
+
`HANZO_S3_SECRET_KEY`, `HANZO_S3_SECURE` (default `true`).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# hanzo-tools-s3
|
|
2
|
+
|
|
3
|
+
MCP tool for Hanzo S3 — S3-compatible object storage (s3.hanzo.ai).
|
|
4
|
+
|
|
5
|
+
Exposes a single `s3` tool with bucket and object actions: `buckets`,
|
|
6
|
+
`make_bucket`, `remove_bucket`, `objects`, `stat`, `remove_object`, `presign`.
|
|
7
|
+
|
|
8
|
+
Credentials come from the environment — inject them from KMS, never plaintext:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
eval "$(hanzo kms inject <project> <env>)" # exports HANZO_S3_*
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Env: `HANZO_S3_ENDPOINT` (default `s3.hanzo.ai`), `HANZO_S3_ACCESS_KEY`,
|
|
15
|
+
`HANZO_S3_SECRET_KEY`, `HANZO_S3_SECURE` (default `true`).
|
|
File without changes
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""MCP tool for Hanzo S3 — S3-compatible object storage.
|
|
2
|
+
|
|
3
|
+
Single tool over the ``hanzo_s3`` client (S3-compatible, backed by
|
|
4
|
+
hanzoai/s3) at s3.hanzo.ai.
|
|
5
|
+
Buckets and objects: list, create, remove, stat, and presign.
|
|
6
|
+
|
|
7
|
+
Auth: credentials from the environment — inject them from KMS, never store
|
|
8
|
+
plaintext:
|
|
9
|
+
|
|
10
|
+
eval "$(hanzo kms inject <project> <env>)" # exports HANZO_S3_*
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from typing import Any, Annotated, final
|
|
19
|
+
from datetime import timedelta
|
|
20
|
+
|
|
21
|
+
from pydantic import Field
|
|
22
|
+
from mcp.server import FastMCP
|
|
23
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
24
|
+
|
|
25
|
+
from hanzo_tools.core.base import BaseTool
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
DESCRIPTION = """Hanzo S3 — S3-compatible object storage (s3.hanzo.ai).
|
|
30
|
+
|
|
31
|
+
Credentials from env: HANZO_S3_ENDPOINT, HANZO_S3_ACCESS_KEY, HANZO_S3_SECRET_KEY
|
|
32
|
+
(inject from KMS — never plaintext).
|
|
33
|
+
|
|
34
|
+
Bucket actions:
|
|
35
|
+
- buckets: List buckets
|
|
36
|
+
- make_bucket: Create a bucket (params: bucket)
|
|
37
|
+
- remove_bucket: Remove an empty bucket (params: bucket)
|
|
38
|
+
|
|
39
|
+
Object actions:
|
|
40
|
+
- objects: List objects (params: bucket, prefix, recursive)
|
|
41
|
+
- stat: Object metadata (params: bucket, object_name)
|
|
42
|
+
- remove_object: Delete an object (params: bucket, object_name)
|
|
43
|
+
- presign: Presigned GET URL (params: bucket, object_name, expires)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_client():
|
|
48
|
+
"""Build an S3 client from environment credentials."""
|
|
49
|
+
from hanzo_s3 import S3Client
|
|
50
|
+
|
|
51
|
+
endpoint = os.getenv("HANZO_S3_ENDPOINT", "s3.hanzo.ai")
|
|
52
|
+
access_key = os.getenv("HANZO_S3_ACCESS_KEY") or os.getenv("AWS_ACCESS_KEY_ID")
|
|
53
|
+
secret_key = os.getenv("HANZO_S3_SECRET_KEY") or os.getenv("AWS_SECRET_ACCESS_KEY")
|
|
54
|
+
secure = os.getenv("HANZO_S3_SECURE", "true").lower() not in ("0", "false", "no")
|
|
55
|
+
|
|
56
|
+
if not access_key or not secret_key:
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
"Missing S3 credentials. Set HANZO_S3_ACCESS_KEY and HANZO_S3_SECRET_KEY "
|
|
59
|
+
"(inject from KMS)."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return S3Client(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@final
|
|
66
|
+
class S3Tool(BaseTool):
|
|
67
|
+
"""MCP tool for Hanzo S3 object storage."""
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def name(self) -> str:
|
|
71
|
+
return "s3"
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def description(self) -> str:
|
|
75
|
+
return DESCRIPTION
|
|
76
|
+
|
|
77
|
+
async def call(
|
|
78
|
+
self,
|
|
79
|
+
ctx: MCPContext,
|
|
80
|
+
action: str = "buckets",
|
|
81
|
+
bucket: str | None = None,
|
|
82
|
+
object_name: str | None = None,
|
|
83
|
+
prefix: str = "",
|
|
84
|
+
recursive: bool = False,
|
|
85
|
+
expires: int = 3600,
|
|
86
|
+
**kwargs: Any,
|
|
87
|
+
) -> str:
|
|
88
|
+
try:
|
|
89
|
+
if action == "buckets":
|
|
90
|
+
return self._buckets()
|
|
91
|
+
elif action == "make_bucket":
|
|
92
|
+
return self._make_bucket(bucket)
|
|
93
|
+
elif action == "remove_bucket":
|
|
94
|
+
return self._remove_bucket(bucket)
|
|
95
|
+
elif action == "objects":
|
|
96
|
+
return self._objects(bucket, prefix, recursive)
|
|
97
|
+
elif action == "stat":
|
|
98
|
+
return self._stat(bucket, object_name)
|
|
99
|
+
elif action == "remove_object":
|
|
100
|
+
return self._remove_object(bucket, object_name)
|
|
101
|
+
elif action == "presign":
|
|
102
|
+
return self._presign(bucket, object_name, expires)
|
|
103
|
+
else:
|
|
104
|
+
return json.dumps({
|
|
105
|
+
"error": f"Unknown action: {action}",
|
|
106
|
+
"available": [
|
|
107
|
+
"buckets", "make_bucket", "remove_bucket",
|
|
108
|
+
"objects", "stat", "remove_object", "presign",
|
|
109
|
+
],
|
|
110
|
+
})
|
|
111
|
+
except RuntimeError as e:
|
|
112
|
+
return json.dumps({"error": str(e)})
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.exception(f"S3 tool error: {e}")
|
|
115
|
+
return json.dumps({"error": f"S3 error: {e}"})
|
|
116
|
+
|
|
117
|
+
# -- Bucket actions ------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def _buckets(self) -> str:
|
|
120
|
+
client = _get_client()
|
|
121
|
+
result = [
|
|
122
|
+
{
|
|
123
|
+
"name": b.name,
|
|
124
|
+
"creation_date": str(b.creation_date) if b.creation_date else None,
|
|
125
|
+
}
|
|
126
|
+
for b in client.list_buckets()
|
|
127
|
+
]
|
|
128
|
+
return json.dumps({"count": len(result), "buckets": result}, indent=2)
|
|
129
|
+
|
|
130
|
+
def _make_bucket(self, bucket: str | None) -> str:
|
|
131
|
+
if not bucket:
|
|
132
|
+
return json.dumps({"error": "Required: bucket"})
|
|
133
|
+
client = _get_client()
|
|
134
|
+
client.make_bucket(bucket)
|
|
135
|
+
return json.dumps({"action": "created", "bucket": bucket}, indent=2)
|
|
136
|
+
|
|
137
|
+
def _remove_bucket(self, bucket: str | None) -> str:
|
|
138
|
+
if not bucket:
|
|
139
|
+
return json.dumps({"error": "Required: bucket"})
|
|
140
|
+
client = _get_client()
|
|
141
|
+
client.remove_bucket(bucket)
|
|
142
|
+
return json.dumps({"action": "removed", "bucket": bucket}, indent=2)
|
|
143
|
+
|
|
144
|
+
# -- Object actions ------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _objects(self, bucket: str | None, prefix: str, recursive: bool) -> str:
|
|
147
|
+
if not bucket:
|
|
148
|
+
return json.dumps({"error": "Required: bucket"})
|
|
149
|
+
client = _get_client()
|
|
150
|
+
result = [
|
|
151
|
+
{
|
|
152
|
+
"key": o.object_name,
|
|
153
|
+
"size": None if o.is_dir else o.size,
|
|
154
|
+
"last_modified": str(o.last_modified) if o.last_modified else None,
|
|
155
|
+
"is_dir": o.is_dir,
|
|
156
|
+
}
|
|
157
|
+
for o in client.list_objects(bucket, prefix=prefix, recursive=recursive)
|
|
158
|
+
]
|
|
159
|
+
return json.dumps(
|
|
160
|
+
{"bucket": bucket, "prefix": prefix, "count": len(result), "objects": result},
|
|
161
|
+
indent=2,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _stat(self, bucket: str | None, object_name: str | None) -> str:
|
|
165
|
+
if not bucket or not object_name:
|
|
166
|
+
return json.dumps({"error": "Required: bucket, object_name"})
|
|
167
|
+
client = _get_client()
|
|
168
|
+
obj = client.stat_object(bucket, object_name)
|
|
169
|
+
return json.dumps({
|
|
170
|
+
"bucket": bucket,
|
|
171
|
+
"object_name": object_name,
|
|
172
|
+
"size": obj.size,
|
|
173
|
+
"etag": obj.etag,
|
|
174
|
+
"content_type": obj.content_type,
|
|
175
|
+
"last_modified": str(obj.last_modified) if obj.last_modified else None,
|
|
176
|
+
}, indent=2)
|
|
177
|
+
|
|
178
|
+
def _remove_object(self, bucket: str | None, object_name: str | None) -> str:
|
|
179
|
+
if not bucket or not object_name:
|
|
180
|
+
return json.dumps({"error": "Required: bucket, object_name"})
|
|
181
|
+
client = _get_client()
|
|
182
|
+
client.remove_object(bucket, object_name)
|
|
183
|
+
return json.dumps({"action": "removed", "bucket": bucket, "object_name": object_name}, indent=2)
|
|
184
|
+
|
|
185
|
+
def _presign(self, bucket: str | None, object_name: str | None, expires: int) -> str:
|
|
186
|
+
if not bucket or not object_name:
|
|
187
|
+
return json.dumps({"error": "Required: bucket, object_name"})
|
|
188
|
+
client = _get_client()
|
|
189
|
+
url = client.presigned_get_object(
|
|
190
|
+
bucket, object_name, expires=timedelta(seconds=expires)
|
|
191
|
+
)
|
|
192
|
+
return json.dumps({"bucket": bucket, "object_name": object_name, "url": url}, indent=2)
|
|
193
|
+
|
|
194
|
+
# -- Registration --------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
197
|
+
"""Register S3 tool with explicit parameters."""
|
|
198
|
+
tool_instance = self
|
|
199
|
+
|
|
200
|
+
@mcp_server.tool(
|
|
201
|
+
name="s3",
|
|
202
|
+
description=DESCRIPTION,
|
|
203
|
+
)
|
|
204
|
+
async def s3(
|
|
205
|
+
action: Annotated[
|
|
206
|
+
str,
|
|
207
|
+
Field(
|
|
208
|
+
description=(
|
|
209
|
+
"Action to perform. "
|
|
210
|
+
"Buckets: buckets, make_bucket, remove_bucket. "
|
|
211
|
+
"Objects: objects, stat, remove_object, presign."
|
|
212
|
+
),
|
|
213
|
+
),
|
|
214
|
+
] = "buckets",
|
|
215
|
+
bucket: Annotated[
|
|
216
|
+
str | None,
|
|
217
|
+
Field(description="Bucket name"),
|
|
218
|
+
] = None,
|
|
219
|
+
object_name: Annotated[
|
|
220
|
+
str | None,
|
|
221
|
+
Field(description="Object key (for stat, remove_object, presign)"),
|
|
222
|
+
] = None,
|
|
223
|
+
prefix: Annotated[
|
|
224
|
+
str,
|
|
225
|
+
Field(description="Object key prefix (for objects)"),
|
|
226
|
+
] = "",
|
|
227
|
+
recursive: Annotated[
|
|
228
|
+
bool,
|
|
229
|
+
Field(description="Recurse into prefixes (for objects)"),
|
|
230
|
+
] = False,
|
|
231
|
+
expires: Annotated[
|
|
232
|
+
int,
|
|
233
|
+
Field(description="Presigned URL lifetime in seconds (for presign)"),
|
|
234
|
+
] = 3600,
|
|
235
|
+
ctx: MCPContext = None,
|
|
236
|
+
) -> str:
|
|
237
|
+
return await tool_instance.call(
|
|
238
|
+
ctx,
|
|
239
|
+
action=action,
|
|
240
|
+
bucket=bucket,
|
|
241
|
+
object_name=object_name,
|
|
242
|
+
prefix=prefix,
|
|
243
|
+
recursive=recursive,
|
|
244
|
+
expires=expires,
|
|
245
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hanzo-tools-s3"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Hanzo S3 MCP tool — buckets and objects on S3-compatible storage"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Hanzo AI", email = "dev@hanzo.ai" }]
|
|
9
|
+
keywords = ["hanzo", "mcp", "s3", "seaweedfs", "object-storage", "tools"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
dependencies = [
|
|
19
|
+
"hanzo-tools-core>=0.1.0",
|
|
20
|
+
"hanzo-s3>=1.0.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.entry-points."hanzo.tools"]
|
|
24
|
+
s3 = "hanzo_tools.s3:TOOLS"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["hanzo_tools"]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""S3Tool test suite — action routing, validation, client wiring."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from hanzo_tools.s3.s3_tool import S3Tool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def tool():
|
|
14
|
+
return S3Tool()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def ctx():
|
|
19
|
+
return MagicMock()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _patch_client(client):
|
|
23
|
+
return patch("hanzo_tools.s3.s3_tool._get_client", return_value=client)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestProperties:
|
|
27
|
+
def test_name(self, tool):
|
|
28
|
+
assert tool.name == "s3"
|
|
29
|
+
|
|
30
|
+
def test_description_sections(self, tool):
|
|
31
|
+
for section in ("Bucket actions", "Object actions"):
|
|
32
|
+
assert section in tool.description
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestActionRouting:
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
async def test_unknown_action_returns_error(self, tool, ctx):
|
|
38
|
+
result = json.loads(await tool.call(ctx, action="bogus"))
|
|
39
|
+
assert "Unknown action" in result["error"]
|
|
40
|
+
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_unknown_action_lists_available(self, tool, ctx):
|
|
43
|
+
result = json.loads(await tool.call(ctx, action="bogus"))
|
|
44
|
+
assert set(result["available"]) == {
|
|
45
|
+
"buckets", "make_bucket", "remove_bucket",
|
|
46
|
+
"objects", "stat", "remove_object", "presign",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestBuckets:
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
async def test_buckets(self, tool, ctx):
|
|
53
|
+
client = MagicMock()
|
|
54
|
+
bucket = MagicMock(name="hanzo-space", creation_date=datetime(2026, 1, 1))
|
|
55
|
+
bucket.name = "hanzo-space"
|
|
56
|
+
client.list_buckets.return_value = [bucket]
|
|
57
|
+
with _patch_client(client):
|
|
58
|
+
result = json.loads(await tool.call(ctx, action="buckets"))
|
|
59
|
+
assert result["count"] == 1
|
|
60
|
+
assert result["buckets"][0]["name"] == "hanzo-space"
|
|
61
|
+
|
|
62
|
+
@pytest.mark.asyncio
|
|
63
|
+
async def test_make_bucket_requires_name(self, tool, ctx):
|
|
64
|
+
result = json.loads(await tool.call(ctx, action="make_bucket"))
|
|
65
|
+
assert "Required" in result["error"]
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_make_bucket(self, tool, ctx):
|
|
69
|
+
client = MagicMock()
|
|
70
|
+
with _patch_client(client):
|
|
71
|
+
result = json.loads(await tool.call(ctx, action="make_bucket", bucket="b1"))
|
|
72
|
+
client.make_bucket.assert_called_once_with("b1")
|
|
73
|
+
assert result["action"] == "created"
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
async def test_remove_bucket(self, tool, ctx):
|
|
77
|
+
client = MagicMock()
|
|
78
|
+
with _patch_client(client):
|
|
79
|
+
result = json.loads(await tool.call(ctx, action="remove_bucket", bucket="b1"))
|
|
80
|
+
client.remove_bucket.assert_called_once_with("b1")
|
|
81
|
+
assert result["action"] == "removed"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TestObjects:
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_objects_requires_bucket(self, tool, ctx):
|
|
87
|
+
result = json.loads(await tool.call(ctx, action="objects"))
|
|
88
|
+
assert "Required" in result["error"]
|
|
89
|
+
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_objects(self, tool, ctx):
|
|
92
|
+
client = MagicMock()
|
|
93
|
+
obj = MagicMock(object_name="a.txt", size=12, is_dir=False,
|
|
94
|
+
last_modified=datetime(2026, 1, 1))
|
|
95
|
+
client.list_objects.return_value = [obj]
|
|
96
|
+
with _patch_client(client):
|
|
97
|
+
result = json.loads(await tool.call(ctx, action="objects", bucket="b1", prefix="a"))
|
|
98
|
+
assert result["count"] == 1
|
|
99
|
+
assert result["objects"][0]["key"] == "a.txt"
|
|
100
|
+
client.list_objects.assert_called_once_with("b1", prefix="a", recursive=False)
|
|
101
|
+
|
|
102
|
+
@pytest.mark.asyncio
|
|
103
|
+
async def test_stat(self, tool, ctx):
|
|
104
|
+
client = MagicMock()
|
|
105
|
+
client.stat_object.return_value = MagicMock(
|
|
106
|
+
size=99, etag="abc", content_type="text/plain",
|
|
107
|
+
last_modified=datetime(2026, 1, 1),
|
|
108
|
+
)
|
|
109
|
+
with _patch_client(client):
|
|
110
|
+
result = json.loads(await tool.call(ctx, action="stat", bucket="b1", object_name="a.txt"))
|
|
111
|
+
assert result["size"] == 99
|
|
112
|
+
assert result["etag"] == "abc"
|
|
113
|
+
|
|
114
|
+
@pytest.mark.asyncio
|
|
115
|
+
async def test_remove_object(self, tool, ctx):
|
|
116
|
+
client = MagicMock()
|
|
117
|
+
with _patch_client(client):
|
|
118
|
+
result = json.loads(await tool.call(ctx, action="remove_object", bucket="b1", object_name="a.txt"))
|
|
119
|
+
client.remove_object.assert_called_once_with("b1", "a.txt")
|
|
120
|
+
assert result["action"] == "removed"
|
|
121
|
+
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_presign(self, tool, ctx):
|
|
124
|
+
client = MagicMock()
|
|
125
|
+
client.presigned_get_object.return_value = "https://s3.hanzo.ai/b1/a.txt?sig=x"
|
|
126
|
+
with _patch_client(client):
|
|
127
|
+
result = json.loads(await tool.call(ctx, action="presign", bucket="b1", object_name="a.txt"))
|
|
128
|
+
assert result["url"].startswith("https://s3.hanzo.ai/")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TestCredentials:
|
|
132
|
+
@pytest.mark.asyncio
|
|
133
|
+
async def test_missing_creds_errors(self, tool, ctx, monkeypatch):
|
|
134
|
+
for var in ("HANZO_S3_ACCESS_KEY", "HANZO_S3_SECRET_KEY",
|
|
135
|
+
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"):
|
|
136
|
+
monkeypatch.delenv(var, raising=False)
|
|
137
|
+
result = json.loads(await tool.call(ctx, action="buckets"))
|
|
138
|
+
assert "credentials" in result["error"].lower()
|