From c3291b967b45f612c07d689f9307357899859c8b Mon Sep 17 00:00:00 2001 From: Vini Brasil Date: Fri, 4 Oct 2024 11:02:50 -0300 Subject: [PATCH] Add `--force` option to `crewai tool publish` (#1383) This commit adds an option to bypass Git remote validations when publishing tools. --- src/crewai/cli/cli.py | 5 +- src/crewai/cli/tools/main.py | 4 +- tests/cli/tools/test_main.py | 578 +++++++++++++++++++---------------- 3 files changed, 316 insertions(+), 271 deletions(-) diff --git a/src/crewai/cli/cli.py b/src/crewai/cli/cli.py index c8660f346..6f9cbd423 100644 --- a/src/crewai/cli/cli.py +++ b/src/crewai/cli/cli.py @@ -276,12 +276,13 @@ def tool_install(handle: str): @tool.command(name="publish") +@click.option("--force", is_flag=True, show_default=True, default=False, help="Bypasses Git remote validations") @click.option("--public", "is_public", flag_value=True, default=False) @click.option("--private", "is_public", flag_value=False) -def tool_publish(is_public: bool): +def tool_publish(is_public: bool, force: bool): tool_cmd = ToolCommand() tool_cmd.login() - tool_cmd.publish(is_public) + tool_cmd.publish(is_public, force) @crewai.group() diff --git a/src/crewai/cli/tools/main.py b/src/crewai/cli/tools/main.py index 68c8f342c..6b0a76e9f 100644 --- a/src/crewai/cli/tools/main.py +++ b/src/crewai/cli/tools/main.py @@ -59,8 +59,8 @@ class ToolCommand(BaseCommand, PlusAPIMixin): finally: os.chdir(old_directory) - def publish(self, is_public: bool): - if not git.Repository().is_synced(): + def publish(self, is_public: bool, force: bool = False): + if not git.Repository().is_synced() and not force: console.print( "[bold red]Failed to publish tool.[/bold red]\n" "Local changes need to be resolved before publishing. Please do the following:\n" diff --git a/tests/cli/tools/test_main.py b/tests/cli/tools/test_main.py index 9f269091b..354d98865 100644 --- a/tests/cli/tools/test_main.py +++ b/tests/cli/tools/test_main.py @@ -1,300 +1,344 @@ -from contextlib import contextmanager import tempfile import unittest import unittest.mock import os +from contextlib import contextmanager + +from pytest import raises from crewai.cli.tools.main import ToolCommand from io import StringIO from unittest.mock import patch, MagicMock +@contextmanager +def in_temp_dir(): + original_dir = os.getcwd() + with tempfile.TemporaryDirectory() as temp_dir: + os.chdir(temp_dir) + try: + yield temp_dir + finally: + os.chdir(original_dir) -class TestToolCommand(unittest.TestCase): - @contextmanager - def in_temp_dir(self): - original_dir = os.getcwd() - with tempfile.TemporaryDirectory() as temp_dir: - os.chdir(temp_dir) - try: - yield temp_dir - finally: - os.chdir(original_dir) - - @patch("crewai.cli.tools.main.subprocess.run") - def test_create_success(self, mock_subprocess): - with self.in_temp_dir(): - tool_command = ToolCommand() - - with patch.object(tool_command, "login") as mock_login, patch( - "sys.stdout", new=StringIO() - ) as fake_out: - tool_command.create("test-tool") - output = fake_out.getvalue() - - self.assertTrue(os.path.isdir("test_tool")) - - self.assertTrue(os.path.isfile(os.path.join("test_tool", "README.md"))) - self.assertTrue(os.path.isfile(os.path.join("test_tool", "pyproject.toml"))) - self.assertTrue( - os.path.isfile( - os.path.join("test_tool", "src", "test_tool", "__init__.py") - ) - ) - self.assertTrue( - os.path.isfile(os.path.join("test_tool", "src", "test_tool", "tool.py")) - ) - - with open( - os.path.join("test_tool", "src", "test_tool", "tool.py"), "r" - ) as f: - content = f.read() - self.assertIn("class TestTool", content) - - mock_login.assert_called_once() - mock_subprocess.assert_called_once_with(["git", "init"], check=True) - - self.assertIn("Creating custom tool test_tool...", output) - - @patch("crewai.cli.tools.main.subprocess.run") - @patch("crewai.cli.plus_api.PlusAPI.get_tool") - def test_install_success(self, mock_get, mock_subprocess_run): - mock_get_response = MagicMock() - mock_get_response.status_code = 200 - mock_get_response.json.return_value = { - "handle": "sample-tool", - "repository": {"handle": "sample-repo", "url": "https://example.com/repo"}, - } - mock_get.return_value = mock_get_response - mock_subprocess_run.return_value = MagicMock(stderr=None) - +@patch("crewai.cli.tools.main.subprocess.run") +def test_create_success(mock_subprocess): + with in_temp_dir(): tool_command = ToolCommand() - with patch("sys.stdout", new=StringIO()) as fake_out: - tool_command.install("sample-tool") + with patch.object(tool_command, "login") as mock_login, patch( + "sys.stdout", new=StringIO() + ) as fake_out: + tool_command.create("test-tool") output = fake_out.getvalue() - mock_get.assert_called_once_with("sample-tool") - mock_subprocess_run.assert_any_call( - ["poetry", "add", "--source", "crewai-sample-repo", "sample-tool"], - capture_output=False, - text=True, - check=True, + assert os.path.isdir("test_tool") + assert os.path.isfile(os.path.join("test_tool", "README.md")) + assert os.path.isfile(os.path.join("test_tool", "pyproject.toml")) + assert os.path.isfile( + os.path.join("test_tool", "src", "test_tool", "__init__.py") ) + assert os.path.isfile(os.path.join("test_tool", "src", "test_tool", "tool.py")) - self.assertIn("Succesfully installed sample-tool", output) + with open( + os.path.join("test_tool", "src", "test_tool", "tool.py"), "r" + ) as f: + content = f.read() + assert "class TestTool" in content - @patch("crewai.cli.plus_api.PlusAPI.get_tool") - def test_install_tool_not_found(self, mock_get): - mock_get_response = MagicMock() - mock_get_response.status_code = 404 - mock_get.return_value = mock_get_response + mock_login.assert_called_once() + mock_subprocess.assert_called_once_with(["git", "init"], check=True) - tool_command = ToolCommand() + assert "Creating custom tool test_tool..." in output - with patch("sys.stdout", new=StringIO()) as fake_out: - with self.assertRaises(SystemExit): - tool_command.install("non-existent-tool") - output = fake_out.getvalue() +@patch("crewai.cli.tools.main.subprocess.run") +@patch("crewai.cli.plus_api.PlusAPI.get_tool") +def test_install_success(mock_get, mock_subprocess_run): + mock_get_response = MagicMock() + mock_get_response.status_code = 200 + mock_get_response.json.return_value = { + "handle": "sample-tool", + "repository": {"handle": "sample-repo", "url": "https://example.com/repo"}, + } + mock_get.return_value = mock_get_response + mock_subprocess_run.return_value = MagicMock(stderr=None) - mock_get.assert_called_once_with("non-existent-tool") - self.assertIn("No tool found with this name", output) + tool_command = ToolCommand() - @patch("crewai.cli.plus_api.PlusAPI.get_tool") - def test_install_api_error(self, mock_get): - mock_get_response = MagicMock() - mock_get_response.status_code = 500 - mock_get.return_value = mock_get_response + with patch("sys.stdout", new=StringIO()) as fake_out: + tool_command.install("sample-tool") + output = fake_out.getvalue() - tool_command = ToolCommand() - - with patch("sys.stdout", new=StringIO()) as fake_out: - with self.assertRaises(SystemExit): - tool_command.install("error-tool") - output = fake_out.getvalue() - - mock_get.assert_called_once_with("error-tool") - self.assertIn("Failed to get tool details", output) - - @patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") - @patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") - @patch( - "crewai.cli.tools.main.get_project_description", return_value="A sample tool" + mock_get.assert_called_once_with("sample-tool") + mock_subprocess_run.assert_any_call( + ["poetry", "add", "--source", "crewai-sample-repo", "sample-tool"], + capture_output=False, + text=True, + check=True, ) - @patch("crewai.cli.tools.main.subprocess.run") - @patch( - "crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"] - ) - @patch( - "crewai.cli.tools.main.open", - new_callable=unittest.mock.mock_open, - read_data=b"sample tarball content", - ) - @patch("crewai.cli.plus_api.PlusAPI.publish_tool") - @patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=True) - def test_publish_success( - self, - mock_is_synced, - mock_publish, - mock_open, - mock_listdir, - mock_subprocess_run, - mock_get_project_description, - mock_get_project_version, - mock_get_project_name, - ): - mock_publish_response = MagicMock() - mock_publish_response.status_code = 200 - mock_publish_response.json.return_value = {"handle": "sample-tool"} - mock_publish.return_value = mock_publish_response + assert "Succesfully installed sample-tool" in output + +@patch("crewai.cli.plus_api.PlusAPI.get_tool") +def test_install_tool_not_found(mock_get): + mock_get_response = MagicMock() + mock_get_response.status_code = 404 + mock_get.return_value = mock_get_response + + tool_command = ToolCommand() + + with patch("sys.stdout", new=StringIO()) as fake_out: + try: + tool_command.install("non-existent-tool") + except SystemExit: + pass + output = fake_out.getvalue() + + mock_get.assert_called_once_with("non-existent-tool") + assert "No tool found with this name" in output + +@patch("crewai.cli.plus_api.PlusAPI.get_tool") +def test_install_api_error(mock_get): + mock_get_response = MagicMock() + mock_get_response.status_code = 500 + mock_get.return_value = mock_get_response + + tool_command = ToolCommand() + + with patch("sys.stdout", new=StringIO()) as fake_out: + try: + tool_command.install("error-tool") + except SystemExit: + pass + output = fake_out.getvalue() + + mock_get.assert_called_once_with("error-tool") + assert "Failed to get tool details" in output + +@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=False) +def test_publish_when_not_in_sync(mock_is_synced): + with patch("sys.stdout", new=StringIO()) as fake_out, \ + raises(SystemExit): tool_command = ToolCommand() tool_command.publish(is_public=True) - mock_get_project_name.assert_called_with(require=True) - mock_get_project_version.assert_called_with(require=True) - mock_get_project_description.assert_called_with(require=False) - mock_subprocess_run.assert_called_with( - ["poetry", "build", "-f", "sdist", "--output", unittest.mock.ANY], - check=True, - capture_output=False, - ) - mock_open.assert_called_with(unittest.mock.ANY, "rb") - mock_publish.assert_called_with( - handle="sample-tool", - is_public=True, - version="1.0.0", - description="A sample tool", - encoded_file=unittest.mock.ANY, - ) + assert "Local changes need to be resolved before publishing" in fake_out.getvalue() - @patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") - @patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") - @patch( - "crewai.cli.tools.main.get_project_description", return_value="A sample tool" +@patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") +@patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") +@patch("crewai.cli.tools.main.get_project_description", return_value="A sample tool") +@patch("crewai.cli.tools.main.subprocess.run") +@patch("crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) +@patch( + "crewai.cli.tools.main.open", + new_callable=unittest.mock.mock_open, + read_data=b"sample tarball content", +) +@patch("crewai.cli.plus_api.PlusAPI.publish_tool") +@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=False) +def test_publish_when_not_in_sync_and_force( + mock_is_synced, + mock_publish, + mock_open, + mock_listdir, + mock_subprocess_run, + mock_get_project_description, + mock_get_project_version, + mock_get_project_name, +): + mock_publish_response = MagicMock() + mock_publish_response.status_code = 200 + mock_publish_response.json.return_value = {"handle": "sample-tool"} + mock_publish.return_value = mock_publish_response + + tool_command = ToolCommand() + tool_command.publish(is_public=True, force=True) + + mock_get_project_name.assert_called_with(require=True) + mock_get_project_version.assert_called_with(require=True) + mock_get_project_description.assert_called_with(require=False) + mock_subprocess_run.assert_called_with( + ["poetry", "build", "-f", "sdist", "--output", unittest.mock.ANY], + check=True, + capture_output=False, ) - @patch("crewai.cli.tools.main.subprocess.run") - @patch( - "crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"] + mock_open.assert_called_with(unittest.mock.ANY, "rb") + mock_publish.assert_called_with( + handle="sample-tool", + is_public=True, + version="1.0.0", + description="A sample tool", + encoded_file=unittest.mock.ANY, ) - @patch( - "crewai.cli.tools.main.open", - new_callable=unittest.mock.mock_open, - read_data=b"sample tarball content", + +@patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") +@patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") +@patch("crewai.cli.tools.main.get_project_description", return_value="A sample tool") +@patch("crewai.cli.tools.main.subprocess.run") +@patch("crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) +@patch( + "crewai.cli.tools.main.open", + new_callable=unittest.mock.mock_open, + read_data=b"sample tarball content", +) +@patch("crewai.cli.plus_api.PlusAPI.publish_tool") +@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=True) +def test_publish_success( + mock_is_synced, + mock_publish, + mock_open, + mock_listdir, + mock_subprocess_run, + mock_get_project_description, + mock_get_project_version, + mock_get_project_name, +): + mock_publish_response = MagicMock() + mock_publish_response.status_code = 200 + mock_publish_response.json.return_value = {"handle": "sample-tool"} + mock_publish.return_value = mock_publish_response + + tool_command = ToolCommand() + tool_command.publish(is_public=True) + + mock_get_project_name.assert_called_with(require=True) + mock_get_project_version.assert_called_with(require=True) + mock_get_project_description.assert_called_with(require=False) + mock_subprocess_run.assert_called_with( + ["poetry", "build", "-f", "sdist", "--output", unittest.mock.ANY], + check=True, + capture_output=False, ) - @patch("crewai.cli.plus_api.PlusAPI.publish_tool") - def test_publish_failure( - self, - mock_publish, - mock_open, - mock_listdir, - mock_subprocess_run, - mock_get_project_description, - mock_get_project_version, - mock_get_project_name, - ): - mock_publish_response = MagicMock() - mock_publish_response.status_code = 422 - mock_publish_response.json.return_value = {"name": ["is already taken"]} - mock_publish.return_value = mock_publish_response - - tool_command = ToolCommand() - - with patch("sys.stdout", new=StringIO()) as fake_out: - with self.assertRaises(SystemExit): - tool_command.publish(is_public=True) - output = fake_out.getvalue() - - mock_publish.assert_called_once() - self.assertIn("Failed to complete operation", output) - self.assertIn("Name is already taken", output) - - @patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") - @patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") - @patch( - "crewai.cli.tools.main.get_project_description", return_value="A sample tool" + mock_open.assert_called_with(unittest.mock.ANY, "rb") + mock_publish.assert_called_with( + handle="sample-tool", + is_public=True, + version="1.0.0", + description="A sample tool", + encoded_file=unittest.mock.ANY, ) - @patch("crewai.cli.tools.main.subprocess.run") - @patch( - "crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"] + +@patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") +@patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") +@patch("crewai.cli.tools.main.get_project_description", return_value="A sample tool") +@patch("crewai.cli.tools.main.subprocess.run") +@patch("crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) +@patch( + "crewai.cli.tools.main.open", + new_callable=unittest.mock.mock_open, + read_data=b"sample tarball content", +) +@patch("crewai.cli.plus_api.PlusAPI.publish_tool") +def test_publish_failure( + mock_publish, + mock_open, + mock_listdir, + mock_subprocess_run, + mock_get_project_description, + mock_get_project_version, + mock_get_project_name, +): + mock_publish_response = MagicMock() + mock_publish_response.status_code = 422 + mock_publish_response.json.return_value = {"name": ["is already taken"]} + mock_publish.return_value = mock_publish_response + + tool_command = ToolCommand() + + with patch("sys.stdout", new=StringIO()) as fake_out: + try: + tool_command.publish(is_public=True) + except SystemExit: + pass + output = fake_out.getvalue() + + mock_publish.assert_called_once() + assert "Failed to complete operation" in output + assert "Name is already taken" in output + +@patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool") +@patch("crewai.cli.tools.main.get_project_version", return_value="1.0.0") +@patch("crewai.cli.tools.main.get_project_description", return_value="A sample tool") +@patch("crewai.cli.tools.main.subprocess.run") +@patch("crewai.cli.tools.main.os.listdir", return_value=["sample-tool-1.0.0.tar.gz"]) +@patch( + "crewai.cli.tools.main.open", + new_callable=unittest.mock.mock_open, + read_data=b"sample tarball content", +) +@patch("crewai.cli.plus_api.PlusAPI.publish_tool") +def test_publish_api_error( + mock_publish, + mock_open, + mock_listdir, + mock_subprocess_run, + mock_get_project_description, + mock_get_project_version, + mock_get_project_name, +): + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.json.return_value = {"error": "Internal Server Error"} + mock_response.ok = False + mock_publish.return_value = mock_response + + tool_command = ToolCommand() + + with patch("sys.stdout", new=StringIO()) as fake_out: + try: + tool_command.publish(is_public=True) + except SystemExit: + pass + output = fake_out.getvalue() + + mock_publish.assert_called_once() + assert "Request to Enterprise API failed" in output + +@patch("crewai.cli.plus_api.PlusAPI.login_to_tool_repository") +@patch("crewai.cli.tools.main.subprocess.run") +def test_login_success(mock_subprocess_run, mock_login): + mock_login_response = MagicMock() + mock_login_response.status_code = 200 + mock_login_response.json.return_value = { + "repositories": [ + { + "handle": "tools", + "url": "https://example.com/repo", + } + ], + "credential": {"username": "user", "password": "pass"}, + } + mock_login.return_value = mock_login_response + + mock_subprocess_run.return_value = MagicMock(stderr=None) + + tool_command = ToolCommand() + + with patch("sys.stdout", new=StringIO()) as fake_out: + tool_command.login() + output = fake_out.getvalue() + + mock_login.assert_called_once() + mock_subprocess_run.assert_any_call( + [ + "poetry", + "source", + "add", + "--priority=explicit", + "crewai-tools", + "https://example.com/repo", + ], + text=True, + check=True, ) - @patch( - "crewai.cli.tools.main.open", - new_callable=unittest.mock.mock_open, - read_data=b"sample tarball content", + mock_subprocess_run.assert_any_call( + [ + "poetry", + "config", + "http-basic.crewai-tools", + "user", + "pass", + ], + capture_output=False, + text=True, + check=True, ) - @patch("crewai.cli.plus_api.PlusAPI.publish_tool") - def test_publish_api_error( - self, - mock_publish, - mock_open, - mock_listdir, - mock_subprocess_run, - mock_get_project_description, - mock_get_project_version, - mock_get_project_name, - ): - mock_response = MagicMock() - mock_response.status_code = 500 - mock_response.json.return_value = {"error": "Internal Server Error"} - mock_response.ok = False - mock_publish.return_value = mock_response - - tool_command = ToolCommand() - - with patch("sys.stdout", new=StringIO()) as fake_out: - with self.assertRaises(SystemExit): - tool_command.publish(is_public=True) - output = fake_out.getvalue() - - mock_publish.assert_called_once() - self.assertIn("Request to Enterprise API failed", output) - - @patch("crewai.cli.plus_api.PlusAPI.login_to_tool_repository") - @patch("crewai.cli.tools.main.subprocess.run") - def test_login_success(self, mock_subprocess_run, mock_login): - mock_login_response = MagicMock() - mock_login_response.status_code = 200 - mock_login_response.json.return_value = { - "repositories": [ - { - "handle": "tools", - "url": "https://example.com/repo", - } - ], - "credential": {"username": "user", "password": "pass"}, - } - mock_login.return_value = mock_login_response - - mock_subprocess_run.return_value = MagicMock(stderr=None) - - tool_command = ToolCommand() - - with patch("sys.stdout", new=StringIO()) as fake_out: - tool_command.login() - output = fake_out.getvalue() - - mock_login.assert_called_once() - mock_subprocess_run.assert_any_call( - [ - "poetry", - "source", - "add", - "--priority=explicit", - "crewai-tools", - "https://example.com/repo", - ], - text=True, - check=True, - ) - mock_subprocess_run.assert_any_call( - [ - "poetry", - "config", - "http-basic.crewai-tools", - "user", - "pass", - ], - capture_output=False, - text=True, - check=True, - ) - self.assertIn("Succesfully authenticated to the tool repository", output) + assert "Succesfully authenticated to the tool repository" in output