diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index 8284e23c7..995b44f7c 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -36,6 +36,7 @@ Field, RootModel, ValidationError, + field_serializer, field_validator, model_validator, ) @@ -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" @@ -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[^<]{1,255}) ?<(?P[^>]{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 '." + 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: @@ -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: @@ -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 diff --git a/tests/unit/semantic_release/cli/test_config.py b/tests/unit/semantic_release/cli/test_config.py index 343748187..6cc799f18 100644 --- a/tests/unit/semantic_release/cli/test_config.py +++ b/tests/unit/semantic_release/cli/test_config.py @@ -10,6 +10,7 @@ import pytest import tomlkit +from git import Actor from pydantic import RootModel, ValidationError from urllib3.util.url import parse_url @@ -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, @@ -185,27 +185,178 @@ def test_default_toml_config_valid(example_project_dir: ExProjectDir): ({"GIT_COMMIT_AUTHOR": "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="foo@bar.com") + 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 = "foo@bar.com" + 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 ", "Foo Bar", "foo@bar.com"), + ("FooBar", "FooBar", "foo@bar.com"), + # only the first line of a multiline value is parsed + ("Foo Bar \nnot-part-of-the-author", "Foo Bar", "foo@bar.com"), + ], +) +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 "}, + "Env Name ", + ), + ( + { + "env": "PSR_TEST_COMMIT_AUTHOR_ENV", + "default": "Default Name ", + }, + {}, + "Default Name ", + ), + ( + { + "env": "PSR_TEST_COMMIT_AUTHOR_ENV", + "default_env": "PSR_TEST_COMMIT_AUTHOR_FALLBACK_ENV", + }, + { + "PSR_TEST_COMMIT_AUTHOR_FALLBACK_ENV": ( + "Fallback Name " + ) + }, + "Fallback Name ", + ), + ], +) +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": "foo@bar.com"}, + "commit_author dict must contain 'name' and 'email' keys.", + ), + ( + {"name": 123, "email": "foo@bar.com"}, + "commit_author 'name' and 'email' must be strings.", + ), + ( + {"name": " ", "email": "foo@bar.com"}, + "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 '.", + ), + ( + "", + "commit_author string must be in the format 'Name '.", + ), + ( + "Foo Bar <>", + "commit_author string must be in the format 'Name '.", + ), + ], +) +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", "foo@bar.com"], 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 " + 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,