diff --git a/VERSION b/VERSION index 17f8e2fbb..c29b32b56 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.60 +3.1.61 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index dcdc6d0c5..6b06dd5bf 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,18 @@ Changelog ========= +3.1.61 +====== + +A fixup release to avoid accidental removal of public class regex on Actor. +It's now deprecated instead. + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.61 + 3.1.60 ====== diff --git a/git/util.py b/git/util.py index b0593feea..a80e667c7 100644 --- a/git/util.py +++ b/git/util.py @@ -80,6 +80,7 @@ Sequence, Tuple, TYPE_CHECKING, + Type, TypeVar, Union, cast, @@ -108,6 +109,7 @@ T_IterableObj = TypeVar("T_IterableObj", bound=Union["IterableObj", "Has_id_attribute"], covariant=True) # So IterableList[Head] is subtype of IterableList[IterableObj]. +T_Actor = TypeVar("T_Actor", bound="Actor") _logger = logging.getLogger(__name__) @@ -853,11 +855,27 @@ def update(self, *args: Any, **kwargs: Any) -> None: self._callable(*args, **kwargs) +class _DeprecatedActorNameEmailRegex: + _pattern = re.compile(r"(.*) <(.*?)>") + + def __get__(self, _instance: Any, _owner: Any) -> Pattern[str]: + warnings.warn( + "Actor.name_email_regex is deprecated and will be removed in GitPython 4.0.0 because searching long " + "malformed strings with it can take quadratic time. Use Actor.from_string() to parse actor identities, " + "or Actor(name, email) when the fields are already separate.", + DeprecationWarning, + stacklevel=2, + ) + return self._pattern + + class Actor: """Actors hold information about a person acting on the repository. They can be committers and authors or anything with a name and an email as mentioned in the git log entries.""" + name_email_regex = _DeprecatedActorNameEmailRegex() + # ENVIRONMENT VARIABLES # These are read when creating new commits. env_author_name = "GIT_AUTHOR_NAME" @@ -891,7 +909,7 @@ def __repr__(self) -> str: return '">' % (self.name, self.email) @classmethod - def _from_string(cls, string: str) -> "Actor": + def from_string(cls: Type[T_Actor], string: str) -> T_Actor: """Create an :class:`Actor` from a string. :param string: @@ -906,10 +924,12 @@ def _from_string(cls, string: str) -> "Actor": left_bracket = line.find("<") right_bracket = line.find(">", left_bracket + 1) if left_bracket >= 0 and right_bracket >= 0: - return Actor(line[:left_bracket].rstrip(), line[left_bracket + 1 : right_bracket]) + return cls(line[:left_bracket].rstrip(), line[left_bracket + 1 : right_bracket]) # Assume the best and use the whole string as name. - return Actor(string, None) + return cls(string, None) + + _from_string = from_string @classmethod def _main_actor( diff --git a/test/lib/helper.py b/test/lib/helper.py index 58923eaef..4135fe5dd 100644 --- a/test/lib/helper.py +++ b/test/lib/helper.py @@ -32,6 +32,7 @@ import os import os.path as osp from stat import S_ISLNK, ST_MODE +import socket import subprocess import sys import tempfile @@ -218,8 +219,15 @@ def git_daemon_launched(base_path, ip, port): base_path=base_path, as_process=True, ) - # Yes, I know... fortunately, this is always going to work if sleep time is just large enough. - time.sleep(1.0 if sys.platform == "win32" else 0.5) + + # Wait until git daemon listens for connections. + for _attempt in range(1, 30): + try: + socket.create_connection((ip, port), timeout=30).close() + break + except ConnectionRefusedError: + time.sleep(0.5) + except Exception as ex: msg = textwrap.dedent( """ diff --git a/test/test_actor.py b/test/test_actor.py index baf6545f1..68afb80d3 100644 --- a/test/test_actor.py +++ b/test/test_actor.py @@ -3,14 +3,15 @@ # This module is part of GitPython and is released under the # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ -from git import Actor +from unittest import mock +from git import Actor from test.lib import TestBase class TestActor(TestBase): def test_from_string_should_separate_name_and_email(self): - a = Actor._from_string("Michael Trier ") + a = Actor.from_string("Michael Trier ") self.assertEqual("Michael Trier", a.name) self.assertEqual("mtrier@example.com", a.email) @@ -23,18 +24,34 @@ def test_from_string_should_separate_name_and_email(self): assert len(m) == 1 def test_from_string_should_handle_just_name(self): - a = Actor._from_string("Michael Trier") + a = Actor.from_string("Michael Trier") self.assertEqual("Michael Trier", a.name) self.assertEqual(None, a.email) + def test_from_string_constructs_subclass(self): + class DerivedActor(Actor): + pass + + self.assertIsInstance(DerivedActor.from_string("name "), DerivedActor) + def test_from_string_handles_unterminated_email_without_regex_backtracking(self): value = "A" * 20_000 + " ") + + message = str(context.warning) + self.assertIn("Actor.from_string()", message) + self.assertIn("Actor(name, email)", message) + assert match is not None + self.assertEqual(match.groups(), ("Michael Trier", "mtrier@example.com")) + def test_from_string_does_not_parse_across_lines(self): - self.assertEqual(Actor._from_string("x \n y "), Actor("x", "a")) + self.assertEqual(Actor.from_string("x \n y "), Actor("x", "a")) def test_from_string_uses_git_delimiters(self): for value, expected in ( @@ -45,12 +62,12 @@ def test_from_string_uses_git_delimiters(self): ("Name ", Actor("Name email>", None)), ): - self.assertEqual(Actor._from_string(value), expected) + self.assertEqual(Actor.from_string(value), expected) def test_should_display_representation(self): - a = Actor._from_string("Michael Trier ") + a = Actor.from_string("Michael Trier ") self.assertEqual('">', repr(a)) def test_str_should_alias_name(self): - a = Actor._from_string("Michael Trier ") + a = Actor.from_string("Michael Trier ") self.assertEqual(a.name, str(a))