Skip to content

Commit d4e5823

Browse files
authored
Add rsconnect server set-default and a server command group (#818)
* feat: add backwards-compatible `rsconnect server` command group * feat: add `rsconnect server set-default` command * docs: document `rsconnect server set-default` and the server group * test: assert set-default in group help and idempotent message * feat: alias bootstrap under the `server` command group * Apply suggestion from @edavidaja
1 parent 21a25d1 commit d4e5823

5 files changed

Lines changed: 221 additions & 2 deletions

File tree

docs/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4848
- Servers can now be marked as the default with `rsconnect add --set-default`.
4949
When neither `-n/--name` nor `-s/--server` is provided, the default server is
5050
used automatically. `rsconnect login` sets the server as default unless
51-
`--no-set-default` is passed. `CONNECT_SERVER` still takes precedence.
51+
`--no-set-default` is passed. `CONNECT_SERVER` still takes precedence. An
52+
already-saved server can be made the default without re-adding it using
53+
`rsconnect server set-default -n <name>`. The new `rsconnect server` command
54+
group also aliases `add`, `list`, `remove`, `details`, and `bootstrap`.
5255
- New `environment` subcommand for managing execution environments on Connect.
5356
- New `integration` subcommand for managing OAuth integrations on Connect.
5457

docs/commands/set-default.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
::: mkdocs-click
2+
:module: rsconnect.main
3+
:command: set_default
4+
:prog_name: rsconnect server set-default

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ nav:
5252
- logout: commands/logout.md
5353
- quickstart: commands/quickstart.md
5454
- remove: commands/remove.md
55+
- set-default: commands/set-default.md
5556
- system: commands/system.md
5657
- version: commands/version.md
5758
- write-manifest: commands/write-manifest.md

rsconnect/main.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -947,7 +947,63 @@ def remove(
947947
if message:
948948
click.echo(message)
949949
if removed_was_default:
950-
click.echo("Note: the removed server was the default. Use `rsconnect add --set-default` to set a new one.")
950+
click.echo("Note: the removed server was the default. Use `rsconnect server set-default` to set a new one.")
951+
952+
953+
@click.command(
954+
"set-default",
955+
short_help="Set an already-saved server as the default.",
956+
help=(
957+
"Mark an already-saved server as the default. The default is used when "
958+
"neither -n/--name nor -s/--server is given to other commands. This only "
959+
"updates local metadata; the server is not contacted. Prefer -n/--name; "
960+
"-s/--server must match the stored URL exactly."
961+
),
962+
no_args_is_help=True,
963+
)
964+
@click.option("--name", "-n", help="The nickname of the server to set as the default.")
965+
@click.option("--server", "-s", help="The URL of the server to set as the default.")
966+
@click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.")
967+
@click.pass_context
968+
def set_default(
969+
ctx: click.Context,
970+
name: Optional[str],
971+
server: Optional[str],
972+
verbose: int,
973+
):
974+
set_verbosity(verbose)
975+
output_params(ctx, locals().items())
976+
977+
with cli_feedback("Checking arguments"):
978+
if name and server:
979+
raise RSConnectException("You must specify only one of -n/--name or -s/--server.")
980+
if not name and not server:
981+
raise RSConnectException("You must specify one of -n/--name or -s/--server.")
982+
983+
if server:
984+
entry = server_store.get_by_url(server)
985+
if entry is None:
986+
raise RSConnectException('URL "%s" was not found.' % server)
987+
name = entry["name"]
988+
989+
server_store.set_default(name)
990+
991+
click.echo('Server "%s" is now the default.' % name)
992+
993+
994+
@cli.group("server", no_args_is_help=True)
995+
def server_group():
996+
"""
997+
Manage connections with Posit Connect and shinyapps.io servers.
998+
"""
999+
1000+
1001+
server_group.add_command(add)
1002+
server_group.add_command(list_servers)
1003+
server_group.add_command(remove)
1004+
server_group.add_command(details)
1005+
server_group.add_command(set_default)
1006+
server_group.add_command(bootstrap)
9511007

9521008

9531009
def _resolve_identity_token(identity_token: Optional[str], identity_token_file: Optional[str]) -> Optional[str]:

tests/test_main.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1773,6 +1773,161 @@ def test_add_set_default(self, tmp_path):
17731773
if original_server_value:
17741774
os.environ["CONNECT_SERVER"] = original_server_value
17751775

1776+
def test_server_set_default_by_name(self, tmp_path):
1777+
from rsconnect.metadata import ServerStore
1778+
1779+
store = ServerStore(base_dir=str(tmp_path))
1780+
store.set("s1", "http://s1.local", api_key="key1", set_as_default=True)
1781+
store.set("s2", "http://s2.local", api_key="key2")
1782+
runner = CliRunner()
1783+
with mock.patch("rsconnect.main.server_store", store):
1784+
result = runner.invoke(cli, ["server", "set-default", "-n", "s2"])
1785+
assert result.exit_code == 0, result.output
1786+
assert 'Server "s2" is now the default.' in result.output
1787+
assert store.get_default()["name"] == "s2"
1788+
1789+
def test_server_set_default_by_url_reports_nickname(self, tmp_path):
1790+
from rsconnect.metadata import ServerStore
1791+
1792+
store = ServerStore(base_dir=str(tmp_path))
1793+
store.set("s1", "http://s1.local", api_key="key1")
1794+
runner = CliRunner()
1795+
with mock.patch("rsconnect.main.server_store", store):
1796+
result = runner.invoke(cli, ["server", "set-default", "-s", "http://s1.local"])
1797+
assert result.exit_code == 0, result.output
1798+
assert 'Server "s1" is now the default.' in result.output
1799+
assert store.get_default()["name"] == "s1"
1800+
1801+
def test_server_set_default_unknown_name(self, tmp_path):
1802+
from rsconnect.metadata import ServerStore
1803+
1804+
store = ServerStore(base_dir=str(tmp_path))
1805+
store.set("s1", "http://s1.local", api_key="key1")
1806+
runner = CliRunner()
1807+
with mock.patch("rsconnect.main.server_store", store):
1808+
result = runner.invoke(cli, ["server", "set-default", "-n", "nope"])
1809+
assert result.exit_code != 0
1810+
assert "does not exist" in result.output
1811+
1812+
def test_server_set_default_unknown_url(self, tmp_path):
1813+
from rsconnect.metadata import ServerStore
1814+
1815+
store = ServerStore(base_dir=str(tmp_path))
1816+
store.set("s1", "http://s1.local", api_key="key1")
1817+
runner = CliRunner()
1818+
with mock.patch("rsconnect.main.server_store", store):
1819+
result = runner.invoke(cli, ["server", "set-default", "-s", "http://absent.local"])
1820+
assert result.exit_code != 0
1821+
assert "was not found" in result.output
1822+
1823+
def test_server_set_default_both_options_error(self, tmp_path):
1824+
from rsconnect.metadata import ServerStore
1825+
1826+
store = ServerStore(base_dir=str(tmp_path))
1827+
store.set("s1", "http://s1.local", api_key="key1")
1828+
runner = CliRunner()
1829+
with mock.patch("rsconnect.main.server_store", store):
1830+
result = runner.invoke(cli, ["server", "set-default", "-n", "s1", "-s", "http://s1.local"])
1831+
assert result.exit_code != 0
1832+
assert "only one of" in result.output
1833+
1834+
def test_server_set_default_no_args_shows_help(self):
1835+
runner = CliRunner()
1836+
result = runner.invoke(cli, ["server", "set-default"])
1837+
assert result.exit_code == 0, result.output
1838+
assert "Usage:" in result.output
1839+
1840+
def test_server_set_default_idempotent(self, tmp_path):
1841+
from rsconnect.metadata import ServerStore
1842+
1843+
store = ServerStore(base_dir=str(tmp_path))
1844+
store.set("s1", "http://s1.local", api_key="key1", set_as_default=True)
1845+
runner = CliRunner()
1846+
with mock.patch("rsconnect.main.server_store", store):
1847+
result = runner.invoke(cli, ["server", "set-default", "-n", "s1"])
1848+
assert result.exit_code == 0, result.output
1849+
assert 'Server "s1" is now the default.' in result.output
1850+
assert store.get_default()["name"] == "s1"
1851+
1852+
def test_server_set_default_shinyapps_entry(self, tmp_path):
1853+
from rsconnect.metadata import ServerStore
1854+
1855+
store = ServerStore(base_dir=str(tmp_path))
1856+
store.set("shiny", "https://api.shinyapps.io", account_name="acct", token="tok", secret="sec")
1857+
runner = CliRunner()
1858+
with mock.patch("rsconnect.main.server_store", store):
1859+
result = runner.invoke(cli, ["server", "set-default", "-n", "shiny"])
1860+
assert result.exit_code == 0, result.output
1861+
assert store.get_default()["name"] == "shiny"
1862+
1863+
def test_no_top_level_set_default(self):
1864+
runner = CliRunner()
1865+
result = runner.invoke(cli, ["set-default", "-n", "s1"])
1866+
assert result.exit_code != 0
1867+
assert "No such command" in result.output or "Usage:" in result.output
1868+
1869+
def test_set_default_moves_list_marker(self, tmp_path):
1870+
from rsconnect.metadata import ServerStore
1871+
1872+
store = ServerStore(base_dir=str(tmp_path))
1873+
store.set("s1", "http://s1.local", api_key="key1", set_as_default=True)
1874+
store.set("s2", "http://s2.local", api_key="key2")
1875+
runner = CliRunner()
1876+
with mock.patch("rsconnect.main.server_store", store):
1877+
runner.invoke(cli, ["server", "set-default", "-n", "s2"])
1878+
result = runner.invoke(cli, ["list"])
1879+
assert result.exit_code == 0, result.output
1880+
s2_line = next(line for line in result.output.splitlines() if '"s2"' in line)
1881+
assert "[default]" in s2_line
1882+
1883+
1884+
class TestServerGroup:
1885+
def test_server_group_help_lists_subcommands(self):
1886+
runner = CliRunner()
1887+
result = runner.invoke(cli, ["server", "--help"])
1888+
assert result.exit_code == 0, result.output
1889+
for sub in ("add", "list", "remove", "details", "set-default", "bootstrap"):
1890+
assert sub in result.output
1891+
1892+
def test_server_bootstrap_alias_help(self):
1893+
runner = CliRunner()
1894+
result = runner.invoke(cli, ["server", "bootstrap", "--help"])
1895+
assert result.exit_code == 0, result.output
1896+
assert "bootstrap" in result.output
1897+
1898+
def test_top_level_bootstrap_still_works(self):
1899+
runner = CliRunner()
1900+
result = runner.invoke(cli, ["bootstrap", "--help"])
1901+
assert result.exit_code == 0, result.output
1902+
1903+
def test_server_group_no_args_shows_help(self):
1904+
runner = CliRunner()
1905+
result = runner.invoke(cli, ["server"])
1906+
assert result.exit_code == 0, result.output
1907+
assert "Usage:" in result.output
1908+
1909+
def test_server_list_alias(self, tmp_path):
1910+
from rsconnect.metadata import ServerStore
1911+
1912+
store = ServerStore(base_dir=str(tmp_path))
1913+
store.set("s1", "http://s1.local", api_key="key1")
1914+
runner = CliRunner()
1915+
with mock.patch("rsconnect.main.server_store", store):
1916+
result = runner.invoke(cli, ["server", "list"])
1917+
assert result.exit_code == 0, result.output
1918+
assert "s1" in result.output
1919+
1920+
def test_top_level_list_still_works(self, tmp_path):
1921+
from rsconnect.metadata import ServerStore
1922+
1923+
store = ServerStore(base_dir=str(tmp_path))
1924+
store.set("s1", "http://s1.local", api_key="key1")
1925+
runner = CliRunner()
1926+
with mock.patch("rsconnect.main.server_store", store):
1927+
result = runner.invoke(cli, ["list"])
1928+
assert result.exit_code == 0, result.output
1929+
assert "s1" in result.output
1930+
17761931

17771932
class TestDeployGit(TestCase):
17781933
"""Tests for deploy git CLI command."""

0 commit comments

Comments
 (0)