Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions novem/api_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,30 @@ def get_ua(is_cli: bool) -> Dict[str, str]:


class NovemException(Exception):
pass
@property
def cli_message(self) -> str:
"""The message to show when raised under the CLI.

Defaults to the full exception text. Subclasses may override to drop
hints that only make sense for library callers — the CLI validates the
token up front (the startup whoami check), so it doesn't need the
"are you authenticated?" nudge.
"""
return str(self)


class Novem404(NovemException):
def __init__(self, message: str):

# 404 errors can occur if users are not authenticated, let them know
# future improvement: consider requesting a fixed endpoint (like
# whoami) and notify if not authenticated
message = f"Resource not found: {message} (Are you authenticated?)"
# 404s can also be how the API answers an unauthenticated request, so
# library callers (which have no up-front auth check) get a nudge. The
# CLI validates auth at startup, so its cli_message omits it.
self._detail = f"Resource not found: {message}"
super().__init__(f"{self._detail} (Are you authenticated?)")

super().__init__(message)
@property
def cli_message(self) -> str:
return self._detail


class Novem403(NovemException):
Expand Down
56 changes: 54 additions & 2 deletions novem/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import readline # type: ignore

from ..api_ref import NovemAPI
from ..config import config as _config_manager
from ..utils import cl, colors, get_config_path, get_current_config
from ..version import __version__
from .common import doc, grid, job, mail, plot, user
Expand All @@ -34,8 +35,14 @@

def _cli_excepthook(exc_type: type, exc_value: BaseException, exc_traceback: Any) -> None:
"""Custom exception handler for CLI mode - suppresses tracebacks unless in debug mode."""
# Just print the exception message without traceback
print(f"{exc_type.__name__}: {exc_value}", file=sys.stderr)
# Our own API exceptions already carry a user-facing message, so surface
# just that (via cli_message, which also drops library-only hints) — the
# internal class name ("Novem404: …") is noise to a CLI user. Unexpected
# errors keep their type to aid debugging.
if isinstance(exc_value, NovemException):
print(exc_value.cli_message, file=sys.stderr)
else:
print(f"{exc_type.__name__}: {exc_value}", file=sys.stderr)


# Server enforces ^[a-z][a-z0-9\-\._]*$ and length <= 128 on token names
Expand Down Expand Up @@ -399,6 +406,45 @@ def print_short(parser: Any) -> None:
print(" novem -u list your connections")


def _exit_if_token_rejected(args: Mapping[str, Any]) -> None:
"""CLI guard: stop early when a configured token is rejected by the server.

Without this, a stale or expired token silently degrades to empty (or
another user's public) results instead of telling the user to re-auth.
Only a definitive 401/403 aborts; transient/network errors fall through so
the real command can surface its own error.

The whoami response does double duty: a successful one seeds the shared
identity cache, so the command's own current-user lookups reuse it instead
of fetching ``/whoami`` again.
"""
_, cfg = get_current_config(**config_from_args(args))
token = cfg.get("token")
if not token:
# Anonymous / unconfigured: not a dead token — let normal flow handle it.
return

try:
novem = NovemAPI(**config_from_args(args), is_cli=True)
resp = novem._session.get(f"{novem._api_root}whoami")
except Exception:
# Network/transient (or unmocked in tests): don't block the command.
return

if resp.status_code == 200:
_config_manager.cache_identity(token, resp.text.strip())
return

if resp.status_code in (401, 403):
print(
f"{cl.WARNING} ! {cl.ENDC}Your novem token is invalid or has expired.\n"
f" Re-authenticate with: {cl.OKCYAN}novem --init{cl.ENDC}",
file=sys.stderr,
)
sys.exit(1)
# Any other status: let the command run and surface its own error.


def run_cli_wrapped() -> None:
colors()

Expand Down Expand Up @@ -682,6 +728,12 @@ def run_cli_wrapped() -> None:
qpr = f"{qpr}cols={sz.columns},rows={sz.lines - prompt_lines}"
args["qpr"] = qpr

# CLI-only: a configured-but-rejected token aborts here with a clear
# message, rather than letting the data commands below silently return
# empty/public results. Also seeds the resolved-identity cache.
if args:
_exit_if_token_rejected(args)

# operate on org group vis listing (if -O <org> -G <group> -p/-m/-g/-j with no vis ID)
if args and args.get("org") and args.get("group") and args.get("plot") is None and "plot" in args:
list_org_group_vis(args, "Plot")
Expand Down
7 changes: 4 additions & 3 deletions novem/cli/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from novem.api_ref import Novem404, NovemAPI
from novem.cli.config import config_from_args
from novem.cli.editor import edit
from novem.cli.gql import NovemGQL, _build_var_lookup, fetch_vde_topics_gql, render_topics
from novem.cli.gql import NovemGQL, _build_var_lookup, _fetch_vde_topics_gql, render_topics
from novem.cli.setup import Share, Tag
from novem.cli.vis import (
list_job_shares,
Expand Down Expand Up @@ -173,8 +173,9 @@ def __call__(self, args: CliArgs) -> None:
# --comments: show topics and comment threads
if args.get("comments"):
gql = NovemGQL.from_args(args)
topics, vde_vars = fetch_vde_topics_gql(gql, self.fragment, name, author=usr)
me = gql._config.get("username", "")
# The topics query already resolves the current user (token-based),
# so reuse it for the "me" highlight instead of a second round-trip.
topics, vde_vars, me = _fetch_vde_topics_gql(gql, self.fragment, name, author=usr)
var_lookup = _build_var_lookup(vde_vars, usr or "", self.fragment, name) if vde_vars else None
print(
render_topics(
Expand Down
Loading