diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4dc9ad2..0be195a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,16 +24,18 @@ jobs: id: select_matrix # Select full matrix when scheduled or when releasing, and normal matrix # otherwise. The matrix is defined as a JSON string. + # This technique documented in: + # https://stackoverflow.com/questions/65384420/how-to-make-a-github-action-matrix-element-conditional # TODO: Find a way to define this with less escapes. run: | if [[ "${{ github.event_name }}" == "schedule" || "${{ github.head_ref }}" =~ ^release_ ]]; then \ - echo "::set-output name=matrix::{ \ + echo "matrix={ \ \"os\": [ \"ubuntu-latest\", \"macos-latest\", \"windows-latest\" ], \ \"python-version\": [ \"3.9\", \"3.10\", \"3.11\", \"3.12\", \"3.13\", \"3.14\" ], \ \"package_level\": [ \"minimum\", \"latest\" ] \ - }"; \ + }" >> $GITHUB_OUTPUT; \ else \ - echo "::set-output name=matrix::{ \ + echo "matrix={ \ \"os\": [ \"ubuntu-latest\" ], \ \"python-version\": [ \"3.9\", \"3.14\" ], \ \"package_level\": [ \"minimum\", \"latest\" ], \ @@ -59,7 +61,7 @@ jobs: \"package_level\": \"latest\" \ } \ ] \ - }"; \ + }" >> $GITHUB_OUTPUT; \ fi - name: Show matrix in JSON run: echo '${{ steps.select_matrix.outputs.matrix }}' @@ -75,22 +77,6 @@ jobs: env: PIP_DISABLE_PIP_VERSION_CHECK: 1 steps: - - name: Set run type (normal, scheduled, release) - id: set-run-type - uses: actions/github-script@v9 - with: - result-encoding: string - script: | - var result - if ("${{ github.event_name }}" == "schedule") { - result = "scheduled" - } else if ("${{ github.head_ref }}".match(/^release_/)) { - result = "release" - } else { - result = "normal" - } - console.log(result) - return result - name: Checkout repo uses: actions/checkout@v6 with: @@ -125,31 +111,26 @@ jobs: - name: Display environment env: PACKAGE_LEVEL: ${{ matrix.package_level }} - RUN_TYPE: ${{ steps.set-run-type.outputs.result }} run: | make env - name: Display initial Python packages env: PACKAGE_LEVEL: ${{ matrix.package_level }} - RUN_TYPE: ${{ steps.set-run-type.outputs.result }} run: | make pip_list - name: Display platform env: PACKAGE_LEVEL: ${{ matrix.package_level }} - RUN_TYPE: ${{ steps.set-run-type.outputs.result }} run: | make platform - name: Development setup env: PACKAGE_LEVEL: ${{ matrix.package_level }} - RUN_TYPE: ${{ steps.set-run-type.outputs.result }} run: | make develop - name: Show installed package versions env: PACKAGE_LEVEL: ${{ matrix.package_level }} - RUN_TYPE: ${{ steps.set-run-type.outputs.result }} run: | make pip_list - name: Show package dependency tree @@ -159,32 +140,27 @@ jobs: - name: Run flake8 env: PACKAGE_LEVEL: ${{ matrix.package_level }} - RUN_TYPE: ${{ steps.set-run-type.outputs.result }} run: | make flake8 - name: Run ruff env: PACKAGE_LEVEL: ${{ matrix.package_level }} - RUN_TYPE: ${{ steps.set-run-type.outputs.result }} run: | make ruff - name: Run pylint env: PACKAGE_LEVEL: ${{ matrix.package_level }} - RUN_TYPE: ${{ steps.set-run-type.outputs.result }} run: | make pylint - name: Run test env: PACKAGE_LEVEL: ${{ matrix.package_level }} - RUN_TYPE: ${{ steps.set-run-type.outputs.result }} # TESTCASES: a value for pytest -k run: | make test - name: Run check_reqs env: PACKAGE_LEVEL: ${{ matrix.package_level }} - RUN_TYPE: ${{ steps.set-run-type.outputs.result }} run: | make check_reqs diff --git a/Makefile b/Makefile index 50318e7..18e4575 100644 --- a/Makefile +++ b/Makefile @@ -57,11 +57,6 @@ else endif endif -# Run type (normal, scheduled, release, local) -ifndef RUN_TYPE - RUN_TYPE := local -endif - # Python major and minor version as a short identifier pymn := $(shell $(PYTHON_CMD) -c "import sys; sys.stdout.write(f'py{sys.version_info[0]}{sys.version_info[1]}')") @@ -134,8 +129,7 @@ help: @echo "Environment variables:" @echo " TESTCASES=... - Testcase filter for pytest -k" @echo " TESTOPTS=... - Options for pytest" - @echo " PACKAGE_LEVEL - Package level to be used for installing dependent Python" - @echo " packages in 'install' and 'develop' targets:" + @echo " PACKAGE_LEVEL - Package level to be used for installing dependent Python packages:" @echo " latest - Latest package versions available on Pypi" @echo " minimum - Minimum versions as defined in minimum-constraints*.txt" @echo " Optional, defaults to 'latest'." @@ -151,6 +145,7 @@ all: develop flake8 ruff pylint check_reqs test .PHONY: check check: flake8 ruff pylint + @echo "Makefile: $@ done." .PHONY: platform platform: @@ -205,33 +200,27 @@ flake8: $(done_dir)/flake8_$(pymn)_$(PACKAGE_LEVEL).done @echo "Makefile: $@ done." $(done_dir)/flake8_$(pymn)_$(PACKAGE_LEVEL).done: $(done_dir)/develop_$(pymn)_$(PACKAGE_LEVEL).done $(flake8_rc_file) $(check_py_files) - @echo "Makefile: Running Flake8" rm -f $@ flake8 --config $(flake8_rc_file) $(check_py_files) echo "done" >$@ - @echo "Makefile: Done running Flake8" .PHONY: ruff ruff: $(done_dir)/ruff_$(pymn)_$(PACKAGE_LEVEL).done @echo "Makefile: $@ done." $(done_dir)/ruff_$(pymn)_$(PACKAGE_LEVEL).done: $(done_dir)/develop_$(pymn)_$(PACKAGE_LEVEL).done $(ruff_rc_file) $(check_py_files) - @echo "Makefile: Running Ruff" rm -f $@ ruff check --unsafe-fixes --config $(ruff_rc_file) $(check_py_files) echo "done" >$@ - @echo "Makefile: Done running Ruff" .PHONY: pylint pylint: $(done_dir)/pylint_$(pymn)_$(PACKAGE_LEVEL).done @echo "Makefile: $@ done." $(done_dir)/pylint_$(pymn)_$(PACKAGE_LEVEL).done: $(done_dir)/develop_$(pymn)_$(PACKAGE_LEVEL).done $(pylint_rc_file) $(check_py_files) - @echo "Makefile: Running Pylint" rm -f $@ pylint $(pylint_opts) --rcfile=$(pylint_rc_file) --output-format=text $(check_py_files) echo "done" >$@ - @echo "Makefile: Done running Pylint" .PHONY: check_reqs check_reqs: $(done_dir)/check_reqs_$(pymn)_$(PACKAGE_LEVEL).done @@ -251,7 +240,7 @@ endif .PHONY: test test: $(done_dir)/develop_$(pymn)_$(PACKAGE_LEVEL).done $(test_py_files) - PYTHONPATH=. pytest $(pytest_general_opts) $(pytest_test_opts) $(test_dir)/function + pytest $(pytest_general_opts) $(pytest_test_opts) $(test_dir)/function @echo "Makefile: $@ done." .PHONY: clean diff --git a/README.md b/README.md index 796c76c..70c8920 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ The generated Python project features the following: * Selection of the license to be used for the new project. * Support for publishing the package to Pypi and the documentation to ReadTheDocs. * Use of semantic versioning (M.N.P). +* Several feature flags for controlling optional features. ## Usage diff --git a/new_{[ cookiecutter.github_repo ]} b/new_{[ cookiecutter.github_repo ]} index e43aa63..b210092 160000 --- a/new_{[ cookiecutter.github_repo ]} +++ b/new_{[ cookiecutter.github_repo ]} @@ -1 +1 @@ -Subproject commit e43aa6309f12ea719a90d5adee5a8d029a73ccb5 +Subproject commit b2100925207f3ee59225510d6fc4212573dd77bb diff --git a/tests/function/test_cookiecutter.py b/tests/function/test_cc_create.py similarity index 96% rename from tests/function/test_cookiecutter.py rename to tests/function/test_cc_create.py index 7ce9c8b..e473401 100644 --- a/tests/function/test_cookiecutter.py +++ b/tests/function/test_cc_create.py @@ -1,15 +1,15 @@ """ -Tests with using cookiecutter. +Tests for creating repos with cookiecutter. """ import os import re -import subprocess import tempfile import shutil import pytest +from ..utils.utils import path_info, run_args # Controls whether the output directory is kept for debugging KEEP_OUT_DIR = False @@ -18,30 +18,12 @@ DEBUG = False -def path_info(file_path): - """ - Return information about the existence of the specified file path and - its parent directories until one exists. - """ - lines = "" - if os.path.isfile(file_path): - lines += f"path_info: File exists: {file_path}\n" - elif os.path.isdir(file_path): - lines += f"path_info: Directory exists: {file_path}\n" - else: - lines += f"path_info: Does not exist: {file_path}\n" - parent_path = os.path.dirname(file_path) - if parent_path != file_path: - lines += path_info(parent_path) - return lines - - TESTCASES_CC_CREATE = [ # Testcases for test_cc_create() # Each testcase is a tuple of: # * desc (str): Brief oneline description of the testcase. - # * input_parms (dict): Non-default input paraneters for cookiecutter. + # * input_parms (dict): Non-default input parameters for cookiecutter. # * exp_out_dir (str): Path names of expected output directory. # * exp_files_present (list of str): Path names of files that are expected # to exist in case of success. @@ -797,7 +779,7 @@ def test_cc_create( exp_lines_absent, exp_rc, exp_stdout_pattern, exp_stderr_pattern): # pylint: disable=unused-argument """ - Test creation of cookiecutter projects. + Test creation of repos with cookiecutter. """ # The template directory, using the git submodule @@ -810,17 +792,8 @@ def test_cc_create( try: parm_args = [f"{name}={value}" for name, value in input_parms.items()] args = ["cookiecutter", "--no-input", template_dir] + parm_args - if DEBUG: - print(f"Debug: args={args!r}") - try: - result = subprocess.run( - args, cwd=tmp_dir, capture_output=True, text=True, check=False, - timeout=30) - except IOError as exc: - raise AssertionError( - f"Cannot run command with args: {args}, " - f"{exc.__class__.__name__}: {exc}") + result = run_args(args=args, cwd=tmp_dir) assert result.returncode == exp_rc, ( f"Unexpected exit code: {result.returncode} (expected: {exp_rc})\n" diff --git a/tests/function/test_cc_make.py b/tests/function/test_cc_make.py new file mode 100644 index 0000000..23a74b9 --- /dev/null +++ b/tests/function/test_cc_make.py @@ -0,0 +1,205 @@ +""" +Tests for running make commands in a repo created with cookiecutter. +""" + +import os +import re +import tempfile +import shutil +import venv + +import pytest + +from ..utils.utils import run_args + +# Controls whether the output directory is kept for debugging +KEEP_OUT_DIR = False + +# Controls whether the 'make' command output is shown for debugging +SHOW_MAKE = False + + +TESTCASES_CC_MAKE = [ + # Testcases for test_cc_make() + + # Each testcase is a tuple of: + # * desc (str): Brief oneline description of the testcase. + # * input_parms (dict): Non-default input parameters for cookiecutter. + # * out_dir (str): Relative path name of output directory. + # * cmd_args (list of str): Command args to run in created repo. + # * exp_rc (int): Expected exit code of command. + # * exp_stdout_pattern (str): Regexp pattern for expected command stdout. + # * exp_stderr_pattern (str): Regexp pattern for expected command stderr. + + ( + "Run make help", + {}, + "new_new-project", + ["make", "help"], + 0, + r"Make targets:", + "" + ), + ( + "Run make pip_list", + {}, + "new_new-project", + ["make", "pip_list"], + 0, + r"Makefile: Python packages as seen by make", + "" + ), + ( + "Run make install", + {}, + "new_new-project", + ["make", "install"], + 0, + r"Makefile: install done", + "" + ), + ( + "Run make check", + {}, + "new_new-project", + ["make", "check"], + 0, + r"Makefile: check done", + "" + ), + ( + "Run make unittest", + {}, + "new_new-project", + ["make", "unittest"], + 0, + r"Makefile: unittest done", + "" + ), +] + + +@pytest.mark.parametrize( + "desc, input_parms, out_dir, cmd_args, exp_rc, exp_stdout_pattern, " + "exp_stderr_pattern", + TESTCASES_CC_MAKE) +def test_cc_make( + desc, input_parms, out_dir, cmd_args, exp_rc, exp_stdout_pattern, + exp_stderr_pattern): + # pylint: disable=unused-argument + """ + Test running make commands in a repo created by cookiecutter. + """ + + # The template directory, using the git submodule + template_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..")) + + try: + + # Create a named temporary directory + tmp_dir = tempfile.mkdtemp(prefix="test_cc_make_") + + # The repo main directory + out_dir = os.path.join(tmp_dir, out_dir) + + # Create the repo using cookiecutter + parm_args = [f"{name}={value}" for name, value in input_parms.items()] + result = run_args( + args=["cookiecutter", "--no-input", template_dir] + parm_args, + cwd=tmp_dir, check=True) + + # Initialize the repo for git and create a first commit so that + # author creation works + run_args(args=["git", "init"], + cwd=out_dir, check=True) + run_args(args=["git", "config", "user.email", "ci@example.com"], + cwd=out_dir, check=True) + run_args(args=["git", "config", "user.name", "CI"], + cwd=out_dir, check=True) + run_args(args=["git", "add", "--all"], + cwd=out_dir, check=True) + run_args(args=["git", "commit", "-asm", "Initial commit"], + cwd=out_dir, check=True) + + # Create a virtual Python environment since the 'make' commands install + # Python packages + venv_dir = os.path.join(tmp_dir, ".venv") + venv.create(venv_dir, with_pip=True, clear=True) + + # Determine the code directory of the virtual Python environment + venv_bin_dir = os.path.join(venv_dir, "bin") + if not os.path.isdir(venv_bin_dir): + venv_bin_dir = os.path.join(venv_dir, "Scripts") # On Windows + if not os.path.isdir(venv_bin_dir): + raise AssertionError( + "Cannot find code directory in virtual Python environment " + f"directory: {venv_dir}") + + # Prepare the environment for running the 'make' command + env = dict(os.environ) + + # "Activate" the virtual Python environment + env["PATH"] = f"{venv_bin_dir}{os.pathsep}{env['PATH']}" + env["VIRTUAL_ENV"] = str(venv_dir) + + # Prevent the parent Python interpreter from leaking in + env["PYTHONHOME"] = "" + + # Remove our own test env vars from the environment, since they are not + # meant for the 'make test' commands of the created repo. + if env.get("TESTOPTS"): + del env["TESTOPTS"] + if env.get("TESTCASES"): + del env["TESTCASES"] + + # Remove any Python/Pip command overrides from the environment, since + # the virtual Python environment provides the default commands. + if env.get("PYTHON_CMD"): + del env["PYTHON_CMD"] + if env.get("PIP_CMD"): + del env["PIP_CMD"] + + # Set RUN_TYPE to 'normal' to have tolerant handling of safety issues. + env["RUN_TYPE"] = "normal" + + # PACKAGE_LEVEL is passed through. + + # Run the 'make' command to be tested + result = run_args(args=cmd_args, cwd=out_dir, timeout=600, env=env) + + if SHOW_MAKE: + cmd_str = " ".join(cmd_args) + print(f"\nDebug: Output of '{cmd_str}':") + print(result.stdout) + print(f"Debug: End of output of '{cmd_str}'") + + assert result.returncode == exp_rc, ( + f"Unexpected exit code: {result.returncode} (expected: {exp_rc})\n" + f"Stdout:\n{result.stdout}\n" + f"Stderr:\n{result.stderr}\n" + ) + + if exp_stdout_pattern is None: + exp_stdout_pattern = "" + m = re.search(exp_stdout_pattern, result.stdout) + assert m is not None, ( + "Unexpected stdout:\n" + f"Expected pattern:\n{exp_stdout_pattern!r}\n" + f"Actual value:\n{result.stdout!r}\n" + ) + + if exp_stderr_pattern is None: + exp_stderr_pattern = "" + m = re.search(exp_stderr_pattern, result.stderr) + assert m is not None, ( + "Unexpected stderr:\n" + f"Expected pattern:\n{exp_stderr_pattern!r}\n" + f"Actual value:\n{result.stderr!r}\n" + ) + + finally: + if KEEP_OUT_DIR: + print(f"Debug: Ouput directory kept for debugging: {tmp_dir}") + else: + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/utils.py b/tests/utils/utils.py new file mode 100644 index 0000000..796193f --- /dev/null +++ b/tests/utils/utils.py @@ -0,0 +1,45 @@ +""" +Utility functions for testing. +""" + +import os +import subprocess + + +def path_info(file_path): + """ + Return information about the existence of the specified file path and + its parent directories until one exists. + """ + lines = "" + if os.path.isfile(file_path): + lines += f"path_info: File exists: {file_path}\n" + elif os.path.isdir(file_path): + lines += f"path_info: Directory exists: {file_path}\n" + else: + lines += f"path_info: Does not exist: {file_path}\n" + parent_path = os.path.dirname(file_path) + if parent_path != file_path: + lines += path_info(parent_path) + return lines + + +def run_args(args, cwd, *, check=False, timeout=30, env=None): + """ + Execute the args as a child process, capturing stdout and stderr. + """ + try: + result = subprocess.run( + args, cwd=cwd, capture_output=True, text=True, check=check, + timeout=timeout, env=env) + except IOError as exc: + raise AssertionError( + f"Cannot run command: {args}, " + f"{exc.__class__.__name__}: {exc}") + except subprocess.CalledProcessError as exc: + raise AssertionError( + f"Cannot run command: {args}, " + f"{exc.__class__.__name__}: {exc}\n" + f"stdout: {exc.stdout}\n" + f"stderr: {exc.stderr}\n") + return result