Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 74 additions & 13 deletions src/semantic_release/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
Field,
RootModel,
ValidationError,
field_serializer,
field_validator,
model_validator,
)
Expand Down Expand Up @@ -364,8 +365,12 @@ class RawConfig(BaseModel):
build_command: Optional[str] = None
build_command_env: List[str] = []
changelog: ChangelogConfig = ChangelogConfig()
commit_author: MaybeFromEnv = EnvConfigVar(
env="GIT_COMMIT_AUTHOR", default=DEFAULT_COMMIT_AUTHOR
commit_author: Actor = Field(
default=cast(
"Actor",
EnvConfigVar(env="GIT_COMMIT_AUTHOR", default=DEFAULT_COMMIT_AUTHOR),
),
validate_default=True,
)
commit_message: str = COMMIT_MESSAGE
commit_parser: NonEmptyString = "conventional"
Expand All @@ -390,6 +395,72 @@ def convert_str_to_path(cls, value: Any) -> Path:
raise TypeError(f"Invalid type: {type(value)}, expected str or Path.")
return Path(value)

# Note: mode="plain" must be declared before mode="before" here, as pydantic
# composes same-field validators in declaration order and a later "before"
# validator wraps (runs prior to) an earlier "plain" validator.
@field_validator("commit_author", mode="plain")
@classmethod
def validate_commit_author(cls, val: Any) -> Actor:
if isinstance(val, Actor):
return val

if isinstance(val, dict):
if "name" not in val or "email" not in val:
msg = "commit_author dict must contain 'name' and 'email' keys."
raise ValueError(msg)
if not isinstance(val["name"], str) or not isinstance(val["email"], str):
msg = "commit_author 'name' and 'email' must be strings."
raise ValueError(msg) # noqa: TRY004
if not val["name"].strip() or not val["email"].strip():
msg = "commit_author 'name' and 'email' cannot be empty."
raise ValueError(msg)
# TODO: add email format validation (breaking change)
return Actor(**val)

if isinstance(val, str):
if not val.strip():
msg = "commit_author string cannot be empty."
raise ValueError(msg)

name_email_pattern = regexp(
r"^(?P<name>[^<]{1,255}) ?<(?P<email>[^>]{1,320})>$"
)
value = val.strip().splitlines()[0]

if not (m := name_email_pattern.search(value)):
msg = "commit_author string must be in the format 'Name <email>'."
raise ValueError(msg)

# TODO: add email format validation (breaking change)
email = m.group("email").strip()

return Actor(name=m.group("name").strip(), email=email)

msg = f"Invalid type for commit_author: {type(val)}, expected Actor, dict, or str."
raise TypeError(msg)

# TODO: apply to more fields that can be set via environment variables
@field_validator("commit_author", mode="before")
@classmethod
def resolve_env_vars(cls, val: Any) -> Any | str | None:
if isinstance(val, EnvConfigVar):
return val.getvalue()

if not isinstance(val, dict):
return val

try:
return EnvConfigVar.model_validate(val).getvalue()
except ValidationError:
if "env" in val:
raise
return val

@field_serializer("commit_author", mode="plain")
@classmethod
def serialize_commit_author(cls, val: Actor) -> str:
return f"{val.name} <{val.email}>"

@field_validator("repo_dir", mode="after")
@classmethod
def verify_git_repo_dir(cls, dir_path: Path) -> Path:
Expand Down Expand Up @@ -741,16 +812,6 @@ def from_raw_config( # noqa: C901
*(regexp(pattern) for pattern in raw.changelog.exclude_commit_patterns),
)

_commit_author_str = cls.resolve_from_env(raw.commit_author) or ""
_commit_author_valid = Actor.name_email_regex.match(_commit_author_str)
if not _commit_author_valid:
raise ValueError(
f"Invalid git author: {_commit_author_str} "
f"should match {Actor.name_email_regex}"
)

commit_author = Actor(*_commit_author_valid.groups())

version_declarations: list[IVersionReplacer] = []

try:
Expand Down Expand Up @@ -909,7 +970,7 @@ def from_raw_config( # noqa: C901
changelog_mask_initial_release=raw.changelog.default_templates.mask_initial_release,
changelog_insertion_flag=raw.changelog.insertion_flag,
assets=raw.assets,
commit_author=commit_author,
commit_author=raw.commit_author,
commit_message=raw.commit_message,
changelog_excluded_commit_patterns=changelog_excluded_commit_patterns,
# TODO: change when we have other styles per parser
Expand Down
179 changes: 165 additions & 14 deletions tests/unit/semantic_release/cli/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import pytest
import tomlkit
from git import Actor
from pydantic import RootModel, ValidationError
from urllib3.util.url import parse_url

Expand All @@ -33,7 +34,6 @@
from semantic_release.enums import LevelBump
from semantic_release.errors import ParserLoadError

from tests.fixtures.repos import repo_w_no_tags_conventional_commits
from tests.util import (
CustomParserOpts,
CustomParserWithNoOpts,
Expand Down Expand Up @@ -185,27 +185,178 @@ def test_default_toml_config_valid(example_project_dir: ExProjectDir):
({"GIT_COMMIT_AUTHOR": "foo <foo>"}, "foo <foo>"),
],
)
@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__)
def test_commit_author_configurable(
example_pyproject_toml: Path,
mock_env: dict[str, str],
expected_author: str,
change_to_ex_proj_dir: None,
):
content = tomlkit.loads(example_pyproject_toml.read_text(encoding="utf-8")).unwrap()

with mock.patch.dict(os.environ, mock_env):
raw = RawConfig.model_validate(content)
runtime = RuntimeContext.from_raw_config(
raw=raw,
global_cli_options=GlobalCommandLineOptions(),
)
resulting_author = (
f"{runtime.commit_author.name} <{runtime.commit_author.email}>"
)
raw = RawConfig.model_validate({})
resulting_author = f"{raw.commit_author.name} <{raw.commit_author.email}>"
assert expected_author == resulting_author


def test_commit_author_accepts_actor_instance():
author = Actor(name="Foo Bar", email="[email protected]")
raw = RawConfig(commit_author=author)
assert author.name == raw.commit_author.name
assert author.email == raw.commit_author.email


def test_commit_author_valid_dict_input():
expected_name = "Foo Bar"
expected_email = "[email protected]"
raw = RawConfig.model_validate(
{"commit_author": {"name": expected_name, "email": expected_email}}
)
assert expected_name == raw.commit_author.name
assert expected_email == raw.commit_author.email


@pytest.mark.parametrize(
"commit_author_str, expected_name, expected_email",
[
("Foo Bar <[email protected]>", "Foo Bar", "[email protected]"),
("FooBar<[email protected]>", "FooBar", "[email protected]"),
# only the first line of a multiline value is parsed
("Foo Bar <[email protected]>\nnot-part-of-the-author", "Foo Bar", "[email protected]"),
],
)
def test_commit_author_valid_string_formats(
commit_author_str: str, expected_name: str, expected_email: str
):
raw = RawConfig.model_validate({"commit_author": commit_author_str})
assert expected_name == raw.commit_author.name
assert expected_email == raw.commit_author.email


@pytest.mark.parametrize(
"commit_author_dict, mock_env, expected_author",
[
(
{"env": "PSR_TEST_COMMIT_AUTHOR_ENV"},
{"PSR_TEST_COMMIT_AUTHOR_ENV": "Env Name <[email protected]>"},
"Env Name <[email protected]>",
),
(
{
"env": "PSR_TEST_COMMIT_AUTHOR_ENV",
"default": "Default Name <[email protected]>",
},
{},
"Default Name <[email protected]>",
),
(
{
"env": "PSR_TEST_COMMIT_AUTHOR_ENV",
"default_env": "PSR_TEST_COMMIT_AUTHOR_FALLBACK_ENV",
},
{
"PSR_TEST_COMMIT_AUTHOR_FALLBACK_ENV": (
"Fallback Name <[email protected]>"
)
},
"Fallback Name <[email protected]>",
),
],
)
def test_commit_author_resolves_env_config_var(
commit_author_dict: dict[str, str],
mock_env: dict[str, str],
expected_author: str,
):
with mock.patch.dict(os.environ, mock_env, clear=True):
raw = RawConfig.model_validate({"commit_author": commit_author_dict})

resulting_author = f"{raw.commit_author.name} <{raw.commit_author.email}>"
assert expected_author == resulting_author


def test_commit_author_env_config_var_resolves_to_none_raises_type_error():
# nested "with" kept separate for py38 compatibility (no parenthesized context managers)
with mock.patch.dict(os.environ, {}, clear=True): # noqa: SIM117
with pytest.raises(TypeError, match="Invalid type for commit_author"):
RawConfig.model_validate(
{"commit_author": {"env": "PSR_TEST_COMMIT_AUTHOR_UNSET_ENV"}}
)


@pytest.mark.parametrize("commit_author_dict", [{"env": 123}])
def test_commit_author_invalid_env_config_var(commit_author_dict: dict[str, int]):
with pytest.raises(ValidationError, match="commit_author.env"):
RawConfig.model_validate({"commit_author": commit_author_dict})


@pytest.mark.parametrize(
"commit_author_dict, expected_err_msg",
[
(
{"name": "Foo Bar"},
"commit_author dict must contain 'name' and 'email' keys.",
),
(
{"email": "[email protected]"},
"commit_author dict must contain 'name' and 'email' keys.",
),
(
{"name": 123, "email": "[email protected]"},
"commit_author 'name' and 'email' must be strings.",
),
(
{"name": " ", "email": "[email protected]"},
"commit_author 'name' and 'email' cannot be empty.",
),
],
)
def test_commit_author_invalid_dict_input(
commit_author_dict: dict[str, Any], expected_err_msg: str
):
with pytest.raises(ValidationError, match=expected_err_msg):
RawConfig.model_validate({"commit_author": commit_author_dict})


@pytest.mark.parametrize(
"commit_author_str, expected_err_msg",
[
("", "commit_author string cannot be empty."),
(" ", "commit_author string cannot be empty."),
(
"NoAngleBracketsHere",
"commit_author string must be in the format 'Name <email>'.",
),
(
"<[email protected]>",
"commit_author string must be in the format 'Name <email>'.",
),
(
"Foo Bar <>",
"commit_author string must be in the format 'Name <email>'.",
),
],
)
def test_commit_author_invalid_string_input(
commit_author_str: str, expected_err_msg: str
):
with pytest.raises(ValidationError, match=expected_err_msg):
RawConfig.model_validate({"commit_author": commit_author_str})


@pytest.mark.parametrize(
"commit_author_val", [123, 12.3, ["Foo Bar", "[email protected]"], None]
)
def test_commit_author_invalid_type_input(commit_author_val: Any):
# TypeError is not a pydantic-recognized validation exception, so it is not
# wrapped into a ValidationError like the other invalid input cases above
with pytest.raises(TypeError, match="Invalid type for commit_author"):
RawConfig.model_validate({"commit_author": commit_author_val})


def test_commit_author_serialization():
name_email_str = "Foo Bar <[email protected]>"
raw = RawConfig.model_validate({"commit_author": name_email_str})
serialized_author = raw.model_dump(mode="json").get("commit_author")
assert name_email_str == serialized_author


def test_load_valid_runtime_config(
build_configured_base_repo: BuildRepoFn,
example_project_dir: ExProjectDir,
Expand Down
Loading