Skip to content

Commit

Permalink
fix(cli): gracefully output configuration validation errors (#772)
Browse files Browse the repository at this point in the history
* test(fixtures): update example project workflow & add config modifier

* test(cli-main): add test for raw config validation error

* fix(cli): gracefully output configuration validation errors
  • Loading branch information
codejedi365 committed Dec 19, 2023
1 parent bb3b631 commit e8c9d51
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 56 deletions.
8 changes: 5 additions & 3 deletions semantic_release/cli/commands/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from click.core import ParameterSource
from git import InvalidGitRepositoryError
from git.repo.base import Repo
from pydantic import ValidationError
from rich.console import Console
from rich.logging import RichHandler

Expand Down Expand Up @@ -142,8 +143,8 @@ def main(
except InvalidConfiguration as exc:
ctx.fail(str(exc))

raw_config = RawConfig.model_validate(config_text)
try:
raw_config = RawConfig.model_validate(config_text)
runtime = RuntimeContext.from_raw_config(
raw_config, repo=repo, global_cli_options=cli_options
)
Expand All @@ -153,8 +154,9 @@ def main(
# multibranch CI it might be desirable to run a non-release branch's pipeline
# without specifying conditional execution of PSR based on branch name
ctx.exit(2 if strict else 0)
except InvalidConfiguration as exc:
ctx.fail(str(exc))
except (ValidationError, InvalidConfiguration) as exc:
click.echo(str(exc), err=True)
ctx.exit(1)
ctx.obj = runtime

# This allows us to mask secrets in the logging
Expand Down
19 changes: 19 additions & 0 deletions tests/command_line/test_main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import json
import os
from textwrap import dedent
from typing import TYPE_CHECKING

import git
import pytest

from semantic_release import __version__
from semantic_release.cli import main

if TYPE_CHECKING:
from click.testing import CliRunner

from tests.fixtures.example_project import UpdatePyprojectTomlFn


def test_main_prints_version_and_exits(cli_runner):
result = cli_runner.invoke(main, ["--version"])
Expand Down Expand Up @@ -116,6 +122,19 @@ def test_errors_when_config_file_does_not_exist_and_passed_explicitly(
assert "does not exist" in result.stderr


@pytest.mark.usefixtures("repo_with_no_tags_angular_commits")
def test_errors_when_config_file_invalid_configuration(
cli_runner: "CliRunner", update_pyproject_toml: "UpdatePyprojectTomlFn"
):
update_pyproject_toml("tool.semantic_release.remote.type", "invalidType")
result = cli_runner.invoke(main, ["--config", "pyproject.toml", "version"])

stderr_lines = result.stderr.splitlines()
assert result.exit_code == 1
assert "1 validation error for RawConfig" in stderr_lines[0]
assert "remote.type" in stderr_lines[1]


def test_uses_default_config_when_no_config_file_found(
tmp_path,
cli_runner,
Expand Down
144 changes: 91 additions & 53 deletions tests/fixtures/example_project.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import os
from contextlib import contextmanager
from pathlib import Path
from textwrap import dedent
from typing import Generator
from typing import TYPE_CHECKING, Generator

import pytest
import tomlkit

from tests.const import (
EXAMPLE_CHANGELOG_MD_CONTENT,
Expand All @@ -16,65 +16,76 @@
EXAMPLE_SETUP_PY_CONTENT,
)

if TYPE_CHECKING:
from typing import Any, Protocol

@contextmanager
def cd(path: Path) -> Generator[Path, None, None]:
ExProjectDir = Path

class UpdatePyprojectTomlFn(Protocol):
def __call__(self, setting: str, value: "Any") -> None:
...


@pytest.fixture
def change_to_tmp_dir(tmp_path: "Path") -> "Generator[None, None, None]":
cwd = os.getcwd()
os.chdir(str(path.resolve()))
yield path
os.chdir(cwd)
os.chdir(str(tmp_path.resolve()))
try:
yield
finally:
os.chdir(cwd)


@pytest.fixture
def example_project(tmp_path: "Path") -> "Generator[Path, None, None]":
with cd(tmp_path):
src_dir = tmp_path / "src"
src_dir.mkdir()
example_dir = src_dir / EXAMPLE_PROJECT_NAME
example_dir.mkdir()
init_py = example_dir / "__init__.py"
init_py.write_text(
dedent(
'''
"""
An example package with a very informative docstring
"""
from ._version import __version__
def hello_world() -> None:
print("Hello World")
'''
)
def example_project(change_to_tmp_dir: None) -> "ExProjectDir":
tmp_path = Path.cwd()
src_dir = tmp_path / "src"
src_dir.mkdir()
example_dir = src_dir / EXAMPLE_PROJECT_NAME
example_dir.mkdir()
init_py = example_dir / "__init__.py"
init_py.write_text(
dedent(
'''
"""
An example package with a very informative docstring
"""
from ._version import __version__
def hello_world() -> None:
print("Hello World")
'''
)
version_py = example_dir / "_version.py"
version_py.write_text(
dedent(
f"""
__version__ = "{EXAMPLE_PROJECT_VERSION}"
"""
)
)
version_py = example_dir / "_version.py"
version_py.write_text(
dedent(
f"""
__version__ = "{EXAMPLE_PROJECT_VERSION}"
"""
)
gitignore = tmp_path / ".gitignore"
gitignore.write_text(
dedent(
f"""
*.pyc
/src/**/{version_py.name}
"""
)
)
gitignore = tmp_path / ".gitignore"
gitignore.write_text(
dedent(
f"""
*.pyc
/src/**/{version_py.name}
"""
)
pyproject_toml = tmp_path / "pyproject.toml"
pyproject_toml.write_text(EXAMPLE_PYPROJECT_TOML_CONTENT)
setup_cfg = tmp_path / "setup.cfg"
setup_cfg.write_text(EXAMPLE_SETUP_CFG_CONTENT)
setup_py = tmp_path / "setup.py"
setup_py.write_text(EXAMPLE_SETUP_PY_CONTENT)
template_dir = tmp_path / "templates"
template_dir.mkdir()
changelog_md = tmp_path / "CHANGELOG.md"
changelog_md.write_text(EXAMPLE_CHANGELOG_MD_CONTENT)
yield tmp_path
)
pyproject_toml = tmp_path / "pyproject.toml"
pyproject_toml.write_text(EXAMPLE_PYPROJECT_TOML_CONTENT)
setup_cfg = tmp_path / "setup.cfg"
setup_cfg.write_text(EXAMPLE_SETUP_CFG_CONTENT)
setup_py = tmp_path / "setup.py"
setup_py.write_text(EXAMPLE_SETUP_PY_CONTENT)
template_dir = tmp_path / "templates"
template_dir.mkdir()
changelog_md = tmp_path / "CHANGELOG.md"
changelog_md.write_text(EXAMPLE_CHANGELOG_MD_CONTENT)
return tmp_path


@pytest.fixture
Expand Down Expand Up @@ -109,3 +120,30 @@ def example_changelog_md(example_project):
@pytest.fixture
def example_project_template_dir(example_project):
return example_project / "templates"


@pytest.fixture
def update_pyproject_toml(
example_project: "Path", example_pyproject_toml: "Path"
) -> "UpdatePyprojectTomlFn":
"""Update the pyproject.toml file with the given content."""
def _update_pyproject_toml(setting: str, value: "Any") -> None:
with open(example_pyproject_toml) as rfd:
pyproject_toml = tomlkit.load(rfd)

new_setting = {}
parts = setting.split(".")
new_setting_key = parts.pop(-1)
new_setting[new_setting_key] = value

pointer = pyproject_toml
for part in parts:
if pointer.get(part, None) is None:
pointer.add(part, tomlkit.table())
pointer = pointer.get(part, {})
pointer.update(new_setting)

with open(example_pyproject_toml, 'w') as wfd:
tomlkit.dump(pyproject_toml, wfd)

return _update_pyproject_toml

0 comments on commit e8c9d51

Please sign in to comment.