Skip to content

Make CLI pip-installable #8772

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 31 commits into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ghcr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ jobs:
- name: Install poetry via pipx
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Run docker runtime tests
run: |
# We install pytest-xdist in order to run tests across CPUs
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/integration-runner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ jobs:
Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.

- name: Install Python dependencies using Poetry
run: poetry install --without evaluation
run: poetry install --with dev,test,runtime

- name: Configure config.toml for testing with Haiku
env:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/py-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
python-version: ${{ matrix.python-version }}
cache: 'poetry'
- name: Install Python dependencies using Poetry
run: poetry install --without evaluation
run: poetry install --with dev,test,runtime
- name: Build Environment
run: make build
- name: Run Unit Tests
Expand All @@ -71,7 +71,7 @@ jobs:
python-version: ${{ matrix.python-version }}
cache: 'poetry'
- name: Install Python dependencies using Poetry
run: poetry install --without evaluation
run: poetry install --with dev,test,runtime
- name: Run Windows unit tests
run: poetry run pytest -svv tests/unit/test_windows_bash.py
- name: Run Windows runtime tests with LocalRuntime
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ install-python-dependencies:
echo "Installing only POETRY_GROUP=${POETRY_GROUP}"; \
poetry install --only $${POETRY_GROUP}; \
else \
poetry install; \
poetry install --with dev,test,runtime; \
fi
@if [ "${INSTALL_PLAYWRIGHT}" != "false" ] && [ "${INSTALL_PLAYWRIGHT}" != "0" ]; then \
if [ -f "/etc/manjaro-release" ]; then \
Expand Down
2 changes: 1 addition & 1 deletion containers/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ RUN apt-get update -y \

COPY ./pyproject.toml ./poetry.lock ./
RUN touch README.md
RUN export POETRY_CACHE_DIR && poetry install --without evaluation --no-root && rm -rf $POETRY_CACHE_DIR
RUN export POETRY_CACHE_DIR && poetry install --no-root && rm -rf $POETRY_CACHE_DIR

FROM python:3.12.3-slim AS openhands-app

Expand Down
10 changes: 7 additions & 3 deletions openhands/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ def on_event(event: Event) -> None:
return new_session_requested


async def main(loop: asyncio.AbstractEventLoop) -> None:
async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
"""Runs the agent in CLI mode."""
args = parse_arguments()

Expand Down Expand Up @@ -419,11 +419,11 @@ async def main(loop: asyncio.AbstractEventLoop) -> None:
)


if __name__ == '__main__':
def main():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(main(loop))
loop.run_until_complete(main_with_loop(loop))
except KeyboardInterrupt:
print('Received keyboard interrupt, shutting down...')
except ConnectionRefusedError as e:
Expand All @@ -445,3 +445,7 @@ async def main(loop: asyncio.AbstractEventLoop) -> None:
except Exception as e:
print(f'Error during cleanup: {e}')
sys.exit(1)


if __name__ == '__main__':
main()
1,182 changes: 599 additions & 583 deletions poetry.lock

Large diffs are not rendered by default.

67 changes: 41 additions & 26 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,47 +20,36 @@ packages = [

[tool.poetry.dependencies]
python = "^3.12,<3.14"
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-generativeai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
google-auth-httplib2 = "*" # For Google Sheets authentication
google-auth-oauthlib = "*" # For Google Sheets OAuth
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-generativeai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
google-auth-httplib2 = "*" # For Google Sheets authentication
google-auth-oauthlib = "*" # For Google Sheets OAuth
termcolor = "*"
docker = "*"
fastapi = "*"
toml = "*"
uvicorn = "*"
types-toml = "*"
uvicorn = "*"
numpy = "*"
json-repair = "*"
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
html2text = "*"
e2b = ">=1.0.5,<1.4.0"
pexpect = "*"
jinja2 = "^3.1.3"
python-multipart = "*"
boto3 = "*"
minio = "^7.2.8"
tenacity = ">=8.5,<10.0"
zope-interface = "7.2"
pathspec = "^0.12.1"
google-cloud-aiplatform = "*"
anthropic = { extras = [ "vertex" ], version = "*" }
tree-sitter = "^0.24.0"
bashlex = "^0.18"
pyjwt = "^2.9.0"
dirhash = "*"
python-frontmatter = "^1.1.0"
python-docx = "*"
PyPDF2 = "*"
python-pptx = "*"
pylatexenc = "*"
tornado = "*"
python-dotenv = "*"
rapidfuzz = "^3.9.0"
whatthepatch = "^1.0.6"
protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = ">=0.66.26,<0.78.0"
Expand All @@ -70,21 +59,36 @@ pygithub = "^2.5.0"
joblib = "*"
openhands-aci = "0.3.0"
python-socketio = "^5.11.4"
redis = ">=5.2,<7.0"
sse-starlette = "^2.1.3"
psutil = "*"
stripe = ">=11.5,<13.0"
ipywidgets = "^8.1.5"
qtconsole = "^5.6.1"
memory-profiler = "^0.61.0"
daytona-sdk = "0.18.1"
python-json-logger = "^3.2.1"
prompt-toolkit = "^3.0.50"
poetry = "^2.1.2"
anyio = "4.9.0"
pythonnet = "*"
fastmcp = "^2.5.2"
mcpm = "1.12.0"
python-frontmatter = "^1.1.0"
# TODO: Should these go into the runtime group?
ipywidgets = "^8.1.5"
qtconsole = "^5.6.1"
PyPDF2 = "*"
python-pptx = "*"
pylatexenc = "*"
python-docx = "*"
bashlex = "^0.18"

# TODO: These are integrations that should probably be optional
redis = ">=5.2,<7.0"
minio = "^7.2.8"
daytona-sdk = "0.18.1"
stripe = ">=11.5,<13.0"
google-cloud-aiplatform = "*"
anthropic = { extras = [ "vertex" ], version = "*" }
boto3 = "*"

[tool.poetry.group.dev]
optional = true

[tool.poetry.group.dev.dependencies]
ruff = "0.11.11"
Expand All @@ -93,6 +97,9 @@ pre-commit = "4.2.0"
build = "*"
types-setuptools = "*"

[tool.poetry.group.test]
optional = true

[tool.poetry.group.test.dependencies]
pytest = "*"
pytest-cov = "*"
Expand All @@ -104,11 +111,18 @@ pandas = "*"
reportlab = "*"
gevent = ">=24.2.1,<26.0.0"

[tool.poetry.group.runtime]
optional = true

[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
jupyter_kernel_gateway = "*"
flake8 = "*"
memory-profiler = "^0.61.0"

[tool.poetry.group.evaluation]
optional = true

[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
Expand All @@ -132,6 +146,7 @@ browsergym-visualwebarena = "0.13.3"
boto3-stubs = { extras = [ "s3" ], version = "^1.37.19" }
pyarrow = "20.0.0" # transitive dependency, pinned here to avoid conflicts
datasets = "*"
joblib = "*"

[tool.poetry.scripts]
openhands = "openhands.cli.main:main"
Expand Down
8 changes: 6 additions & 2 deletions tests/unit/test_agent_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,7 @@ async def test_first_user_message_with_identical_content(test_event_stream, mock
await controller.close()


@pytest.mark.asyncio
async def test_agent_controller_processes_null_observation_with_cause():
"""Test that AgentController processes NullObservation events with a cause value.

Expand All @@ -1395,6 +1396,9 @@ async def test_agent_controller_processes_null_observation_with_cause():

# Create a mock agent with necessary attributes
mock_agent = MagicMock(spec=Agent)
mock_agent.get_system_message = MagicMock(
return_value=None,
)
mock_agent.llm = MagicMock(spec=LLM)
mock_agent.llm.metrics = Metrics()
mock_agent.llm.config = OpenHandsConfig().get_llm_config()
Expand All @@ -1408,14 +1412,14 @@ async def test_agent_controller_processes_null_observation_with_cause():
)

# Patch the controller's step method to track calls
with patch.object(controller, 'step') as mock_step:
with patch.object(controller, '_step') as mock_step:
# Create and add the first user message (will have ID 0)
user_message = MessageAction(content='First user message')
user_message._source = EventSource.USER # type: ignore[attr-defined]
event_stream.add_event(user_message, EventSource.USER)

# Give it a little time to process
await asyncio.sleep(0.3)
await asyncio.sleep(1)

# Get all events from the stream
events = list(event_stream.get_events())
Expand Down
10 changes: 5 additions & 5 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ async def test_main_without_task(
mock_run_session.return_value = False

# Run the function
await cli.main(loop)
await cli.main_with_loop(loop)

# Assertions
mock_parse_args.assert_called_once()
Expand Down Expand Up @@ -458,7 +458,7 @@ async def test_main_with_task(
mock_run_session.side_effect = [True, False]

# Run the function
await cli.main(loop)
await cli.main_with_loop(loop)

# Assertions
mock_parse_args.assert_called_once()
Expand Down Expand Up @@ -553,7 +553,7 @@ async def test_main_with_session_name_passes_name_to_run_session(
mock_run_session.return_value = False

# Run the function
await cli.main(loop)
await cli.main_with_loop(loop)

# Assertions
mock_parse_args.assert_called_once()
Expand Down Expand Up @@ -713,7 +713,7 @@ async def test_main_security_check_fails(
mock_check_security.return_value = False

# Run the function
await cli.main(loop)
await cli.main_with_loop(loop)

# Assertions
mock_parse_args.assert_called_once()
Expand Down Expand Up @@ -796,7 +796,7 @@ async def test_config_loading_order(
mock_run_session.return_value = False # No new session requested

# Run the function
await cli.main(loop)
await cli.main_with_loop(loop)

# Assertions for argument parsing and config setup
mock_parse_args.assert_called_once()
Expand Down
Loading