diff --git a/tests/unit_tests/test_tethys_cli/test_gen_commands.py b/tests/unit_tests/test_tethys_cli/test_gen_commands.py index f411457e4..6630a1807 100644 --- a/tests/unit_tests/test_tethys_cli/test_gen_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_gen_commands.py @@ -1,6 +1,8 @@ import unittest from unittest import mock from pathlib import Path +import tempfile + from tethys_cli.gen_commands import ( get_environment_value, @@ -10,7 +12,11 @@ generate_command, gen_vendor_static_files, download_vendor_static_files, + parse_setup_py, + gen_pyproject, + pyproject_post_process, get_destination_path, + get_target_tethys_app_dir, GEN_APACHE_OPTION, GEN_APACHE_SERVICE_OPTION, GEN_NGINX_OPTION, @@ -22,6 +28,7 @@ GEN_SECRETS_OPTION, GEN_META_YAML_OPTION, GEN_PACKAGE_JSON_OPTION, + GEN_PYPROJECT_OPTION, GEN_REQUIREMENTS_OPTION, VALID_GEN_OBJECTS, ) @@ -777,14 +784,14 @@ def test_get_destination_path_vendor(self, mock_isdir, mock_check_file): @mock.patch("tethys_cli.gen_commands.render_template") @mock.patch("tethys_cli.gen_commands.get_destination_path") def test_generate_commmand_post_process_func( - self, mock_get_path, mock_render, mock_write_path, mock_commands + self, mock_gdp, mock_render, mock_write_path, mock_commands ): mock_commands.__getitem__.return_value = (mock.MagicMock(), mock.MagicMock()) mock_args = mock.MagicMock( type="test", ) generate_command(mock_args) - mock_get_path.assert_called_once_with(mock_args) + mock_gdp.assert_called_once_with(mock_args) mock_render.assert_called_once() mock_write_path.assert_called_once() mock_commands.__getitem__.assert_called_once() @@ -821,3 +828,374 @@ def test_generate_command_secrets_yaml_tethys_home_not_exists( mock_mkdir.assert_called() rts_call_args = mock_write_info.call_args_list[0] self.assertIn("A Tethys Secrets file", rts_call_args.args[0]) + + @mock.patch("tethys_cli.gen_commands.Path.cwd") + def test_get_target_tethys_app_dir_no_directory(self, mock_cwd): + mock_args = mock.MagicMock(directory=None) + mock_cwd.return_value = Path("/current/working/dir") + + result = get_target_tethys_app_dir(mock_args) + + self.assertEqual(result, Path("/current/working/dir")) + mock_cwd.assert_called_once() + + @mock.patch("tethys_cli.gen_commands.Path.is_dir") + def test_get_target_tethys_app_dir_with_valid_directory(self, mock_is_dir): + with tempfile.TemporaryDirectory() as temp_dir: + mock_args = mock.MagicMock(directory=temp_dir) + mock_is_dir.return_value = True + + result = get_target_tethys_app_dir(mock_args) + + self.assertEqual(result, Path(temp_dir)) + mock_is_dir.assert_called_once() + + @mock.patch("tethys_cli.gen_commands.exit", side_effect=SystemExit) + @mock.patch("tethys_cli.gen_commands.write_error") + @mock.patch("tethys_cli.gen_commands.Path.is_dir") + def test_get_target_tethys_app_dir_with_invalid_directory( + self, mock_is_dir, mock_write_error, mock_exit + ): + mock_args = mock.MagicMock(directory="/invalid/directory") + mock_is_dir.return_value = False + + with self.assertRaises(SystemExit): + get_target_tethys_app_dir(mock_args) + + mock_is_dir.assert_called_once() + error_msg = mock_write_error.call_args.args[0] + self.assertIn( + 'The specified directory "/invalid/directory" is not valid.', error_msg + ) + mock_exit.assert_called_once_with(1) + + @mock.patch("tethys_cli.gen_commands.Path.is_dir", return_value=True) + @mock.patch("tethys_cli.gen_commands.get_target_tethys_app_dir") + def test_get_destination_path_pyproject(self, mock_gttad, _): + args = mock.MagicMock( + type=GEN_PYPROJECT_OPTION, + directory=Path("/test_dir"), + ) + + expected_result = "/test_dir/pyproject.toml" + mock_gttad.return_value = expected_result + + actual_result = get_destination_path(args) + mock_gttad.assert_called_once_with(args) + self.assertEqual(actual_result, expected_result) + + @mock.patch("tethys_cli.gen_commands.exit", side_effect=SystemExit) + @mock.patch("tethys_cli.gen_commands.get_target_tethys_app_dir") + @mock.patch("tethys_cli.gen_commands.write_error") + def test_generate_command_pyproject_no_setup_py( + self, mock_write_error, mock_gttad, mock_exit + ): + mock_args = mock.MagicMock( + type=GEN_PYPROJECT_OPTION, + directory=None, + spec=["overwrite"], + ) + + with tempfile.TemporaryDirectory() as temp_dir: + app_dir = Path(temp_dir) + mock_gttad.return_value = app_dir + + with self.assertRaises(SystemExit): + gen_pyproject(mock_args) + + error_msg = mock_write_error.call_args.args[0] + + expected = f'The specified Tethys app directory "{app_dir}" does not contain a setup.py file.' + self.assertIn(expected, error_msg) + + mock_exit.assert_called_once_with(1) + + def test_parse_setup_py(self): + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + setup_path = temp_dir / "setup.py" + + import textwrap + + # Write a fake setup.py into the temp folder + setup_path.write_text( + textwrap.dedent( + """ + app_package = 'test_app' + + from setuptools import setup + + setup( + description='A test description', + author='Test Author', + author_email='test@example.com', + keywords=['alpha', 'beta'], + license='MIT', + ) + """ + ) + ) + + metadata = parse_setup_py(setup_path) + + assert metadata["app_package"] == "test_app" + assert metadata["description"] == "A test description" + assert metadata["author"] == "Test Author" + assert metadata["author_email"] == "test@example.com" + assert metadata["keywords"] == "alpha, beta" + assert metadata["license"] == "MIT" + + @mock.patch("tethys_cli.gen_commands.write_error") + def test_parse_setup_py_no_setup(self, mock_write_error): + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + setup_path = temp_dir / "setup.py" + + metadata = parse_setup_py(setup_path) + + self.assertIsNone(metadata) + + error_msg = mock_write_error.call_args.args[0] + + expected = f"Failed to parse setup.py: [Errno 2] No such file or directory: '{setup_path}'" + self.assertIn(expected, error_msg) + + @mock.patch("tethys_cli.gen_commands.write_warning") + @mock.patch("tethys_cli.gen_commands.exit", side_effect=SystemExit) + def test_parse_setup_py_invalid_package_name(self, mock_exit, mock_write_warning): + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + setup_path = temp_dir / "setup.py" + + import textwrap + + # Write a fake setup.py into the temp folder + setup_path.write_text( + textwrap.dedent( + """ + app_package = fake_function() + + from setuptools import setup + + setup( + description='A test description', + author='Test Author', + author_email='test@example.com', + keywords=['alpha', 'beta'], + license='MIT', + ) + """ + ) + ) + with self.assertRaises(SystemExit): + parse_setup_py(setup_path) + + warning_msg = mock_write_warning.call_args.args[0] + + expected = "Found invalid 'app_package' in setup.py: 'fake_function()'" + + self.assertIn(expected, warning_msg) + + mock_exit.assert_called_once_with(1) + + @mock.patch("tethys_cli.gen_commands.write_warning") + @mock.patch("tethys_cli.gen_commands.exit", side_effect=SystemExit) + def test_parse_setup_py_no_app_package(self, mock_exit, mock_write_warning): + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + setup_path = temp_dir / "setup.py" + + import textwrap + + # Write a fake setup.py into the temp folder + setup_path.write_text( + textwrap.dedent( + """ + from setuptools import setup + + setup( + description='A test description', + author='Test Author', + author_email='test@example.com', + keywords=['alpha', 'beta'], + license='MIT', + ) + """ + ) + ) + with self.assertRaises(SystemExit): + parse_setup_py(setup_path) + + warning_msg = mock_write_warning.call_args.args[0] + expected = "Could not find 'app_package' in setup.py." + self.assertIn(expected, warning_msg) + + mock_exit.assert_called_once_with(1) + + @mock.patch("tethys_cli.gen_commands.write_warning") + @mock.patch("tethys_cli.gen_commands.exit", side_effect=SystemExit) + def test_parse_setup_py_invalid_setup_attr(self, mock_exit, mock_write_warning): + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + setup_path = temp_dir / "setup.py" + + import textwrap + + # Write a fake setup.py into the temp folder + setup_path.write_text( + textwrap.dedent( + """ + from setuptools import setup + + app_package = 'test_app' + + setup( + description='A test description', + author=fake_function(), + author_email='test@example.com', + keywords=['alpha', 'beta'], + license='MIT', + ) + """ + ) + ) + with self.assertRaises(SystemExit): + parse_setup_py(setup_path) + + warning_msg = mock_write_warning.call_args.args[0] + expected = "Found invalid 'author' in setup.py: 'fake_function()'" + self.assertIn(expected, warning_msg) + + mock_exit.assert_called_once_with(1) + + @mock.patch("tethys_cli.gen_commands.input", return_value="yes") + @mock.patch("tethys_cli.gen_commands.write_info") + @mock.patch("tethys_cli.gen_commands.get_destination_path") + def test_pyproject_post_process_remove_setup_yes( + self, mock_gdp, mock_write_info, _ + ): + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + setup_path = temp_dir / "setup.py" + + setup_path.write_text("fake content") + + mock_gdp.return_value = temp_dir / "pyproject.toml" + + mock_args = mock.MagicMock(type=GEN_PYPROJECT_OPTION) + pyproject_post_process(mock_args) + + # Verify setup.py was removed + self.assertFalse(setup_path.exists()) + + mock_write_info.assert_called_once() + info_msg = mock_write_info.call_args.args[0] + self.assertIn(f'Removed setup.py file at "{setup_path}".', info_msg) + + @mock.patch("tethys_cli.gen_commands.input", return_value="no") + @mock.patch("tethys_cli.gen_commands.get_destination_path") + def test_pyproject_post_process_remove_setup_no(self, mock_gdp, _): + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + setup_path = temp_dir / "setup.py" + + setup_path.write_text("fake content") + + mock_gdp.return_value = temp_dir / "pyproject.toml" + + mock_args = mock.MagicMock(type=GEN_PYPROJECT_OPTION) + + pyproject_post_process(mock_args) + + # Verify setup.py still exists + self.assertTrue(setup_path.exists()) + + @mock.patch("tethys_cli.gen_commands.input", return_value="yes") + @mock.patch("tethys_cli.gen_commands.write_error") + @mock.patch("tethys_cli.gen_commands.get_destination_path") + def test_pyproject_post_process_setup_not_found( + self, mock_gdp, mock_write_error, _ + ): + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + + mock_gdp.return_value = temp_dir / "pyproject.toml" + + mock_args = mock.MagicMock(type=GEN_PYPROJECT_OPTION) + + pyproject_post_process(mock_args) + + mock_write_error.assert_called_once() + error_msg = mock_write_error.call_args.args[0] + self.assertIn( + f'The specified Tethys app directory "{temp_dir}" does not contain a setup.py file', + error_msg, + ) + + @mock.patch("tethys_cli.gen_commands.write_info") + @mock.patch("tethys_cli.gen_commands.get_destination_path") + @mock.patch("tethys_cli.gen_commands.input", side_effect=["invalid", "maybe", "y"]) + def test_pyproject_post_process_invalid_input_retry(self, mock_input, mock_gdp, _): + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + setup_path = temp_dir / "setup.py" + + setup_path.write_text("fake content") + + mock_gdp.return_value = temp_dir / "pyproject.toml" + + mock_args = mock.MagicMock(type=GEN_PYPROJECT_OPTION) + + pyproject_post_process(mock_args) + + # Verify setup.py was removed + self.assertFalse(setup_path.exists()) + + # Verify input was called 3 times + self.assertEqual(mock_input.call_count, 3) + + @mock.patch("tethys_cli.gen_commands.parse_setup_py") + def test_gen_pyproject(self, mock_sp): + with tempfile.TemporaryDirectory() as temp_dir: + expected_value = { + "app_package": "test_app", + "description": "A test description", + "author": "Test Author", + "author_email": "test@example.com", + } + mock_sp.return_value = expected_value + + temp_dir = Path(temp_dir) + setup_path = temp_dir / "setup.py" + + setup_path.write_text("fake content") + + mock_args = mock.MagicMock( + type=GEN_PYPROJECT_OPTION, + directory=temp_dir, + spec=["overwrite"], + ) + + result = gen_pyproject(mock_args) + + self.assertEqual(result, expected_value) + + @mock.patch("tethys_cli.gen_commands.write_error") + @mock.patch("tethys_cli.gen_commands.exit", side_effect=SystemExit) + def test_gen_pyproject_no_setup(self, mock_exit, mock_write_error): + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + mock_args = mock.MagicMock( + type=GEN_PYPROJECT_OPTION, + directory=temp_dir, + spec=["overwrite"], + ) + with self.assertRaises(SystemExit): + gen_pyproject(mock_args) + + mock_write_error.assert_called_once() + error_msg = mock_write_error.call_args.args[0] + expected = f'The specified Tethys app directory "{temp_dir}" does not contain a setup.py file.' + self.assertIn(expected, error_msg) + + mock_exit.assert_called_once_with(1) diff --git a/tests/unit_tests/test_tethys_cli/test_install_commands.py b/tests/unit_tests/test_tethys_cli/test_install_commands.py index 256a3b8da..8e9407aba 100644 --- a/tests/unit_tests/test_tethys_cli/test_install_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_install_commands.py @@ -70,7 +70,7 @@ def __init__(self, name, required): self.assertIn("test_name", po_call_args[2][0][0]) @mock.patch("tethys_cli.cli_colors.pretty_output") - @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.install_commands.call", return_value=0) def test_run_sync_stores(self, mock_call, mock_pretty_output): from tethys_apps.models import PersistentStoreConnectionSetting @@ -777,7 +777,7 @@ def tearDown(self): @mock.patch("tethys_cli.cli_colors.pretty_output") @mock.patch("builtins.input", side_effect=["x", "n"]) - @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.install_commands.call", return_value=0) def test_install_file_not_generate(self, mock_call, _, mock_pretty_output): chdir("..") # move to a different directory that doesn't have an install.yml args = mock.MagicMock( @@ -801,7 +801,7 @@ def test_install_file_not_generate(self, mock_call, _, mock_pretty_output): @mock.patch("tethys_cli.cli_colors.pretty_output") @mock.patch("builtins.input", side_effect=["y"]) - @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.install_commands.exit") def test_install_file_generate(self, mock_exit, mock_call, _, __): chdir("..") # move to a different directory that doesn't have an install.yml @@ -821,7 +821,7 @@ def test_install_file_generate(self, mock_exit, mock_call, _, __): mock_exit.assert_called_with(0) @mock.patch("tethys_cli.install_commands.run_services") - @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.cli_colors.pretty_output") def test_no_conda_input_file(self, mock_pretty_output, _, __): file_path = self.root_app_path / "install-no-dep.yml" @@ -848,7 +848,7 @@ def test_no_conda_input_file(self, mock_pretty_output, _, __): self.assertIn("Successfully installed test_app.", po_call_args[5][0][0]) @mock.patch("tethys_cli.install_commands.run_services") - @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.cli_colors.pretty_output") def test_input_file_with_post(self, mock_pretty_output, _, __): file_path = self.root_app_path / "install-with-post.yml" @@ -883,9 +883,9 @@ def test_input_file_with_post(self, mock_pretty_output, _, __): @mock.patch("tethys_cli.install_commands.run_services") @mock.patch("tethys_cli.install_commands.run_sync_stores") @mock.patch("tethys_cli.install_commands.run_interactive_services") - @mock.patch("tethys_cli.install_commands.call") @mock.patch("tethys_cli.install_commands.run_portal_install", return_value=False) @mock.patch("tethys_cli.install_commands.run_services") + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.cli_colors.pretty_output") def test_skip_input_file(self, mock_pretty_output, _, __, ___, ____, _____, ______): file_path = self.root_app_path / "install-skip-setup.yml" @@ -928,7 +928,7 @@ def test_skip_input_file(self, mock_pretty_output, _, __, ___, ____, _____, ____ ) @mock.patch("tethys_cli.install_commands.run_services") - @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.install_commands.conda_run", return_value=["", "", 1]) @mock.patch("tethys_cli.cli_colors.pretty_output") def test_conda_and_pip_package_install( @@ -989,7 +989,7 @@ def test_conda_and_pip_package_install( @mock.patch("tethys_cli.install_commands.write_warning") @mock.patch("tethys_cli.install_commands.conda_available") # CHANGED @mock.patch("tethys_cli.install_commands.run_services") - @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.cli_colors.pretty_output") def test_conda_install_no_conda_proceed( self, mock_pretty_output, mock_call, _, mock_conda_available, mock_warn, __ @@ -1041,7 +1041,7 @@ def test_conda_install_no_conda_proceed( @mock.patch("tethys_cli.install_commands.write_warning") @mock.patch("tethys_cli.install_commands.has_module") @mock.patch("tethys_cli.install_commands.run_services") - @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.cli_colors.pretty_output") def test_conda_install_no_conda_proceed_quietly( self, mock_pretty_output, mock_call, _, mock_has_module, mock_warn, __ @@ -1150,7 +1150,7 @@ def test_conda_install_no_conda_error( without_dependencies=False, ) mock_conda_available.return_value = False # CHANGED - mock_call.side_effect = [Exception, None, None, None] + mock_call.side_effect = [Exception, None, 0, None] install_commands.install_command(args) self.assertEqual(mock_warn.call_count, 2) @@ -1192,7 +1192,7 @@ def test_conda_install_no_conda_error( self.assertEqual(["tethys", "db", "sync"], mock_call.mock_calls[3][1][0]) @mock.patch("tethys_cli.install_commands.run_services") - @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.install_commands.conda_run", return_value=["", "", 1]) @mock.patch("tethys_cli.cli_colors.pretty_output") def test_without_dependencies( @@ -1241,7 +1241,7 @@ def test_without_dependencies( self.assertEqual(["tethys", "db", "sync"], mock_call.mock_calls[1][1][0]) @mock.patch("tethys_cli.install_commands.run_services") - @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.install_commands.conda_run", return_value=["", "", 1]) @mock.patch("tethys_cli.cli_colors.pretty_output") def test_conda_and_pip_package_install_only_dependencies( @@ -1297,7 +1297,7 @@ def test_conda_and_pip_package_install_only_dependencies( ) @mock.patch("tethys_cli.install_commands.run_services") - @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.install_commands.conda_run", return_value=["", "", 1]) @mock.patch("tethys_cli.cli_colors.pretty_output") def test_conda_and_pip_package_install_update_installed( @@ -1372,11 +1372,11 @@ def test_interactive_custom_setting_set(self, mock_pretty_output, mock_gas, _): po_call_args[7][0][0], ) + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.install_commands.input", side_effect=["cat", "y"]) @mock.patch("tethys_cli.install_commands.get_app_settings") @mock.patch("tethys_cli.install_commands.getpass.getpass") @mock.patch("tethys_cli.install_commands.Path.exists") - @mock.patch("tethys_cli.install_commands.call") @mock.patch("tethys_cli.install_commands.generate_salt_string") @mock.patch("tethys_cli.install_commands.yaml.safe_load") @mock.patch("tethys_cli.install_commands.yaml.dump") @@ -1392,11 +1392,11 @@ def test_interactive_custom_setting_set_secret_no_previous_secret_file( mock_yml_dump, mock_yml_safe_load, mock_generate_salt_string, - mock_subprocess_call, mock_path_exist, mock_get_pass, mock_gas, _, + __, ): mock_cs = mock.MagicMock() mock_cs.name = "mock_cs" @@ -1405,7 +1405,6 @@ def test_interactive_custom_setting_set_secret_no_previous_secret_file( mock_gas.return_value = {"unlinked_settings": [mock_cs]} mock_get_pass.return_value = "my_secret_string" mock_path_exist.side_effect = [False, True] - mock_subprocess_call.return_value = mock.MagicMock() mock_generate_salt_string.return_value.decode.return_value = "my_salt_string" app_target_name = "foo" @@ -1448,11 +1447,11 @@ def test_interactive_custom_setting_set_secret_no_previous_secret_file( self.assertIn("Enter the desired value", po_call_args[10][0][0]) self.assertEqual(mock_cs.name + " successfully set", po_call_args[11][0][0]) + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.install_commands.input", side_effect=["cat", "y"]) @mock.patch("tethys_cli.install_commands.get_app_settings") @mock.patch("tethys_cli.install_commands.getpass.getpass") @mock.patch("tethys_cli.install_commands.Path.exists") - @mock.patch("tethys_cli.install_commands.call") @mock.patch("tethys_cli.install_commands.generate_salt_string") @mock.patch("tethys_cli.install_commands.yaml.safe_load") @mock.patch("tethys_cli.install_commands.yaml.dump") @@ -1468,11 +1467,11 @@ def test_interactive_custom_setting_set_secret_with_previous_empty_secret_file( mock_yml_dump, mock_yml_safe_load, mock_generate_salt_string, - mock_subprocess_call, mock_path_exist, mock_get_pass, mock_gas, _, + __, ): mock_cs = mock.MagicMock() mock_cs.name = "mock_cs" @@ -1481,7 +1480,6 @@ def test_interactive_custom_setting_set_secret_with_previous_empty_secret_file( mock_gas.return_value = {"unlinked_settings": [mock_cs]} mock_get_pass.return_value = "my_secret_string" mock_path_exist.return_value = True - mock_subprocess_call.return_value = mock.MagicMock() mock_generate_salt_string.return_value.decode.return_value = "my_salt_string" app_target_name = "foo" @@ -1520,11 +1518,11 @@ def test_interactive_custom_setting_set_secret_with_previous_empty_secret_file( self.assertIn("Enter the desired value", po_call_args[9][0][0]) self.assertEqual(mock_cs.name + " successfully set", po_call_args[10][0][0]) + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.install_commands.input", side_effect=["cat", "n"]) @mock.patch("tethys_cli.install_commands.get_app_settings") @mock.patch("tethys_cli.install_commands.getpass.getpass") @mock.patch("tethys_cli.install_commands.Path.exists") - @mock.patch("tethys_cli.install_commands.call") @mock.patch("tethys_cli.install_commands.generate_salt_string") @mock.patch("tethys_cli.install_commands.yaml.safe_load") @mock.patch("tethys_cli.install_commands.yaml.dump") @@ -1540,11 +1538,11 @@ def test_interactive_custom_setting_set_secret_without_salt_previous_empty_secre mock_yml_dump, mock_yml_safe_load, mock_generate_salt_string, - mock_subprocess_call, mock_path_exist, mock_get_pass, mock_gas, _, + __, ): mock_cs = mock.MagicMock() mock_cs.name = "mock_cs" @@ -1553,7 +1551,6 @@ def test_interactive_custom_setting_set_secret_without_salt_previous_empty_secre mock_gas.return_value = {"unlinked_settings": [mock_cs]} mock_get_pass.return_value = "my_secret_string" mock_path_exist.return_value = True - mock_subprocess_call.return_value = mock.MagicMock() mock_generate_salt_string.return_value.decode.return_value = "my_salt_string" app_target_name = "foo" before_content_secret_empty = {"secrets": {"version": "1.0"}} @@ -1589,11 +1586,11 @@ def test_interactive_custom_setting_set_secret_without_salt_previous_empty_secre self.assertIn("Enter the desired value", po_call_args[8][0][0]) self.assertEqual(mock_cs.name + " successfully set", po_call_args[9][0][0]) + @mock.patch("tethys_cli.install_commands.call", return_value=0) @mock.patch("tethys_cli.install_commands.input", side_effect=["cat", "n"]) @mock.patch("tethys_cli.install_commands.get_app_settings") @mock.patch("tethys_cli.install_commands.getpass.getpass") @mock.patch("tethys_cli.install_commands.Path.exists") - @mock.patch("tethys_cli.install_commands.call") @mock.patch("tethys_cli.install_commands.generate_salt_string") @mock.patch("tethys_cli.install_commands.yaml.safe_load") @mock.patch("tethys_cli.install_commands.yaml.dump") @@ -1609,11 +1606,11 @@ def test_interactive_custom_setting_set_secret_with_salt_previous_empty_secret_f mock_yml_dump, mock_yml_safe_load, mock_generate_salt_string, - mock_subprocess_call, mock_path_exist, mock_get_pass, mock_gas, _, + __, ): mock_cs = mock.MagicMock() mock_cs.name = "mock_cs" @@ -1622,7 +1619,6 @@ def test_interactive_custom_setting_set_secret_with_salt_previous_empty_secret_f mock_gas.return_value = {"unlinked_settings": [mock_cs]} mock_get_pass.return_value = "my_secret_string" mock_path_exist.return_value = True - mock_subprocess_call.return_value = mock.MagicMock() mock_generate_salt_string.return_value.decode.return_value = "my_salt_string" app_target_name = "foo" @@ -2034,3 +2030,107 @@ def test_npm_install_formatting_error( "Successfully installed dependencies for test_app.", po_call_args[2][0][0], ) + + @mock.patch( + "tethys_cli.install_commands.open_file", + return_value={ + "name": "test_app", + "requirements": {"npm": {"test": "1.1.1"}}, + }, + ) + @mock.patch("tethys_cli.install_commands.run_services") + @mock.patch("tethys_cli.install_commands.call", return_value=1) + @mock.patch("tethys_cli.install_commands.Path") + @mock.patch("tethys_cli.cli_colors.pretty_output") + def test_setup_py_deprecation_warning( + self, + mock_pretty_output, + mock_path, + _, + __, + ___, + ): + """Test that a warning is displayed when setup.py file is detected.""" + file_path = self.root_app_path / "install-no-dep.yml" + + mock_path.return_value = file_path + mock_setup_py = mock.MagicMock() + mock_setup_py.exists.return_value = True + + mock_parent = mock.MagicMock() + mock_parent.__truediv__.return_value = mock_setup_py + file_path_mock = mock.MagicMock() + file_path_mock.parent = mock_parent + file_path_mock.exists.return_value = True + mock_path.return_value = file_path_mock + + args = mock.MagicMock( + file=None, + develop=False, + verbose=False, + services_file=None, + update_installed=False, + no_db_sync=False, + only_dependencies=False, + without_dependencies=True, + ) + + install_commands.install_command(args) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + warning_messages = [call[0][0] for call in po_call_args] + # Check that the deprecation warnings are present + self.assertIn( + "WARNING: setup.py file detected. The use of setup.py is deprecated and may cause installation issues.", + warning_messages, + ) + self.assertIn( + "Please migrate to pyproject.toml for defining your app's metadata and dependencies.", + warning_messages, + ) + + @mock.patch( + "tethys_cli.install_commands.open_file", + return_value={ + "name": "test_app", + "requirements": {"npm": {"test": "1.1.1"}}, + }, + ) + @mock.patch("tethys_cli.install_commands.run_services") + @mock.patch("tethys_cli.install_commands.call", return_value=1) + @mock.patch("tethys_cli.install_commands.Path") + @mock.patch("tethys_cli.cli_colors.pretty_output") + def test_pip_error( + self, + mock_pretty_output, + mock_path, + _, + __, + ___, + ): + """Test that a warning is displayed when setup.py file is detected.""" + file_path = self.root_app_path / "install-no-dep.yml" + + mock_path.return_value = file_path + + args = mock.MagicMock( + file=None, + develop=False, + verbose=False, + services_file=None, + update_installed=False, + no_db_sync=False, + only_dependencies=False, + without_dependencies=True, + ) + + install_commands.install_command(args) + + po_call_args = mock_pretty_output().__enter__().write.call_args_list + warning_messages = [call[0][0] for call in po_call_args] + + # Check that the pip error message is displayed + self.assertIn( + "ERROR: Application installation failed with exit code 1.", + warning_messages, + ) diff --git a/tethys_cli/gen_commands.py b/tethys_cli/gen_commands.py index ae9ffe1a3..e80c66147 100644 --- a/tethys_cli/gen_commands.py +++ b/tethys_cli/gen_commands.py @@ -69,6 +69,7 @@ GEN_INSTALL_OPTION = "install" GEN_META_YAML_OPTION = "metayaml" GEN_PACKAGE_JSON_OPTION = "package_json" +GEN_PYPROJECT_OPTION = "pyproject" GEN_REQUIREMENTS_OPTION = "requirements" FILE_NAMES = { @@ -83,6 +84,7 @@ GEN_INSTALL_OPTION: "install.yml", GEN_META_YAML_OPTION: "meta.yaml", GEN_PACKAGE_JSON_OPTION: "package.json", + GEN_PYPROJECT_OPTION: "pyproject.toml", GEN_REQUIREMENTS_OPTION: "requirements.txt", } @@ -98,6 +100,7 @@ GEN_INSTALL_OPTION, GEN_META_YAML_OPTION, GEN_PACKAGE_JSON_OPTION, + GEN_PYPROJECT_OPTION, GEN_REQUIREMENTS_OPTION, ) @@ -512,6 +515,108 @@ def download_vendor_static_files(args, cwd=None): write_error(msg) +def parse_setup_py(setup_file_path): + """ + Parse metadata from a Tethys app setup.py file. + """ + import ast + + try: + tree = ast.parse(open(setup_file_path).read()) + except Exception as e: + write_error(f"Failed to parse setup.py: {e}") + return None + + metadata = { + "app_package": "", + "description": "", + "author": "", + "author_email": "", + "keywords": "", + "license": "", + } + + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + target = node.targets[0] + if isinstance(target, ast.Name) and target.id == "app_package": + try: + metadata["app_package"] = ast.literal_eval(node.value) + except Exception: + write_warning( + f"Found invalid 'app_package' in setup.py: '{ast.unparse(node.value)}'" + ) + exit(1) + + # setup function + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "setup" + ): + for kw in node.keywords: + if kw.arg in metadata: + try: + val = ast.literal_eval(kw.value) + if kw.arg == "keywords" and isinstance(val, list): + val = ", ".join(val) + metadata[kw.arg] = val + except Exception: + write_warning( + f"Found invalid '{kw.arg}' in setup.py: '{ast.unparse(kw.value)}'" + ) + exit(1) + + if not metadata["app_package"]: + write_warning("Could not find 'app_package' in setup.py.") + exit(1) + + return metadata + + +def gen_pyproject(args): + app_dir = get_target_tethys_app_dir(args) + + setup_py_path = app_dir / "setup.py" + if not setup_py_path.is_file(): + write_error( + f'The specified Tethys app directory "{app_dir}" does not contain a setup.py file.' + ) + exit(1) + + else: + setup_py_metadata = parse_setup_py(setup_py_path) + + return setup_py_metadata + + +def pyproject_post_process(args): + file_path = get_destination_path(args, check_existence=False) + app_folder_path = Path(file_path).parent + if args.type == GEN_PYPROJECT_OPTION: + valid_options = ("y", "n", "yes", "no") + yes_options = ("y", "yes") + + remove_setup_file = input( + "Would you like to remove the old setup.py file? (y/n):" + ).lower() + + while remove_setup_file not in valid_options: + remove_setup_file = input( + "Invalid option. Remove setup.py file? (y/n): " + ).lower() + + if remove_setup_file in yes_options: + setup_py_path = app_folder_path / "setup.py" + if not setup_py_path.is_file(): + write_error( + f'The specified Tethys app directory "{app_folder_path}" does not contain a setup.py file.' + ) + else: + setup_py_path.unlink() + write_info(f'Removed setup.py file at "{setup_py_path}".') + + def gen_install(args): write_info( "Please review the generated install.yml file and fill in the appropriate information " @@ -563,6 +668,9 @@ def get_destination_path(args, check_existence=True): if args.type in [GEN_SERVICES_OPTION, GEN_INSTALL_OPTION]: destination_dir = Path.cwd() + if args.type == GEN_PYPROJECT_OPTION: + destination_dir = get_target_tethys_app_dir(args) + elif args.type == GEN_META_YAML_OPTION: destination_dir = Path(TETHYS_SRC) / "conda.recipe" @@ -621,10 +729,23 @@ def render_template(file_type, context, destination_path): Path(destination_path).write_text(template.render(context)) -def write_path_to_console(file_path): +def write_path_to_console(file_path, args): write_info(f'File generated at "{file_path}".') +def get_target_tethys_app_dir(args): + """Get the target directory for a Tethys app provided in args.""" + if args.directory: + app_dir = Path(args.directory) + if not app_dir.is_dir(): + write_error(f'The specified directory "{app_dir}" is not valid.') + exit(1) + else: + app_dir = Path.cwd() + + return app_dir + + GEN_COMMANDS = { GEN_APACHE_OPTION: proxy_server_context, GEN_APACHE_SERVICE_OPTION: gen_apache_service, @@ -637,6 +758,7 @@ def write_path_to_console(file_path): GEN_INSTALL_OPTION: gen_install, GEN_META_YAML_OPTION: gen_meta_yaml, GEN_PACKAGE_JSON_OPTION: (gen_vendor_static_files, download_vendor_static_files), + GEN_PYPROJECT_OPTION: (gen_pyproject, pyproject_post_process), GEN_REQUIREMENTS_OPTION: gen_requirements_txt, } @@ -661,6 +783,6 @@ def generate_command(args): render_template(args.type, context, destination_path) - write_path_to_console(destination_path) + write_path_to_console(destination_path, args) post_process_func(args) diff --git a/tethys_cli/gen_templates/pyproject b/tethys_cli/gen_templates/pyproject new file mode 100644 index 000000000..2aeb2771d --- /dev/null +++ b/tethys_cli/gen_templates/pyproject @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "tethysapp-{{app_package}}" +description = "{{description|default('')}}" +{% if license_name %}license = {text = "{{license|default('')}}"}{% endif %} +keywords = [{{', '.join(keywords.split(','))}}] +{% if author and author_email %}authors = [ + {name = "{{author|default('')}}", email = "{{author_email|default('')}}"}, +]{% endif %} +classifiers = [ + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers",{% if license_name %} + "License :: OSI Approved :: {{license_name}}",{% endif %} + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", +] +dynamic = ["version"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["*"] + +[tool.setuptools.package-data] +"tethysapp.{{app_package}}" = [ + "templates/**/*", + "public/**/*", + "resources/**/*", + "workspaces/**/*" +] \ No newline at end of file diff --git a/tethys_cli/install_commands.py b/tethys_cli/install_commands.py index f02b4a64e..db8c6b5ed 100644 --- a/tethys_cli/install_commands.py +++ b/tethys_cli/install_commands.py @@ -855,10 +855,30 @@ def install_command(args): else: cmd.append(".") + # Check for deprecated setup.py file in the same directory as install.yml + setup_py_path = file_path.parent / "setup.py" + if setup_py_path.exists(): + write_warning( + "WARNING: setup.py file detected. The use of setup.py is deprecated and may cause installation issues." + ) + write_warning( + "Please migrate to pyproject.toml for defining your app's metadata and dependencies." + ) + write_warning( + "You can use 'tethys gen pyproject' to help migrate to pyproject.toml." + ) + if args.verbose: - call(cmd, stderr=STDOUT) + return_code = call(cmd, stderr=STDOUT) else: - call(cmd, stdout=FNULL, stderr=STDOUT) + return_code = call(cmd, stdout=FNULL, stderr=STDOUT) + + if return_code != 0: + write_error( + f"ERROR: Application installation failed with exit code {return_code}." + ) + + return if args.no_db_sync: write_success(f"Successfully installed {app_name}.") @@ -907,6 +927,7 @@ def install_command(args): process = Popen(str(path_to_post), shell=True, stdout=PIPE) stdout = process.communicate()[0] write_msg("Post Script Result: {}".format(stdout)) + write_success(f"Successfully installed {app_name}.")