From 2479ebe3576db86dedd3af7923e72946fa109731 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Fri, 21 Nov 2025 16:45:56 -0700 Subject: [PATCH 01/11] * Added deprecation warning for installing with setup.py file * Added test for coverage --- .../test_tethys_cli/test_install_commands.py | 41 +++++++++++++++++++ tethys_cli/install_commands.py | 11 +++++ 2 files changed, 52 insertions(+) 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..f15df28e6 100644 --- a/tests/unit_tests/test_tethys_cli/test_install_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_install_commands.py @@ -2034,3 +2034,44 @@ def test_npm_install_formatting_error( "Successfully installed dependencies for test_app.", po_call_args[2][0][0], ) + + @mock.patch("tethys_cli.install_commands.Path") + @mock.patch("tethys_cli.install_commands.run_services") + @mock.patch("tethys_cli.install_commands.call") + @mock.patch("tethys_cli.cli_colors.pretty_output") + def test_setup_py_deprecation_warning(self, mock_pretty_output, mock_call, _, 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, + verbose=False, + no_db_sync=False, + only_dependencies=False, + without_dependencies=False, + ) + + 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, + ) \ No newline at end of file diff --git a/tethys_cli/install_commands.py b/tethys_cli/install_commands.py index f02b4a64e..2f52adc0f 100644 --- a/tethys_cli/install_commands.py +++ b/tethys_cli/install_commands.py @@ -907,6 +907,17 @@ 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)) + + # 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_success(f"Successfully installed {app_name}.") From e4399c996b93f2d366a9e860eaad0a0fb60097a4 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Mon, 24 Nov 2025 08:57:10 -0700 Subject: [PATCH 02/11] Fixed formatting --- .../test_tethys_cli/test_install_commands.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 f15df28e6..99aed2fb2 100644 --- a/tests/unit_tests/test_tethys_cli/test_install_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_install_commands.py @@ -2039,21 +2039,23 @@ def test_npm_install_formatting_error( @mock.patch("tethys_cli.install_commands.run_services") @mock.patch("tethys_cli.install_commands.call") @mock.patch("tethys_cli.cli_colors.pretty_output") - def test_setup_py_deprecation_warning(self, mock_pretty_output, mock_call, _, mock_path): + def test_setup_py_deprecation_warning( + self, mock_pretty_output, mock_call, _, 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, verbose=False, @@ -2074,4 +2076,4 @@ def test_setup_py_deprecation_warning(self, mock_pretty_output, mock_call, _, mo self.assertIn( "Please migrate to pyproject.toml for defining your app's metadata and dependencies.", warning_messages, - ) \ No newline at end of file + ) From cbb6c7ed6cccf9977b3fbcf015984d67b7cc7ec5 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Mon, 24 Nov 2025 09:34:03 -0700 Subject: [PATCH 03/11] Fix for test hanging --- tests/unit_tests/test_tethys_cli/test_install_commands.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 99aed2fb2..4fd5307f2 100644 --- a/tests/unit_tests/test_tethys_cli/test_install_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_install_commands.py @@ -2058,9 +2058,12 @@ def test_setup_py_deprecation_warning( args = mock.MagicMock( file=None, + develop=False, verbose=False, + services_file=None, + update_installed=False, no_db_sync=False, - only_dependencies=False, + only_dependencies=True, without_dependencies=False, ) From b9632f16f53ab44288cb8c58490e5a00ed3272ce Mon Sep 17 00:00:00 2001 From: jakeymac Date: Mon, 24 Nov 2025 16:27:57 -0700 Subject: [PATCH 04/11] * Fixed tests * Updated error checking in install command * Addd test for coverage --- .../test_tethys_cli/test_install_commands.py | 105 +++++++++++++----- tethys_cli/install_commands.py | 31 ++++-- 2 files changed, 94 insertions(+), 42 deletions(-) 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 4fd5307f2..ac7f38a49 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" @@ -2035,12 +2031,19 @@ def test_npm_install_formatting_error( po_call_args[2][0][0], ) - @mock.patch("tethys_cli.install_commands.Path") + @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") + @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_call, _, mock_path + 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" @@ -2063,8 +2066,8 @@ def test_setup_py_deprecation_warning( services_file=None, update_installed=False, no_db_sync=False, - only_dependencies=True, - without_dependencies=False, + only_dependencies=False, + without_dependencies=True, ) install_commands.install_command(args) @@ -2080,3 +2083,45 @@ def test_setup_py_deprecation_warning( "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, + ) \ No newline at end of file diff --git a/tethys_cli/install_commands.py b/tethys_cli/install_commands.py index 2f52adc0f..1e8dcd7c6 100644 --- a/tethys_cli/install_commands.py +++ b/tethys_cli/install_commands.py @@ -856,9 +856,26 @@ def install_command(args): cmd.append(".") 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: + # 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." + ) + else: + 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}.") @@ -908,16 +925,6 @@ def install_command(args): stdout = process.communicate()[0] write_msg("Post Script Result: {}".format(stdout)) - # 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_success(f"Successfully installed {app_name}.") From d36e3ba1015c1a06c113159142d811ba1424ad30 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Mon, 24 Nov 2025 17:18:21 -0700 Subject: [PATCH 05/11] Formatting fixes --- .../test_tethys_cli/test_install_commands.py | 21 +++++++++++++------ tethys_cli/install_commands.py | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) 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 ac7f38a49..8e9407aba 100644 --- a/tests/unit_tests/test_tethys_cli/test_install_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_install_commands.py @@ -2043,7 +2043,12 @@ def test_npm_install_formatting_error( @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, _, __, ___, + 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" @@ -2083,7 +2088,7 @@ def test_setup_py_deprecation_warning( "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={ @@ -2096,13 +2101,17 @@ def test_setup_py_deprecation_warning( @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, _, __, ___, + 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, @@ -2119,9 +2128,9 @@ def test_pip_error( 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, - ) \ No newline at end of file + ) diff --git a/tethys_cli/install_commands.py b/tethys_cli/install_commands.py index 1e8dcd7c6..49d570f46 100644 --- a/tethys_cli/install_commands.py +++ b/tethys_cli/install_commands.py @@ -859,7 +859,7 @@ def install_command(args): return_code = call(cmd, stderr=STDOUT) else: return_code = call(cmd, stdout=FNULL, stderr=STDOUT) - + if return_code != 0: # Check for deprecated setup.py file in the same directory as install.yml setup_py_path = file_path.parent / "setup.py" @@ -874,7 +874,7 @@ def install_command(args): write_error( f"ERROR: Application installation failed with exit code {return_code}." ) - + return if args.no_db_sync: From 2e8406821fe05e73e76119fd35e1c7776d582fe9 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Tue, 25 Nov 2025 16:05:44 -0700 Subject: [PATCH 06/11] Added gen pyproject command to convert setup.py to a pyproject.toml file in older Tethys apps --- tethys_cli/gen_commands.py | 120 ++++++++++++++++++++++++++++- tethys_cli/gen_templates/pyproject | 40 ++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tethys_cli/gen_templates/pyproject diff --git a/tethys_cli/gen_commands.py b/tethys_cli/gen_commands.py index ae9ffe1a3..65d77bea8 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, ) @@ -208,6 +211,12 @@ def add_gen_parser(subparsers): dest="run_as_user", help="The user to run the Supervisor Apache service as. Defaults to 'root'.", ) + gen_parser.add_argument( + "-f", + "--tethys-app-directory", + dest="tethys_app_directory", + help="Path to the Tethys app directory with a setup.py file to generate the pyproject.toml for", + ) gen_parser.set_defaults( func=generate_command, client_max_body_size="75M", @@ -512,6 +521,71 @@ 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: + pass + + # 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: + pass + + 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 gen_install(args): write_info( "Please review the generated install.yml file and fill in the appropriate information " @@ -563,6 +637,12 @@ 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: + if args.tethys_app_directory: + destination_dir = Path(args.tethys_app_directory).absolute() + else: + destination_dir = Path.cwd() + elif args.type == GEN_META_YAML_OPTION: destination_dir = Path(TETHYS_SRC) / "conda.recipe" @@ -621,8 +701,43 @@ 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}".') + 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: + 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.' + ) + else: + setup_py_path.unlink() + write_info(f'Removed setup.py file at "{setup_py_path}".') + + +def get_target_tethys_app_dir(args): + if args.tethys_app_directory: + app_dir = Path(args.tethys_app_directory) + if not app_dir.is_dir(): + write_error(f'The specified Tethys app directory "{app_dir}" is not valid.') + exit(1) + else: + app_dir = Path.cwd() + + return app_dir GEN_COMMANDS = { @@ -637,6 +752,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, GEN_REQUIREMENTS_OPTION: gen_requirements_txt, } @@ -661,6 +777,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 From c78d5a60c2c399fe64f585ae0851dac4551d04ff Mon Sep 17 00:00:00 2001 From: jakeymac Date: Tue, 25 Nov 2025 16:31:51 -0700 Subject: [PATCH 07/11] Added notice to install command to let users know to use new pyproject gen command to migrate their old setup.py files to pyproject --- tethys_cli/install_commands.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tethys_cli/install_commands.py b/tethys_cli/install_commands.py index 49d570f46..8965e1419 100644 --- a/tethys_cli/install_commands.py +++ b/tethys_cli/install_commands.py @@ -870,6 +870,9 @@ def install_command(args): 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." + ) else: write_error( f"ERROR: Application installation failed with exit code {return_code}." From 6d5211629962077f026fa7abd671b10fa2ff2b52 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Wed, 3 Dec 2025 16:11:56 -0700 Subject: [PATCH 08/11] * Users will now always be warned to migrate to pyproject.toml from setup.py regardlessof the install failing or not * Added error handling and error messages to setup.py parser helper function * Moved logic that handles user input to delete the old setup.py file or not to a post process function * Removed new tethys_app-directory argument and reverted uses of it to already existing "directory" argument --- tethys_cli/gen_commands.py | 83 +++++++++++++++++----------------- tethys_cli/install_commands.py | 32 ++++++------- 2 files changed, 58 insertions(+), 57 deletions(-) diff --git a/tethys_cli/gen_commands.py b/tethys_cli/gen_commands.py index 65d77bea8..c2cf0f2b5 100644 --- a/tethys_cli/gen_commands.py +++ b/tethys_cli/gen_commands.py @@ -211,12 +211,6 @@ def add_gen_parser(subparsers): dest="run_as_user", help="The user to run the Supervisor Apache service as. Defaults to 'root'.", ) - gen_parser.add_argument( - "-f", - "--tethys-app-directory", - dest="tethys_app_directory", - help="Path to the Tethys app directory with a setup.py file to generate the pyproject.toml for", - ) gen_parser.set_defaults( func=generate_command, client_max_body_size="75M", @@ -549,7 +543,9 @@ def parse_setup_py(setup_file_path): try: metadata["app_package"] = ast.literal_eval(node.value) except Exception: - pass + write_warning(f"Found invalid 'app_package' in setup.py: '{ast.unparse(node.value)}'") + exit(1) + return None # setup function if ( @@ -565,8 +561,13 @@ def parse_setup_py(setup_file_path): val = ", ".join(val) metadata[kw.arg] = val except Exception: - pass - + 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 @@ -584,6 +585,33 @@ def gen_pyproject(args): 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): @@ -638,10 +666,7 @@ def get_destination_path(args, check_existence=True): destination_dir = Path.cwd() if args.type == GEN_PYPROJECT_OPTION: - if args.tethys_app_directory: - destination_dir = Path(args.tethys_app_directory).absolute() - else: - destination_dir = Path.cwd() + destination_dir = get_target_tethys_app_dir(args) elif args.type == GEN_META_YAML_OPTION: destination_dir = Path(TETHYS_SRC) / "conda.recipe" @@ -703,36 +728,12 @@ def render_template(file_type, context, destination_path): def write_path_to_console(file_path, args): write_info(f'File generated at "{file_path}".') - 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: - 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.' - ) - else: - setup_py_path.unlink() - write_info(f'Removed setup.py file at "{setup_py_path}".') - def get_target_tethys_app_dir(args): - if args.tethys_app_directory: - app_dir = Path(args.tethys_app_directory) + if args.directory: + app_dir = Path(args.directory) if not app_dir.is_dir(): - write_error(f'The specified Tethys app directory "{app_dir}" is not valid.') + write_error(f'The specified directory "{app_dir}" is not valid.') exit(1) else: app_dir = Path.cwd() @@ -752,7 +753,7 @@ def get_target_tethys_app_dir(args): 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, + GEN_PYPROJECT_OPTION: (gen_pyproject, pyproject_post_process), GEN_REQUIREMENTS_OPTION: gen_requirements_txt, } diff --git a/tethys_cli/install_commands.py b/tethys_cli/install_commands.py index 8965e1419..db8c6b5ed 100644 --- a/tethys_cli/install_commands.py +++ b/tethys_cli/install_commands.py @@ -855,28 +855,28 @@ 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: return_code = call(cmd, stderr=STDOUT) else: return_code = call(cmd, stdout=FNULL, stderr=STDOUT) if return_code != 0: - # 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." - ) - else: - write_error( - f"ERROR: Application installation failed with exit code {return_code}." - ) + write_error( + f"ERROR: Application installation failed with exit code {return_code}." + ) return From 8808feca9448c65d994707d5548d2c406c1f5ace Mon Sep 17 00:00:00 2001 From: jakeymac Date: Fri, 5 Dec 2025 14:24:07 -0700 Subject: [PATCH 09/11] Added tests --- .../test_tethys_cli/test_gen_commands.py | 281 +++++++++++++++++- tethys_cli/gen_commands.py | 15 +- 2 files changed, 289 insertions(+), 7 deletions(-) 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..2087c1de5 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,6 +12,9 @@ generate_command, gen_vendor_static_files, download_vendor_static_files, + parse_setup_py, + gen_pyproject, + pyproject_post_process, get_destination_path, GEN_APACHE_OPTION, GEN_APACHE_SERVICE_OPTION, @@ -22,6 +27,7 @@ GEN_SECRETS_OPTION, GEN_META_YAML_OPTION, GEN_PACKAGE_JSON_OPTION, + GEN_PYPROJECT_OPTION, GEN_REQUIREMENTS_OPTION, VALID_GEN_OBJECTS, ) @@ -777,14 +783,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 +827,274 @@ 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.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) + + # exit should be called once + mock_exit.assert_called_once() + + 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() + + @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() + + @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() + + @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) diff --git a/tethys_cli/gen_commands.py b/tethys_cli/gen_commands.py index c2cf0f2b5..4ceeb0e50 100644 --- a/tethys_cli/gen_commands.py +++ b/tethys_cli/gen_commands.py @@ -543,7 +543,9 @@ def parse_setup_py(setup_file_path): 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)}'") + write_warning( + f"Found invalid 'app_package' in setup.py: '{ast.unparse(node.value)}'" + ) exit(1) return None @@ -561,13 +563,15 @@ def parse_setup_py(setup_file_path): val = ", ".join(val) metadata[kw.arg] = val except Exception: - write_warning(f"Found invalid '{kw.arg}' in setup.py: '{ast.unparse(kw.value)}'") + 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 @@ -585,7 +589,7 @@ def gen_pyproject(args): 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) @@ -729,6 +733,7 @@ def render_template(file_type, context, destination_path): def write_path_to_console(file_path, args): write_info(f'File generated at "{file_path}".') + def get_target_tethys_app_dir(args): if args.directory: app_dir = Path(args.directory) From a15815c8c72f4f03391b716a9cbf97a3a1122df1 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Mon, 8 Dec 2025 17:17:49 -0700 Subject: [PATCH 10/11] Added testing for coverage --- .../test_tethys_cli/test_gen_commands.py | 128 +++++++++++++++++- tethys_cli/gen_commands.py | 2 +- 2 files changed, 124 insertions(+), 6 deletions(-) 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 2087c1de5..20dbd93a1 100644 --- a/tests/unit_tests/test_tethys_cli/test_gen_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_gen_commands.py @@ -16,6 +16,7 @@ gen_pyproject, pyproject_post_process, get_destination_path, + get_target_tethys_app_dir, GEN_APACHE_OPTION, GEN_APACHE_SERVICE_OPTION, GEN_NGINX_OPTION, @@ -828,6 +829,74 @@ def test_generate_command_secrets_yaml_tethys_home_not_exists( 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.check_for_existing_file") + @mock.patch("tethys_cli.gen_commands.Path.is_dir", return_value=True) + def test_get_destination_path_vendor(self, mock_isdir, mock_check_file): + mock_args = mock.MagicMock( + type=GEN_PACKAGE_JSON_OPTION, + directory=False, + ) + result = get_destination_path(mock_args) + mock_isdir.assert_called() + mock_check_file.assert_called_once() + self.assertEqual( + result, str(Path(TETHYS_SRC) / "tethys_portal" / "static" / "package.json") + ) + @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") @@ -852,8 +921,7 @@ def test_generate_command_pyproject_no_setup_py( expected = f'The specified Tethys app directory "{app_dir}" does not contain a setup.py file.' self.assertIn(expected, error_msg) - # exit should be called once - mock_exit.assert_called_once() + mock_exit.assert_called_once_with(1) def test_parse_setup_py(self): with tempfile.TemporaryDirectory() as temp_dir: @@ -941,7 +1009,7 @@ def test_parse_setup_py_invalid_package_name(self, mock_exit, mock_write_warning self.assertIn(expected, warning_msg) - mock_exit.assert_called_once() + 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) @@ -975,7 +1043,7 @@ def test_parse_setup_py_no_app_package(self, mock_exit, mock_write_warning): expected = "Could not find 'app_package' in setup.py." self.assertIn(expected, warning_msg) - mock_exit.assert_called_once() + 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) @@ -1011,7 +1079,7 @@ def test_parse_setup_py_invalid_setup_attr(self, mock_exit, mock_write_warning): expected = "Found invalid 'author' in setup.py: 'fake_function()'" self.assertIn(expected, warning_msg) - mock_exit.assert_called_once() + 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") @@ -1098,3 +1166,53 @@ def test_pyproject_post_process_invalid_input_retry(self, mock_input, mock_gdp, # 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/tethys_cli/gen_commands.py b/tethys_cli/gen_commands.py index 4ceeb0e50..e80c66147 100644 --- a/tethys_cli/gen_commands.py +++ b/tethys_cli/gen_commands.py @@ -547,7 +547,6 @@ def parse_setup_py(setup_file_path): f"Found invalid 'app_package' in setup.py: '{ast.unparse(node.value)}'" ) exit(1) - return None # setup function if ( @@ -735,6 +734,7 @@ def write_path_to_console(file_path, args): 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(): From c060f62ea4c4ed4909ebe109bc2eb5316fc1a634 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Mon, 8 Dec 2025 17:21:02 -0700 Subject: [PATCH 11/11] Formatting fixes --- .../test_tethys_cli/test_gen_commands.py | 37 +++++-------------- 1 file changed, 10 insertions(+), 27 deletions(-) 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 20dbd93a1..6630a1807 100644 --- a/tests/unit_tests/test_tethys_cli/test_gen_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_gen_commands.py @@ -833,9 +833,9 @@ def test_generate_command_secrets_yaml_tethys_home_not_exists( 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() @@ -844,9 +844,9 @@ 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() @@ -858,13 +858,15 @@ def test_get_target_tethys_app_dir_with_invalid_directory( ): 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) + 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) @@ -882,21 +884,6 @@ def test_get_destination_path_pyproject(self, mock_gttad, _): mock_gttad.assert_called_once_with(args) self.assertEqual(actual_result, expected_result) - - @mock.patch("tethys_cli.gen_commands.check_for_existing_file") - @mock.patch("tethys_cli.gen_commands.Path.is_dir", return_value=True) - def test_get_destination_path_vendor(self, mock_isdir, mock_check_file): - mock_args = mock.MagicMock( - type=GEN_PACKAGE_JSON_OPTION, - directory=False, - ) - result = get_destination_path(mock_args) - mock_isdir.assert_called() - mock_check_file.assert_called_once() - self.assertEqual( - result, str(Path(TETHYS_SRC) / "tethys_portal" / "static" / "package.json") - ) - @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") @@ -1204,7 +1191,7 @@ def test_gen_pyproject_no_setup(self, mock_exit, mock_write_error): spec=["overwrite"], ) with self.assertRaises(SystemExit): - gen_pyproject(mock_args) + gen_pyproject(mock_args) mock_write_error.assert_called_once() error_msg = mock_write_error.call_args.args[0] @@ -1212,7 +1199,3 @@ def test_gen_pyproject_no_setup(self, mock_exit, mock_write_error): self.assertIn(expected, error_msg) mock_exit.assert_called_once_with(1) - - - -