flyfile 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.
- flyfile/__init__.py +1 -0
- flyfile/cli/__init__.py +0 -0
- flyfile/cli/main.py +407 -0
- flyfile/cli/output.py +98 -0
- flyfile/client/__init__.py +0 -0
- flyfile/client/client.py +366 -0
- flyfile/client/errors.py +54 -0
- flyfile/client/transfer.py +231 -0
- flyfile/core/__init__.py +1 -0
- flyfile/core/compress.py +78 -0
- flyfile/core/hashing.py +14 -0
- flyfile/core/ids.py +26 -0
- flyfile/core/tarstream.py +81 -0
- flyfile/server/__init__.py +0 -0
- flyfile/server/app.py +68 -0
- flyfile/server/config.py +16 -0
- flyfile/server/errors.py +18 -0
- flyfile/server/relay.py +111 -0
- flyfile/server/routes_objects.py +273 -0
- flyfile/server/routes_uploads.py +111 -0
- flyfile/server/store.py +262 -0
- flyfile-0.1.0.dist-info/METADATA +78 -0
- flyfile-0.1.0.dist-info/RECORD +25 -0
- flyfile-0.1.0.dist-info/WHEEL +4 -0
- flyfile-0.1.0.dist-info/entry_points.txt +3 -0
flyfile/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
flyfile/cli/__init__.py
ADDED
|
File without changes
|
flyfile/cli/main.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""flyfile CLI — agent-friendly:JSON 输出、稳定退出码、绝不交互挂起。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from flyfile import __version__
|
|
15
|
+
from flyfile.client.client import FlyfileClient
|
|
16
|
+
from flyfile.client.errors import EXIT_USAGE, FlyfileError
|
|
17
|
+
from flyfile.cli import output as out
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="flyfile",
|
|
21
|
+
help="Agent-native data transfer: push/pull/send anything.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
add_completion=False,
|
|
24
|
+
pretty_exceptions_enable=False,
|
|
25
|
+
)
|
|
26
|
+
config_app = typer.Typer(help="Manage ~/.flyfile/config.yaml")
|
|
27
|
+
app.add_typer(config_app, name="config")
|
|
28
|
+
|
|
29
|
+
CONFIG_PATH = Path("~/.flyfile/config.yaml").expanduser()
|
|
30
|
+
|
|
31
|
+
_DUR = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_duration(text: Optional[str]) -> Optional[int]:
|
|
35
|
+
if not text:
|
|
36
|
+
return None
|
|
37
|
+
m = re.fullmatch(r"(\d+)([smhd])", text)
|
|
38
|
+
if not m:
|
|
39
|
+
raise FlyfileError(EXIT_USAGE, "usage",
|
|
40
|
+
f"invalid duration '{text}', expected e.g. 30s / 10m / 2h / 7d")
|
|
41
|
+
return int(m.group(1)) * _DUR[m.group(2)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config() -> dict:
|
|
45
|
+
if not CONFIG_PATH.exists():
|
|
46
|
+
return {}
|
|
47
|
+
import yaml
|
|
48
|
+
return yaml.safe_load(CONFIG_PATH.read_text()) or {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_client(server: Optional[str], token: Optional[str]) -> FlyfileClient:
|
|
52
|
+
cfg = load_config()
|
|
53
|
+
server = server or os.environ.get("FLYFILE_SERVER") or cfg.get("server")
|
|
54
|
+
token = token or os.environ.get("FLYFILE_TOKEN") or cfg.get("token", "")
|
|
55
|
+
if not server:
|
|
56
|
+
raise FlyfileError(EXIT_USAGE, "usage", "no server configured",
|
|
57
|
+
suggestion="set FLYFILE_SERVER, or run: flyfile config set server http://host:18632")
|
|
58
|
+
return FlyfileClient(server, token)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run(fn, json_flag: bool):
|
|
62
|
+
"""统一执行入口:错误 → JSON 错误体 + 契约退出码。"""
|
|
63
|
+
json_mode = out.is_json_mode(json_flag)
|
|
64
|
+
try:
|
|
65
|
+
result = fn()
|
|
66
|
+
if result is not None:
|
|
67
|
+
out.emit(result, json_mode)
|
|
68
|
+
except FlyfileError as e:
|
|
69
|
+
print(json.dumps(e.to_json(), ensure_ascii=False), file=sys.stderr)
|
|
70
|
+
raise typer.Exit(e.exit_code)
|
|
71
|
+
except (BrokenPipeError, KeyboardInterrupt):
|
|
72
|
+
raise typer.Exit(1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
ServerOpt = typer.Option(None, "--server", help="Server URL (env: FLYFILE_SERVER)")
|
|
76
|
+
TokenOpt = typer.Option(None, "--token", help="Auth token (env: FLYFILE_TOKEN)")
|
|
77
|
+
JsonOpt = typer.Option(False, "--json", help="Force JSON output (auto when not a TTY)")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def push(
|
|
82
|
+
source: str = typer.Argument(..., help="File, directory, or '-' for stdin"),
|
|
83
|
+
name: Optional[str] = typer.Option(None, help="Object name (default: filename)"),
|
|
84
|
+
tag: list[str] = typer.Option([], "--tag", help="Tag, repeatable"),
|
|
85
|
+
description: Optional[str] = typer.Option(None),
|
|
86
|
+
ttl: Optional[str] = typer.Option(None, help="Expiry: 30s / 10m / 2h / 7d (default: keep forever)"),
|
|
87
|
+
reads: Optional[int] = typer.Option(None, help="Burn after N reads"),
|
|
88
|
+
no_compress: bool = typer.Option(False, "--no-compress"),
|
|
89
|
+
content_type: Optional[str] = typer.Option(None),
|
|
90
|
+
server: Optional[str] = ServerOpt,
|
|
91
|
+
token: Optional[str] = TokenOpt,
|
|
92
|
+
json_flag: bool = JsonOpt,
|
|
93
|
+
):
|
|
94
|
+
"""Upload a file/dir/stdin to the server. Idempotent (content-addressed dedup)."""
|
|
95
|
+
def go():
|
|
96
|
+
client = get_client(server, token)
|
|
97
|
+
try:
|
|
98
|
+
prog = out.progress_printer("push")
|
|
99
|
+
obj = client.push(source, name, tag, description, parse_duration(ttl),
|
|
100
|
+
reads, no_compress, content_type, progress=prog)
|
|
101
|
+
if prog:
|
|
102
|
+
sys.stderr.write("\r\x1b[K") # 清掉流式路径(总量未知)残留的进度行
|
|
103
|
+
if out.is_json_mode(json_flag):
|
|
104
|
+
return obj
|
|
105
|
+
dedup = " (deduped)" if obj.get("deduped") else ""
|
|
106
|
+
print(f"pushed {obj['name']} id={obj['id']} {out.fmt_size(obj['size'])}{dedup}")
|
|
107
|
+
return None
|
|
108
|
+
finally:
|
|
109
|
+
client.close()
|
|
110
|
+
run(go, json_flag)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
def pull(
|
|
115
|
+
ref: str = typer.Argument(..., metavar="ID_OR_NAME",
|
|
116
|
+
help="Object id, or name (glob ok) resolved via query"),
|
|
117
|
+
output: Optional[str] = typer.Option(None, "-o", "--output",
|
|
118
|
+
help="Destination path, '-' for stdout (text defaults to stdout)"),
|
|
119
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing destination"),
|
|
120
|
+
latest: bool = typer.Option(False, "--latest",
|
|
121
|
+
help="If a name matches multiple objects, take the newest"),
|
|
122
|
+
server: Optional[str] = ServerOpt,
|
|
123
|
+
token: Optional[str] = TokenOpt,
|
|
124
|
+
json_flag: bool = JsonOpt,
|
|
125
|
+
):
|
|
126
|
+
"""Download an object by id or name. Dirs are extracted, text goes to stdout."""
|
|
127
|
+
def go():
|
|
128
|
+
client = get_client(server, token)
|
|
129
|
+
try:
|
|
130
|
+
result = client.pull(ref, output, force,
|
|
131
|
+
progress=out.progress_printer("pull"), latest=latest)
|
|
132
|
+
if result.get("output") == "-":
|
|
133
|
+
return None
|
|
134
|
+
if out.is_json_mode(json_flag):
|
|
135
|
+
return result
|
|
136
|
+
print(f"{result['name']} -> {result['output']} {out.fmt_size(result['size'])}")
|
|
137
|
+
return None
|
|
138
|
+
finally:
|
|
139
|
+
client.close()
|
|
140
|
+
run(go, json_flag)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command()
|
|
144
|
+
def ls(
|
|
145
|
+
name: Optional[str] = typer.Option(None, help="Glob on name, e.g. 'log*'"),
|
|
146
|
+
tag: list[str] = typer.Option([], "--tag", help="Filter by tag (AND), repeatable"),
|
|
147
|
+
kind: Optional[str] = typer.Option(None, help="text | file | dir"),
|
|
148
|
+
since: Optional[str] = typer.Option(None, help="Created within: 2h / 7d"),
|
|
149
|
+
until: Optional[str] = typer.Option(None, help="Created before: 2h / 7d ago"),
|
|
150
|
+
limit: int = typer.Option(50),
|
|
151
|
+
offset: int = typer.Option(0),
|
|
152
|
+
server: Optional[str] = ServerOpt,
|
|
153
|
+
token: Optional[str] = TokenOpt,
|
|
154
|
+
json_flag: bool = JsonOpt,
|
|
155
|
+
):
|
|
156
|
+
"""Query objects by metadata."""
|
|
157
|
+
def go():
|
|
158
|
+
client = get_client(server, token)
|
|
159
|
+
try:
|
|
160
|
+
now = int(time.time())
|
|
161
|
+
return client.ls(
|
|
162
|
+
name=name, tags=tag, kind=kind, limit=limit, offset=offset,
|
|
163
|
+
created_after=now - parse_duration(since) if since else None,
|
|
164
|
+
created_before=now - parse_duration(until) if until else None,
|
|
165
|
+
)
|
|
166
|
+
finally:
|
|
167
|
+
client.close()
|
|
168
|
+
run(go, json_flag)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command()
|
|
172
|
+
def info(
|
|
173
|
+
ref: str = typer.Argument(..., metavar="ID_OR_NAME"),
|
|
174
|
+
latest: bool = typer.Option(False, "--latest",
|
|
175
|
+
help="If a name matches multiple objects, take the newest"),
|
|
176
|
+
server: Optional[str] = ServerOpt,
|
|
177
|
+
token: Optional[str] = TokenOpt,
|
|
178
|
+
json_flag: bool = JsonOpt,
|
|
179
|
+
):
|
|
180
|
+
"""Show object metadata by id or name (does not consume reads)."""
|
|
181
|
+
def go():
|
|
182
|
+
client = get_client(server, token)
|
|
183
|
+
try:
|
|
184
|
+
return client.resolve(ref, latest)
|
|
185
|
+
finally:
|
|
186
|
+
client.close()
|
|
187
|
+
run(go, json_flag)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.command()
|
|
191
|
+
def preview(
|
|
192
|
+
ref: str = typer.Argument(..., metavar="ID_OR_NAME"),
|
|
193
|
+
nbytes: int = typer.Option(2048, "--bytes", help="Bytes of head to preview"),
|
|
194
|
+
latest: bool = typer.Option(False, "--latest",
|
|
195
|
+
help="If a name matches multiple objects, take the newest"),
|
|
196
|
+
server: Optional[str] = ServerOpt,
|
|
197
|
+
token: Optional[str] = TokenOpt,
|
|
198
|
+
json_flag: bool = JsonOpt,
|
|
199
|
+
):
|
|
200
|
+
"""Peek content without downloading or consuming reads (dir → file list)."""
|
|
201
|
+
def go():
|
|
202
|
+
client = get_client(server, token)
|
|
203
|
+
try:
|
|
204
|
+
return client.preview(ref, nbytes, latest)
|
|
205
|
+
finally:
|
|
206
|
+
client.close()
|
|
207
|
+
run(go, json_flag)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command()
|
|
211
|
+
def rm(
|
|
212
|
+
ref: str = typer.Argument(..., metavar="ID_OR_NAME"),
|
|
213
|
+
if_exists: bool = typer.Option(False, "--if-exists", help="Exit 0 even if missing"),
|
|
214
|
+
latest: bool = typer.Option(False, "--latest",
|
|
215
|
+
help="If a name matches multiple objects, take the newest"),
|
|
216
|
+
server: Optional[str] = ServerOpt,
|
|
217
|
+
token: Optional[str] = TokenOpt,
|
|
218
|
+
json_flag: bool = JsonOpt,
|
|
219
|
+
):
|
|
220
|
+
"""Delete an object."""
|
|
221
|
+
def go():
|
|
222
|
+
client = get_client(server, token)
|
|
223
|
+
try:
|
|
224
|
+
try:
|
|
225
|
+
return client.rm(ref, latest)
|
|
226
|
+
except FlyfileError as e:
|
|
227
|
+
if if_exists and e.error == "not_found":
|
|
228
|
+
return {"deleted": None, "existed": False}
|
|
229
|
+
raise
|
|
230
|
+
finally:
|
|
231
|
+
client.close()
|
|
232
|
+
run(go, json_flag)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.command()
|
|
236
|
+
def send(
|
|
237
|
+
source: str = typer.Argument(..., help="File, directory, or '-' for stdin"),
|
|
238
|
+
code: Optional[str] = typer.Option(None, help="Channel code (default: auto word-word)"),
|
|
239
|
+
timeout: int = typer.Option(300, help="Seconds to wait for the receiver"),
|
|
240
|
+
server: Optional[str] = ServerOpt,
|
|
241
|
+
token: Optional[str] = TokenOpt,
|
|
242
|
+
json_flag: bool = JsonOpt,
|
|
243
|
+
):
|
|
244
|
+
"""Stream directly to another client via the server (data never stored).
|
|
245
|
+
|
|
246
|
+
Emits NDJSON events: code → done. Give the code to the receiver:
|
|
247
|
+
flyfile recv <code>
|
|
248
|
+
"""
|
|
249
|
+
def go():
|
|
250
|
+
client = get_client(server, token)
|
|
251
|
+
try:
|
|
252
|
+
json_mode = out.is_json_mode(json_flag)
|
|
253
|
+
|
|
254
|
+
def on_event(ev: dict):
|
|
255
|
+
if json_mode:
|
|
256
|
+
out.emit_event(ev)
|
|
257
|
+
elif ev["event"] == "code":
|
|
258
|
+
print(f"code: {ev['code']}\nwaiting for receiver... "
|
|
259
|
+
f"(flyfile recv {ev['code']})", file=sys.stderr)
|
|
260
|
+
elif ev["event"] == "done":
|
|
261
|
+
sys.stderr.write("\r\x1b[K")
|
|
262
|
+
print(f"sent ({out.fmt_size(ev['bytes'])} on wire)", file=sys.stderr)
|
|
263
|
+
|
|
264
|
+
client.send(source, code, timeout, on_event,
|
|
265
|
+
progress=out.progress_printer("send"))
|
|
266
|
+
return None
|
|
267
|
+
finally:
|
|
268
|
+
client.close()
|
|
269
|
+
run(go, json_flag)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@app.command()
|
|
273
|
+
def recv(
|
|
274
|
+
code: str = typer.Argument(..., help="Channel code from the sender"),
|
|
275
|
+
output: Optional[str] = typer.Option(None, "-o", "--output"),
|
|
276
|
+
force: bool = typer.Option(False, "--force"),
|
|
277
|
+
timeout: int = typer.Option(300, help="Seconds to wait for the sender"),
|
|
278
|
+
server: Optional[str] = ServerOpt,
|
|
279
|
+
token: Optional[str] = TokenOpt,
|
|
280
|
+
json_flag: bool = JsonOpt,
|
|
281
|
+
):
|
|
282
|
+
"""Receive a direct transfer by channel code."""
|
|
283
|
+
def go():
|
|
284
|
+
client = get_client(server, token)
|
|
285
|
+
try:
|
|
286
|
+
prog = out.progress_printer("recv")
|
|
287
|
+
result = client.recv(code, output, force, timeout, progress=prog)
|
|
288
|
+
if prog:
|
|
289
|
+
sys.stderr.write("\r\x1b[K")
|
|
290
|
+
if result.get("output") == "-":
|
|
291
|
+
return None
|
|
292
|
+
if out.is_json_mode(json_flag):
|
|
293
|
+
return result
|
|
294
|
+
print(f"{result['name']} -> {result['output']}")
|
|
295
|
+
return None
|
|
296
|
+
finally:
|
|
297
|
+
client.close()
|
|
298
|
+
run(go, json_flag)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@app.command()
|
|
302
|
+
def serve(
|
|
303
|
+
host: str = typer.Option("0.0.0.0"),
|
|
304
|
+
port: int = typer.Option(8632),
|
|
305
|
+
data_dir: Optional[Path] = typer.Option(None, help="Default: ~/.local/share/flyfile"),
|
|
306
|
+
token: Optional[str] = typer.Option(None, help="Empty = no auth (LAN only!)"),
|
|
307
|
+
):
|
|
308
|
+
"""Run the flyfile server (single worker; relay pairing lives in-process)."""
|
|
309
|
+
import uvicorn
|
|
310
|
+
from flyfile.server.app import create_app
|
|
311
|
+
from flyfile.server.config import ServerConfig
|
|
312
|
+
|
|
313
|
+
kw = {}
|
|
314
|
+
if data_dir:
|
|
315
|
+
kw["data_dir"] = data_dir
|
|
316
|
+
if token is not None:
|
|
317
|
+
kw["token"] = token
|
|
318
|
+
uvicorn.run(create_app(ServerConfig(host=host, port=port, **kw)),
|
|
319
|
+
host=host, port=port, workers=1)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@app.command()
|
|
323
|
+
def stats(
|
|
324
|
+
server: Optional[str] = ServerOpt,
|
|
325
|
+
token: Optional[str] = TokenOpt,
|
|
326
|
+
json_flag: bool = JsonOpt,
|
|
327
|
+
):
|
|
328
|
+
"""Server-side storage stats."""
|
|
329
|
+
def go():
|
|
330
|
+
client = get_client(server, token)
|
|
331
|
+
try:
|
|
332
|
+
return client.stats()
|
|
333
|
+
finally:
|
|
334
|
+
client.close()
|
|
335
|
+
run(go, json_flag)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@app.command()
|
|
339
|
+
def schema(command: Optional[str] = typer.Argument(None)):
|
|
340
|
+
"""Dump the command tree as JSON (for agent introspection)."""
|
|
341
|
+
def describe(cmd, name):
|
|
342
|
+
info = {"name": name, "help": (cmd.help or "").strip()}
|
|
343
|
+
if hasattr(cmd, "commands"): # group(typer 可能 vendor click,不做 isinstance)
|
|
344
|
+
info["commands"] = [describe(sub, n) for n, sub in cmd.commands.items()]
|
|
345
|
+
else:
|
|
346
|
+
info["params"] = [
|
|
347
|
+
{"name": p.name, "opts": list(p.opts), "required": p.required,
|
|
348
|
+
"default": None if p.default in (None, ...) else str(p.default),
|
|
349
|
+
"help": getattr(p, "help", None)}
|
|
350
|
+
for p in cmd.params
|
|
351
|
+
]
|
|
352
|
+
return info
|
|
353
|
+
|
|
354
|
+
root = typer.main.get_command(app)
|
|
355
|
+
if command:
|
|
356
|
+
cmd = root.commands.get(command)
|
|
357
|
+
if not cmd:
|
|
358
|
+
print(json.dumps({"error": "not_found", "message": f"no command '{command}'"}),
|
|
359
|
+
file=sys.stderr)
|
|
360
|
+
raise typer.Exit(3)
|
|
361
|
+
print(json.dumps(describe(cmd, command), ensure_ascii=False, indent=2))
|
|
362
|
+
else:
|
|
363
|
+
print(json.dumps(describe(root, "flyfile"), ensure_ascii=False, indent=2))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@app.command()
|
|
367
|
+
def version(json_flag: bool = JsonOpt):
|
|
368
|
+
"""Print version."""
|
|
369
|
+
if out.is_json_mode(json_flag):
|
|
370
|
+
print(json.dumps({"version": __version__}))
|
|
371
|
+
else:
|
|
372
|
+
print(__version__)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@config_app.command("set")
|
|
376
|
+
def config_set(key: str, value: str):
|
|
377
|
+
"""Set a config key (server / token)."""
|
|
378
|
+
if key not in ("server", "token"):
|
|
379
|
+
print(json.dumps({"error": "usage", "message": f"unknown key '{key}', allowed: server, token"}),
|
|
380
|
+
file=sys.stderr)
|
|
381
|
+
raise typer.Exit(EXIT_USAGE)
|
|
382
|
+
import yaml
|
|
383
|
+
cfg = load_config()
|
|
384
|
+
cfg[key] = value
|
|
385
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
CONFIG_PATH.write_text(yaml.safe_dump(cfg, default_flow_style=False, allow_unicode=True))
|
|
387
|
+
print(json.dumps({key: value}))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@config_app.command("get")
|
|
391
|
+
def config_get(key: str):
|
|
392
|
+
cfg = load_config()
|
|
393
|
+
if key not in cfg:
|
|
394
|
+
raise typer.Exit(3)
|
|
395
|
+
print(cfg[key])
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@config_app.command("show")
|
|
399
|
+
def config_show():
|
|
400
|
+
cfg = load_config()
|
|
401
|
+
if "token" in cfg:
|
|
402
|
+
cfg["token"] = cfg["token"][:4] + "***"
|
|
403
|
+
print(json.dumps(cfg, ensure_ascii=False))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
if __name__ == "__main__":
|
|
407
|
+
app()
|
flyfile/cli/output.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""输出规范:数据走 stdout,进度/提示走 stderr;非 TTY 自动 JSON。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_json_mode(json_flag: bool) -> bool:
|
|
10
|
+
return json_flag or not sys.stdout.isatty()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def emit(data, json_mode: bool) -> None:
|
|
14
|
+
if json_mode:
|
|
15
|
+
print(json.dumps(_humanize(data), ensure_ascii=False))
|
|
16
|
+
else:
|
|
17
|
+
_pretty(data)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def emit_event(event: dict) -> None:
|
|
21
|
+
"""NDJSON 事件流(send 等长驻命令用),立即 flush。"""
|
|
22
|
+
print(json.dumps(event, ensure_ascii=False), flush=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ts(v):
|
|
26
|
+
return datetime.fromtimestamp(v, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if v else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _humanize(data):
|
|
30
|
+
if isinstance(data, list):
|
|
31
|
+
return [_humanize(x) for x in data]
|
|
32
|
+
if isinstance(data, dict):
|
|
33
|
+
out = dict(data)
|
|
34
|
+
for k in ("created_at", "expires_at", "last_read_at"):
|
|
35
|
+
if k in out and isinstance(out[k], (int, float)):
|
|
36
|
+
out[k] = _ts(out[k])
|
|
37
|
+
out.pop("manifest", None) if out.get("kind") != "dir" else None
|
|
38
|
+
return out
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _size(n) -> str:
|
|
43
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
44
|
+
if n < 1024 or unit == "TB":
|
|
45
|
+
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
|
46
|
+
n /= 1024
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pretty(data) -> None:
|
|
50
|
+
if isinstance(data, list):
|
|
51
|
+
if not data:
|
|
52
|
+
print("(empty)")
|
|
53
|
+
return
|
|
54
|
+
rows = [(o["id"], o["kind"], _size(o["size"]), ",".join(o.get("tags") or []) or "-",
|
|
55
|
+
_ts(o["created_at"]), o["name"]) for o in data]
|
|
56
|
+
widths = [max(len(str(r[i])) for r in rows) for i in range(5)]
|
|
57
|
+
for r in rows:
|
|
58
|
+
print(" ".join(str(v).ljust(w) for v, w in zip(r[:5], widths)) + " " + r[5])
|
|
59
|
+
elif isinstance(data, dict):
|
|
60
|
+
for k, v in _humanize(data).items():
|
|
61
|
+
if v is not None and k != "manifest":
|
|
62
|
+
print(f"{k}: {v}")
|
|
63
|
+
else:
|
|
64
|
+
print(data)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fmt_size(n: float) -> str:
|
|
68
|
+
return _size(n)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def progress_printer(label: str):
|
|
72
|
+
"""TTY 下的字节级进度 + 实时速度(stderr);非 TTY 静默。
|
|
73
|
+
回调签名 (done_bytes, total_bytes),渲染节流 10Hz。"""
|
|
74
|
+
if not sys.stderr.isatty():
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
import time as _time
|
|
78
|
+
state = {"t0": None, "last": 0.0}
|
|
79
|
+
|
|
80
|
+
def show(done: int, total: int):
|
|
81
|
+
now = _time.monotonic()
|
|
82
|
+
if state["t0"] is None:
|
|
83
|
+
state["t0"] = now
|
|
84
|
+
if done < total and now - state["last"] < 0.1:
|
|
85
|
+
return
|
|
86
|
+
state["last"] = now
|
|
87
|
+
elapsed = now - state["t0"]
|
|
88
|
+
speed = f"{_size(done / elapsed)}/s" if elapsed > 0.5 else "--"
|
|
89
|
+
if total > 0:
|
|
90
|
+
pct = done * 100 // total
|
|
91
|
+
sys.stderr.write(f"\r\x1b[K{label}: {_size(done)}/{_size(total)} ({pct}%) {speed}")
|
|
92
|
+
else: # 流式,总量未知(dir/stdin)
|
|
93
|
+
sys.stderr.write(f"\r\x1b[K{label}: {_size(done)} {speed}")
|
|
94
|
+
sys.stderr.flush()
|
|
95
|
+
if total > 0 and done >= total:
|
|
96
|
+
sys.stderr.write("\n")
|
|
97
|
+
|
|
98
|
+
return show
|
|
File without changes
|