Skip to content

Commit 44f1d4c

Browse files
sammuliclaude
andcommitted
feat(cli): fdp login/logout and auto-login on fdp run
Wire auth.login/logout into new `fdp login [--write]` and `fdp logout` subcommands; thread auto_login=True into setup_environment for `fdp run`; fix do_env to emit the device-declared bearer env-var name instead of hardcoded BEARER_TOKEN. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 00390bc commit 44f1d4c

2 files changed

Lines changed: 117 additions & 2 deletions

File tree

fdp/cli.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
import shlex
1919
import subprocess
2020
import sys
21+
from datetime import datetime, timezone
2122
from pathlib import Path
2223

24+
from . import auth
2325
from .catalog import catalog
2426
from .environment import (
2527
build_device_config, resolve_bearer_token, _resolve_device_handle,
@@ -45,7 +47,34 @@ def do_env(args) -> None:
4547
print(f"export {key}={shlex.quote(str(value))}")
4648
token = resolve_bearer_token(handle)
4749
if token:
48-
print(f"export BEARER_TOKEN={shlex.quote(token)}")
50+
env_var = auth.bearer_env(handle) or "BEARER_TOKEN"
51+
print(f"export {env_var}={shlex.quote(token)}")
52+
53+
54+
def do_login(args) -> None:
55+
handle = _resolve_device_handle(args.default_device)
56+
try:
57+
result = auth.login(handle, write=args.write)
58+
except auth.AuthError as exc:
59+
print(f"Login failed: {exc}", file=sys.stderr)
60+
sys.exit(1)
61+
if result is None:
62+
print(f"Device '{handle.schema.name}' needs no bearer token.")
63+
return
64+
if result.exp:
65+
when = datetime.fromtimestamp(
66+
result.exp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
67+
else:
68+
when = "unknown"
69+
print(f"Logged in to {result.device} ({result.scope}); "
70+
f"token valid until {when}.")
71+
72+
73+
def do_logout(args) -> None:
74+
handle = _resolve_device_handle(args.default_device)
75+
removed = auth.logout(handle)
76+
print("Removed cached token."
77+
if removed else "No cached token to remove.")
4978

5079

5180
def do_run(args) -> None:
@@ -212,12 +241,22 @@ def build_parser() -> argparse.ArgumentParser:
212241
help="Run a command with FDP env applied")
213242
p_run.add_argument("command_args", nargs=argparse.REMAINDER,
214243
help="Command and args to pass through")
215-
p_run.set_defaults(func=do_run)
244+
p_run.set_defaults(func=do_run, auto_login=True)
216245

217246
p_env = sub.add_parser("env",
218247
help="Print env vars for shell eval")
219248
p_env.set_defaults(func=do_env)
220249

250+
p_login = sub.add_parser("login",
251+
help="Acquire/refresh a bearer token via pelican")
252+
p_login.add_argument("--write", action="store_true",
253+
help="Request a write-scoped token (default: read).")
254+
p_login.set_defaults(func=do_login, needs_env=False)
255+
256+
p_logout = sub.add_parser("logout",
257+
help="Delete the cached bearer token")
258+
p_logout.set_defaults(func=do_logout, needs_env=False)
259+
221260
p_ls = sub.add_parser("ls", help="List files on the FDP")
222261
p_ls.add_argument("--dirs-only", "-d", action="store_true",
223262
help="Only show subdirectories")
@@ -288,6 +327,7 @@ def main(argv=None) -> None:
288327
setup_environment(
289328
device=args.default_device,
290329
bearer_token=args.bearer_token or None,
330+
auto_login=getattr(args, "auto_login", False),
291331
)
292332

293333
args.func(args)

tests/test_cli.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import sys
2323
import unittest
2424
from contextlib import ExitStack, redirect_stdout
25+
from types import SimpleNamespace
2526
from unittest import mock
2627

2728

@@ -329,5 +330,79 @@ def test_default_device_passed_to_setup_environment(self):
329330
self.assertEqual(kwargs.get("device"), "d3d")
330331

331332

333+
class TestCliLoginLogout(unittest.TestCase):
334+
def test_login_dispatches_to_auth_login(self):
335+
from fdp import cli, auth
336+
ep = _make_catalog_ep("d3d", _D3D_TEST_YAML)
337+
ct = auth.CachedToken(device="d3d", scope="read", exp=None)
338+
with ExitStack() as stack:
339+
stack.enter_context(mock.patch.object(
340+
sys, "argv", ["fdp", "login"]))
341+
stack.enter_context(mock.patch(
342+
"fdp.catalog.entry_points", return_value=[ep]))
343+
from fdp.catalog import catalog as _cat
344+
_cat._cache = None
345+
login_mock = stack.enter_context(
346+
mock.patch.object(cli.auth, "login", return_value=ct))
347+
buf = io.StringIO()
348+
with redirect_stdout(buf):
349+
cli.main()
350+
login_mock.assert_called_once()
351+
self.assertEqual(login_mock.call_args.kwargs.get("write"), False)
352+
353+
def test_login_write_flag(self):
354+
from fdp import cli, auth
355+
ep = _make_catalog_ep("d3d", _D3D_TEST_YAML)
356+
ct = auth.CachedToken(device="d3d", scope="write", exp=None)
357+
with ExitStack() as stack:
358+
stack.enter_context(mock.patch.object(
359+
sys, "argv", ["fdp", "login", "--write"]))
360+
stack.enter_context(mock.patch(
361+
"fdp.catalog.entry_points", return_value=[ep]))
362+
from fdp.catalog import catalog as _cat
363+
_cat._cache = None
364+
login_mock = stack.enter_context(
365+
mock.patch.object(cli.auth, "login", return_value=ct))
366+
with redirect_stdout(io.StringIO()):
367+
cli.main()
368+
self.assertEqual(login_mock.call_args.kwargs.get("write"), True)
369+
370+
def test_logout_dispatches(self):
371+
from fdp import cli
372+
ep = _make_catalog_ep("d3d", _D3D_TEST_YAML)
373+
with ExitStack() as stack:
374+
stack.enter_context(mock.patch.object(
375+
sys, "argv", ["fdp", "logout"]))
376+
stack.enter_context(mock.patch(
377+
"fdp.catalog.entry_points", return_value=[ep]))
378+
from fdp.catalog import catalog as _cat
379+
_cat._cache = None
380+
logout_mock = stack.enter_context(
381+
mock.patch.object(cli.auth, "logout", return_value=True))
382+
with redirect_stdout(io.StringIO()):
383+
cli.main()
384+
logout_mock.assert_called_once()
385+
386+
def test_run_sets_auto_login_true(self):
387+
from fdp import cli
388+
ep = _make_catalog_ep("d3d", _D3D_TEST_YAML)
389+
with ExitStack() as stack:
390+
stack.enter_context(mock.patch.object(
391+
sys, "argv", ["fdp", "run", "true"]))
392+
stack.enter_context(mock.patch(
393+
"fdp.catalog.entry_points", return_value=[ep]))
394+
from fdp.catalog import catalog as _cat
395+
_cat._cache = None
396+
setup_mock = stack.enter_context(
397+
mock.patch.object(cli, "setup_environment"))
398+
stack.enter_context(mock.patch.object(
399+
cli.subprocess, "run",
400+
return_value=SimpleNamespace(returncode=0)))
401+
with self.assertRaises(SystemExit):
402+
cli.main()
403+
self.assertEqual(
404+
setup_mock.call_args.kwargs.get("auto_login"), True)
405+
406+
332407
if __name__ == "__main__":
333408
unittest.main()

0 commit comments

Comments
 (0)