diff --git a/src/usethis/_core/ci.py b/src/usethis/_core/ci.py index b3a28ad9..bb20ca2c 100644 --- a/src/usethis/_core/ci.py +++ b/src/usethis/_core/ci.py @@ -30,7 +30,9 @@ ] -def use_ci_bitbucket(*, remove: bool = False, how: bool = False) -> None: +def use_ci_bitbucket( + *, remove: bool = False, how: bool = False, matrix_python: bool = True +) -> None: if how: print_how_to_use_ci_bitbucket() return @@ -49,7 +51,7 @@ def use_ci_bitbucket(*, remove: bool = False, how: bool = False) -> None: for tool in _CI_QA_TOOLS: tool().update_bitbucket_steps() - PytestTool().update_bitbucket_steps() + PytestTool().update_bitbucket_steps(matrix_python=matrix_python) print_how_to_use_ci_bitbucket() else: diff --git a/src/usethis/_tool/base.py b/src/usethis/_tool/base.py index 5761f8ab..2ffa9640 100644 --- a/src/usethis/_tool/base.py +++ b/src/usethis/_tool/base.py @@ -570,12 +570,16 @@ def get_install_method(self) -> Literal["pre-commit", "devdep"] | None: return "pre-commit" return None - def get_bitbucket_steps(self) -> list[BitbucketStep]: + def get_bitbucket_steps(self, *, matrix_python: bool = True) -> list[BitbucketStep]: """Get the Bitbucket pipeline step associated with this tool. By default, this creates a single step using the tool's default_command(). Tools can override this method for more complex step requirements (e.g., pytest with multiple Python versions, or Ruff with separate linter/formatter steps). + + Args: + matrix_python: Whether to use a Python version matrix. When False, + only the current development version is used. """ try: cmd = self.default_command() @@ -627,23 +631,27 @@ def remove_bitbucket_steps(self) -> None: if step.name in self.get_managed_bitbucket_step_names(): remove_bitbucket_step_from_default(step) - def update_bitbucket_steps(self) -> None: + def update_bitbucket_steps(self, *, matrix_python: bool = True) -> None: """Add Bitbucket steps associated with this tool, and remove outdated ones. Only runs if Bitbucket is used in the project. + + Args: + matrix_python: Whether to use a Python version matrix. When False, + only the current development version is used. """ if not is_bitbucket_used() or not self.is_used(): return # Add the new steps - for step in self.get_bitbucket_steps(): + for step in self.get_bitbucket_steps(matrix_python=matrix_python): add_bitbucket_step_in_default(step) # Remove any old steps that are not active managed by this tool for step in get_steps_in_default(): if step.name in self.get_managed_bitbucket_step_names() and not any( bitbucket_steps_are_equivalent(step, step_) - for step_ in self.get_bitbucket_steps() + for step_ in self.get_bitbucket_steps(matrix_python=matrix_python) ): remove_bitbucket_step_from_default(step) diff --git a/src/usethis/_tool/impl/pre_commit.py b/src/usethis/_tool/impl/pre_commit.py index 490772d4..c0658900 100644 --- a/src/usethis/_tool/impl/pre_commit.py +++ b/src/usethis/_tool/impl/pre_commit.py @@ -68,7 +68,7 @@ def get_dev_deps(self, *, unconditional: bool = False) -> list[Dependency]: def get_managed_files(self) -> list[Path]: return [Path(".pre-commit-config.yaml")] - def get_bitbucket_steps(self) -> list[BitbucketStep]: + def get_bitbucket_steps(self, *, matrix_python: bool = True) -> list[BitbucketStep]: backend = get_backend() if backend is BackendEnum.uv: diff --git a/src/usethis/_tool/impl/pytest.py b/src/usethis/_tool/impl/pytest.py index 3c2a2fbc..f673ecee 100644 --- a/src/usethis/_tool/impl/pytest.py +++ b/src/usethis/_tool/impl/pytest.py @@ -24,6 +24,7 @@ from usethis._integrations.file.setup_cfg.io_ import SetupCFGManager from usethis._integrations.project.build import has_pyproject_toml_declared_build_system from usethis._integrations.project.layout import get_source_dir_str +from usethis._integrations.python.version import get_python_major_version from usethis._tool.base import Tool from usethis._tool.config import ConfigEntry, ConfigItem, ConfigSpec from usethis._tool.rule import RuleConfig @@ -222,8 +223,11 @@ def get_active_config_file_managers(self) -> set[KeyValueFileManager]: raise NotImplementedError(msg) return {preferred_file_manager} - def get_bitbucket_steps(self) -> list[BitbucketStep]: - versions = get_supported_major_python_versions() + def get_bitbucket_steps(self, *, matrix_python: bool = True) -> list[BitbucketStep]: + if matrix_python: + versions = get_supported_major_python_versions() + else: + versions = [get_python_major_version()] backend = get_backend() @@ -272,7 +276,7 @@ def get_managed_bitbucket_step_names(self) -> list[str]: return sorted(names) - def update_bitbucket_steps(self) -> None: + def update_bitbucket_steps(self, *, matrix_python: bool = True) -> None: """Update the pytest-related Bitbucket Pipelines steps. A bespoke function is needed here to ensure we inform the user about the need @@ -284,7 +288,7 @@ def update_bitbucket_steps(self) -> None: # But otherwise if not early exiting, we are going to add steps so we might # need to inform the user - super().update_bitbucket_steps() + super().update_bitbucket_steps(matrix_python=matrix_python) backend = get_backend() diff --git a/src/usethis/_tool/impl/ruff.py b/src/usethis/_tool/impl/ruff.py index 6f479800..55ecf151 100644 --- a/src/usethis/_tool/impl/ruff.py +++ b/src/usethis/_tool/impl/ruff.py @@ -266,7 +266,7 @@ def get_pre_commit_config(self) -> PreCommitConfig: inform_how_to_use_on_migrate=True, # The pre-commit commands are not simpler than the venv-based commands ) - def get_bitbucket_steps(self) -> list[BitbucketStep]: + def get_bitbucket_steps(self, *, matrix_python: bool = True) -> list[BitbucketStep]: backend = get_backend() steps = [] diff --git a/src/usethis/_ui/interface/ci.py b/src/usethis/_ui/interface/ci.py index 5bd69a12..d3943403 100644 --- a/src/usethis/_ui/interface/ci.py +++ b/src/usethis/_ui/interface/ci.py @@ -14,6 +14,11 @@ def bitbucket( remove: bool = typer.Option( False, "--remove", help="Remove Bitbucket Pipelines CI instead of adding it." ), + matrix_python: bool = typer.Option( + True, + "--matrix-python/--no-matrix-python", + help="Test against multiple Python versions.", + ), offline: bool = offline_opt, quiet: bool = quiet_opt, frozen: bool = frozen_opt, @@ -28,12 +33,15 @@ def bitbucket( with ( usethis_config.set( - offline=offline, quiet=quiet, frozen=frozen, backend=backend + offline=offline, + quiet=quiet, + frozen=frozen, + backend=backend, ), files_manager(), ): try: - use_ci_bitbucket(remove=remove) + use_ci_bitbucket(remove=remove, matrix_python=matrix_python) except UsethisError as err: err_print(err) raise typer.Exit(code=1) from None diff --git a/tests/usethis/_core/test_core_ci.py b/tests/usethis/_core/test_core_ci.py index 1fd4f141..356c2741 100644 --- a/tests/usethis/_core/test_core_ci.py +++ b/tests/usethis/_core/test_core_ci.py @@ -2,6 +2,7 @@ import pytest +import usethis._integrations.python.version from usethis._config import usethis_config from usethis._config_file import files_manager from usethis._core.ci import use_ci_bitbucket @@ -584,3 +585,79 @@ def test_message(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]): "ℹ Consider `usethis tool pytest` to test your code for the pipeline.\n" # noqa: RUF001 "☐ Run your pipeline via the Bitbucket website.\n" ) + + class TestPythonMatrix: + def test_matrix_enabled_by_default(self, uv_init_dir: Path): + """Test that matrix is enabled by default and creates multiple test steps.""" + # Arrange + (uv_init_dir / "tests").mkdir() + (uv_init_dir / "tests" / "conftest.py").touch() + + with change_cwd(uv_init_dir), files_manager(): + PyprojectTOMLManager()[["project"]]["requires-python"] = ">=3.12,<3.14" + + # Act + use_ci_bitbucket() + + # Assert + contents = (uv_init_dir / "bitbucket-pipelines.yml").read_text() + assert "Test on 3.12" in contents + assert "Test on 3.13" in contents + + def test_matrix_disabled_creates_single_step( + self, uv_init_dir: Path, monkeypatch: pytest.MonkeyPatch + ): + """Test that --no-matrix-python creates only one test step for current version.""" + # Arrange + monkeypatch.setattr( + usethis._integrations.python.version, + "get_python_version", + lambda: "3.10.0", + ) + (uv_init_dir / "tests").mkdir() + (uv_init_dir / "tests" / "conftest.py").touch() + + with ( + change_cwd(uv_init_dir), + files_manager(), + ): + PyprojectTOMLManager()[["project"]]["requires-python"] = ">=3.12,<3.14" + + # Act + use_ci_bitbucket(matrix_python=False) + + # Assert + contents = (uv_init_dir / "bitbucket-pipelines.yml").read_text() + # Should only have one test step for the current development version (3.10) + assert "Test on 3.10" in contents + # Should NOT have other versions + assert "Test on 3.12" not in contents + assert "Test on 3.13" not in contents + + def test_matrix_disabled_with_none_backend( + self, bare_dir: Path, monkeypatch: pytest.MonkeyPatch + ): + """Test that --no-matrix-python works with backend=none.""" + # Arrange + monkeypatch.setattr( + usethis._integrations.python.version, + "get_python_version", + lambda: "3.11.0", + ) + (bare_dir / "tests").mkdir() + (bare_dir / "tests" / "conftest.py").touch() + + with ( + change_cwd(bare_dir), + files_manager(), + usethis_config.set(backend=BackendEnum.none), + ): + # Act + use_ci_bitbucket(matrix_python=False) + + # Assert + contents = (bare_dir / "bitbucket-pipelines.yml").read_text() + # Should have one test step for Python 3.11 + assert "Test on 3.11" in contents + # Should use image instead of uv + assert "image: python:3.11" in contents diff --git a/tests/usethis/_ui/interface/test_interface_ci.py b/tests/usethis/_ui/interface/test_interface_ci.py index 47f29441..469344c8 100644 --- a/tests/usethis/_ui/interface/test_interface_ci.py +++ b/tests/usethis/_ui/interface/test_interface_ci.py @@ -158,3 +158,54 @@ def test_import_pipeline_error_handled(self, tmp_path: Path): # Assert - error should be caught and handled, not propagate as unhandled exception assert result.exit_code == 1, result.output assert "import pipeline" in result.output.lower() + + def test_no_matrix_python_flag( + self, uv_init_dir: Path, monkeypatch: pytest.MonkeyPatch + ): + """Test that --no-matrix-python flag creates single test step via CLI.""" + # Arrange + monkeypatch.setattr( + usethis._integrations.python.version, + "get_python_version", + lambda: "3.10.0", + ) + (uv_init_dir / "tests").mkdir() + (uv_init_dir / "tests" / "conftest.py").touch() + + with PyprojectTOMLManager() as mgr: + mgr[["project"]]["requires-python"] = ">=3.12,<3.14" + + # Act + runner = CliRunner() + with change_cwd(uv_init_dir): + result = runner.invoke_safe(app, ["--no-matrix-python"]) + + # Assert + assert result.exit_code == 0, result.output + contents = (uv_init_dir / "bitbucket-pipelines.yml").read_text() + # Should only have one test step for the current development version (3.10) + assert "Test on 3.10" in contents + # Should NOT have other versions + assert "Test on 3.12" not in contents + assert "Test on 3.13" not in contents + + def test_matrix_python_flag_enabled_default(self, uv_init_dir: Path): + """Test that --matrix-python (default) creates multiple test steps via CLI.""" + # Arrange + (uv_init_dir / "tests").mkdir() + (uv_init_dir / "tests" / "conftest.py").touch() + + with PyprojectTOMLManager() as mgr: + mgr[["project"]]["requires-python"] = ">=3.12,<3.14" + + # Act + runner = CliRunner() + with change_cwd(uv_init_dir): + result = runner.invoke_safe(app, ["--matrix-python"]) + + # Assert + assert result.exit_code == 0, result.output + contents = (uv_init_dir / "bitbucket-pipelines.yml").read_text() + # Should have multiple test steps + assert "Test on 3.12" in contents + assert "Test on 3.13" in contents