CLI(V1): GUI Launcher (#11257)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Rohit Malhotra
2025-10-07 11:23:58 -04:00
committed by GitHub
parent 80dc2efaab
commit 23d325cb16
6 changed files with 604 additions and 28 deletions

View File

@@ -0,0 +1,56 @@
"""Main argument parser for OpenHands CLI."""
import argparse
def create_main_parser() -> argparse.ArgumentParser:
"""Create the main argument parser with CLI as default and serve as subcommand.
Returns:
The configured argument parser
"""
parser = argparse.ArgumentParser(
description='OpenHands CLI - Terminal User Interface for OpenHands AI Agent',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
By default, OpenHands runs in CLI mode (terminal interface).
Use 'serve' subcommand to launch the GUI server instead.
Examples:
openhands # Start CLI mode
openhands --resume conversation-id # Resume a conversation in CLI mode
openhands serve # Launch GUI server
openhands serve --gpu # Launch GUI server with GPU support
"""
)
# CLI arguments at top level (default mode)
parser.add_argument(
'--resume',
type=str,
help='Conversation ID to resume'
)
# Only serve as subcommand
subparsers = parser.add_subparsers(
dest='command',
help='Additional commands'
)
# Add serve subcommand
serve_parser = subparsers.add_parser(
'serve',
help='Launch the OpenHands GUI server using Docker (web interface)'
)
serve_parser.add_argument(
'--mount-cwd',
action='store_true',
help='Mount the current working directory in the Docker container'
)
serve_parser.add_argument(
'--gpu',
action='store_true',
help='Enable GPU support in the Docker container'
)
return parser

View File

@@ -0,0 +1,31 @@
"""Argument parser for serve subcommand."""
import argparse
def add_serve_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
"""Add serve subcommand parser.
Args:
subparsers: The subparsers object to add the serve parser to
Returns:
The serve argument parser
"""
serve_parser = subparsers.add_parser(
'serve',
help='Launch the OpenHands GUI server using Docker (web interface)'
)
serve_parser.add_argument(
'--mount-cwd',
help='Mount the current working directory into the GUI server container',
action='store_true',
default=False,
)
serve_parser.add_argument(
'--gpu',
help='Enable GPU support by mounting all GPUs into the Docker container via nvidia-docker',
action='store_true',
default=False,
)
return serve_parser

View File

@@ -0,0 +1,229 @@
"""GUI launcher for OpenHands CLI."""
import os
import shutil
import subprocess
import sys
from pathlib import Path
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from openhands_cli.locations import PERSISTENCE_DIR
def _format_docker_command_for_logging(cmd: list[str]) -> str:
"""Format a Docker command for logging with grey color.
Args:
cmd (list[str]): The Docker command as a list of strings
Returns:
str: The formatted command string in grey HTML color
"""
cmd_str = ' '.join(cmd)
return f'<grey>Running Docker command: {cmd_str}</grey>'
def check_docker_requirements() -> bool:
"""Check if Docker is installed and running.
Returns:
bool: True if Docker is available and running, False otherwise.
"""
# Check if Docker is installed
if not shutil.which('docker'):
print_formatted_text(
HTML('<ansired>❌ Docker is not installed or not in PATH.</ansired>')
)
print_formatted_text(
HTML(
'<grey>Please install Docker first: https://docs.docker.com/get-docker/</grey>'
)
)
return False
# Check if Docker daemon is running
try:
result = subprocess.run(
['docker', 'info'], capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
print_formatted_text(
HTML('<ansired>❌ Docker daemon is not running.</ansired>')
)
print_formatted_text(
HTML('<grey>Please start Docker and try again.</grey>')
)
return False
except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
print_formatted_text(
HTML('<ansired>❌ Failed to check Docker status.</ansired>')
)
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
return False
return True
def ensure_config_dir_exists() -> Path:
"""Ensure the OpenHands configuration directory exists and return its path."""
path = Path(PERSISTENCE_DIR)
path.mkdir(exist_ok=True, parents=True)
return path
def get_openhands_version() -> str:
"""Get the OpenHands version for Docker images.
Returns:
str: The version string to use for Docker images
"""
# For now, use 'latest' as the default version
# In the future, this could be read from a version file or environment variable
return os.environ.get('OPENHANDS_VERSION', 'latest')
def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None:
"""Launch the OpenHands GUI server using Docker.
Args:
mount_cwd: If True, mount the current working directory into the container.
gpu: If True, enable GPU support by mounting all GPUs into the container via nvidia-docker.
"""
print_formatted_text(
HTML('<ansiblue>🚀 Launching OpenHands GUI server...</ansiblue>')
)
print_formatted_text('')
# Check Docker requirements
if not check_docker_requirements():
sys.exit(1)
# Ensure config directory exists
config_dir = ensure_config_dir_exists()
# Get the current version for the Docker image
version = get_openhands_version()
runtime_image = f'docker.all-hands.dev/all-hands-ai/runtime:{version}-nikolaik'
app_image = f'docker.all-hands.dev/all-hands-ai/openhands:{version}'
print_formatted_text(HTML('<grey>Pulling required Docker images...</grey>'))
# Pull the runtime image first
pull_cmd = ['docker', 'pull', runtime_image]
print_formatted_text(HTML(_format_docker_command_for_logging(pull_cmd)))
try:
subprocess.run(
pull_cmd,
check=True,
timeout=300, # 5 minutes timeout
)
except subprocess.CalledProcessError:
print_formatted_text(
HTML('<ansired>❌ Failed to pull runtime image.</ansired>')
)
sys.exit(1)
except subprocess.TimeoutExpired:
print_formatted_text(
HTML('<ansired>❌ Timeout while pulling runtime image.</ansired>')
)
sys.exit(1)
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Starting OpenHands GUI server...</ansigreen>')
)
print_formatted_text(
HTML('<grey>The server will be available at: http://localhost:3000</grey>')
)
print_formatted_text(HTML('<grey>Press Ctrl+C to stop the server.</grey>'))
print_formatted_text('')
# Build the Docker command
docker_cmd = [
'docker',
'run',
'-it',
'--rm',
'--pull=always',
'-e',
f'SANDBOX_RUNTIME_CONTAINER_IMAGE={runtime_image}',
'-e',
'LOG_ALL_EVENTS=true',
'-v',
'/var/run/docker.sock:/var/run/docker.sock',
'-v',
f'{config_dir}:/.openhands',
]
# Add GPU support if requested
if gpu:
print_formatted_text(
HTML('<ansigreen>🖥️ Enabling GPU support via nvidia-docker...</ansigreen>')
)
# Add the --gpus all flag to enable all GPUs
docker_cmd.insert(2, '--gpus')
docker_cmd.insert(3, 'all')
# Add environment variable to pass GPU support to sandbox containers
docker_cmd.extend(
[
'-e',
'SANDBOX_ENABLE_GPU=true',
]
)
# Add current working directory mount if requested
if mount_cwd:
cwd = Path.cwd()
# Following the documentation at https://docs.all-hands.dev/usage/runtimes/docker#connecting-to-your-filesystem
docker_cmd.extend(
[
'-e',
f'SANDBOX_VOLUMES={cwd}:/workspace:rw',
]
)
# Set user ID for Unix-like systems only
if os.name != 'nt': # Not Windows
try:
user_id = subprocess.check_output(['id', '-u'], text=True).strip()
docker_cmd.extend(['-e', f'SANDBOX_USER_ID={user_id}'])
except (subprocess.CalledProcessError, FileNotFoundError):
# If 'id' command fails or doesn't exist, skip setting user ID
pass
# Print the folder that will be mounted to inform the user
print_formatted_text(
HTML(
f'<ansigreen>📂 Mounting current directory:</ansigreen> <ansiyellow>{cwd}</ansiyellow> <ansigreen>to</ansigreen> <ansiyellow>/workspace</ansiyellow>'
)
)
docker_cmd.extend(
[
'-p',
'3000:3000',
'--add-host',
'host.docker.internal:host-gateway',
'--name',
'openhands-app',
app_image,
]
)
try:
# Log and run the Docker command
print_formatted_text(HTML(_format_docker_command_for_logging(docker_cmd)))
subprocess.run(docker_cmd, check=True)
except subprocess.CalledProcessError as e:
print_formatted_text('')
print_formatted_text(
HTML('<ansired>❌ Failed to start OpenHands GUI server.</ansired>')
)
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
sys.exit(1)
except KeyboardInterrupt:
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✓ OpenHands GUI server stopped successfully.</ansigreen>')
)
sys.exit(0)

View File

@@ -4,9 +4,9 @@ Simple main entry point for OpenHands CLI.
This is a simplified version that demonstrates the TUI functionality. This is a simplified version that demonstrates the TUI functionality.
""" """
import argparse
import logging import logging
import os import os
import sys
import warnings import warnings
debug_env = os.getenv('DEBUG', 'false').lower() debug_env = os.getenv('DEBUG', 'false').lower()
@@ -17,7 +17,7 @@ if debug_env != '1' and debug_env != 'true':
from prompt_toolkit import print_formatted_text from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML from prompt_toolkit.formatted_text import HTML
from openhands_cli.agent_chat import run_cli_entry from openhands_cli.argparsers.main_parser import create_main_parser
def main() -> None: def main() -> None:
@@ -27,35 +27,28 @@ def main() -> None:
ImportError: If agent chat dependencies are missing ImportError: If agent chat dependencies are missing
Exception: On other error conditions Exception: On other error conditions
""" """
parser = argparse.ArgumentParser( parser = create_main_parser()
description='OpenHands CLI - Terminal User Interface for OpenHands AI Agent'
)
parser.add_argument(
'--resume',
type=str,
help='Conversation ID to use for the session. If not provided, a random UUID will be generated.',
)
args = parser.parse_args() args = parser.parse_args()
try: try:
# Start agent chat if args.command == 'serve':
run_cli_entry(resume_conversation_id=args.resume) # Import gui_launcher only when needed
from openhands_cli.gui_launcher import launch_gui_server
except ImportError as e: launch_gui_server(mount_cwd=args.mount_cwd, gpu=args.gpu)
print_formatted_text( else:
HTML(f'<red>Error: Agent chat requires additional dependencies: {e}</red>') # Default CLI behavior - no subcommand needed
) # Import agent_chat only when needed
print_formatted_text( from openhands_cli.agent_chat import run_cli_entry
HTML('<yellow>Please ensure the agent SDK is properly installed.</yellow>')
) # Start agent chat
raise run_cli_entry(resume_conversation_id=args.resume)
except KeyboardInterrupt: except KeyboardInterrupt:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>')) print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
except EOFError: except EOFError:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>')) print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
except Exception as e: except Exception as e:
print_formatted_text(HTML(f'<red>Error starting agent chat: {e}</red>')) print_formatted_text(HTML(f'<red>Error: {e}</red>'))
import traceback import traceback
traceback.print_exc() traceback.print_exc()

View File

@@ -0,0 +1,201 @@
"""Tests for GUI launcher functionality."""
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from openhands_cli.gui_launcher import (
_format_docker_command_for_logging,
check_docker_requirements,
get_openhands_version,
launch_gui_server,
)
class TestFormatDockerCommand:
"""Test the Docker command formatting function."""
@pytest.mark.parametrize(
"cmd,expected",
[
(
['docker', 'run', 'hello-world'],
'<grey>Running Docker command: docker run hello-world</grey>',
),
(
['docker', 'run', '-it', '--rm', '-p', '3000:3000', 'openhands:latest'],
'<grey>Running Docker command: docker run -it --rm -p 3000:3000 openhands:latest</grey>',
),
([], '<grey>Running Docker command: </grey>'),
],
)
def test_format_docker_command(self, cmd, expected):
"""Test formatting Docker commands."""
result = _format_docker_command_for_logging(cmd)
assert result == expected
class TestCheckDockerRequirements:
"""Test Docker requirements checking."""
@pytest.mark.parametrize(
"which_return,run_side_effect,expected_result,expected_print_count",
[
# Docker not installed
(None, None, False, 2),
# Docker daemon not running
('/usr/bin/docker', MagicMock(returncode=1), False, 2),
# Docker timeout
('/usr/bin/docker', subprocess.TimeoutExpired('docker info', 10), False, 2),
# Docker available
('/usr/bin/docker', MagicMock(returncode=0), True, 0),
],
)
@patch('shutil.which')
@patch('subprocess.run')
def test_docker_requirements(
self, mock_run, mock_which, which_return, run_side_effect, expected_result, expected_print_count
):
"""Test Docker requirements checking scenarios."""
mock_which.return_value = which_return
if run_side_effect is not None:
if isinstance(run_side_effect, Exception):
mock_run.side_effect = run_side_effect
else:
mock_run.return_value = run_side_effect
with patch('openhands_cli.gui_launcher.print_formatted_text') as mock_print:
result = check_docker_requirements()
assert result is expected_result
assert mock_print.call_count == expected_print_count
class TestGetOpenHandsVersion:
"""Test version retrieval."""
@pytest.mark.parametrize(
"env_value,expected",
[
(None, 'latest'), # No environment variable set
('1.2.3', '1.2.3'), # Environment variable set
],
)
def test_version_retrieval(self, env_value, expected):
"""Test version retrieval from environment."""
if env_value:
os.environ['OPENHANDS_VERSION'] = env_value
result = get_openhands_version()
assert result == expected
class TestLaunchGuiServer:
"""Test GUI server launching."""
@patch('openhands_cli.gui_launcher.check_docker_requirements')
@patch('openhands_cli.gui_launcher.print_formatted_text')
def test_launch_gui_server_docker_not_available(self, mock_print, mock_check_docker):
"""Test that launch_gui_server exits when Docker is not available."""
mock_check_docker.return_value = False
with pytest.raises(SystemExit) as exc_info:
launch_gui_server()
assert exc_info.value.code == 1
@pytest.mark.parametrize(
"pull_side_effect,run_side_effect,expected_exit_code,mount_cwd,gpu",
[
# Docker pull failure
(subprocess.CalledProcessError(1, 'docker pull'), None, 1, False, False),
# Docker pull timeout
(subprocess.TimeoutExpired('docker pull', 300), None, 1, False, False),
# Docker run failure
(MagicMock(returncode=0), subprocess.CalledProcessError(1, 'docker run'), 1, False, False),
# KeyboardInterrupt during run
(MagicMock(returncode=0), KeyboardInterrupt(), 0, False, False),
# Success with mount_cwd
(MagicMock(returncode=0), MagicMock(returncode=0), None, True, False),
# Success with GPU
(MagicMock(returncode=0), MagicMock(returncode=0), None, False, True),
],
)
@patch('openhands_cli.gui_launcher.check_docker_requirements')
@patch('openhands_cli.gui_launcher.ensure_config_dir_exists')
@patch('openhands_cli.gui_launcher.get_openhands_version')
@patch('subprocess.run')
@patch('subprocess.check_output')
@patch('pathlib.Path.cwd')
@patch('openhands_cli.gui_launcher.print_formatted_text')
def test_launch_gui_server_scenarios(
self,
mock_print,
mock_cwd,
mock_check_output,
mock_run,
mock_version,
mock_config_dir,
mock_check_docker,
pull_side_effect,
run_side_effect,
expected_exit_code,
mount_cwd,
gpu,
):
"""Test various GUI server launch scenarios."""
# Setup mocks
mock_check_docker.return_value = True
mock_config_dir.return_value = Path('/home/user/.openhands')
mock_version.return_value = 'latest'
mock_check_output.return_value = '1000\n'
mock_cwd.return_value = Path('/current/dir')
# Configure subprocess.run side effects
side_effects = []
if pull_side_effect is not None:
if isinstance(pull_side_effect, Exception):
side_effects.append(pull_side_effect)
else:
side_effects.append(pull_side_effect)
if run_side_effect is not None:
if isinstance(run_side_effect, Exception):
side_effects.append(run_side_effect)
else:
side_effects.append(run_side_effect)
mock_run.side_effect = side_effects
# Test the function
if expected_exit_code is not None:
with pytest.raises(SystemExit) as exc_info:
launch_gui_server(mount_cwd=mount_cwd, gpu=gpu)
assert exc_info.value.code == expected_exit_code
else:
# Should not raise SystemExit for successful cases
launch_gui_server(mount_cwd=mount_cwd, gpu=gpu)
# Verify subprocess.run was called correctly
assert mock_run.call_count == 2 # Pull and run commands
# Check pull command
pull_call = mock_run.call_args_list[0]
pull_cmd = pull_call[0][0]
assert pull_cmd[0:3] == ['docker', 'pull', 'docker.all-hands.dev/all-hands-ai/runtime:latest-nikolaik']
# Check run command
run_call = mock_run.call_args_list[1]
run_cmd = run_call[0][0]
assert run_cmd[0:2] == ['docker', 'run']
if mount_cwd:
assert 'SANDBOX_VOLUMES=/current/dir:/workspace:rw' in ' '.join(run_cmd)
assert 'SANDBOX_USER_ID=1000' in ' '.join(run_cmd)
if gpu:
assert '--gpus' in run_cmd
assert 'all' in run_cmd
assert 'SANDBOX_ENABLE_GPU=true' in ' '.join(run_cmd)

View File

@@ -1,15 +1,19 @@
"""Tests for main entry point functionality.""" """Tests for main entry point functionality."""
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from openhands_cli import simple_main from openhands_cli import simple_main
from openhands_cli.simple_main import main
class TestMainEntryPoint: class TestMainEntryPoint:
"""Test the main entry point behavior.""" """Test the main entry point behavior."""
@patch('openhands_cli.simple_main.run_cli_entry') @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands']) @patch('sys.argv', ['openhands'])
def test_main_starts_agent_chat_directly( def test_main_starts_agent_chat_directly(
self, mock_run_agent_chat: MagicMock self, mock_run_agent_chat: MagicMock
@@ -24,7 +28,7 @@ class TestMainEntryPoint:
# Should call run_cli_entry with no resume conversation ID # Should call run_cli_entry with no resume conversation ID
mock_run_agent_chat.assert_called_once_with(resume_conversation_id=None) mock_run_agent_chat.assert_called_once_with(resume_conversation_id=None)
@patch('openhands_cli.simple_main.run_cli_entry') @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands']) @patch('sys.argv', ['openhands'])
def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None: def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None:
"""Test that main() handles ImportError gracefully.""" """Test that main() handles ImportError gracefully."""
@@ -36,7 +40,7 @@ class TestMainEntryPoint:
assert str(exc_info.value) == 'Missing dependency' assert str(exc_info.value) == 'Missing dependency'
@patch('openhands_cli.simple_main.run_cli_entry') @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands']) @patch('sys.argv', ['openhands'])
def test_main_handles_keyboard_interrupt( def test_main_handles_keyboard_interrupt(
self, mock_run_agent_chat: MagicMock self, mock_run_agent_chat: MagicMock
@@ -48,7 +52,7 @@ class TestMainEntryPoint:
# Should complete without raising an exception (graceful exit) # Should complete without raising an exception (graceful exit)
simple_main.main() simple_main.main()
@patch('openhands_cli.simple_main.run_cli_entry') @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands']) @patch('sys.argv', ['openhands'])
def test_main_handles_eof_error(self, mock_run_agent_chat: MagicMock) -> None: def test_main_handles_eof_error(self, mock_run_agent_chat: MagicMock) -> None:
"""Test that main() handles EOFError gracefully.""" """Test that main() handles EOFError gracefully."""
@@ -58,7 +62,7 @@ class TestMainEntryPoint:
# Should complete without raising an exception (graceful exit) # Should complete without raising an exception (graceful exit)
simple_main.main() simple_main.main()
@patch('openhands_cli.simple_main.run_cli_entry') @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands']) @patch('sys.argv', ['openhands'])
def test_main_handles_general_exception( def test_main_handles_general_exception(
self, mock_run_agent_chat: MagicMock self, mock_run_agent_chat: MagicMock
@@ -72,7 +76,7 @@ class TestMainEntryPoint:
assert str(exc_info.value) == 'Unexpected error' assert str(exc_info.value) == 'Unexpected error'
@patch('openhands_cli.simple_main.run_cli_entry') @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands', '--resume', 'test-conversation-id']) @patch('sys.argv', ['openhands', '--resume', 'test-conversation-id'])
def test_main_with_resume_argument(self, mock_run_agent_chat: MagicMock) -> None: def test_main_with_resume_argument(self, mock_run_agent_chat: MagicMock) -> None:
"""Test that main() passes resume conversation ID when provided.""" """Test that main() passes resume conversation ID when provided."""
@@ -86,3 +90,65 @@ class TestMainEntryPoint:
mock_run_agent_chat.assert_called_once_with( mock_run_agent_chat.assert_called_once_with(
resume_conversation_id='test-conversation-id' resume_conversation_id='test-conversation-id'
) )
@pytest.mark.parametrize(
"argv,expected_kwargs",
[
(['openhands'], {"resume_conversation_id": None}),
(['openhands', '--resume', 'test-id'], {"resume_conversation_id": 'test-id'}),
],
)
def test_main_cli_calls_run_cli_entry(monkeypatch, argv, expected_kwargs):
# Patch sys.argv since main() takes no params
monkeypatch.setattr(sys, "argv", argv, raising=False)
called = {}
fake_agent_chat = SimpleNamespace(
run_cli_entry=lambda **kw: called.setdefault("kwargs", kw)
)
# Provide the symbol that main() will import
monkeypatch.setitem(sys.modules, "openhands_cli.agent_chat", fake_agent_chat)
# Execute (no SystemExit expected on success)
main()
assert called["kwargs"] == expected_kwargs
@pytest.mark.parametrize(
"argv,expected_kwargs",
[
(['openhands', 'serve'], {"mount_cwd": False, "gpu": False}),
(['openhands', 'serve', '--mount-cwd'], {"mount_cwd": True, "gpu": False}),
(['openhands', 'serve', '--gpu'], {"mount_cwd": False, "gpu": True}),
(['openhands', 'serve', '--mount-cwd', '--gpu'], {"mount_cwd": True, "gpu": True}),
],
)
def test_main_serve_calls_launch_gui_server(monkeypatch, argv, expected_kwargs):
monkeypatch.setattr(sys, "argv", argv, raising=False)
called = {}
fake_gui = SimpleNamespace(
launch_gui_server=lambda **kw: called.setdefault("kwargs", kw)
)
monkeypatch.setitem(sys.modules, "openhands_cli.gui_launcher", fake_gui)
main()
assert called["kwargs"] == expected_kwargs
@pytest.mark.parametrize(
"argv,expected_exit_code",
[
(['openhands', 'invalid-command'], 2), # argparse error
(['openhands', '--help'], 0), # top-level help
(['openhands', 'serve', '--help'], 0), # subcommand help
],
)
def test_help_and_invalid(monkeypatch, argv, expected_exit_code):
monkeypatch.setattr(sys, "argv", argv, raising=False)
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == expected_exit_code