teammate-sync 0.1.0__py3-none-any.whl
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.
- teammate_sync/__init__.py +0 -0
- teammate_sync/auth.py +51 -0
- teammate_sync/backend.py +375 -0
- teammate_sync/cli.py +461 -0
- teammate_sync/daemon.py +433 -0
- teammate_sync/hook.py +183 -0
- teammate_sync/server.py +400 -0
- teammate_sync/share_cli.py +180 -0
- teammate_sync-0.1.0.dist-info/METADATA +151 -0
- teammate_sync-0.1.0.dist-info/RECORD +13 -0
- teammate_sync-0.1.0.dist-info/WHEEL +5 -0
- teammate_sync-0.1.0.dist-info/entry_points.txt +2 -0
- teammate_sync-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
teammate_sync/auth.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auth helper for teammate-sync clients (daemon + MCP).
|
|
3
|
+
|
|
4
|
+
Reads ~/.teammate-sync/auth.json, written by `teammate-sync init` (Phase 5c).
|
|
5
|
+
Format:
|
|
6
|
+
{
|
|
7
|
+
"token": "gho_...", # GitHub OAuth access token
|
|
8
|
+
"org": "SolarCheckr", # workspace = GitHub org name
|
|
9
|
+
"backend_url": "https://teammate-sync-backend.fly.dev"
|
|
10
|
+
}
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_AUTH_FILE = "~/.teammate-sync/auth.json"
|
|
18
|
+
DEFAULT_BACKEND_URL = "https://teammate-sync-backend.fly.dev"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def auth_file_path() -> Path:
|
|
22
|
+
return Path(os.environ.get("TEAMMATE_AUTH_FILE", DEFAULT_AUTH_FILE)).expanduser()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_auth() -> dict:
|
|
26
|
+
"""Read the auth file. Raises with a clear message if missing or malformed."""
|
|
27
|
+
path = auth_file_path()
|
|
28
|
+
if not path.exists():
|
|
29
|
+
raise FileNotFoundError(
|
|
30
|
+
f"teammate-sync auth file not found at {path}.\n"
|
|
31
|
+
f"Run `teammate-sync init` to sign in with GitHub and create it."
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(path.read_text())
|
|
35
|
+
except json.JSONDecodeError as e:
|
|
36
|
+
raise ValueError(f"Auth file {path} is not valid JSON: {e}")
|
|
37
|
+
for required in ("token", "org"):
|
|
38
|
+
if not data.get(required):
|
|
39
|
+
raise ValueError(f"Auth file {path} missing required field: {required!r}")
|
|
40
|
+
data.setdefault("backend_url", DEFAULT_BACKEND_URL)
|
|
41
|
+
return data
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_auth(token: str, org: str, backend_url: str = DEFAULT_BACKEND_URL) -> Path:
|
|
45
|
+
"""Persist the auth file with restrictive permissions (0600)."""
|
|
46
|
+
path = auth_file_path()
|
|
47
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
payload = {"token": token, "org": org, "backend_url": backend_url}
|
|
49
|
+
path.write_text(json.dumps(payload, indent=2))
|
|
50
|
+
path.chmod(0o600)
|
|
51
|
+
return path
|
teammate_sync/backend.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage backend abstraction for teammate-sync.
|
|
3
|
+
|
|
4
|
+
Three implementations:
|
|
5
|
+
- LocalBackend — writes to a local filesystem directory (dev / tests)
|
|
6
|
+
- S3Backend — direct-to-S3 (legacy from Phase 3a, kept for hosted-self-deploy)
|
|
7
|
+
- HTTPBackend — calls the teammate-sync cloud backend (Phase 5+, the default)
|
|
8
|
+
|
|
9
|
+
Selected via env var TEAMMATE_BACKEND (local|s3|cloud). The daemon and MCP
|
|
10
|
+
server both construct the backend the same way, so they always agree.
|
|
11
|
+
|
|
12
|
+
The interface is intentionally minimal — just key/value bytes — so adding
|
|
13
|
+
a new backend later (R2, GCS, etc.) is a small isolated change.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SYNC_STATE_FILENAME = ".sync-state.json"
|
|
24
|
+
ACTIVE_SESSIONS_FILENAME = ".active-sessions.json"
|
|
25
|
+
SHARED_SESSIONS_FILENAME = ".shared-sessions.json"
|
|
26
|
+
|
|
27
|
+
# Control files are managed by the system (daemon writes sync state, hooks
|
|
28
|
+
# write active sessions, share-cli writes shared sessions). They're
|
|
29
|
+
# addressable via get_bytes/put_bytes but omitted from list_keys so the MCP
|
|
30
|
+
# doesn't try to render them as corpus content. The daemon also never
|
|
31
|
+
# uploads .shared-sessions.json to the backend — it's a local-only
|
|
32
|
+
# permission gate.
|
|
33
|
+
CONTROL_FILES = {
|
|
34
|
+
SYNC_STATE_FILENAME,
|
|
35
|
+
ACTIVE_SESSIONS_FILENAME,
|
|
36
|
+
SHARED_SESSIONS_FILENAME,
|
|
37
|
+
}
|
|
38
|
+
# Transient/process-local files that should never appear in the corpus
|
|
39
|
+
# (fcntl lock files, atomic-write tempfiles).
|
|
40
|
+
SKIP_SUFFIXES = (".lock", ".tmp")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StorageBackend(ABC):
|
|
44
|
+
"""Abstract storage backend for mirroring a teammate's working corpus."""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def list_keys(self) -> list[str]:
|
|
48
|
+
"""List all keys (relative paths) in the backend, excluding the state file."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def get_bytes(self, key: str) -> bytes | None:
|
|
52
|
+
"""Read bytes at key. Returns None if missing."""
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def put_bytes(self, key: str, data: bytes) -> None:
|
|
56
|
+
"""Write bytes at key."""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def delete_key(self, key: str) -> None:
|
|
60
|
+
"""Delete the object at key. No-op if missing."""
|
|
61
|
+
|
|
62
|
+
def get_state(self) -> dict | None:
|
|
63
|
+
raw = self.get_bytes(SYNC_STATE_FILENAME)
|
|
64
|
+
if raw is None:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(raw.decode("utf-8"))
|
|
68
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def put_state(self) -> None:
|
|
72
|
+
state = {
|
|
73
|
+
"last_sync_iso": datetime.now(timezone.utc).isoformat(),
|
|
74
|
+
"last_sync_epoch": time.time(),
|
|
75
|
+
}
|
|
76
|
+
self.put_bytes(
|
|
77
|
+
SYNC_STATE_FILENAME,
|
|
78
|
+
json.dumps(state, indent=2).encode("utf-8"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class LocalBackend(StorageBackend):
|
|
83
|
+
def __init__(self, target_dir: str | Path):
|
|
84
|
+
self.target = Path(target_dir).expanduser().resolve()
|
|
85
|
+
self.target.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
def list_keys(self) -> list[str]:
|
|
88
|
+
keys: list[str] = []
|
|
89
|
+
for f in self.target.rglob("*"):
|
|
90
|
+
if not f.is_file():
|
|
91
|
+
continue
|
|
92
|
+
if f.name in CONTROL_FILES or f.name.endswith(SKIP_SUFFIXES):
|
|
93
|
+
continue
|
|
94
|
+
keys.append(str(f.relative_to(self.target)))
|
|
95
|
+
return sorted(keys)
|
|
96
|
+
|
|
97
|
+
def get_bytes(self, key: str) -> bytes | None:
|
|
98
|
+
path = self.target / key
|
|
99
|
+
if not path.exists():
|
|
100
|
+
return None
|
|
101
|
+
return path.read_bytes()
|
|
102
|
+
|
|
103
|
+
def put_bytes(self, key: str, data: bytes) -> None:
|
|
104
|
+
path = self.target / key
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
path.write_bytes(data)
|
|
107
|
+
|
|
108
|
+
def delete_key(self, key: str) -> None:
|
|
109
|
+
path = self.target / key
|
|
110
|
+
if path.exists():
|
|
111
|
+
path.unlink()
|
|
112
|
+
|
|
113
|
+
def __repr__(self) -> str:
|
|
114
|
+
return f"LocalBackend(target={self.target})"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class S3Backend(StorageBackend):
|
|
118
|
+
def __init__(self, bucket: str, prefix: str = "", region: str | None = None):
|
|
119
|
+
import boto3 # lazy import — only required if S3 backend is in use
|
|
120
|
+
|
|
121
|
+
self.bucket = bucket
|
|
122
|
+
# Normalize prefix: no leading "/", always trailing "/" (or empty)
|
|
123
|
+
normalized = prefix.lstrip("/").rstrip("/")
|
|
124
|
+
self.prefix = (normalized + "/") if normalized else ""
|
|
125
|
+
self._region = region
|
|
126
|
+
self.s3 = boto3.client("s3", region_name=region)
|
|
127
|
+
|
|
128
|
+
def _key(self, key: str) -> str:
|
|
129
|
+
return self.prefix + key
|
|
130
|
+
|
|
131
|
+
def list_keys(self) -> list[str]:
|
|
132
|
+
keys: list[str] = []
|
|
133
|
+
paginator = self.s3.get_paginator("list_objects_v2")
|
|
134
|
+
kwargs = {"Bucket": self.bucket}
|
|
135
|
+
if self.prefix:
|
|
136
|
+
kwargs["Prefix"] = self.prefix
|
|
137
|
+
for page in paginator.paginate(**kwargs):
|
|
138
|
+
for obj in page.get("Contents", []) or []:
|
|
139
|
+
full_key = obj["Key"]
|
|
140
|
+
rel = full_key[len(self.prefix):] if self.prefix else full_key
|
|
141
|
+
if rel in CONTROL_FILES or rel == "" or rel.endswith(SKIP_SUFFIXES):
|
|
142
|
+
continue
|
|
143
|
+
keys.append(rel)
|
|
144
|
+
return sorted(keys)
|
|
145
|
+
|
|
146
|
+
def get_bytes(self, key: str) -> bytes | None:
|
|
147
|
+
from botocore.exceptions import ClientError
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
resp = self.s3.get_object(Bucket=self.bucket, Key=self._key(key))
|
|
151
|
+
return resp["Body"].read()
|
|
152
|
+
except ClientError as e:
|
|
153
|
+
if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
|
|
154
|
+
return None
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
def put_bytes(self, key: str, data: bytes) -> None:
|
|
158
|
+
self.s3.put_object(Bucket=self.bucket, Key=self._key(key), Body=data)
|
|
159
|
+
|
|
160
|
+
def delete_key(self, key: str) -> None:
|
|
161
|
+
# delete_object is idempotent
|
|
162
|
+
self.s3.delete_object(Bucket=self.bucket, Key=self._key(key))
|
|
163
|
+
|
|
164
|
+
def __repr__(self) -> str:
|
|
165
|
+
return f"S3Backend(bucket={self.bucket}, prefix={self.prefix!r}, region={self._region})"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class HTTPBackend(StorageBackend):
|
|
169
|
+
"""
|
|
170
|
+
Storage backend that talks to the teammate-sync cloud backend over HTTP.
|
|
171
|
+
|
|
172
|
+
DAEMON use: teammate=<my own github handle>, since daemon writes its own
|
|
173
|
+
files. The backend authoritatively binds the owner to the auth token,
|
|
174
|
+
so wrong values get rejected — but we still need it right for the
|
|
175
|
+
list/delete paths which take teammate as a parameter.
|
|
176
|
+
|
|
177
|
+
MCP use: teammate=<the queried engineer>. Reads scoped to that teammate.
|
|
178
|
+
Writes would 403 (owner mismatch) — which is correct, MCP never writes.
|
|
179
|
+
|
|
180
|
+
All requests authenticated with a GitHub OAuth access token via Bearer.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, backend_url: str, token: str, org: str, teammate: str):
|
|
184
|
+
import httpx # lazy
|
|
185
|
+
|
|
186
|
+
self.backend_url = backend_url.rstrip("/")
|
|
187
|
+
self.org = org
|
|
188
|
+
self.teammate = teammate
|
|
189
|
+
self._token = token
|
|
190
|
+
self._client = httpx.Client(
|
|
191
|
+
base_url=self.backend_url,
|
|
192
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
193
|
+
timeout=30.0,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _basename(self, key: str) -> str:
|
|
197
|
+
return Path(key).name
|
|
198
|
+
|
|
199
|
+
def list_keys(self) -> list[str]:
|
|
200
|
+
r = self._client.get(
|
|
201
|
+
"/v1/files",
|
|
202
|
+
params={"org": self.org, "teammate": self.teammate},
|
|
203
|
+
)
|
|
204
|
+
r.raise_for_status()
|
|
205
|
+
keys = [f["path"] for f in r.json().get("files", [])]
|
|
206
|
+
# Filter control + skip-suffix files client-side (same semantics as
|
|
207
|
+
# LocalBackend and S3Backend).
|
|
208
|
+
return [
|
|
209
|
+
k for k in keys
|
|
210
|
+
if self._basename(k) not in CONTROL_FILES
|
|
211
|
+
and not self._basename(k).endswith(SKIP_SUFFIXES)
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
def get_bytes(self, key: str) -> bytes | None:
|
|
215
|
+
r = self._client.get(
|
|
216
|
+
"/v1/files/get",
|
|
217
|
+
params={"org": self.org, "teammate": self.teammate, "path": key},
|
|
218
|
+
)
|
|
219
|
+
if r.status_code == 404:
|
|
220
|
+
return None
|
|
221
|
+
r.raise_for_status()
|
|
222
|
+
return r.content
|
|
223
|
+
|
|
224
|
+
def put_bytes(self, key: str, data: bytes) -> None:
|
|
225
|
+
import base64
|
|
226
|
+
|
|
227
|
+
r = self._client.post(
|
|
228
|
+
"/v1/files",
|
|
229
|
+
json={
|
|
230
|
+
"org": self.org,
|
|
231
|
+
"path": key,
|
|
232
|
+
"content_b64": base64.b64encode(data).decode("ascii"),
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
r.raise_for_status()
|
|
236
|
+
|
|
237
|
+
def delete_key(self, key: str) -> None:
|
|
238
|
+
r = self._client.delete(
|
|
239
|
+
"/v1/files",
|
|
240
|
+
params={"org": self.org, "path": key},
|
|
241
|
+
)
|
|
242
|
+
if r.status_code != 404:
|
|
243
|
+
r.raise_for_status()
|
|
244
|
+
|
|
245
|
+
def get_state(self) -> dict | None:
|
|
246
|
+
r = self._client.get(
|
|
247
|
+
"/v1/state",
|
|
248
|
+
params={"org": self.org, "teammate": self.teammate},
|
|
249
|
+
)
|
|
250
|
+
r.raise_for_status()
|
|
251
|
+
data = r.json()
|
|
252
|
+
epoch = data.get("last_sync_epoch")
|
|
253
|
+
if not isinstance(epoch, (int, float)):
|
|
254
|
+
return None
|
|
255
|
+
return {"last_sync_epoch": epoch, "last_sync_iso": ""}
|
|
256
|
+
|
|
257
|
+
def put_state(self) -> None:
|
|
258
|
+
r = self._client.post("/v1/state", params={"org": self.org})
|
|
259
|
+
r.raise_for_status()
|
|
260
|
+
|
|
261
|
+
def purge_owner(self) -> int:
|
|
262
|
+
"""
|
|
263
|
+
Single-call optimization: delete every file the caller owns in this
|
|
264
|
+
workspace. Used by the daemon's cleanup when share-mode flips off.
|
|
265
|
+
"""
|
|
266
|
+
r = self._client.delete("/v1/files/purge", params={"org": self.org})
|
|
267
|
+
r.raise_for_status()
|
|
268
|
+
return r.json().get("deleted", 0)
|
|
269
|
+
|
|
270
|
+
def __repr__(self) -> str:
|
|
271
|
+
return f"HTTPBackend(url={self.backend_url}, org={self.org}, teammate={self.teammate})"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def make_backend_from_env() -> StorageBackend:
|
|
275
|
+
"""
|
|
276
|
+
Construct the WRITER backend based on env vars. Used by the daemon to
|
|
277
|
+
decide where its own teammate's content goes.
|
|
278
|
+
|
|
279
|
+
Backend selection via TEAMMATE_BACKEND:
|
|
280
|
+
- "cloud" (DEFAULT): talks to teammate-sync cloud backend. Reads auth
|
|
281
|
+
from ~/.teammate-sync/auth.json (see auth.py).
|
|
282
|
+
- "s3": legacy direct-to-S3 (set TEAMMATE_S3_BUCKET + TEAMMATE_HANDLE).
|
|
283
|
+
- "local": local filesystem (set TEAMMATE_CORPUS_DIR).
|
|
284
|
+
"""
|
|
285
|
+
import httpx # lazy
|
|
286
|
+
|
|
287
|
+
backend = os.environ.get("TEAMMATE_BACKEND", "cloud").lower()
|
|
288
|
+
|
|
289
|
+
if backend == "local":
|
|
290
|
+
target = os.environ.get("TEAMMATE_CORPUS_DIR", "./example_data")
|
|
291
|
+
return LocalBackend(target)
|
|
292
|
+
|
|
293
|
+
if backend == "s3":
|
|
294
|
+
bucket = os.environ.get("TEAMMATE_S3_BUCKET")
|
|
295
|
+
if not bucket:
|
|
296
|
+
raise ValueError("TEAMMATE_S3_BUCKET must be set when TEAMMATE_BACKEND=s3")
|
|
297
|
+
handle = os.environ.get("TEAMMATE_HANDLE")
|
|
298
|
+
explicit_prefix = os.environ.get("TEAMMATE_S3_PREFIX")
|
|
299
|
+
if handle:
|
|
300
|
+
prefix = handle.strip("/") + "/"
|
|
301
|
+
elif explicit_prefix is not None:
|
|
302
|
+
prefix = explicit_prefix
|
|
303
|
+
else:
|
|
304
|
+
raise ValueError(
|
|
305
|
+
"Either TEAMMATE_HANDLE (preferred) or TEAMMATE_S3_PREFIX "
|
|
306
|
+
"must be set when TEAMMATE_BACKEND=s3"
|
|
307
|
+
)
|
|
308
|
+
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
|
|
309
|
+
return S3Backend(bucket=bucket, prefix=prefix, region=region)
|
|
310
|
+
|
|
311
|
+
if backend == "cloud":
|
|
312
|
+
from auth import read_auth
|
|
313
|
+
|
|
314
|
+
auth = read_auth() # raises with a clear message if missing
|
|
315
|
+
# Resolve our own GitHub handle so the daemon's list/delete calls
|
|
316
|
+
# target our own corpus (writes are owner-bound by the backend anyway).
|
|
317
|
+
r = httpx.get(
|
|
318
|
+
f"{auth['backend_url'].rstrip('/')}/v1/me",
|
|
319
|
+
headers={"Authorization": f"Bearer {auth['token']}"},
|
|
320
|
+
timeout=10.0,
|
|
321
|
+
)
|
|
322
|
+
if r.status_code != 200:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"Cloud backend rejected token (/v1/me → {r.status_code}). "
|
|
325
|
+
f"Re-run `teammate-sync init` to refresh."
|
|
326
|
+
)
|
|
327
|
+
self_handle = r.json()["github_handle"]
|
|
328
|
+
return HTTPBackend(
|
|
329
|
+
backend_url=auth["backend_url"],
|
|
330
|
+
token=auth["token"],
|
|
331
|
+
org=auth["org"],
|
|
332
|
+
teammate=self_handle,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
raise ValueError(f"Unknown TEAMMATE_BACKEND: {backend!r}. Use 'cloud', 's3', or 'local'.")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def make_s3_backend_for(handle: str) -> S3Backend:
|
|
339
|
+
"""
|
|
340
|
+
Construct a READER backend for a specific teammate handle. Used by the
|
|
341
|
+
MCP server to query any teammate by parameter.
|
|
342
|
+
|
|
343
|
+
Reads bucket + region from env (TEAMMATE_S3_BUCKET, AWS_REGION) and
|
|
344
|
+
composes the prefix from the handle.
|
|
345
|
+
"""
|
|
346
|
+
bucket = os.environ.get("TEAMMATE_S3_BUCKET")
|
|
347
|
+
if not bucket:
|
|
348
|
+
raise ValueError("TEAMMATE_S3_BUCKET must be set")
|
|
349
|
+
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
|
|
350
|
+
prefix = handle.strip("/") + "/"
|
|
351
|
+
return S3Backend(bucket=bucket, prefix=prefix, region=region)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def list_s3_teammates() -> list[str]:
|
|
355
|
+
"""
|
|
356
|
+
Discover available teammates by listing top-level "directories" in the
|
|
357
|
+
configured S3 bucket. Used by the MCP server's list_teammates tool.
|
|
358
|
+
Returns handles without trailing slash.
|
|
359
|
+
"""
|
|
360
|
+
import boto3
|
|
361
|
+
|
|
362
|
+
bucket = os.environ.get("TEAMMATE_S3_BUCKET")
|
|
363
|
+
if not bucket:
|
|
364
|
+
raise ValueError("TEAMMATE_S3_BUCKET must be set")
|
|
365
|
+
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
|
|
366
|
+
s3 = boto3.client("s3", region_name=region)
|
|
367
|
+
|
|
368
|
+
handles: list[str] = []
|
|
369
|
+
paginator = s3.get_paginator("list_objects_v2")
|
|
370
|
+
for page in paginator.paginate(Bucket=bucket, Delimiter="/"):
|
|
371
|
+
for entry in page.get("CommonPrefixes", []) or []:
|
|
372
|
+
p = entry.get("Prefix", "").rstrip("/")
|
|
373
|
+
if p:
|
|
374
|
+
handles.append(p)
|
|
375
|
+
return sorted(handles)
|