diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index 76282ea..0273068 100644 --- a/.github/workflows/installer.yml +++ b/.github/workflows/installer.yml @@ -19,6 +19,28 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + unit-test: + name: Unit Test / Ubuntu / Python ${{ matrix.python-version }} + runs-on: ubuntu-22.04 + strategy: + matrix: + # using importlib to import hyphenated modules doesn't seem to work in Python 3.11 right now + python-version: [ "3.7", "3.8", "3.9", "3.10" ] + fail-fast: false + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install pytest + run: python -m pip install pytest + + - name: Run PyTest + run: python -m pytest ./tests + default: name: ${{ matrix.os }} / ${{ matrix.python-version }} / install-poetry.py ${{ matrix.args }} runs-on: ${{ matrix.image }} diff --git a/README.md b/README.md index 6c2c00c..5881475 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,16 @@ You can also install Poetry for a `git` repository by using the `--git` option: curl -sSL https://install.python-poetry.org | python3 - --git https://github.com/python-poetry/poetry.git@master ```` +If you need Poetry to write errors to stderr, you can use `--stderr` option or the `$POETRY_LOG_STDERR` +environment variable: + +> _Note: In CI environments, this will be enabled automatically._ + +```bash +curl -sSL https://install.python-poetry.org | python3 - --stderr +curl -sSL https://install.python-poetry.org | POETRY_LOG_STDERR=1 python3 - +```` + > **Note**: The installer does not support Python < 3.6. ## Known Issues diff --git a/install-poetry.py b/install-poetry.py index b6d32d4..4e1215a 100644 --- a/install-poetry.py +++ b/install-poetry.py @@ -898,6 +898,13 @@ def main(): "of Poetry available online." ), ) + parser.add_argument( + "--stderr", + dest="stderr", + action="store_true", + help=("Log installation errors to stderr instead of a log file."), + default=False, + ) args = parser.parse_args() @@ -912,6 +919,16 @@ def main(): git=args.git, ) + disable_log_file = args.stderr or string_to_bool( + os.getenv("POETRY_LOG_STDERR", "0") + ) + + if not disable_log_file and string_to_bool(os.getenv("CI", "0")): + installer._write( + colorize("info", "CI environment detected. Writing logs to stderr.") + ) + disable_log_file = True + if args.uninstall or string_to_bool(os.getenv("POETRY_UNINSTALL", "0")): return installer.uninstall() @@ -923,15 +940,22 @@ def main(): if e.log is not None: import traceback - _, path = tempfile.mkstemp( - suffix=".log", - prefix="poetry-installer-error-", - dir=str(Path.cwd()), - text=True, + error = ( + f"{e.log}\n" + f"Traceback:\n\n{''.join(traceback.format_tb(e.__traceback__))}" ) - installer._write(colorize("error", f"See {path} for error logs.")) - text = f"{e.log}\nTraceback:\n\n{''.join(traceback.format_tb(e.__traceback__))}" - Path(path).write_text(text) + + if disable_log_file: + installer._write(colorize("error", error)) + else: + _, path = tempfile.mkstemp( + suffix=".log", + prefix="poetry-installer-error-", + dir=str(Path.cwd()), + text=True, + ) + installer._write(colorize("error", f"See {path} for error logs.")) + Path(path).write_text(error) return e.return_code diff --git a/tests/test_install_poetry.py b/tests/test_install_poetry.py new file mode 100644 index 0000000..15c4e78 --- /dev/null +++ b/tests/test_install_poetry.py @@ -0,0 +1,170 @@ +import importlib +import re +import typing +import unittest + +from unittest.mock import MagicMock +from unittest.mock import patch + + +module = importlib.import_module("install-poetry") + + +class InstallPoetryTestCase(unittest.TestCase): + def setUp(self): + self.__patchers = [] + + self.__patchers.append(patch("install-poetry.Installer")) + self.__mock_installer_cls = self.__patchers[-1].start() + self.__installer = MagicMock() + self.__mock_installer_cls.return_value = self.__installer + + self.__patchers.append(patch("install-poetry.argparse.ArgumentParser")) + self.__mock_argument_parser_cls = self.__patchers[-1].start() + self.__parser = MagicMock() + self.__args = MagicMock() + self.__args.version = None + self.__args.preview = False + self.__args.force = False + self.__args.accept_all = False + self.__args.path = None + self.__args.git = None + self.__args.stderr = False + self.__args.uninstall = False + self.__mock_argument_parser_cls.return_value = self.__parser + self.__parser.parse_args.return_value = self.__args + + self.__patchers.append(patch("install-poetry.os")) + self.__mock_os = self.__patchers[-1].start() + self.__mock_os.getenv.side_effect = self.__getenv + self.__env = {} + + self.__patchers.append(patch("install-poetry.colorize")) + self.__mock_colorize = self.__patchers[-1].start() + self.__mock_colorize.side_effect = self.__colorize + self.__messages = [] + + self.__patchers.append(patch("install-poetry.tempfile")) + self.__mock_tempfile = self.__patchers[-1].start() + self.__mock_tempfile.mkstemp.side_effect = self.__mkstemp + self.__tmp_file = None + + self.__patchers.append(patch("install-poetry.Path")) + self.__mock_path_cls = self.__patchers[-1].start() + + def tearDown(self): + for patcher in self.__patchers: + patcher.stop() + + def test_install_poetry_main__happy(self): + self.__installer.run.return_value = 0 + + return_code = module.main() + + self.__mock_installer_cls.assert_called_with( + version=None, + preview=False, + force=False, + accept_all=True, + path=None, + git=None, + ) + + self.__installer.uninstall.assert_not_called() + + self.assertEqual(return_code, 0) + + def test_install_poetry_main__default_install_error(self): + self.__installer.run.side_effect = [ + module.PoetryInstallationError(1, "a fake poetry installation error") + ] + + return_code = module.main() + + self.__assert_no_matching_message( + "error", re.compile("a fake poetry installation error") + ) + self.__assert_any_matching_message( + "error", re.compile(f"See {self.__tmp_file} for error logs") + ) + + self.assertEqual(return_code, 1) + self.__mock_path_cls(self.__tmp_file).write_text.assert_called_once() + + def test_install_poetry_main__stderr_arg(self): + self.__args.stderr = True + self.__installer.run.side_effect = [ + module.PoetryInstallationError(1, "a fake poetry installation error") + ] + + return_code = module.main() + + self.__assert_no_matching_message("info", re.compile("CI environment detected")) + self.__assert_any_matching_message( + "error", re.compile("a fake poetry installation error") + ) + + self.assertEqual(return_code, 1) + self.__mock_path_cls.assert_not_called() + + def test_install_poetry_main__log_stderr_var(self): + self.__env["POETRY_LOG_STDERR"] = "1" + self.__installer.run.side_effect = [ + module.PoetryInstallationError(1, "a fake poetry installation error") + ] + + return_code = module.main() + + self.__assert_no_matching_message("info", re.compile("CI environment detected")) + self.__assert_any_matching_message( + "error", re.compile("a fake poetry installation error") + ) + + self.assertEqual(return_code, 1) + self.__mock_path_cls.assert_not_called() + + def test_install_poetry_main__ci(self): + self.__env["CI"] = "1" + self.__installer.run.side_effect = [ + module.PoetryInstallationError(1, "a fake poetry installation error") + ] + + return_code = module.main() + + self.__assert_any_matching_message( + "info", re.compile("CI environment detected") + ) + self.__assert_any_matching_message( + "error", re.compile("a fake poetry installation error") + ) + + self.assertEqual(return_code, 1) + self.__mock_path_cls.assert_not_called() + + def __colorize(self, severity: str, message: str) -> str: + self.__messages.append((severity, message)) + return f"{severity}:{message}" + + def __getenv( + self, key: str, default: typing.Optional[str] = None + ) -> typing.Optional[str]: + return self.__env.get(key, default) + + def __mkstemp(self, suffix="suffix", prefix="prefix", dir=None, text=None): + self.__tmp_file = f"{prefix}unittest{suffix}" + return None, self.__tmp_file + + def __assert_any_matching_message(self, severity: str, pattern: re.Pattern): + self.assertGreater(self.__count_matching_message(severity, pattern), 0) + + def __assert_no_matching_message(self, severity: str, pattern: re.Pattern): + self.assertEqual(self.__count_matching_message(severity, pattern), 0) + + def __count_matching_message(self, severity: str, pattern: re.Pattern): + return len( + [ + message + for message in self.__messages + if severity == message[0] and pattern.search(message[1]) + ] + )