diff --git a/README.md b/README.md index 9243311..3e4bbc7 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,8 @@ with-connect --license ./rstudio-connect.lic You can eval the output to set the variables in your shell: ```bash -eval $(with-connect --license ./rstudio-connect.lic) +eval "$(with-connect --license ./rstudio-connect.lic)" +export CONNECT_API_KEY CONNECT_SERVER CONTAINER_ID curl -H "Authorization: Key $CONNECT_API_KEY" $CONNECT_SERVER/__api__/v1/content # Stop Connect when done (--stop without argument uses $CONTAINER_ID) diff --git a/main.py b/main.py index 8be4ad5..7911ad6 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,34 @@ REGISTRY_CUTOVER_YEAR = 2026 REGISTRY_CUTOVER_MONTH = 4 +DESCRIPTION = """\ +Starts Posit Connect in Docker and waits until it is ready. + +With no command, prints CONNECT_API_KEY, CONNECT_SERVER and CONTAINER_ID +as KEY=value lines on stdout and leaves the container running. +Stop it later with: with-connect --stop CONTAINER_ID +Progress and errors go to stderr. + +With "-- COMMAND", runs COMMAND with CONNECT_API_KEY and CONNECT_SERVER +set, then stops the container and exits with the command's exit code.""" + +EPILOG = """\ +examples: + with-connect --license ./rstudio-connect.lic + with-connect --license ./rstudio-connect.lic -- bash -c 'curl -H "Authorization: Key $CONNECT_API_KEY" $CONNECT_SERVER/__api__/v1/content' +""" + + +def port_number(value: str) -> int: + """Parse --port. Docker can only map a fixed host port from 1 to 65535.""" + try: + port = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"invalid port value: '{value}'") + if not 1 <= port <= 65535: + raise argparse.ArgumentTypeError(f"{port} is not a valid port. Use a free port from 1 to 65535.") + return port + def parse_args(): """ @@ -31,7 +59,9 @@ def parse_args(): and the command to run against Connect. """ parser = argparse.ArgumentParser( - description="Run Posit Connect with optional command execution" + description=DESCRIPTION, + epilog=EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--version", @@ -45,7 +75,7 @@ def parse_args(): ) parser.add_argument( "--image", - help="Container image to use, including tag (overrides --version)", + help="Container image to use, including tag (use instead of --version)", ) parser.add_argument( "--config", help="Path to rstudio-connect.gcfg configuration file" @@ -64,7 +94,7 @@ def parse_args(): ) parser.add_argument( "--port", - type=int, + type=port_number, default=3939, help="Port to map the Connect container to (default: 3939)", ) @@ -74,7 +104,7 @@ def parse_args(): default=None, const="", # sentinel for --stop without argument metavar="CONTAINER_ID", - help="Stop a running Connect container by ID (uses CONTAINER_ID env var if not specified)", + help="Stop a running Connect container by ID (uses the exported CONTAINER_ID env var if not specified)", ) # Handle -- separator and capture remaining args diff --git a/test_main.py b/test_main.py index 37b2c26..fa0071d 100644 --- a/test_main.py +++ b/test_main.py @@ -1,8 +1,10 @@ +import io import os import subprocess import sys import tempfile -from unittest.mock import MagicMock, Mock +from contextlib import redirect_stderr, redirect_stdout +from unittest.mock import MagicMock, Mock, patch import docker import main @@ -265,6 +267,68 @@ def test_stop_argument_in_help(): assert "CONTAINER_ID" in result.stdout +def parse_with_argv(*args): + with patch.object(sys, "argv", ["with-connect", *args]): + return main.parse_args() + + +def parse_exit(*args): + """Run parse_args expecting it to exit. Returns the exit code and all output.""" + output = io.StringIO() + with patch.object(sys, "argv", ["with-connect", *args]), redirect_stdout(output), redirect_stderr(output): + try: + main.parse_args() + except SystemExit as e: + return e.code, output.getvalue() + raise AssertionError("parse_args did not exit") + + +def test_port_zero_is_rejected(): + code, output = parse_exit("--port", "0") + + assert code == 2 + assert "0 is not a valid port" in output + + +def test_port_above_range_is_rejected(): + code, output = parse_exit("--port", "70000") + + assert code == 2 + assert "70000 is not a valid port" in output + + +def test_port_not_a_number_is_rejected(): + code, output = parse_exit("--port", "abc") + + assert code == 2 + assert "invalid port value: 'abc'" in output + + +def test_port_range_limits_are_accepted(): + assert parse_with_argv("--port", "1").port == 1 + assert parse_with_argv("--port", "65535").port == 65535 + + +def test_help_explains_how_to_stop(): + code, output = parse_exit("--help") + help_text = " ".join(output.split()) + + assert code == 0 + assert "Stop it later with: with-connect --stop CONTAINER_ID" in help_text + assert "exported CONTAINER_ID" in help_text + + +def test_command_after_separator(): + args = parse_with_argv("--port", "4000", "--", "start", "--json") + + assert args.port == 4000 + assert args.command == ["start", "--json"] + + +def test_no_command_without_separator(): + assert parse_with_argv("--quiet").command == [] + + def test_stop_nonexistent_container(): """Test that --stop with nonexistent container returns error.""" result = subprocess.run( @@ -350,6 +414,27 @@ def test_stop_nonexistent_container(): test_stop_argument_in_help() print("✓ test_stop_argument_in_help passed") + test_port_zero_is_rejected() + print("✓ test_port_zero_is_rejected passed") + + test_port_above_range_is_rejected() + print("✓ test_port_above_range_is_rejected passed") + + test_port_not_a_number_is_rejected() + print("✓ test_port_not_a_number_is_rejected passed") + + test_port_range_limits_are_accepted() + print("✓ test_port_range_limits_are_accepted passed") + + test_help_explains_how_to_stop() + print("✓ test_help_explains_how_to_stop passed") + + test_command_after_separator() + print("✓ test_command_after_separator passed") + + test_no_command_without_separator() + print("✓ test_no_command_without_separator passed") + test_stop_nonexistent_container() print("✓ test_stop_nonexistent_container passed")