diff --git a/src/poetry/core/masonry/builders/builder.py b/src/poetry/core/masonry/builders/builder.py index 9f569299e..ec961634c 100644 --- a/src/poetry/core/masonry/builders/builder.py +++ b/src/poetry/core/masonry/builders/builder.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import re import sys import warnings @@ -14,8 +13,6 @@ from poetry.core.poetry import Poetry -AUTHOR_REGEX = re.compile(r"(?u)^(?P[- .,\w\d'’\"()]+) <(?P.+?)>$") - METADATA_BASE = """\ Metadata-Version: 2.1 Name: {name} @@ -343,14 +340,10 @@ def convert_script_files(self) -> list[Path]: return script_files @classmethod - def convert_author(cls, author: str) -> dict[str, str]: - m = AUTHOR_REGEX.match(author) - if m is None: - raise RuntimeError(f"{author} does not match regex") - - name = m.group("name") - email = m.group("email") + def convert_author(cls, author: str) -> dict[str, str | None]: + from poetry.core.utils.helpers import parse_author + name, email = parse_author(author) return {"name": name, "email": email} diff --git a/src/poetry/core/packages/package.py b/src/poetry/core/packages/package.py index 1651ca6b6..151fc1642 100644 --- a/src/poetry/core/packages/package.py +++ b/src/poetry/core/packages/package.py @@ -16,6 +16,7 @@ from poetry.core.packages.dependency_group import MAIN_GROUP from poetry.core.packages.specification import PackageSpecification from poetry.core.packages.utils.utils import create_nested_marker +from poetry.core.utils.helpers import parse_author from poetry.core.version.exceptions import InvalidVersion from poetry.core.version.markers import parse_marker @@ -32,6 +33,8 @@ T = TypeVar("T", bound="Package") +# TODO: once poetry.console.commands.init.InitCommand._validate_author +# uses poetry.core.utils.helpers.parse_author, this can be removed. AUTHOR_REGEX = re.compile(r"(?u)^(?P[- .,\w\d'’\"():&]+)(?: <(?P.+?)>)?$") @@ -231,34 +234,14 @@ def _get_author(self) -> dict[str, str | None]: if not self._authors: return {"name": None, "email": None} - m = AUTHOR_REGEX.match(self._authors[0]) - - if m is None: - raise ValueError( - "Invalid author string. Must be in the format: " - "John Smith " - ) - - name = m.group("name") - email = m.group("email") - + name, email = parse_author(self._authors[0]) return {"name": name, "email": email} def _get_maintainer(self) -> dict[str, str | None]: if not self._maintainers: return {"name": None, "email": None} - m = AUTHOR_REGEX.match(self._maintainers[0]) - - if m is None: - raise ValueError( - "Invalid maintainer string. Must be in the format: " - "John Smith " - ) - - name = m.group("name") - email = m.group("email") - + name, email = parse_author(self._maintainers[0]) return {"name": name, "email": email} @property diff --git a/src/poetry/core/utils/helpers.py b/src/poetry/core/utils/helpers.py index dd41b2459..6ae14d51b 100644 --- a/src/poetry/core/utils/helpers.py +++ b/src/poetry/core/utils/helpers.py @@ -8,6 +8,7 @@ import warnings from contextlib import contextmanager +from email.utils import parseaddr from pathlib import Path from typing import Any from typing import Iterator @@ -105,3 +106,23 @@ def readme_content_type(path: str | Path) -> str: return "text/markdown" else: return "text/plain" + + +def parse_author(address: str) -> tuple[str, str | None]: + """Parse name and address parts from an email address string. + + >>> parse_author("John Doe ") + ('John Doe', 'john.doe@example.com') + + :param address: the email address string to parse. + :return: a 2-tuple with the parsed name and optional email address. + :raises ValueError: if the parsed string does not contain a name. + """ + if "@" not in address: + return address, None + name, email = parseaddr(address) + if not name or ( + email and address not in [f"{name} <{email}>", f'"{name}" <{email}>'] + ): + raise ValueError(f"Invalid author string: {address!r}") + return name, email or None diff --git a/tests/packages/test_package.py b/tests/packages/test_package.py index 998b25e22..1ed790193 100644 --- a/tests/packages/test_package.py +++ b/tests/packages/test_package.py @@ -55,14 +55,11 @@ def test_package_authors() -> None: def test_package_authors_invalid() -> None: package = Package("foo", "0.1.0") - package.authors.insert(0, "" - ) + assert str(e.value) == "Invalid author string: 'john.doe@example.com'" @pytest.mark.parametrize( @@ -78,11 +75,14 @@ def test_package_authors_invalid() -> None: ("Doe, John", None), ("(Doe, John)", None), ("John Doe", "john@john.doe"), - ("Doe, John", "dj@john.doe"), ("MyCompanyName R&D", "rnd@MyCompanyName.MyTLD"), ("John-Paul: Doe", None), - ("John-Paul: Doe", "jp@nomail.none"), ("John Doe the 3rd", "3rd@jd.net"), + (" None: @@ -102,11 +102,8 @@ def test_package_authors_valid(name: str, email: str | None) -> None: [ ("",), ("john@john.doe",), - ("",), + ("John-Paul: Doe ",), ], ) def test_package_author_names_invalid(name: str) -> None: diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index f8fe4393c..cd4fd7e3b 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -8,6 +8,7 @@ import pytest from poetry.core.utils.helpers import combine_unicode +from poetry.core.utils.helpers import parse_author from poetry.core.utils.helpers import parse_requires from poetry.core.utils.helpers import readme_content_type from poetry.core.utils.helpers import temporary_directory @@ -118,3 +119,52 @@ def test_utils_helpers_readme_content_type( readme: str | Path, content_type: str ) -> None: assert readme_content_type(readme) == content_type + + +@pytest.mark.parametrize( + "author, name, email", + [ + # Verify the (probable) default use case + ("John Doe ", "John Doe", "john.doe@example.com"), + # Name only + ("John Doe", "John Doe", None), + # Name with a “special” character + email address + ( + "R&D ", + "R&D", + "researchanddevelopment@example.com", + ), + # Name with a “special” character only + ("R&D", "R&D", None), + # Name with fancy unicode character + email address + ( + "my·fancy corp ", + "my·fancy corp", + "my-fancy-corp@example.com", + ), + # Name with fancy unicode character only + ("my·fancy corp", "my·fancy corp", None), + ], +) +def test_utils_helpers_parse_author(author: str, name: str, email: str | None) -> None: + """Test valid inputs for the :func:`parse_author` function.""" + assert parse_author(author) == (name, email) + + +@pytest.mark.parametrize( + "author", + [ + # Email address only, wrapped in angular brackets + "", + # Email address only + "john.doe@example.com", + # Non-RFC-conform cases with unquoted commas + "asf,dfu@t.b", + "asf,", + "asf, dfu@t.b", + ], +) +def test_utils_helpers_parse_author_invalid(author: str) -> None: + """Test invalid inputs for the :func:`parse_author` function.""" + with pytest.raises(ValueError): + parse_author(author)