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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
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