diff --git a/devel/generate-command-doc b/devel/generate-command-doc index 34ba1083..a2fd4d80 100755 --- a/devel/generate-command-doc +++ b/devel/generate-command-doc @@ -46,6 +46,10 @@ os.environ.update({ # Ensure we detect a browser for stable `nextstrain view` output. "BROWSER": "/bin/true", + + # Ensure HOST and PORT are stable for `nextstrain view` output. + "HOST": "127.0.0.1", + "PORT": "4000", }) from nextstrain.cli import make_parser diff --git a/nextstrain/cli/__init__.py b/nextstrain/cli/__init__.py index 5c1b110d..57ec8694 100644 --- a/nextstrain/cli/__init__.py +++ b/nextstrain/cli/__init__.py @@ -17,7 +17,7 @@ from types import SimpleNamespace from .argparse import HelpFormatter, register_commands, register_default_command -from .command import build, view, deploy, remote, shell, update, setup, check_setup, login, logout, whoami, version, init_shell, authorization, debugger +from .command import all_commands, version from .debug import DEBUGGING from .errors import NextstrainCliError, UsageError from .util import warn @@ -69,31 +69,8 @@ def make_parser(): formatter_class = HelpFormatter, ) - # Maintain these manually for now while the list is very small. If we need - # to support pluggable commands or command discovery, we can switch to - # using the "entry points" system: - # https://setuptools.readthedocs.io/en/latest/setuptools.html#dynamic-discovery-of-services-and-plugins - # - commands = [ - build, - view, - deploy, - remote, - shell, - update, - setup, - check_setup, - login, - logout, - whoami, - version, - init_shell, - authorization, - debugger, - ] - register_default_command(parser) - register_commands(parser, commands) + register_commands(parser, all_commands) register_version_alias(parser) return parser diff --git a/nextstrain/cli/aws/cognito/__init__.py b/nextstrain/cli/aws/cognito/__init__.py index 94c20ef9..9cd22018 100644 --- a/nextstrain/cli/aws/cognito/__init__.py +++ b/nextstrain/cli/aws/cognito/__init__.py @@ -194,7 +194,7 @@ def renew_tokens(self, *, refresh_token): self.verify_tokens( id_token = result.get("IdToken"), access_token = result.get("AccessToken"), - refresh_token = refresh_token) + refresh_token = result.get("RefreshToken", refresh_token)) def verify_tokens(self, *, id_token, access_token, refresh_token): diff --git a/nextstrain/cli/browser.py b/nextstrain/cli/browser.py new file mode 100644 index 00000000..3e77ffaf --- /dev/null +++ b/nextstrain/cli/browser.py @@ -0,0 +1,67 @@ +""" +Web browser interaction. + +.. envvar:: BROWSER + + A ``PATH``-like list of web browsers to try in preference order, before + falling back to a set of default browsers. May be program names, e.g. + ``firefox``, or absolute paths to specific executables, e.g. + ``/usr/bin/firefox``. + +.. envvar:: NOBROWSER + + If set to a truthy value (e.g. 1) then no web browser will be considered + available. This can be useful to prevent opening of a browser when there + are not other means of doing so. +""" +import webbrowser +from threading import Thread, ThreadError +from os import environ +from .util import warn + + +if environ.get("NOBROWSER"): + BROWSER = None +else: + # Avoid text-mode browsers + TERM = environ.pop("TERM", None) + try: + BROWSER = webbrowser.get() + except: + BROWSER = None + finally: + if TERM is not None: + environ["TERM"] = TERM + + +def open_browser(url: str, new_thread: bool = True): + """ + Opens *url* in a web browser. + + Opens in a new tab, if possible, and raises the window to the top, if + possible. + + Launches the browser from a separate thread by default so waiting on the + browser child process doesn't block the main (or calling) thread. Set + *new_thread* to False to launch from the same thread as the caller (e.g. if + you've already spawned a dedicated thread or process for the browser). + Note that some registered browsers launch in the background themselves, but + not all do, so this feature makes launch behaviour consistent across + browsers. + + Prints a warning to stderr if a browser can't be found or can't be + launched, as automatically opening a browser is considered a + nice-but-not-necessary feature. + """ + if not BROWSER: + warn(f"Couldn't open <{url}> in browser: no browser found") + return + + try: + if new_thread: + Thread(target = open_browser, args = (url, False), daemon = True).start() + else: + # new = 2 means new tab, if possible + BROWSER.open(url, new = 2, autoraise = True) + except (ThreadError, webbrowser.Error) as err: + warn(f"Couldn't open <{url}> in browser: {err!r}") diff --git a/nextstrain/cli/command/__init__.py b/nextstrain/cli/command/__init__.py index e69de29b..6f6b0931 100644 --- a/nextstrain/cli/command/__init__.py +++ b/nextstrain/cli/command/__init__.py @@ -0,0 +1,43 @@ +from . import ( + build, + view, + deploy, + remote, + shell, + update, + setup, + check_setup, + login, + logout, + whoami, + version, + init_shell, + authorization, + debugger, +) + +# Maintain this list manually for now while its relatively static. If we need +# to support pluggable commands or command discovery, we can switch to using +# the "entry points" system: +# https://setuptools.readthedocs.io/en/latest/setuptools.html#dynamic-discovery-of-services-and-plugins +# +# The order of this list is important and intentional: it determines the order +# in various user interfaces, e.g. `nextstrain --help`. +# +all_commands = [ + build, + view, + deploy, + remote, + shell, + update, + setup, + check_setup, + login, + logout, + whoami, + version, + init_shell, + authorization, + debugger, +] diff --git a/nextstrain/cli/command/view.py b/nextstrain/cli/command/view.py index 2a5eb008..f8eb5309 100644 --- a/nextstrain/cli/command/view.py +++ b/nextstrain/cli/command/view.py @@ -48,7 +48,6 @@ from multiprocessing import Process, ProcessError import re import requests -import webbrowser from inspect import cleandoc from os import environ from pathlib import Path @@ -57,6 +56,7 @@ from typing import Iterable, NamedTuple, Tuple, Union from .. import runner from ..argparse import add_extended_help_flags, SUPPRESS, SKIP_AUTO_DEFAULT_IN_HELP +from ..browser import BROWSER, open_browser as __open_browser from ..runner import docker, ambient, conda, singularity from ..util import colored, remove_suffix, warn from ..volume import NamedVolume @@ -67,16 +67,6 @@ PORT = environ.get("PORT") or "4000" -# Avoid text-mode browsers -TERM = environ.pop("TERM", None) -try: - BROWSER = webbrowser.get() -except: - BROWSER = None -finally: - if TERM is not None: - environ["TERM"] = TERM - OPEN_DEFAULT = bool(BROWSER) @@ -454,8 +444,4 @@ def _open_browser(url: str): warn(f"Couldn't open <{url}> in browser: Auspice never started listening") return - try: - # new = 2 means new tab, if possible - BROWSER.open(url, new = 2, autoraise = True) - except webbrowser.Error as err: - warn(f"Couldn't open <{url}> in browser: {err!r}") + __open_browser(url, new_thread = False) diff --git a/nextstrain/cli/remote/nextstrain_dot_org.py b/nextstrain/cli/remote/nextstrain_dot_org.py index f148380d..6cb38d85 100644 --- a/nextstrain/cli/remote/nextstrain_dot_org.py +++ b/nextstrain/cli/remote/nextstrain_dot_org.py @@ -464,12 +464,14 @@ def delete(url: urllib.parse.ParseResult, recursively: bool = False, dry_run: bo raise UserError(f"Path {path} does not seem to exist") for resource in resources: - yield "nextstrain.org" + str(resource.path) + endpoint = api_endpoint(resource.path) + + yield endpoint if dry_run: continue - response = http.delete(api_endpoint(resource.path)) + response = http.delete(endpoint) raise_for_status(response) @@ -648,6 +650,10 @@ def __init__(self): def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: if self.user and origin(request.url) == origin(NEXTSTRAIN_DOT_ORG): request.headers["Authorization"] = self.user.http_authorization + + # Used in error handling for more informative error messages + request._user = self.user # type: ignore + return request @@ -708,7 +714,10 @@ def raise_for_status(response: requests.Response) -> None: """, msg = indent("\n".join(wrap(msg)), " ")) from err elif status in {401, 403}: - user = current_user() + try: + user = response.request._user # type: ignore + except AttributeError: + user = None if user: challenge = authn_challenge(response) if status == 401 else None diff --git a/nextstrain/cli/remote/s3.py b/nextstrain/cli/remote/s3.py index 5769a644..180a46fd 100644 --- a/nextstrain/cli/remote/s3.py +++ b/nextstrain/cli/remote/s3.py @@ -7,10 +7,7 @@ Nextstrain :term:`datasets ` and :term:`narratives ` hosted on `Amazon S3 `_. This functionality is primarily intended for use by the Nextstrain team and -operators of self-hosted :term:`docs:Auspice` instances. It is also used to -manage the contents of :doc:`Nextstrain Groups -` that have not migrated to using the -:doc:`/remotes/nextstrain.org`. +operators of self-hosted :term:`docs:Auspice` instances. Remote paths