From 7f65b5bb979b2ad0ff251ce02eda9f0ade1bc24f Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Mon, 30 Oct 2023 03:00:09 +0100 Subject: [PATCH] Initial code with software tests --- pueblo/__init__.py | 2 ++ pueblo/cli.py | 25 +++++++++++++++++++ pueblo/testing/__init__.py | 0 pueblo/testing/notebook.py | 49 ++++++++++++++++++++++++++++++++++++++ pueblo/util/__init__.py | 0 pueblo/util/cli.py | 46 +++++++++++++++++++++++++++++++++++ pueblo/util/environ.py | 21 ++++++++++++++++ pueblo/util/logging.py | 32 +++++++++++++++++++++++++ tests/__init__.py | 0 tests/test_cli.py | 17 +++++++++++++ tests/test_environ.py | 30 +++++++++++++++++++++++ tests/test_logging.py | 16 +++++++++++++ tests/test_testing.py | 5 ++++ 13 files changed, 243 insertions(+) create mode 100644 pueblo/__init__.py create mode 100644 pueblo/cli.py create mode 100644 pueblo/testing/__init__.py create mode 100644 pueblo/testing/notebook.py create mode 100644 pueblo/util/__init__.py create mode 100644 pueblo/util/cli.py create mode 100644 pueblo/util/environ.py create mode 100644 pueblo/util/logging.py create mode 100644 tests/__init__.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_environ.py create mode 100644 tests/test_logging.py create mode 100644 tests/test_testing.py diff --git a/pueblo/__init__.py b/pueblo/__init__.py new file mode 100644 index 0000000..453fd8b --- /dev/null +++ b/pueblo/__init__.py @@ -0,0 +1,2 @@ +from pueblo.util.environ import getenvpass +from pueblo.util.logging import setup_logging diff --git a/pueblo/cli.py b/pueblo/cli.py new file mode 100644 index 0000000..be98e60 --- /dev/null +++ b/pueblo/cli.py @@ -0,0 +1,25 @@ +import logging + +import click +from click_aliases import ClickAliasedGroup + +from pueblo.util.cli import boot_click + +logger = logging.getLogger(__name__) + + +def help_pueblo(): + """ + Pueblo - a Python toolbox library. + + pueblo --version + """ # noqa: E501 + + +@click.group(cls=ClickAliasedGroup) +@click.version_option(package_name="pueblo") +@click.option("--verbose", is_flag=True, required=False, help="Turn on logging") +@click.option("--debug", is_flag=True, required=False, help="Turn on logging with debug level") +@click.pass_context +def cli(ctx: click.Context, verbose: bool, debug: bool): + return boot_click(ctx, verbose, debug) diff --git a/pueblo/testing/__init__.py b/pueblo/testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pueblo/testing/notebook.py b/pueblo/testing/notebook.py new file mode 100644 index 0000000..a2b4822 --- /dev/null +++ b/pueblo/testing/notebook.py @@ -0,0 +1,49 @@ +import typing as t + +import pytest + + +def monkeypatch_pytest_notebook_treat_cell_exit_as_notebook_skip(): + """ + Patch `pytest-notebook`, in fact `nbclient.client.NotebookClient`, + to propagate cell-level `pytest.exit()` invocations as signals + to mark the whole notebook as skipped. + + In order not to be too intrusive, the feature only skips notebooks + when being explicitly instructed, by adding `[skip-notebook]` at the + end of the `reason` string. Example: + + import pytest + if "ACME_API_KEY" not in os.environ: + pytest.exit("ACME_API_KEY not given [skip-notebook]") + + https://github.com/chrisjsewell/pytest-notebook/issues/43 + """ + from nbclient.client import NotebookClient + from nbclient.exceptions import CellExecutionError + from nbformat import NotebookNode + + async_execute_cell_dist = NotebookClient.async_execute_cell + + async def async_execute_cell( + self, + cell: NotebookNode, + cell_index: int, + execution_count: t.Optional[int] = None, + store_history: bool = True, + ) -> NotebookNode: + try: + return await async_execute_cell_dist( + self, + cell, + cell_index, + execution_count=execution_count, + store_history=store_history, + ) + except CellExecutionError as ex: + if ex.ename == "Exit" and ex.evalue.endswith("[skip-notebook]"): + raise pytest.skip(ex.evalue) + else: + raise + + NotebookClient.async_execute_cell = async_execute_cell diff --git a/pueblo/util/__init__.py b/pueblo/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pueblo/util/cli.py b/pueblo/util/cli.py new file mode 100644 index 0000000..a8d18f8 --- /dev/null +++ b/pueblo/util/cli.py @@ -0,0 +1,46 @@ +import logging +import textwrap +import typing as t + +from pueblo.util.logging import setup_logging + +logger = logging.getLogger(__name__) + + +def boot_click(ctx: "click.Context", verbose: bool = False, debug: bool = False): + """ + Bootstrap the CLI application. + """ + + # Adjust log level according to `verbose` / `debug` flags. + log_level = logging.INFO + if debug: + log_level = logging.DEBUG + + # Setup logging, according to `verbose` / `debug` flags. + setup_logging(level=log_level, verbose=verbose) + + +def docstring_format_verbatim(text: t.Optional[str]) -> str: + """ + Format docstring to be displayed verbatim as a help text by Click. + + - https://click.palletsprojects.com/en/8.1.x/documentation/#preventing-rewrapping + - https://github.com/pallets/click/issues/56 + """ + text = text or "" + text = textwrap.dedent(text) + lines = [line if line.strip() else "\b" for line in text.splitlines()] + return "\n".join(lines) + + +def make_command(cli, name, helpfun, aliases=None): + """ + Convenience shortcut for creating a subcommand. + """ + return cli.command( + name, + help=docstring_format_verbatim(helpfun.__doc__), + context_settings={"max_content_width": 100}, + aliases=aliases, + ) diff --git a/pueblo/util/environ.py b/pueblo/util/environ.py new file mode 100644 index 0000000..df88b2b --- /dev/null +++ b/pueblo/util/environ.py @@ -0,0 +1,21 @@ +import os +import getpass +from dotenv import load_dotenv, find_dotenv + + +def getenvpass(env_var: str, prompt: str, skip_pytest_notebook: bool = True) -> str: + """ + Read variable from environment or `.env` file. + If it is not defined, prompt interactively. + + FIXME: Needs a patch to make it work with `pytest-notebook`, + see https://github.com/chrisjsewell/pytest-notebook/issues/43. + """ + load_dotenv(find_dotenv()) + if env_var not in os.environ: + if "PYTEST_CURRENT_TEST" in os.environ and skip_pytest_notebook: + import pytest + pytest.exit(f"{env_var} not given [skip-notebook]") + else: + os.environ[env_var] = getpass.getpass(prompt) + return os.environ.get(env_var) diff --git a/pueblo/util/logging.py b/pueblo/util/logging.py new file mode 100644 index 0000000..6359c23 --- /dev/null +++ b/pueblo/util/logging.py @@ -0,0 +1,32 @@ +import importlib +import logging +import sys + + +def setup_logging(level=logging.INFO, verbose: bool = False): + try: + importlib.import_module("colorlog") + setup_logging_colorlog(level=level, verbose=verbose) + except ImportError: + setup_logging_standard(level=level, verbose=verbose) + + +def setup_logging_standard(level=logging.INFO, verbose: bool = False): + log_format = "%(asctime)-15s [%(name)-35s] %(levelname)-8s: %(message)s" + logging.basicConfig(format=log_format, stream=sys.stderr, level=level) + + +def setup_logging_colorlog(level=logging.INFO, verbose: bool = False): + import colorlog + from colorlog.escape_codes import escape_codes + reset = escape_codes["reset"] + log_format = f"%(asctime)-15s [%(name)-36s] %(log_color)s%(levelname)-8s:{reset} %(message)s" + + handler = colorlog.StreamHandler() + handler.setFormatter(colorlog.ColoredFormatter(log_format)) + + logging.basicConfig(format=log_format, level=level, handlers=[handler]) + + +def tweak_log_levels(): + logging.getLogger("urllib3.connectionpool").setLevel(level=logging.INFO) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..9578fa5 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,17 @@ +from click.testing import CliRunner + +from pueblo.cli import cli + + +def test_version(): + """ + CLI test: Invoke `pueblo --version` + """ + runner = CliRunner() + + result = runner.invoke( + cli, + args="--version", + catch_exceptions=False, + ) + assert result.exit_code == 0 diff --git a/tests/test_environ.py b/tests/test_environ.py new file mode 100644 index 0000000..90de7dc --- /dev/null +++ b/tests/test_environ.py @@ -0,0 +1,30 @@ +import os +from unittest import mock + +import pytest + +from pueblo.util.environ import getenvpass + + +@pytest.fixture(scope="function", autouse=True) +def init_testcase(): + """ + Make sure each test case uses a blank canvas. + """ + if "ACME_API_KEY" in os.environ: + del os.environ["ACME_API_KEY"] + + +def test_getenvpass_prompt(mocker): + mocker.patch("getpass.getpass", return_value="foobar") + retval = getenvpass("ACME_API_KEY", "really?", skip_pytest_notebook=False) + assert retval == "foobar" + + +def test_getenvpass_environ(): + with \ + mock.patch("getpass.getpass"), \ + mock.patch("pytest.exit") as exit_mock: + retval = getenvpass("ACME_API_KEY", "really?") + assert retval is None + exit_mock.assert_called_once_with("ACME_API_KEY not given [skip-notebook]",) diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..635eda1 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,16 @@ +import sys + +from pueblo.util.logging import setup_logging, tweak_log_levels + + +def test_setup_logging_default(): + setup_logging() + + +def test_setup_logging_no_colorlog(mocker): + mocker.patch.dict(sys.modules, {"colorlog": None}) + setup_logging() + + +def test_tweak_log_levels(): + tweak_log_levels() diff --git a/tests/test_testing.py b/tests/test_testing.py new file mode 100644 index 0000000..45f6c0d --- /dev/null +++ b/tests/test_testing.py @@ -0,0 +1,5 @@ +from pueblo.testing.notebook import monkeypatch_pytest_notebook_treat_cell_exit_as_notebook_skip + + +def test_monkeypatch_pytest_notebook_treat_cell_exit_as_notebook_skip(): + monkeypatch_pytest_notebook_treat_cell_exit_as_notebook_skip()