Skip to content

Commit 5a99494

Browse files
authored
feat: Allow passing the server URL positionally for login & logout (#796)
This commit adjusts the `login` and `logout` subcommands to accept the Connect server URL as a positional argument (in addition to the -s/--server option), making them more intuitive to use: $ rsconnect login https://connect.company.internal rather than $ rsconnect login -s https://connect.company.internal Unit tests are included. Signed-off-by: Aaron Jacobs <aaron.jacobs@posit.co>
1 parent 7f2933d commit 5a99494

2 files changed

Lines changed: 109 additions & 5 deletions

File tree

rsconnect/main.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,7 +1042,8 @@ def remove(
10421042
),
10431043
no_args_is_help=True,
10441044
)
1045-
@click.option("--server", "-s", envvar="CONNECT_SERVER", required=True, help="The URL of the Posit Connect server.")
1045+
@click.argument("server_arg", metavar="SERVER", required=False)
1046+
@click.option("--server", "-s", envvar="CONNECT_SERVER", help="The URL of the Posit Connect server.")
10461047
@click.option("--name", "-n", help="Nickname for the server (defaults to server hostname).")
10471048
@click.option("--insecure", "-i", envvar="CONNECT_INSECURE", is_flag=True, help="Disable TLS certificate verification.")
10481049
@click.option(
@@ -1068,7 +1069,8 @@ def remove(
10681069
@click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.")
10691070
@cli_exception_handler
10701071
def login(
1071-
server: str,
1072+
server_arg: Optional[str],
1073+
server: Optional[str],
10721074
name: Optional[str],
10731075
insecure: bool,
10741076
cacert: Optional[str],
@@ -1079,6 +1081,17 @@ def login(
10791081
):
10801082
set_verbosity(verbose)
10811083

1084+
# Only treat --server as conflicting with the positional argument when it
1085+
# was given explicitly on the command line. A value sourced from the
1086+
# CONNECT_SERVER environment variable should not block the positional form.
1087+
server_source = validation.get_parameter_source_name_from_ctx("server", click.get_current_context())
1088+
server_from_option = server_source == "COMMANDLINE"
1089+
if server_arg and server and server_from_option:
1090+
raise RSConnectException("You must specify only one of SERVER or -s/--server.")
1091+
server = server_arg or server
1092+
if not server:
1093+
raise RSConnectException("You must specify the server as a SERVER argument or with -s/--server.")
1094+
10821095
if not server.startswith("http"):
10831096
raise RSConnectException("Server URL must begin with http or https.")
10841097

@@ -1175,27 +1188,33 @@ def _do_login(cid: str) -> dict[str, Any]:
11751188
short_help="Remove stored OAuth credentials for a Posit Connect server.",
11761189
help=(
11771190
"Remove locally-stored OAuth credentials for a Posit Connect server. "
1178-
"One of --name or --server is required. "
1191+
"The server is identified by a positional SERVER argument, -s/--server, or -n/--name. "
11791192
"The server entry is preserved (for re-login without re-registration); "
11801193
"use 'rsconnect remove' to delete the entry entirely."
11811194
),
11821195
no_args_is_help=True,
11831196
)
1197+
@click.argument("server_arg", metavar="SERVER", required=False)
11841198
@click.option("--name", "-n", help="The nickname of the Posit Connect server to log out from.")
11851199
@click.option("--server", "-s", help="The URL of the Posit Connect server to log out from.")
11861200
@click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.")
11871201
@cli_exception_handler
11881202
def logout(
1203+
server_arg: Optional[str],
11891204
name: Optional[str],
11901205
server: Optional[str],
11911206
verbose: int,
11921207
):
11931208
set_verbosity(verbose)
11941209

1210+
if server_arg and server:
1211+
raise RSConnectException("Specify only one of SERVER or -s/--server.")
1212+
server = server_arg or server
1213+
11951214
if name and server:
1196-
raise RSConnectException("Specify only one of --name or --server.")
1215+
raise RSConnectException("Specify only one of --name, --server, or SERVER.")
11971216
if not name and not server:
1198-
raise RSConnectException("Specify one of --name or --server.")
1217+
raise RSConnectException("Specify one of --name, --server, or SERVER.")
11991218

12001219
entry = None
12011220
if name:

tests/test_oauth.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,71 @@ def test_login_success(
433433
assert result.exit_code == 0, result.output
434434
assert "Logged in" in result.output
435435

436+
@patch("rsconnect.oauth.keyring_store_token", return_value=True)
437+
@patch("rsconnect.oauth.login_with_browser")
438+
@patch("rsconnect.oauth.register_client", return_value="new-client-id")
439+
@patch("rsconnect.oauth.discover_oauth_metadata")
440+
def test_login_positional_server(
441+
self,
442+
mock_discover: MagicMock,
443+
mock_register: MagicMock,
444+
mock_login: MagicMock,
445+
mock_keyring: MagicMock,
446+
):
447+
from click.testing import CliRunner
448+
449+
from rsconnect.main import cli
450+
451+
mock_discover.return_value = FAKE_METADATA
452+
mock_login.return_value = {"access_token": "at-1", "refresh_token": "rt-1", "expires_in": 3600}
453+
454+
runner = CliRunner()
455+
result = runner.invoke(cli, ["login", FAKE_URL, "--name", "test-server"])
456+
457+
assert result.exit_code == 0, result.output
458+
assert "Logged in" in result.output
459+
460+
@patch("rsconnect.oauth.keyring_store_token", return_value=True)
461+
@patch("rsconnect.oauth.login_with_browser")
462+
@patch("rsconnect.oauth.register_client", return_value="new-client-id")
463+
@patch("rsconnect.oauth.discover_oauth_metadata")
464+
def test_login_positional_server_overrides_connect_server_env(
465+
self,
466+
mock_discover: MagicMock,
467+
mock_register: MagicMock,
468+
mock_login: MagicMock,
469+
mock_keyring: MagicMock,
470+
):
471+
from click.testing import CliRunner
472+
473+
from rsconnect.main import cli
474+
475+
mock_discover.return_value = FAKE_METADATA
476+
mock_login.return_value = {"access_token": "at-1", "refresh_token": "rt-1", "expires_in": 3600}
477+
478+
runner = CliRunner()
479+
result = runner.invoke(
480+
cli,
481+
["login", FAKE_URL, "--name", "test-server"],
482+
env={"CONNECT_SERVER": "https://env-server.example.com"},
483+
)
484+
485+
assert result.exit_code == 0, result.output
486+
assert "Logged in" in result.output
487+
# The positional argument should win over the CONNECT_SERVER envvar.
488+
assert mock_discover.call_args.args[0] == FAKE_URL
489+
490+
def test_login_positional_and_option_server_conflict(self):
491+
from click.testing import CliRunner
492+
493+
from rsconnect.main import cli
494+
495+
runner = CliRunner()
496+
result = runner.invoke(cli, ["login", FAKE_URL, "--server", FAKE_URL])
497+
498+
assert result.exit_code != 0
499+
assert "only one of SERVER" in result.output
500+
436501
def test_login_missing_server(self):
437502
from click.testing import CliRunner
438503

@@ -478,6 +543,26 @@ def test_logout_success(self, mock_store: MagicMock, mock_keyring_del: MagicMock
478543
assert result.exit_code == 0, result.output
479544
mock_keyring_del.assert_called_once()
480545

546+
@patch("rsconnect.oauth.keyring_delete_tokens")
547+
@patch("rsconnect.main.server_store")
548+
def test_logout_positional_server(self, mock_store: MagicMock, mock_keyring_del: MagicMock):
549+
from click.testing import CliRunner
550+
551+
from rsconnect.main import cli
552+
553+
mock_store.get_by_url.return_value = {
554+
"name": "myserver",
555+
"url": FAKE_URL,
556+
"oauth_client_id": "client-123",
557+
}
558+
mock_store.update_oauth_tokens = MagicMock()
559+
560+
runner = CliRunner()
561+
result = runner.invoke(cli, ["logout", FAKE_URL])
562+
563+
assert result.exit_code == 0, result.output
564+
mock_keyring_del.assert_called_once()
565+
481566

482567
class TestListCommand:
483568
@patch("rsconnect.oauth.keyring_get_tokens", return_value=("at-from-keyring", None))

0 commit comments

Comments
 (0)