From 5d402e66b1533721d68bc94fb4f031d8f9365257 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Tue, 19 Mar 2024 17:05:06 -0400 Subject: [PATCH 01/58] Initial commit --- test/deprecation/test_attributes.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 test/deprecation/test_attributes.py diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py new file mode 100644 index 0000000..2673cc1 --- /dev/null +++ b/test/deprecation/test_attributes.py @@ -0,0 +1,10 @@ +"""Tests for dynamic and static attribute errors.""" + +import pytest + +import git + + +def test_no_attribute() -> None: + with pytest.raises(AttributeError): + git.foo From e851bb35eef3cb1694028f6efa75bbc7c8d9a687 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Tue, 19 Mar 2024 17:15:02 -0400 Subject: [PATCH 02/58] Test attribute access and importing separately Rather than only testing attribute access. This also adds some tools. --- test/deprecation/test_attributes.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 2673cc1..aea3278 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -2,9 +2,14 @@ import pytest -import git +def test_cannot_get_undefined() -> None: + import git -def test_no_attribute() -> None: with pytest.raises(AttributeError): git.foo + + +def test_cannot_import_undefined() -> None: + with pytest.raises(ImportError): + from git import foo From 6afd9820e2c998982c995ba19d94f5122dfa14a3 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 20 Mar 2024 20:14:22 -0400 Subject: [PATCH 03/58] Expand to test top-level deprecated names + Add pytest configuration for VS Code. --- test/deprecation/test_attributes.py | 89 ++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index aea3278..428dab2 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -1,5 +1,7 @@ """Tests for dynamic and static attribute errors.""" +import importlib + import pytest @@ -12,4 +14,89 @@ def test_cannot_get_undefined() -> None: def test_cannot_import_undefined() -> None: with pytest.raises(ImportError): - from git import foo + from git import foo # noqa: F401 + + +def test_util_alias_access_resolves() -> None: + """These resolve for now, though they're private we do not guarantee this.""" + import git + + assert git.util is git.index.util + + +def test_util_alias_import_resolves() -> None: + from git import util + import git + + util is git.index.util + + +def test_util_alias_access_warns() -> None: + import git + + with pytest.deprecated_call() as ctx: + git.util + + assert len(ctx) == 1 + message = ctx[0].message.args[0] + assert "git.util" in message + assert "git.index.util" in message + assert "should not be relied on" in message + + +def test_util_alias_import_warns() -> None: + with pytest.deprecated_call() as ctx: + from git import util # noqa: F401 + + message = ctx[0].message.args[0] + assert "git.util" in message + assert "git.index.util" in message + assert "should not be relied on" in message + + +_parametrize_by_private_alias = pytest.mark.parametrize( + "name, fullname", + [ + ("head", "git.refs.head"), + ("log", "git.refs.log"), + ("reference", "git.refs.reference"), + ("symbolic", "git.refs.symbolic"), + ("tag", "git.refs.tag"), + ("base", "git.index.base"), + ("fun", "git.index.fun"), + ("typ", "git.index.typ"), + ], +) + + +@_parametrize_by_private_alias +def test_private_module_alias_access_resolves(name: str, fullname: str) -> None: + """These resolve for now, though they're private we do not guarantee this.""" + import git + + assert getattr(git, name) is importlib.import_module(fullname) + + +@_parametrize_by_private_alias +def test_private_module_alias_import_resolves(name: str, fullname: str) -> None: + exec(f"from git import {name}") + locals()[name] is importlib.import_module(fullname) + + +@_parametrize_by_private_alias +def test_private_module_alias_access_warns(name: str, fullname: str) -> None: + import git + + with pytest.deprecated_call() as ctx: + getattr(git, name) + + assert len(ctx) == 1 + assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.") + + +@_parametrize_by_private_alias +def test_private_module_alias_import_warns(name: str, fullname: str) -> None: + with pytest.deprecated_call() as ctx: + exec(f"from git import {name}") + + assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.") From 58ebfa76f1ab50125e4cef5d109346600902c1ad Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 20 Mar 2024 20:20:04 -0400 Subject: [PATCH 04/58] Hoist `import git` to module level in test module Because it's going to be necessary to express things in terms of it in parametrization markings, in order for mypy to show the expected errors for names that are available dynamically but deliberately static type errors. --- test/deprecation/test_attributes.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 428dab2..85aa7a5 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -4,10 +4,10 @@ import pytest +import git -def test_cannot_get_undefined() -> None: - import git +def test_cannot_get_undefined() -> None: with pytest.raises(AttributeError): git.foo @@ -19,21 +19,16 @@ def test_cannot_import_undefined() -> None: def test_util_alias_access_resolves() -> None: """These resolve for now, though they're private we do not guarantee this.""" - import git - assert git.util is git.index.util def test_util_alias_import_resolves() -> None: from git import util - import git util is git.index.util def test_util_alias_access_warns() -> None: - import git - with pytest.deprecated_call() as ctx: git.util @@ -72,8 +67,6 @@ def test_util_alias_import_warns() -> None: @_parametrize_by_private_alias def test_private_module_alias_access_resolves(name: str, fullname: str) -> None: """These resolve for now, though they're private we do not guarantee this.""" - import git - assert getattr(git, name) is importlib.import_module(fullname) @@ -85,8 +78,6 @@ def test_private_module_alias_import_resolves(name: str, fullname: str) -> None: @_parametrize_by_private_alias def test_private_module_alias_access_warns(name: str, fullname: str) -> None: - import git - with pytest.deprecated_call() as ctx: getattr(git, name) From 9cb783551484d09d48bc9ce7c9f60afd07d11555 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 20 Mar 2024 20:40:17 -0400 Subject: [PATCH 05/58] Test static typing of private module aliases This tests that mypy considers them not to be present. Setting warn_unused_ignores (in mypy.ini, for this project) is key, since that is what verifies that the type errors really do occur, based on the suppressions written for them. --- test/deprecation/test_attributes.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 85aa7a5..53612bd 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -9,12 +9,12 @@ def test_cannot_get_undefined() -> None: with pytest.raises(AttributeError): - git.foo + git.foo # type: ignore[attr-defined] def test_cannot_import_undefined() -> None: with pytest.raises(ImportError): - from git import foo # noqa: F401 + from git import foo # type: ignore[attr-defined] # noqa: F401 def test_util_alias_access_resolves() -> None: @@ -49,6 +49,21 @@ def test_util_alias_import_warns() -> None: assert "should not be relied on" in message +def test_private_module_aliases() -> None: + """These exist dynamically but mypy will show them as absent (intentionally). + + More detailed dynamic behavior is examined in the subsequent test cases. + """ + git.head # type: ignore[attr-defined] + git.log # type: ignore[attr-defined] + git.reference # type: ignore[attr-defined] + git.symbolic # type: ignore[attr-defined] + git.tag # type: ignore[attr-defined] + git.base # type: ignore[attr-defined] + git.fun # type: ignore[attr-defined] + git.typ # type: ignore[attr-defined] + + _parametrize_by_private_alias = pytest.mark.parametrize( "name, fullname", [ From 8652576e440c2d54449774e6c0207b3f9bf716d0 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 20 Mar 2024 20:52:56 -0400 Subject: [PATCH 06/58] Make mypy easier to run; improve docstrings --- test/deprecation/test_attributes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 53612bd..6df1359 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -18,7 +18,7 @@ def test_cannot_import_undefined() -> None: def test_util_alias_access_resolves() -> None: - """These resolve for now, though they're private we do not guarantee this.""" + """These resolve for now, though they're private and we do not guarantee this.""" assert git.util is git.index.util @@ -50,7 +50,7 @@ def test_util_alias_import_warns() -> None: def test_private_module_aliases() -> None: - """These exist dynamically but mypy will show them as absent (intentionally). + """These exist dynamically (for now) but mypy treats them as absent (intentionally). More detailed dynamic behavior is examined in the subsequent test cases. """ From 51faec972040c48180e61c859c84d83a7b5f9948 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 20 Mar 2024 20:57:05 -0400 Subject: [PATCH 07/58] Add a couple missing assert keywords --- test/deprecation/test_attributes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 6df1359..b9ca1d7 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -25,7 +25,7 @@ def test_util_alias_access_resolves() -> None: def test_util_alias_import_resolves() -> None: from git import util - util is git.index.util + assert util is git.index.util def test_util_alias_access_warns() -> None: @@ -88,7 +88,7 @@ def test_private_module_alias_access_resolves(name: str, fullname: str) -> None: @_parametrize_by_private_alias def test_private_module_alias_import_resolves(name: str, fullname: str) -> None: exec(f"from git import {name}") - locals()[name] is importlib.import_module(fullname) + assert locals()[name] is importlib.import_module(fullname) @_parametrize_by_private_alias From 2ca66e1cfc005e6360ad95bea5201db7b54827ef Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 20 Mar 2024 21:00:05 -0400 Subject: [PATCH 08/58] Clarify how test_private_module_aliases is statically checkable --- test/deprecation/test_attributes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index b9ca1d7..386ae18 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -52,6 +52,9 @@ def test_util_alias_import_warns() -> None: def test_private_module_aliases() -> None: """These exist dynamically (for now) but mypy treats them as absent (intentionally). + This code verifies the effect of static type checking when analyzed by mypy, if mypy + is configured with ``warn_unused_ignores = true``. + More detailed dynamic behavior is examined in the subsequent test cases. """ git.head # type: ignore[attr-defined] From c0bdcb25607640476016867f9c2da9f0433ee24d Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Thu, 21 Mar 2024 02:04:21 -0400 Subject: [PATCH 09/58] Move fixture-sharing tests into a class --- test/deprecation/test_attributes.py | 46 +++++++++++++---------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 386ae18..7af7700 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -49,8 +49,8 @@ def test_util_alias_import_warns() -> None: assert "should not be relied on" in message -def test_private_module_aliases() -> None: - """These exist dynamically (for now) but mypy treats them as absent (intentionally). +def test_private_module_aliases_exist_dynamically() -> None: + """These exist at runtime (for now) but mypy treats them as absent (intentionally). This code verifies the effect of static type checking when analyzed by mypy, if mypy is configured with ``warn_unused_ignores = true``. @@ -67,7 +67,7 @@ def test_private_module_aliases() -> None: git.typ # type: ignore[attr-defined] -_parametrize_by_private_alias = pytest.mark.parametrize( +@pytest.mark.parametrize( "name, fullname", [ ("head", "git.refs.head"), @@ -80,32 +80,26 @@ def test_private_module_aliases() -> None: ("typ", "git.index.typ"), ], ) +class TestPrivateModuleAliases: + """Tests of the private module aliases' shared specific runtime behaviors.""" + def test_private_module_alias_access_resolves(self, name: str, fullname: str) -> None: + """These resolve for now, though they're private we do not guarantee this.""" + assert getattr(git, name) is importlib.import_module(fullname) -@_parametrize_by_private_alias -def test_private_module_alias_access_resolves(name: str, fullname: str) -> None: - """These resolve for now, though they're private we do not guarantee this.""" - assert getattr(git, name) is importlib.import_module(fullname) - - -@_parametrize_by_private_alias -def test_private_module_alias_import_resolves(name: str, fullname: str) -> None: - exec(f"from git import {name}") - assert locals()[name] is importlib.import_module(fullname) - - -@_parametrize_by_private_alias -def test_private_module_alias_access_warns(name: str, fullname: str) -> None: - with pytest.deprecated_call() as ctx: - getattr(git, name) + def test_private_module_alias_import_resolves(self, name: str, fullname: str) -> None: + exec(f"from git import {name}") + assert locals()[name] is importlib.import_module(fullname) - assert len(ctx) == 1 - assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.") + def test_private_module_alias_access_warns(self, name: str, fullname: str) -> None: + with pytest.deprecated_call() as ctx: + getattr(git, name) + assert len(ctx) == 1 + assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.") -@_parametrize_by_private_alias -def test_private_module_alias_import_warns(name: str, fullname: str) -> None: - with pytest.deprecated_call() as ctx: - exec(f"from git import {name}") + def test_private_module_alias_import_warns(self, name: str, fullname: str) -> None: + with pytest.deprecated_call() as ctx: + exec(f"from git import {name}") - assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.") + assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.") From 08d967408b885856c5d36d14a96c30e683a34b5c Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Fri, 22 Mar 2024 01:33:00 -0400 Subject: [PATCH 10/58] Add FIXME for what to do next --- test/deprecation/test_attributes.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 7af7700..f249ae8 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -103,3 +103,14 @@ def test_private_module_alias_import_warns(self, name: str, fullname: str) -> No exec(f"from git import {name}") assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.") + + +reveal_type(git.util.git_working_dir) + +# FIXME: Add one or more test cases that access something like git.util.git_working_dir +# to verify that it is available, and also use assert_type on it to ensure mypy knows +# that accesses to expressions of the form git.util.XYZ resolve to git.index.util.XYZ. +# +# It may be necessary for GitPython, in git/__init__.py, to import util from git.index +# explicitly before (still) deleting the util global, in order for mypy to know what is +# going on. Also check pyright. From 52c7575c1e781e1fc691d30a5cecc23e0e29b582 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Fri, 22 Mar 2024 10:52:32 -0400 Subject: [PATCH 11/58] Fix a test docstring --- test/deprecation/test_attributes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index f249ae8..69a1aa1 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -84,7 +84,7 @@ class TestPrivateModuleAliases: """Tests of the private module aliases' shared specific runtime behaviors.""" def test_private_module_alias_access_resolves(self, name: str, fullname: str) -> None: - """These resolve for now, though they're private we do not guarantee this.""" + """These resolve for now, though they're private and we do not guarantee this.""" assert getattr(git, name) is importlib.import_module(fullname) def test_private_module_alias_import_resolves(self, name: str, fullname: str) -> None: From dabe0f3c06b46c7eff5d96ca94d3ee572a71911c Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Fri, 22 Mar 2024 18:56:02 -0400 Subject: [PATCH 12/58] Test resolution into git.index.util using git.util --- test/deprecation/test_attributes.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 69a1aa1..74f51a0 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -1,6 +1,7 @@ """Tests for dynamic and static attribute errors.""" import importlib +from typing import Type import pytest @@ -28,6 +29,23 @@ def test_util_alias_import_resolves() -> None: assert util is git.index.util +def test_util_alias_members_resolve() -> None: + """git.index.util members can be accessed via git.util, and mypy recognizes it.""" + # TODO: When typing_extensions is made a test dependency, use assert_type for this. + gu_tfs = git.util.TemporaryFileSwap + from git.index.util import TemporaryFileSwap + + def accepts_tfs_type(t: Type[TemporaryFileSwap]) -> None: + pass + + def rejects_tfs_type(t: Type[git.Git]) -> None: + pass + + accepts_tfs_type(gu_tfs) + rejects_tfs_type(gu_tfs) # type: ignore[arg-type] + assert gu_tfs is TemporaryFileSwap + + def test_util_alias_access_warns() -> None: with pytest.deprecated_call() as ctx: git.util @@ -103,14 +121,3 @@ def test_private_module_alias_import_warns(self, name: str, fullname: str) -> No exec(f"from git import {name}") assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.") - - -reveal_type(git.util.git_working_dir) - -# FIXME: Add one or more test cases that access something like git.util.git_working_dir -# to verify that it is available, and also use assert_type on it to ensure mypy knows -# that accesses to expressions of the form git.util.XYZ resolve to git.index.util.XYZ. -# -# It may be necessary for GitPython, in git/__init__.py, to import util from git.index -# explicitly before (still) deleting the util global, in order for mypy to know what is -# going on. Also check pyright. From d51b3d1219837594ba38cebd6fa6a975e9981128 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Fri, 22 Mar 2024 19:01:11 -0400 Subject: [PATCH 13/58] Fix brittle way of checking warning messages Which was causing a type error. --- test/deprecation/test_attributes.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 74f51a0..0f142fb 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -51,7 +51,7 @@ def test_util_alias_access_warns() -> None: git.util assert len(ctx) == 1 - message = ctx[0].message.args[0] + message = str(ctx[0].message) assert "git.util" in message assert "git.index.util" in message assert "should not be relied on" in message @@ -61,7 +61,7 @@ def test_util_alias_import_warns() -> None: with pytest.deprecated_call() as ctx: from git import util # noqa: F401 - message = ctx[0].message.args[0] + message = str(ctx[0].message) assert "git.util" in message assert "git.index.util" in message assert "should not be relied on" in message @@ -114,10 +114,12 @@ def test_private_module_alias_access_warns(self, name: str, fullname: str) -> No getattr(git, name) assert len(ctx) == 1 - assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.") + message = str(ctx[0].message) + assert message.endswith(f"Use {fullname} instead.") def test_private_module_alias_import_warns(self, name: str, fullname: str) -> None: with pytest.deprecated_call() as ctx: exec(f"from git import {name}") - assert ctx[0].message.args[0].endswith(f"Use {fullname} instead.") + message = str(ctx[0].message) + assert message.endswith(f"Use {fullname} instead.") From 05bc6593a386c8d9f4ca34cc2e2163688c2eb5cb Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Fri, 22 Mar 2024 19:09:44 -0400 Subject: [PATCH 14/58] Clarify todo --- test/deprecation/test_attributes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 0f142fb..829ff29 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -31,7 +31,6 @@ def test_util_alias_import_resolves() -> None: def test_util_alias_members_resolve() -> None: """git.index.util members can be accessed via git.util, and mypy recognizes it.""" - # TODO: When typing_extensions is made a test dependency, use assert_type for this. gu_tfs = git.util.TemporaryFileSwap from git.index.util import TemporaryFileSwap @@ -41,8 +40,10 @@ def accepts_tfs_type(t: Type[TemporaryFileSwap]) -> None: def rejects_tfs_type(t: Type[git.Git]) -> None: pass + # TODO: When typing_extensions is made a test dependency, use assert_type for this. accepts_tfs_type(gu_tfs) rejects_tfs_type(gu_tfs) # type: ignore[arg-type] + assert gu_tfs is TemporaryFileSwap From 1f693450035b3308b368291be4e1e8b62357d514 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 14:35:36 -0400 Subject: [PATCH 15/58] Start reorganizing tests more in the style of GitPython's --- test/deprecation/test_attributes.py | 142 +++++++++++++++------------- 1 file changed, 77 insertions(+), 65 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 829ff29..97aa9bc 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -1,4 +1,8 @@ -"""Tests for dynamic and static attribute errors.""" +"""Tests for dynamic and static attribute errors in GitPython's top-level git module. + +Provided mypy has ``warn_unused_ignores = true`` set, running mypy on these test cases +checks static typing of the code under test. (Running pytest checks dynamic behavior.) +""" import importlib from typing import Type @@ -18,17 +22,6 @@ def test_cannot_import_undefined() -> None: from git import foo # type: ignore[attr-defined] # noqa: F401 -def test_util_alias_access_resolves() -> None: - """These resolve for now, though they're private and we do not guarantee this.""" - assert git.util is git.index.util - - -def test_util_alias_import_resolves() -> None: - from git import util - - assert util is git.index.util - - def test_util_alias_members_resolve() -> None: """git.index.util members can be accessed via git.util, and mypy recognizes it.""" gu_tfs = git.util.TemporaryFileSwap @@ -68,59 +61,78 @@ def test_util_alias_import_warns() -> None: assert "should not be relied on" in message -def test_private_module_aliases_exist_dynamically() -> None: - """These exist at runtime (for now) but mypy treats them as absent (intentionally). - - This code verifies the effect of static type checking when analyzed by mypy, if mypy - is configured with ``warn_unused_ignores = true``. - - More detailed dynamic behavior is examined in the subsequent test cases. - """ - git.head # type: ignore[attr-defined] - git.log # type: ignore[attr-defined] - git.reference # type: ignore[attr-defined] - git.symbolic # type: ignore[attr-defined] - git.tag # type: ignore[attr-defined] - git.base # type: ignore[attr-defined] - git.fun # type: ignore[attr-defined] - git.typ # type: ignore[attr-defined] - - -@pytest.mark.parametrize( - "name, fullname", - [ - ("head", "git.refs.head"), - ("log", "git.refs.log"), - ("reference", "git.refs.reference"), - ("symbolic", "git.refs.symbolic"), - ("tag", "git.refs.tag"), - ("base", "git.index.base"), - ("fun", "git.index.fun"), - ("typ", "git.index.typ"), - ], +# Split out util and have all its tests be separate, above. +_MODULE_ALIAS_TARGETS = ( + git.refs.head, + git.refs.log, + git.refs.reference, + git.refs.symbolic, + git.refs.tag, + git.index.base, + git.index.fun, + git.index.typ, + git.index.util, ) -class TestPrivateModuleAliases: - """Tests of the private module aliases' shared specific runtime behaviors.""" - def test_private_module_alias_access_resolves(self, name: str, fullname: str) -> None: - """These resolve for now, though they're private and we do not guarantee this.""" - assert getattr(git, name) is importlib.import_module(fullname) - def test_private_module_alias_import_resolves(self, name: str, fullname: str) -> None: - exec(f"from git import {name}") - assert locals()[name] is importlib.import_module(fullname) - - def test_private_module_alias_access_warns(self, name: str, fullname: str) -> None: - with pytest.deprecated_call() as ctx: - getattr(git, name) - - assert len(ctx) == 1 - message = str(ctx[0].message) - assert message.endswith(f"Use {fullname} instead.") - - def test_private_module_alias_import_warns(self, name: str, fullname: str) -> None: - with pytest.deprecated_call() as ctx: - exec(f"from git import {name}") - - message = str(ctx[0].message) - assert message.endswith(f"Use {fullname} instead.") +def test_private_module_alias_access_on_git_module() -> None: + """Private alias access works, warns, and except for util is a mypy error.""" + with pytest.deprecated_call() as ctx: + assert ( + git.head, # type: ignore[attr-defined] + git.log, # type: ignore[attr-defined] + git.reference, # type: ignore[attr-defined] + git.symbolic, # type: ignore[attr-defined] + git.tag, # type: ignore[attr-defined] + git.base, # type: ignore[attr-defined] + git.fun, # type: ignore[attr-defined] + git.typ, # type: ignore[attr-defined] + git.util, + ) == _MODULE_ALIAS_TARGETS + + messages = [str(w.message) for w in ctx] + for target, message in zip(_MODULE_ALIAS_TARGETS[:-1], messages[:-1], strict=True): + assert message.endswith(f"Use {target.__name__} instead.") + + util_message = messages[-1] + assert "git.util" in util_message + assert "git.index.util" in util_message + assert "should not be relied on" in util_message + + +def test_private_module_alias_import_from_git_module() -> None: + """Private alias import works, warns, and except for util is a mypy error.""" + with pytest.deprecated_call() as ctx: + from git import head # type: ignore[attr-defined] + from git import log # type: ignore[attr-defined] + from git import reference # type: ignore[attr-defined] + from git import symbolic # type: ignore[attr-defined] + from git import tag # type: ignore[attr-defined] + from git import base # type: ignore[attr-defined] + from git import fun # type: ignore[attr-defined] + from git import typ # type: ignore[attr-defined] + from git import util + + assert ( + head, + log, + reference, + symbolic, + tag, + base, + fun, + typ, + util, + ) == _MODULE_ALIAS_TARGETS + + # FIXME: This fails because, with imports, multiple consecutive accesses may occur. + # In practice, with CPython, it is always exactly two accesses, the first from the + # equivalent of a hasattr, and the second to fetch the attribute intentionally. + messages = [str(w.message) for w in ctx] + for target, message in zip(_MODULE_ALIAS_TARGETS[:-1], messages[:-1], strict=True): + assert message.endswith(f"Use {target.__name__} instead.") + + util_message = messages[-1] + assert "git.util" in util_message + assert "git.index.util" in util_message + assert "should not be relied on" in util_message From 7305282632764c3e236b732cf22e221d8fd2f547 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 15:50:39 -0400 Subject: [PATCH 16/58] Finish reorganizing tests; fix assertion for duplicated messages --- test/deprecation/test_attributes.py | 98 ++++++++++++++--------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 97aa9bc..35e2e48 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -4,61 +4,72 @@ checks static typing of the code under test. (Running pytest checks dynamic behavior.) """ -import importlib +from itertools import groupby from typing import Type import pytest +from typing_extensions import assert_type import git -def test_cannot_get_undefined() -> None: +def test_cannot_access_undefined() -> None: + """Accessing a bogus attribute in git remains both a dynamic and static error.""" with pytest.raises(AttributeError): git.foo # type: ignore[attr-defined] def test_cannot_import_undefined() -> None: + """Importing a bogus attribute from git remains both a dynamic and static error.""" with pytest.raises(ImportError): from git import foo # type: ignore[attr-defined] # noqa: F401 -def test_util_alias_members_resolve() -> None: - """git.index.util members can be accessed via git.util, and mypy recognizes it.""" - gu_tfs = git.util.TemporaryFileSwap - from git.index.util import TemporaryFileSwap +def test_util_alias_access() -> None: + """Accessing util in git works, warns, and mypy verifies it and its attributes.""" + # The attribute access should succeed. + with pytest.deprecated_call() as ctx: + util = git.util - def accepts_tfs_type(t: Type[TemporaryFileSwap]) -> None: - pass + # There should be exactly one warning and it should have our util-specific message. + (message,) = [str(entry.message) for entry in ctx] + assert "git.util" in message + assert "git.index.util" in message + assert "should not be relied on" in message - def rejects_tfs_type(t: Type[git.Git]) -> None: - pass + # We check access through the util alias to the TemporaryFileSwap member, since it + # is slightly simpler to validate and reason about than the other public members, + # which are functions (specifically, higher-order functions for use as decorators). + from git.index.util import TemporaryFileSwap - # TODO: When typing_extensions is made a test dependency, use assert_type for this. - accepts_tfs_type(gu_tfs) - rejects_tfs_type(gu_tfs) # type: ignore[arg-type] + assert_type(util.TemporaryFileSwap, Type[TemporaryFileSwap]) - assert gu_tfs is TemporaryFileSwap + # This comes after the static assertion, just in case it would affect the inference. + assert util.TemporaryFileSwap is TemporaryFileSwap -def test_util_alias_access_warns() -> None: +def test_util_alias_import() -> None: + """Importing util from git works, warns, and mypy verifies it and its attributes.""" + # The import should succeed. with pytest.deprecated_call() as ctx: - git.util + from git import util - assert len(ctx) == 1 - message = str(ctx[0].message) + # There may be multiple warnings. In CPython there will be currently always be + # exactly two, possibly due to the equivalent of calling hasattr to do a pre-check + # prior to retrieving the attribute for actual use. However, all warnings should + # have the same message, and it should be our util-specific message. + (message,) = {str(entry.message) for entry in ctx} assert "git.util" in message assert "git.index.util" in message assert "should not be relied on" in message + # As above, we check access through the util alias to the TemporaryFileSwap member. + from git.index.util import TemporaryFileSwap -def test_util_alias_import_warns() -> None: - with pytest.deprecated_call() as ctx: - from git import util # noqa: F401 + assert_type(util.TemporaryFileSwap, Type[TemporaryFileSwap]) - message = str(ctx[0].message) - assert "git.util" in message - assert "git.index.util" in message - assert "should not be relied on" in message + # This comes after the static assertion, just in case it would affect the inference. + assert util.TemporaryFileSwap is TemporaryFileSwap # Split out util and have all its tests be separate, above. @@ -71,12 +82,11 @@ def test_util_alias_import_warns() -> None: git.index.base, git.index.fun, git.index.typ, - git.index.util, ) -def test_private_module_alias_access_on_git_module() -> None: - """Private alias access works, warns, and except for util is a mypy error.""" +def test_private_module_alias_access() -> None: + """Non-util private alias access works, warns, but is a deliberate mypy error.""" with pytest.deprecated_call() as ctx: assert ( git.head, # type: ignore[attr-defined] @@ -87,21 +97,16 @@ def test_private_module_alias_access_on_git_module() -> None: git.base, # type: ignore[attr-defined] git.fun, # type: ignore[attr-defined] git.typ, # type: ignore[attr-defined] - git.util, ) == _MODULE_ALIAS_TARGETS + # Each should have warned exactly once, and note what to use instead. messages = [str(w.message) for w in ctx] - for target, message in zip(_MODULE_ALIAS_TARGETS[:-1], messages[:-1], strict=True): + for target, message in zip(_MODULE_ALIAS_TARGETS, messages, strict=True): assert message.endswith(f"Use {target.__name__} instead.") - util_message = messages[-1] - assert "git.util" in util_message - assert "git.index.util" in util_message - assert "should not be relied on" in util_message - -def test_private_module_alias_import_from_git_module() -> None: - """Private alias import works, warns, and except for util is a mypy error.""" +def test_private_module_alias_import() -> None: + """Non-util private alias access works, warns, but is a deliberate mypy error.""" with pytest.deprecated_call() as ctx: from git import head # type: ignore[attr-defined] from git import log # type: ignore[attr-defined] @@ -111,7 +116,6 @@ def test_private_module_alias_import_from_git_module() -> None: from git import base # type: ignore[attr-defined] from git import fun # type: ignore[attr-defined] from git import typ # type: ignore[attr-defined] - from git import util assert ( head, @@ -122,17 +126,13 @@ def test_private_module_alias_import_from_git_module() -> None: base, fun, typ, - util, ) == _MODULE_ALIAS_TARGETS - # FIXME: This fails because, with imports, multiple consecutive accesses may occur. - # In practice, with CPython, it is always exactly two accesses, the first from the - # equivalent of a hasattr, and the second to fetch the attribute intentionally. - messages = [str(w.message) for w in ctx] - for target, message in zip(_MODULE_ALIAS_TARGETS[:-1], messages[:-1], strict=True): + # Each import may warn multiple times. In CPython there will be currently always be + # exactly two warnings per import, possibly due to the equivalent of calling hasattr + # to do a pre-check prior to retrieving the attribute for actual use. However, for + # each import, all messages should be the same and should note what to use instead. + messages_with_duplicates = [str(w.message) for w in ctx] + messages = [message for message, _ in groupby(messages_with_duplicates)] + for target, message in zip(_MODULE_ALIAS_TARGETS, messages, strict=True): assert message.endswith(f"Use {target.__name__} instead.") - - util_message = messages[-1] - assert "git.util" in util_message - assert "git.index.util" in util_message - assert "should not be relied on" in util_message From 8fb1496c6f5a0bd0fcc082169b136fa17a5a94de Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 15:55:57 -0400 Subject: [PATCH 17/58] Add imports so pyright recognized refs and index pyright still reports git.util as private, as it should. (mypy does not, or does not by default, report private member access.) --- test/deprecation/test_attributes.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 35e2e48..95feaaf 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -11,6 +11,14 @@ from typing_extensions import assert_type import git +import git.index.base +import git.index.fun +import git.index.typ +import git.refs.head +import git.refs.log +import git.refs.reference +import git.refs.symbolic +import git.refs.tag def test_cannot_access_undefined() -> None: From fb8687e0dacb0c7fa500dc7bbcb19baecfa45f3c Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 16:05:53 -0400 Subject: [PATCH 18/58] Expand and clarify test module docstring About why there are so many separate mypy suppressions even when they could be consolidated into a smaller number in some places. --- test/deprecation/test_attributes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 95feaaf..218b9dd 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -1,7 +1,11 @@ """Tests for dynamic and static attribute errors in GitPython's top-level git module. Provided mypy has ``warn_unused_ignores = true`` set, running mypy on these test cases -checks static typing of the code under test. (Running pytest checks dynamic behavior.) +checks static typing of the code under test. This is the reason for the many separate +single-line attr-defined suppressions, so those should not be replaced with a smaller +number of more broadly scoped suppressions, even where it is feasible to do so. + +Running pytest checks dynamic behavior as usual. """ from itertools import groupby From 827a6c1d05ccbaef5e312f0f65dccd19cb8e0ce9 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 16:08:37 -0400 Subject: [PATCH 19/58] Tiny import tweak --- test/deprecation/test_attributes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 218b9dd..1bcca44 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -8,7 +8,7 @@ Running pytest checks dynamic behavior as usual. """ -from itertools import groupby +import itertools from typing import Type import pytest @@ -145,6 +145,6 @@ def test_private_module_alias_import() -> None: # to do a pre-check prior to retrieving the attribute for actual use. However, for # each import, all messages should be the same and should note what to use instead. messages_with_duplicates = [str(w.message) for w in ctx] - messages = [message for message, _ in groupby(messages_with_duplicates)] + messages = [message for message, _ in itertools.groupby(messages_with_duplicates)] for target, message in zip(_MODULE_ALIAS_TARGETS, messages, strict=True): assert message.endswith(f"Use {target.__name__} instead.") From 1c9a7c18f88646433c321e3c32f8711c9452567d Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 16:12:59 -0400 Subject: [PATCH 20/58] Pick a better name for _MODULE_ALIAS_TARGETS And add a docstring to document it, mainly to clarify that util is intentionally omitted from that constant. --- test/deprecation/test_attributes.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 1bcca44..6e98a5e 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -85,7 +85,7 @@ def test_util_alias_import() -> None: # Split out util and have all its tests be separate, above. -_MODULE_ALIAS_TARGETS = ( +_PRIVATE_MODULE_ALIAS_TARGETS = ( git.refs.head, git.refs.log, git.refs.reference, @@ -95,6 +95,7 @@ def test_util_alias_import() -> None: git.index.fun, git.index.typ, ) +"""Targets of private aliases in the git module to some modules, not including util.""" def test_private_module_alias_access() -> None: @@ -109,11 +110,11 @@ def test_private_module_alias_access() -> None: git.base, # type: ignore[attr-defined] git.fun, # type: ignore[attr-defined] git.typ, # type: ignore[attr-defined] - ) == _MODULE_ALIAS_TARGETS + ) == _PRIVATE_MODULE_ALIAS_TARGETS # Each should have warned exactly once, and note what to use instead. messages = [str(w.message) for w in ctx] - for target, message in zip(_MODULE_ALIAS_TARGETS, messages, strict=True): + for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages, strict=True): assert message.endswith(f"Use {target.__name__} instead.") @@ -138,7 +139,7 @@ def test_private_module_alias_import() -> None: base, fun, typ, - ) == _MODULE_ALIAS_TARGETS + ) == _PRIVATE_MODULE_ALIAS_TARGETS # Each import may warn multiple times. In CPython there will be currently always be # exactly two warnings per import, possibly due to the equivalent of calling hasattr @@ -146,5 +147,5 @@ def test_private_module_alias_import() -> None: # each import, all messages should be the same and should note what to use instead. messages_with_duplicates = [str(w.message) for w in ctx] messages = [message for message, _ in itertools.groupby(messages_with_duplicates)] - for target, message in zip(_MODULE_ALIAS_TARGETS, messages, strict=True): + for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages, strict=True): assert message.endswith(f"Use {target.__name__} instead.") From 4b48a79f41c3e1653cb90299207e6ec146f8bd92 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 16:36:22 -0400 Subject: [PATCH 21/58] Use typing_extensions only if needed --- test/deprecation/test_attributes.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 6e98a5e..2150186 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -9,8 +9,14 @@ """ import itertools +import sys from typing import Type +if sys.version_info >= (3, 11): + from typing import assert_type +else: + from typing_extensions import assert_type + import pytest from typing_extensions import assert_type From acf80f5b58528f70dc9153c1d6a3cfe6a67fc37a Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 17:07:32 -0400 Subject: [PATCH 22/58] Fix zip calls This omits strict=True, which is only supported in Python 3.10 and later, and instead explicitly asserts that the arguments are the same length (which is arguably better for its explicitness anyway). --- test/deprecation/test_attributes.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index 2150186..cd26f60 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -120,7 +120,10 @@ def test_private_module_alias_access() -> None: # Each should have warned exactly once, and note what to use instead. messages = [str(w.message) for w in ctx] - for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages, strict=True): + + assert len(messages) == len(_PRIVATE_MODULE_ALIAS_TARGETS) + + for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages): assert message.endswith(f"Use {target.__name__} instead.") @@ -153,5 +156,8 @@ def test_private_module_alias_import() -> None: # each import, all messages should be the same and should note what to use instead. messages_with_duplicates = [str(w.message) for w in ctx] messages = [message for message, _ in itertools.groupby(messages_with_duplicates)] - for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages, strict=True): + + assert len(messages) == len(_PRIVATE_MODULE_ALIAS_TARGETS) + + for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages): assert message.endswith(f"Use {target.__name__} instead.") From b7f1faa021b55d5663afce22e2c8bc938b1f50d1 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 17:19:39 -0400 Subject: [PATCH 23/58] Fix (and improve wording) of docstrings --- test/deprecation/test_attributes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index cd26f60..e4fb399 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -105,7 +105,7 @@ def test_util_alias_import() -> None: def test_private_module_alias_access() -> None: - """Non-util private alias access works, warns, but is a deliberate mypy error.""" + """Non-util private alias access works but warns and is a deliberate mypy error.""" with pytest.deprecated_call() as ctx: assert ( git.head, # type: ignore[attr-defined] @@ -128,7 +128,7 @@ def test_private_module_alias_access() -> None: def test_private_module_alias_import() -> None: - """Non-util private alias access works, warns, but is a deliberate mypy error.""" + """Non-util private alias import works but warns and is a deliberate mypy error.""" with pytest.deprecated_call() as ctx: from git import head # type: ignore[attr-defined] from git import log # type: ignore[attr-defined] From f9fb99716789f03261724bb426350b5a2c5a6dc6 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 17:46:57 -0400 Subject: [PATCH 24/58] Remove extra import "from typing_extensions" --- test/deprecation/test_attributes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_attributes.py index e4fb399..eb909b2 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_attributes.py @@ -18,7 +18,6 @@ from typing_extensions import assert_type import pytest -from typing_extensions import assert_type import git import git.index.base From 70c427951dad9c70cdd60c9162c78f424173356a Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 18:12:19 -0400 Subject: [PATCH 25/58] Start on test_compat And rename test_attributes to test_toplevel accordingly. --- test/deprecation/test_compat.py | 33 +++++++++++++++++++ .../{test_attributes.py => test_toplevel.py} | 6 ++-- 2 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 test/deprecation/test_compat.py rename test/deprecation/{test_attributes.py => test_toplevel.py} (95%) diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py new file mode 100644 index 0000000..dd2f0b0 --- /dev/null +++ b/test/deprecation/test_compat.py @@ -0,0 +1,33 @@ +"""Tests for dynamic and static errors and warnings in GitPython's git.compat module. + +These tests verify that the is_ aliases are available, and are even listed in +the output of dir(), but issue warnings, and that bogus (misspelled or unrecognized) +attribute access is still an error both at runtime and with mypy. This is similar to +some of the tests in test_toplevel, but the situation being tested here is simpler +because it does not involve unintuitive module aliasing or import behavior. So this only +tests attribute access, not "from" imports (whose behavior can be intuitively inferred). +""" + +import os +import sys + +import pytest + +import git.compat + + +_MESSAGE_LEADER = "{} and other is_ aliases are deprecated." + + +def test_cannot_access_undefined() -> None: + """Accessing a bogus attribute in git.compat remains a dynamic and static error.""" + with pytest.raises(AttributeError): + git.compat.foo # type: ignore[attr-defined] + + +def test_is_win() -> None: + with pytest.deprecated_call() as ctx: + value = git.compat.is_win + (message,) = [str(entry.message) for entry in ctx] # Exactly one message. + assert message.startswith(_MESSAGE_LEADER.format("git.compat.is_win")) + assert value == (os.name == "nt") diff --git a/test/deprecation/test_attributes.py b/test/deprecation/test_toplevel.py similarity index 95% rename from test/deprecation/test_attributes.py rename to test/deprecation/test_toplevel.py index eb909b2..2a66212 100644 --- a/test/deprecation/test_attributes.py +++ b/test/deprecation/test_toplevel.py @@ -1,4 +1,4 @@ -"""Tests for dynamic and static attribute errors in GitPython's top-level git module. +"""Tests for dynamic and static errors and warnings in GitPython's top-level git module. Provided mypy has ``warn_unused_ignores = true`` set, running mypy on these test cases checks static typing of the code under test. This is the reason for the many separate @@ -31,13 +31,13 @@ def test_cannot_access_undefined() -> None: - """Accessing a bogus attribute in git remains both a dynamic and static error.""" + """Accessing a bogus attribute in git remains a dynamic and static error.""" with pytest.raises(AttributeError): git.foo # type: ignore[attr-defined] def test_cannot_import_undefined() -> None: - """Importing a bogus attribute from git remains both a dynamic and static error.""" + """Importing a bogus attribute from git remains a dynamic and static error.""" with pytest.raises(ImportError): from git import foo # type: ignore[attr-defined] # noqa: F401 From 0354b3845922bd6944ac3ff5617b4f93c0921e12 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 18:19:36 -0400 Subject: [PATCH 26/58] Expand to test all three is_ aliases --- test/deprecation/test_compat.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py index dd2f0b0..6d2d87a 100644 --- a/test/deprecation/test_compat.py +++ b/test/deprecation/test_compat.py @@ -25,9 +25,29 @@ def test_cannot_access_undefined() -> None: git.compat.foo # type: ignore[attr-defined] -def test_is_win() -> None: +def test_is_platform() -> None: + """The is_ aliases work, warn, and mypy accepts code accessing them.""" + fully_qualified_names = [ + "git.compat.is_win", + "git.compat.is_posix", + "git.compat.is_darwin", + ] + with pytest.deprecated_call() as ctx: - value = git.compat.is_win - (message,) = [str(entry.message) for entry in ctx] # Exactly one message. - assert message.startswith(_MESSAGE_LEADER.format("git.compat.is_win")) - assert value == (os.name == "nt") + is_win = git.compat.is_win + is_posix = git.compat.is_posix + is_darwin = git.compat.is_darwin + + messages = [str(entry.message) for entry in ctx] + assert len(messages) == 3 + + for fullname, message in zip(fully_qualified_names, messages): + assert message.startswith(_MESSAGE_LEADER.format(fullname)) + + # These exactly reproduce the expressions in the code under test, so they are not + # good for testing that the values are correct. Instead, the purpose of this test is + # to ensure that any dynamic machinery put in place in git.compat to cause warnings + # to be issued does not get in the way of the intended values being accessed. + assert is_win == (os.name == "nt") + assert is_posix == (os.name == "posix") + assert is_darwin == (sys.platform == "darwin") From 2265ad21f595d8f4983312a35a2fbd174b277abe Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 18:22:30 -0400 Subject: [PATCH 27/58] Slightly improve docstrings --- test/deprecation/test_compat.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py index 6d2d87a..45d631e 100644 --- a/test/deprecation/test_compat.py +++ b/test/deprecation/test_compat.py @@ -1,7 +1,7 @@ """Tests for dynamic and static errors and warnings in GitPython's git.compat module. -These tests verify that the is_ aliases are available, and are even listed in -the output of dir(), but issue warnings, and that bogus (misspelled or unrecognized) +These tests verify that the is_ attributes are available, and are even listed +in the output of dir(), but issue warnings, and that bogus (misspelled or unrecognized) attribute access is still an error both at runtime and with mypy. This is similar to some of the tests in test_toplevel, but the situation being tested here is simpler because it does not involve unintuitive module aliasing or import behavior. So this only @@ -15,8 +15,8 @@ import git.compat - _MESSAGE_LEADER = "{} and other is_ aliases are deprecated." +"""Form taken by the beginning of the warnings issues for is_ access.""" def test_cannot_access_undefined() -> None: @@ -26,7 +26,7 @@ def test_cannot_access_undefined() -> None: def test_is_platform() -> None: - """The is_ aliases work, warn, and mypy accepts code accessing them.""" + """The is_ attributes work, warn, and mypy accepts code accessing them.""" fully_qualified_names = [ "git.compat.is_win", "git.compat.is_posix", From 1cb3aa061b71b6ccd366279722911ff065379e7f Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 18:27:55 -0400 Subject: [PATCH 28/58] Add test of dir() on git.compat --- test/deprecation/test_compat.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py index 45d631e..08911d1 100644 --- a/test/deprecation/test_compat.py +++ b/test/deprecation/test_compat.py @@ -51,3 +51,17 @@ def test_is_platform() -> None: assert is_win == (os.name == "nt") assert is_posix == (os.name == "posix") assert is_darwin == (sys.platform == "darwin") + + +def test_dir() -> None: + """dir() on git.compat lists attributes meant to be public, even if deprecated.""" + expected = { + "defenc", + "safe_decode", + "safe_encode", + "win_encode", + "is_darwin", + "is_win", + "is_posix", + } + assert expected <= set(dir(git.compat)) From a05f26e5bff1c2b0c06a95874b95af08bcc28c73 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 18:28:05 -0400 Subject: [PATCH 29/58] Add static type assertions to is_platform test --- test/deprecation/test_compat.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py index 08911d1..c3cc5b0 100644 --- a/test/deprecation/test_compat.py +++ b/test/deprecation/test_compat.py @@ -11,6 +11,11 @@ import os import sys +if sys.version_info >= (3, 11): + from typing import assert_type +else: + from typing_extensions import assert_type + import pytest import git.compat @@ -38,6 +43,10 @@ def test_is_platform() -> None: is_posix = git.compat.is_posix is_darwin = git.compat.is_darwin + assert_type(is_win, bool) + assert_type(is_posix, bool) + assert_type(is_darwin, bool) + messages = [str(entry.message) for entry in ctx] assert len(messages) == 3 From c04b64a2e588083083dd60c85fa4b635144d67d4 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 18:29:23 -0400 Subject: [PATCH 30/58] Refactor test_compat.test_dir for clarity --- test/deprecation/test_compat.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py index c3cc5b0..6e42d02 100644 --- a/test/deprecation/test_compat.py +++ b/test/deprecation/test_compat.py @@ -64,7 +64,7 @@ def test_is_platform() -> None: def test_dir() -> None: """dir() on git.compat lists attributes meant to be public, even if deprecated.""" - expected = { + expected_subset = { "defenc", "safe_decode", "safe_encode", @@ -73,4 +73,5 @@ def test_dir() -> None: "is_win", "is_posix", } - assert expected <= set(dir(git.compat)) + actual = set(dir(git.compat)) + assert expected_subset <= actual From 51d2c7e210f74d166ea23d4255097cd21e8d5bde Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 18:45:31 -0400 Subject: [PATCH 31/58] Add top-level dir() tests --- test/deprecation/test_toplevel.py | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py index 2a66212..f74f094 100644 --- a/test/deprecation/test_toplevel.py +++ b/test/deprecation/test_toplevel.py @@ -160,3 +160,52 @@ def test_private_module_alias_import() -> None: for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages): assert message.endswith(f"Use {target.__name__} instead.") + + +def test_dir_contains_public_attributes() -> None: + """All public attributes of the git module are present when dir() is called on it. + + This is naturally the case, but some ways of adding dynamic attribute access + behavior can change it, especially if __dir__ is defined but care is not taken to + preserve the contents that should already be present. + + Note that dir() should usually automatically list non-public attributes if they are + actually "physically" present as well, so the approach taken here to test it should + not be reproduced if __dir__ is added (instead, a call to globals() could be used, + as its keys list the automatic values). + """ + expected_subset = set(git.__all__) + actual = set(dir(git)) + assert expected_subset <= actual + + +def test_dir_does_not_contain_util() -> None: + """The util attribute is absent from the dir() of git. + + Because this behavior is less confusing than including it, where its meaning would + be assumed by users examining the dir() for what is available. + """ + assert "util" not in dir(git) + + +def test_dir_does_not_contain_private_module_aliases() -> None: + """Names from inside index and refs only pretend to be there and are not in dir(). + + The reason for omitting these is not that they are private, since private members + are usually included in dir() when actually present. Instead, these are only sort + of even there, no longer being imported and only being resolved dynamically for the + time being. In addition, it would be confusing to list these because doing so would + obscure the module structure of GitPython. + """ + expected_absent = { + "head", + "log", + "reference", + "symbolic", + "tag", + "base", + "fun", + "typ", + } + actual = set(dir(git)) + assert not (expected_absent & actual), "They should be completely disjoint." From c24e946f2070a6c7037238b05daf1a853ef752e4 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 18:47:27 -0400 Subject: [PATCH 32/58] Remove old comment meant as todo (that was done) --- test/deprecation/test_toplevel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py index f74f094..fe7045d 100644 --- a/test/deprecation/test_toplevel.py +++ b/test/deprecation/test_toplevel.py @@ -89,7 +89,6 @@ def test_util_alias_import() -> None: assert util.TemporaryFileSwap is TemporaryFileSwap -# Split out util and have all its tests be separate, above. _PRIVATE_MODULE_ALIAS_TARGETS = ( git.refs.head, git.refs.log, From e66f3d5e5acda8c999ad3fc49cae25216576d7d5 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 19:01:24 -0400 Subject: [PATCH 33/58] Test that top-level aliases point modules with normal __name__ --- test/deprecation/test_toplevel.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py index fe7045d..135cc53 100644 --- a/test/deprecation/test_toplevel.py +++ b/test/deprecation/test_toplevel.py @@ -102,6 +102,26 @@ def test_util_alias_import() -> None: """Targets of private aliases in the git module to some modules, not including util.""" +_PRIVATE_MODULE_ALIAS_TARGET_NAMES = ( + "git.refs.head", + "git.refs.log", + "git.refs.reference", + "git.refs.symbolic", + "git.refs.tag", + "git.index.base", + "git.index.fun", + "git.index.typ", +) +"""Expected ``__name__`` attributes of targets of private aliases in the git module.""" + + +def test_alias_target_module_names_are_by_location() -> None: + """The aliases are weird, but their targets are normal, even in ``__name__``.""" + actual = [module.__name__ for module in _PRIVATE_MODULE_ALIAS_TARGETS] + expected = list(_PRIVATE_MODULE_ALIAS_TARGET_NAMES) + assert actual == expected + + def test_private_module_alias_access() -> None: """Non-util private alias access works but warns and is a deliberate mypy error.""" with pytest.deprecated_call() as ctx: From 3e9feba585e8bb7817b61bdeb096bd6b16a9fde1 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 19:02:56 -0400 Subject: [PATCH 34/58] Use names directly on other tests The tets are written broadly (per the style elsewhere in the test suite of GitPython that these might end up placed or adapted into), but narrowing the message-checking tests in this specific way has the further advantage that the logic of the code under test will be less reflected in the logic of the tests, so that bugs are less likely to be missed by being duplicated across code and tests. --- test/deprecation/test_toplevel.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py index 135cc53..16c41d4 100644 --- a/test/deprecation/test_toplevel.py +++ b/test/deprecation/test_toplevel.py @@ -141,8 +141,8 @@ def test_private_module_alias_access() -> None: assert len(messages) == len(_PRIVATE_MODULE_ALIAS_TARGETS) - for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages): - assert message.endswith(f"Use {target.__name__} instead.") + for fullname, message in zip(_PRIVATE_MODULE_ALIAS_TARGET_NAMES, messages): + assert message.endswith(f"Use {fullname} instead.") def test_private_module_alias_import() -> None: @@ -177,8 +177,8 @@ def test_private_module_alias_import() -> None: assert len(messages) == len(_PRIVATE_MODULE_ALIAS_TARGETS) - for target, message in zip(_PRIVATE_MODULE_ALIAS_TARGETS, messages): - assert message.endswith(f"Use {target.__name__} instead.") + for fullname, message in zip(_PRIVATE_MODULE_ALIAS_TARGET_NAMES, messages): + assert message.endswith(f"Use {fullname} instead.") def test_dir_contains_public_attributes() -> None: From d1c277e510be954df7e7839020415159535ea810 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 19:07:35 -0400 Subject: [PATCH 35/58] Fix a small docstring typo --- test/deprecation/test_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py index 6e42d02..0da5462 100644 --- a/test/deprecation/test_compat.py +++ b/test/deprecation/test_compat.py @@ -21,7 +21,7 @@ import git.compat _MESSAGE_LEADER = "{} and other is_ aliases are deprecated." -"""Form taken by the beginning of the warnings issues for is_ access.""" +"""Form taken by the beginning of the warnings issued for is_ access.""" def test_cannot_access_undefined() -> None: From e4c083aaa8f49442817306e71ee8c7010306aa82 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 19:17:15 -0400 Subject: [PATCH 36/58] Improve description in test module docstrings --- test/deprecation/test_compat.py | 2 +- test/deprecation/test_toplevel.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py index 0da5462..5007fa1 100644 --- a/test/deprecation/test_compat.py +++ b/test/deprecation/test_compat.py @@ -1,4 +1,4 @@ -"""Tests for dynamic and static errors and warnings in GitPython's git.compat module. +"""Tests for dynamic and static characteristics of git.compat module attributes. These tests verify that the is_ attributes are available, and are even listed in the output of dir(), but issue warnings, and that bogus (misspelled or unrecognized) diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py index 16c41d4..3989386 100644 --- a/test/deprecation/test_toplevel.py +++ b/test/deprecation/test_toplevel.py @@ -1,4 +1,4 @@ -"""Tests for dynamic and static errors and warnings in GitPython's top-level git module. +"""Tests for dynamic and static characteristics of top-level git module attributes. Provided mypy has ``warn_unused_ignores = true`` set, running mypy on these test cases checks static typing of the code under test. This is the reason for the many separate From 0d2a2d142f3e3e2e894cc0f395e3e63f0e0daf78 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 23:12:12 -0400 Subject: [PATCH 37/58] Start on test_types --- test/deprecation/test_types.py | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test/deprecation/test_types.py diff --git a/test/deprecation/test_types.py b/test/deprecation/test_types.py new file mode 100644 index 0000000..a2ac458 --- /dev/null +++ b/test/deprecation/test_types.py @@ -0,0 +1,39 @@ +"""Tests for dynamic and static characteristics of git.types module attributes.""" + +import sys + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +import pytest + +import git.types + + +def test_cannot_access_undefined() -> None: + """Accessing a bogus attribute in git.types remains a dynamic and static error.""" + with pytest.raises(AttributeError): + git.types.foo # type: ignore[attr-defined] + + +def test_lit_commit_ish() -> None: + """ """ + # It would be fine to test attribute access rather than a "from" import. But a + # "from" import is more likely to appear in actual usage, so it is used here. + with pytest.deprecated_call() as ctx: + from git.types import Lit_commit_ish + + # As noted in test_toplevel.test_util_alias_import, there may be multiple warnings, + # but all with the same message. + (message,) = {str(entry.message) for entry in ctx} + assert "Lit_commit_ish is deprecated." in message + assert 'Literal["commit", "tag", "blob", "tree"]' in message, "Has old definition." + assert 'Literal["commit", "tag"]' in message, "Has new definition." + assert "GitObjectTypeString" in message, "Has new type name for old definition." + + _: Lit_commit_ish = "commit" # type: ignore[valid-type] + + # It should be as documented (even though deliberately unusable in static checks). + assert Lit_commit_ish == Literal["commit", "tag"] From c8e4ea98be35fd70cca767e2efeb19745827d800 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 23:13:53 -0400 Subject: [PATCH 38/58] Explain substring assertions in test_toplevel --- test/deprecation/test_toplevel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/deprecation/test_toplevel.py b/test/deprecation/test_toplevel.py index 3989386..54dc8e3 100644 --- a/test/deprecation/test_toplevel.py +++ b/test/deprecation/test_toplevel.py @@ -76,9 +76,9 @@ def test_util_alias_import() -> None: # prior to retrieving the attribute for actual use. However, all warnings should # have the same message, and it should be our util-specific message. (message,) = {str(entry.message) for entry in ctx} - assert "git.util" in message - assert "git.index.util" in message - assert "should not be relied on" in message + assert "git.util" in message, "Has alias." + assert "git.index.util" in message, "Has target." + assert "should not be relied on" in message, "Distinct from other messages." # As above, we check access through the util alias to the TemporaryFileSwap member. from git.index.util import TemporaryFileSwap From ab08a2493d0ced10f3db5af6ec90005dd8c15970 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 23:17:55 -0400 Subject: [PATCH 39/58] Expand Lit_commit_ish test name and write docstring --- test/deprecation/test_types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_types.py b/test/deprecation/test_types.py index a2ac458..4194359 100644 --- a/test/deprecation/test_types.py +++ b/test/deprecation/test_types.py @@ -18,8 +18,8 @@ def test_cannot_access_undefined() -> None: git.types.foo # type: ignore[attr-defined] -def test_lit_commit_ish() -> None: - """ """ +def test_can_access_lit_commit_ish_but_it_is_not_usable() -> None: + """Lit_commit_ish_can be accessed, but warns and is an invalid type annotation.""" # It would be fine to test attribute access rather than a "from" import. But a # "from" import is more likely to appear in actual usage, so it is used here. with pytest.deprecated_call() as ctx: From 4889a133fa154b76f3e72359ac59c8071e17f991 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 23:26:43 -0400 Subject: [PATCH 40/58] Clarify test_compat.test_dir --- test/deprecation/test_compat.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py index 5007fa1..6699a24 100644 --- a/test/deprecation/test_compat.py +++ b/test/deprecation/test_compat.py @@ -63,15 +63,19 @@ def test_is_platform() -> None: def test_dir() -> None: - """dir() on git.compat lists attributes meant to be public, even if deprecated.""" + """dir() on git.compat includes all public attributes, even if deprecated. + + As dir() usually does, it also has nonpublic attributes, which should also not be + removed by a custom __dir__ function, but those are less important to test. + """ expected_subset = { + "is_win", + "is_posix", + "is_darwin", "defenc", "safe_decode", "safe_encode", "win_encode", - "is_darwin", - "is_win", - "is_posix", } actual = set(dir(git.compat)) assert expected_subset <= actual From c5662135ae644e47e7e3264c06e544925b716d3e Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sat, 23 Mar 2024 23:32:11 -0400 Subject: [PATCH 41/58] Add test of dir() on git.types --- test/deprecation/test_types.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/deprecation/test_types.py b/test/deprecation/test_types.py index 4194359..cd53fa2 100644 --- a/test/deprecation/test_types.py +++ b/test/deprecation/test_types.py @@ -37,3 +37,30 @@ def test_can_access_lit_commit_ish_but_it_is_not_usable() -> None: # It should be as documented (even though deliberately unusable in static checks). assert Lit_commit_ish == Literal["commit", "tag"] + + +def test_dir() -> None: + """dir() on git.types includes public names, even ``Lit_commit_ish``. + + It also contains private names that we don't test. See test_compat.test_dir. + """ + expected_subset = { + "PathLike", + "TBD", + "AnyGitObject", + "Tree_ish", + "Commit_ish", + "GitObjectTypeString", + "Lit_commit_ish", + "Lit_config_levels", + "ConfigLevels_Tup", + "CallableProgress", + "assert_never", + "Files_TD", + "Total_TD", + "HSH_TD", + "Has_Repo", + "Has_id_attribute", + } + actual = set(dir(git.types)) + assert expected_subset <= actual From 75b8b435b5174460829f1c58ce8187a0dcc11806 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 24 Mar 2024 01:31:33 -0400 Subject: [PATCH 42/58] Clarify comment about is_ value assertions --- test/deprecation/test_compat.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/deprecation/test_compat.py b/test/deprecation/test_compat.py index 6699a24..22f7330 100644 --- a/test/deprecation/test_compat.py +++ b/test/deprecation/test_compat.py @@ -53,10 +53,10 @@ def test_is_platform() -> None: for fullname, message in zip(fully_qualified_names, messages): assert message.startswith(_MESSAGE_LEADER.format(fullname)) - # These exactly reproduce the expressions in the code under test, so they are not - # good for testing that the values are correct. Instead, the purpose of this test is - # to ensure that any dynamic machinery put in place in git.compat to cause warnings - # to be issued does not get in the way of the intended values being accessed. + # These assertions exactly reproduce the expressions in the code under test, so they + # are not good for testing that the values are correct. Instead, their purpose is to + # ensure that any dynamic machinery put in place in git.compat to cause warnings to + # be issued does not get in the way of the intended values being accessed. assert is_win == (os.name == "nt") assert is_posix == (os.name == "posix") assert is_darwin == (sys.platform == "darwin") From b998cdad45e53a69a38c5da670512eff38349267 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 24 Mar 2024 19:31:00 -0400 Subject: [PATCH 43/58] Start on test module about Git.USE_SHELL and Git attributes --- test/deprecation/test_cmd_git.py | 168 +++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 test/deprecation/test_cmd_git.py diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py new file mode 100644 index 0000000..fc8bab6 --- /dev/null +++ b/test/deprecation/test_cmd_git.py @@ -0,0 +1,168 @@ +"""Tests for static and dynamic characteristics of Git class and instance attributes. + +Currently this all relates to the deprecated :class:`Git.USE_SHELL` class attribute, +which can also be accessed through instances. Some tests directly verify its behavior, +including deprecation warnings, while others verify that other aspects of attribute +access are not inadvertently broken by mechanisms introduced to issue the warnings. +""" + +import contextlib +import sys +from typing import Generator + +if sys.version_info >= (3, 11): + from typing import assert_type +else: + from typing_extensions import assert_type + +import pytest + +from git.cmd import Git + +_USE_SHELL_DEPRECATED_FRAGMENT = "Git.USE_SHELL is deprecated" +"""Text contained in all USE_SHELL deprecation warnings, and starting most of them.""" + +_USE_SHELL_DANGEROUS_FRAGMENT = "Setting Git.USE_SHELL to True is unsafe and insecure" +"""Beginning text of USE_SHELL deprecation warnings when USE_SHELL is set True.""" + + +@pytest.fixture +def reset_backing_attribute() -> Generator[None, None, None]: + """Fixture to reset the private ``_USE_SHELL`` attribute. + + This is used to decrease the likelihood of state changes leaking out and affecting + other tests. But the goal is not to assert that ``_USE_SHELL`` is used, nor anything + about how or when it is used, which is an implementation detail subject to change. + + This is possible but inelegant to do with pytest's monkeypatch fixture, which only + restores attributes that it has previously been used to change, create, or remove. + """ + no_value = object() + try: + old_value = Git._USE_SHELL + except AttributeError: + old_value = no_value + + yield + + if old_value is no_value: + with contextlib.suppress(AttributeError): + del Git._USE_SHELL + else: + Git._USE_SHELL = old_value + + +def test_cannot_access_undefined_on_git_class() -> None: + """Accessing a bogus attribute on the Git class remains a dynamic and static error. + + This differs from Git instances, where most attribute names will dynamically + synthesize a "bound method" that runs a git subcommand when called. + """ + with pytest.raises(AttributeError): + Git.foo # type: ignore[attr-defined] + + +def test_get_use_shell_on_class_default() -> None: + """USE_SHELL can be read as a class attribute, defaulting to False and warning.""" + with pytest.deprecated_call() as ctx: + use_shell = Git.USE_SHELL + + (message,) = [str(entry.message) for entry in ctx] # Exactly one warning. + assert message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT) + + assert_type(use_shell, bool) + + # This comes after the static assertion, just in case it would affect the inference. + assert not use_shell + + +# FIXME: More robustly check that each operation really issues exactly one deprecation +# warning, even if this requires relying more on reset_backing_attribute doing its job. +def test_use_shell_on_class(reset_backing_attribute) -> None: + """USE_SHELL can be written and re-read as a class attribute, always warning.""" + # We assert in a "safe" order, using reset_backing_attribute only as a backstop. + with pytest.deprecated_call() as ctx: + Git.USE_SHELL = True + set_value = Git.USE_SHELL + Git.USE_SHELL = False + reset_value = Git.USE_SHELL + + # The attribute should take on the values set to it. + assert set_value is True + assert reset_value is False + + messages = [str(entry.message) for entry in ctx] + set_message, check_message, reset_message, recheck_message = messages + + # Setting it to True should produce the special warning for that. + assert _USE_SHELL_DEPRECATED_FRAGMENT in set_message + assert set_message.startswith(_USE_SHELL_DANGEROUS_FRAGMENT) + + # All other operations should produce a usual warning. + assert check_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT) + assert reset_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT) + assert recheck_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT) + + +# FIXME: Test behavior on instances (where we can get but not set). + +# FIXME: Test behavior with multiprocessing (the attribute needs to pickle properly). + + +_EXPECTED_DIR_SUBSET = { + "cat_file_all", + "cat_file_header", + "GIT_PYTHON_TRACE", + "USE_SHELL", # The attribute we get deprecation warnings for. + "GIT_PYTHON_GIT_EXECUTABLE", + "refresh", + "is_cygwin", + "polish_url", + "check_unsafe_protocols", + "check_unsafe_options", + "AutoInterrupt", + "CatFileContentStream", + "__init__", + "__getattr__", + "set_persistent_git_options", + "working_dir", + "version_info", + "execute", + "environment", + "update_environment", + "custom_environment", + "transform_kwarg", + "transform_kwargs", + "__call__", + "_call_process", # Not currently considered public, but unlikely to change. + "get_object_header", + "get_object_data", + "stream_object_data", + "clear_cache", +} +"""Some stable attributes dir() should include on the Git class and its instances. + +This is intentionally incomplete, but includes substantial variety. Most importantly, it +includes both ``USE_SHELL`` and a wide sampling of other attributes. +""" + + +def test_class_dir() -> None: + """dir() on the Git class includes its statically known attributes. + + This tests that the mechanism that adds dynamic behavior to USE_SHELL accesses so + that all accesses issue warnings does not break dir() for the class, neither for + USE_SHELL nor for ordinary (non-deprecated) attributes. + """ + actual = set(dir(Git)) + assert _EXPECTED_DIR_SUBSET <= actual + + +def test_instance_dir() -> None: + """dir() on Git objects includes its statically known attributes. + + This is like test_class_dir, but for Git instance rather than the class itself. + """ + instance = Git() + actual = set(dir(instance)) + assert _EXPECTED_DIR_SUBSET <= actual From bc8cb9cedb2f01fe0778e29b101127682af959f2 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 24 Mar 2024 21:05:57 -0400 Subject: [PATCH 44/58] Make test_use_shell_on_class more robust --- test/deprecation/test_cmd_git.py | 54 ++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index fc8bab6..319bf78 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -9,6 +9,7 @@ import contextlib import sys from typing import Generator +import warnings if sys.version_info >= (3, 11): from typing import assert_type @@ -26,9 +27,16 @@ """Beginning text of USE_SHELL deprecation warnings when USE_SHELL is set True.""" +@contextlib.contextmanager +def _suppress_deprecation_warning() -> Generator[None, None, None]: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + yield + + @pytest.fixture -def reset_backing_attribute() -> Generator[None, None, None]: - """Fixture to reset the private ``_USE_SHELL`` attribute. +def try_restore_use_shell_state() -> Generator[None, None, None]: + """Fixture to attempt to restore state associated with the ``USE_SHELL`` attribute. This is used to decrease the likelihood of state changes leaking out and affecting other tests. But the goal is not to assert that ``_USE_SHELL`` is used, nor anything @@ -38,18 +46,27 @@ def reset_backing_attribute() -> Generator[None, None, None]: restores attributes that it has previously been used to change, create, or remove. """ no_value = object() + try: - old_value = Git._USE_SHELL + old_backing_value = Git._USE_SHELL except AttributeError: - old_value = no_value + old_backing_value = no_value + try: + with _suppress_deprecation_warning(): + old_public_value = Git.USE_SHELL - yield + # This doesn't have its own try-finally because pytest catches exceptions raised + # during the yield. (The outer try-finally catches exceptions in this fixture.) + yield - if old_value is no_value: - with contextlib.suppress(AttributeError): - del Git._USE_SHELL - else: - Git._USE_SHELL = old_value + with _suppress_deprecation_warning(): + Git.USE_SHELL = old_public_value + finally: + if old_backing_value is no_value: + with contextlib.suppress(AttributeError): + del Git._USE_SHELL + else: + Git._USE_SHELL = old_backing_value def test_cannot_access_undefined_on_git_class() -> None: @@ -76,23 +93,26 @@ def test_get_use_shell_on_class_default() -> None: assert not use_shell -# FIXME: More robustly check that each operation really issues exactly one deprecation -# warning, even if this requires relying more on reset_backing_attribute doing its job. -def test_use_shell_on_class(reset_backing_attribute) -> None: +def test_use_shell_on_class(try_restore_use_shell_state) -> None: """USE_SHELL can be written and re-read as a class attribute, always warning.""" - # We assert in a "safe" order, using reset_backing_attribute only as a backstop. - with pytest.deprecated_call() as ctx: + with pytest.deprecated_call() as setting: Git.USE_SHELL = True + with pytest.deprecated_call() as checking: set_value = Git.USE_SHELL + with pytest.deprecated_call() as resetting: Git.USE_SHELL = False + with pytest.deprecated_call() as rechecking: reset_value = Git.USE_SHELL # The attribute should take on the values set to it. assert set_value is True assert reset_value is False - messages = [str(entry.message) for entry in ctx] - set_message, check_message, reset_message, recheck_message = messages + # Each access should warn exactly once. + (set_message,) = [str(entry.message) for entry in setting] + (check_message,) = [str(entry.message) for entry in checking] + (reset_message,) = [str(entry.message) for entry in resetting] + (recheck_message,) = [str(entry.message) for entry in rechecking] # Setting it to True should produce the special warning for that. assert _USE_SHELL_DEPRECATED_FRAGMENT in set_message From da9365909a6e30de514c9ca60330cc4d1a5809f4 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 25 Mar 2024 00:06:40 -0400 Subject: [PATCH 45/58] Write most remaining Git attribute/deprecation tests --- test/deprecation/test_cmd_git.py | 99 ++++++++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index 319bf78..ef6f5b5 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -17,6 +17,7 @@ from typing_extensions import assert_type import pytest +from pytest import WarningsRecorder from git.cmd import Git @@ -93,17 +94,34 @@ def test_get_use_shell_on_class_default() -> None: assert not use_shell -def test_use_shell_on_class(try_restore_use_shell_state) -> None: - """USE_SHELL can be written and re-read as a class attribute, always warning.""" - with pytest.deprecated_call() as setting: - Git.USE_SHELL = True - with pytest.deprecated_call() as checking: - set_value = Git.USE_SHELL - with pytest.deprecated_call() as resetting: - Git.USE_SHELL = False - with pytest.deprecated_call() as rechecking: - reset_value = Git.USE_SHELL +def test_get_use_shell_on_instance_default() -> None: + """USE_SHELL can be read as an instance attribute, defaulting to False and warning. + + This is the same as test_get_use_shell_on_class_default above, but for instances. + The test is repeated, instead of using parametrization, for clearer static analysis. + """ + instance = Git() + with pytest.deprecated_call() as ctx: + use_shell = instance.USE_SHELL + + (message,) = [str(entry.message) for entry in ctx] # Exactly one warning. + assert message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT) + + assert_type(use_shell, bool) + + # This comes after the static assertion, just in case it would affect the inference. + assert not use_shell + + +def _assert_use_shell_full_results( + set_value: bool, + reset_value: bool, + setting: WarningsRecorder, + checking: WarningsRecorder, + resetting: WarningsRecorder, + rechecking: WarningsRecorder, +) -> None: # The attribute should take on the values set to it. assert set_value is True assert reset_value is False @@ -124,7 +142,66 @@ def test_use_shell_on_class(try_restore_use_shell_state) -> None: assert recheck_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT) -# FIXME: Test behavior on instances (where we can get but not set). +def test_use_shell_set_and_get_on_class(try_restore_use_shell_state: None) -> None: + """USE_SHELL can be set and re-read as a class attribute, always warning.""" + with pytest.deprecated_call() as setting: + Git.USE_SHELL = True + with pytest.deprecated_call() as checking: + set_value = Git.USE_SHELL + with pytest.deprecated_call() as resetting: + Git.USE_SHELL = False + with pytest.deprecated_call() as rechecking: + reset_value = Git.USE_SHELL + + _assert_use_shell_full_results( + set_value, + reset_value, + setting, + checking, + resetting, + rechecking, + ) + + +def test_use_shell_set_on_class_get_on_instance(try_restore_use_shell_state: None) -> None: + """USE_SHELL can be set on the class and read on an instance, always warning. + + This is like test_use_shell_set_and_get_on_class but it performs reads on an + instance. There is some redundancy here in assertions about warnings when the + attribute is set, but it is a separate test so that any bugs where a read on the + class (or an instance) is needed first before a read on an instance (or the class) + are detected. + """ + instance = Git() + + with pytest.deprecated_call() as setting: + Git.USE_SHELL = True + with pytest.deprecated_call() as checking: + set_value = instance.USE_SHELL + with pytest.deprecated_call() as resetting: + Git.USE_SHELL = False + with pytest.deprecated_call() as rechecking: + reset_value = instance.USE_SHELL + + _assert_use_shell_full_results( + set_value, + reset_value, + setting, + checking, + resetting, + rechecking, + ) + + +@pytest.mark.parametrize("value", [False, True]) +def test_use_shell_cannot_set_on_instance( + value: bool, + try_restore_use_shell_state: None, # In case of a bug where it does set USE_SHELL. +) -> None: + instance = Git() + with pytest.raises(AttributeError): + instance.USE_SHELL = value + # FIXME: Test behavior with multiprocessing (the attribute needs to pickle properly). From 30c1b9780cc3f08c3298373477e484494c72a699 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 25 Mar 2024 14:59:13 -0400 Subject: [PATCH 46/58] Begin multiprocessing misadventure There is no per-instance state involved in USE_SHELL, so pickling is far less directly relevant than usual to multiprocessing: the spawn and forkserver methods will not preserve a subsequently changed attribute value unless side effects of loading a module (or other unpickling of a function or its arguments that are submitted to run on a worker subprocess) causes it to run again; the fork method will. This will be (automatically) the same with any combination of metaclasses, properties, and custom descriptors as in the more straightforward case of a simple class attribute. Subtleties arise in the code that uses GitPython and multiprocessing, but should not arise unintentionally from the change in implementation of USE_SHELL done to add deprecation warnings, except possibly with respect to whether warnings will be repeated in worker processes, which is less important than whether the actual state is preserved. --- test/deprecation/test_cmd_git.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index ef6f5b5..72c1f7c 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -6,6 +6,7 @@ access are not inadvertently broken by mechanisms introduced to issue the warnings. """ +from concurrent.futures import ProcessPoolExecutor import contextlib import sys from typing import Generator @@ -36,7 +37,7 @@ def _suppress_deprecation_warning() -> Generator[None, None, None]: @pytest.fixture -def try_restore_use_shell_state() -> Generator[None, None, None]: +def restore_use_shell_state() -> Generator[None, None, None]: """Fixture to attempt to restore state associated with the ``USE_SHELL`` attribute. This is used to decrease the likelihood of state changes leaking out and affecting @@ -142,7 +143,7 @@ def _assert_use_shell_full_results( assert recheck_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT) -def test_use_shell_set_and_get_on_class(try_restore_use_shell_state: None) -> None: +def test_use_shell_set_and_get_on_class(restore_use_shell_state: None) -> None: """USE_SHELL can be set and re-read as a class attribute, always warning.""" with pytest.deprecated_call() as setting: Git.USE_SHELL = True @@ -163,7 +164,7 @@ def test_use_shell_set_and_get_on_class(try_restore_use_shell_state: None) -> No ) -def test_use_shell_set_on_class_get_on_instance(try_restore_use_shell_state: None) -> None: +def test_use_shell_set_on_class_get_on_instance(restore_use_shell_state: None) -> None: """USE_SHELL can be set on the class and read on an instance, always warning. This is like test_use_shell_set_and_get_on_class but it performs reads on an @@ -196,14 +197,31 @@ class (or an instance) is needed first before a read on an instance (or the clas @pytest.mark.parametrize("value", [False, True]) def test_use_shell_cannot_set_on_instance( value: bool, - try_restore_use_shell_state: None, # In case of a bug where it does set USE_SHELL. + restore_use_shell_state: None, # In case of a bug where it does set USE_SHELL. ) -> None: instance = Git() with pytest.raises(AttributeError): instance.USE_SHELL = value -# FIXME: Test behavior with multiprocessing (the attribute needs to pickle properly). +def _check_use_shell_in_worker(value: bool) -> None: + # USE_SHELL should have the value set in the parent before starting the worker. + assert Git.USE_SHELL is value + + # FIXME: Check that mutation still works and raises the warning. + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +@pytest.mark.parametrize("value", [False, True]) +def test_use_shell_preserved_in_multiprocessing( + value: bool, + restore_use_shell_state: None, +) -> None: + """The USE_SHELL class attribute pickles accurately for multiprocessing.""" + Git.USE_SHELL = value + with ProcessPoolExecutor(max_workers=1) as executor: + # Calling result() marshals any exception back to this process and raises it. + executor.submit(_check_use_shell_in_worker, value).result() _EXPECTED_DIR_SUBSET = { From 8d5b3e17704146c8061cacdf3c79bc2766417c3f Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Mon, 25 Mar 2024 15:01:23 -0400 Subject: [PATCH 47/58] Somewhat clarify multiprocessing misadventure --- test/deprecation/test_cmd_git.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index 72c1f7c..04fb074 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -204,11 +204,8 @@ def test_use_shell_cannot_set_on_instance( instance.USE_SHELL = value -def _check_use_shell_in_worker(value: bool) -> None: - # USE_SHELL should have the value set in the parent before starting the worker. - assert Git.USE_SHELL is value - - # FIXME: Check that mutation still works and raises the warning. +def _get_value_in_current_process() -> bool: + return Git.USE_SHELL @pytest.mark.filterwarnings("ignore::DeprecationWarning") @@ -220,8 +217,8 @@ def test_use_shell_preserved_in_multiprocessing( """The USE_SHELL class attribute pickles accurately for multiprocessing.""" Git.USE_SHELL = value with ProcessPoolExecutor(max_workers=1) as executor: - # Calling result() marshals any exception back to this process and raises it. - executor.submit(_check_use_shell_in_worker, value).result() + marshaled_value = executor.submit(_get_value_in_current_process).result() + assert marshaled_value is value _EXPECTED_DIR_SUBSET = { From 8713fb3f45dcb036e73358a9e9e55d0f82187f30 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Tue, 26 Mar 2024 13:09:08 -0400 Subject: [PATCH 48/58] Discuss multiprocessing in module docstring; remove bad test --- test/deprecation/test_cmd_git.py | 44 ++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index 04fb074..9dfe372 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -1,12 +1,35 @@ """Tests for static and dynamic characteristics of Git class and instance attributes. -Currently this all relates to the deprecated :class:`Git.USE_SHELL` class attribute, +Currently this all relates to the deprecated :attr:`Git.USE_SHELL` class attribute, which can also be accessed through instances. Some tests directly verify its behavior, including deprecation warnings, while others verify that other aspects of attribute access are not inadvertently broken by mechanisms introduced to issue the warnings. + +A note on multiprocessing: Because USE_SHELL has no instance state, this module does not +include tests of pickling and multiprocessing. + +- Just as with a simple class attribute, when a class attribute with custom logic is + later set to a new value, it may have either its initial value or the new value when + accessed from a worker process, depending on the process start method. With "fork", + changes are preserved. With "spawn" or "forkserver", re-importing the modules causes + initial values to be set. Then the value in the parent at the time it dispatches the + task is only set in the children if the parent has the task set it, or if it is set as + a side effect of importing needed modules, or of unpickling objects passed to the + child (for example, if it is set in a top-level statement of the module that defines + the function submitted for the child worker process to call). + +- When an attribute gains new logic provided by a property or custom descriptor, and the + attribute involves instance-level state, incomplete or corrupted pickling can break + multiprocessing. (For example, if an instance attribute is reimplemented using a + descriptor that stores data in a global WeakKeyDictionary, pickled instances should be + tested to ensure they are still working correctly.) But nothing like that applies + here, because instance state is not involved. Although the situation is inherently + complex as described above, it is independent of the attribute implementation. + +- That USE_SHELL cannot be set on instances, and that when retrieved on instances it + always gives the same value as on the class, is covered in the tests here. """ -from concurrent.futures import ProcessPoolExecutor import contextlib import sys from typing import Generator @@ -204,23 +227,6 @@ def test_use_shell_cannot_set_on_instance( instance.USE_SHELL = value -def _get_value_in_current_process() -> bool: - return Git.USE_SHELL - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -@pytest.mark.parametrize("value", [False, True]) -def test_use_shell_preserved_in_multiprocessing( - value: bool, - restore_use_shell_state: None, -) -> None: - """The USE_SHELL class attribute pickles accurately for multiprocessing.""" - Git.USE_SHELL = value - with ProcessPoolExecutor(max_workers=1) as executor: - marshaled_value = executor.submit(_get_value_in_current_process).result() - assert marshaled_value is value - - _EXPECTED_DIR_SUBSET = { "cat_file_all", "cat_file_header", From 515b0f4b95492b9f8eb9747860a730a966c4134f Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Tue, 26 Mar 2024 16:03:58 -0400 Subject: [PATCH 49/58] Discuss metaclass conflicts in module docstring --- test/deprecation/test_cmd_git.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index 9dfe372..a5d241c 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -5,8 +5,11 @@ including deprecation warnings, while others verify that other aspects of attribute access are not inadvertently broken by mechanisms introduced to issue the warnings. -A note on multiprocessing: Because USE_SHELL has no instance state, this module does not -include tests of pickling and multiprocessing. +A note on multiprocessing +========================= + +Because USE_SHELL has no instance state, this module does not include tests of pickling +and multiprocessing: - Just as with a simple class attribute, when a class attribute with custom logic is later set to a new value, it may have either its initial value or the new value when @@ -28,6 +31,26 @@ - That USE_SHELL cannot be set on instances, and that when retrieved on instances it always gives the same value as on the class, is covered in the tests here. + +A note on metaclass conflicts +============================= + +The most important DeprecationWarning is for the code ``Git.USE_SHELL = True``, which is +a security risk. But this warning may not be possible to implement without a custom +metaclass. This is because a descriptor in a class can customize all forms of attribute +access on its instances, but can only customize getting an attribute on the class. +Retrieving a descriptor from a class calls its ``__get__`` method (if defined), but +replacing or deleting it does not call its ``__set__`` or ``__delete__`` methods. + +Adding a metaclass is a potentially breaking change. This is because derived classes +that use an unrelated metaclass, whether directly or by inheriting from a class such as +abc.ABC that uses one, will raise TypeError when defined. These would have to be +modified to use a newly introduced metaclass that is a lower bound of both. Subclasses +remain unbroken in the far more typical case that they use no custom metaclass. + +The tests in this module do not establish whether the danger of setting Git.USE_SHELL to +True is high enough, and applications of deriving from Git and using an unrelated custom +metaclass marginal enough, to justify introducing a metaclass to issue the warnings. """ import contextlib From 30e94457926107797c51286a8735e0062d14aa6c Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Tue, 26 Mar 2024 18:30:34 -0400 Subject: [PATCH 50/58] Revise module docstring for clarity --- test/deprecation/test_cmd_git.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index a5d241c..2ebcb3a 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -11,19 +11,19 @@ Because USE_SHELL has no instance state, this module does not include tests of pickling and multiprocessing: -- Just as with a simple class attribute, when a class attribute with custom logic is - later set to a new value, it may have either its initial value or the new value when - accessed from a worker process, depending on the process start method. With "fork", - changes are preserved. With "spawn" or "forkserver", re-importing the modules causes - initial values to be set. Then the value in the parent at the time it dispatches the - task is only set in the children if the parent has the task set it, or if it is set as - a side effect of importing needed modules, or of unpickling objects passed to the - child (for example, if it is set in a top-level statement of the module that defines - the function submitted for the child worker process to call). +- Just as with a simple class attribute, when a class attribute with custom logic is set + to another value, even before a worker process is created that uses the class, the + worker process may see either the initial or new value, depending on the process start + method. With "fork", changes are preserved. With "spawn" or "forkserver", re-importing + the modules causes initial values to be set. Then the value in the parent at the time + it dispatches the task is only set in the children if the parent has the task set it, + or if it is set as a side effect of importing needed modules, or of unpickling objects + passed to the child (for example, if it is set in a top-level statement of the module + that defines the function submitted for the child worker process to call). - When an attribute gains new logic provided by a property or custom descriptor, and the attribute involves instance-level state, incomplete or corrupted pickling can break - multiprocessing. (For example, if an instance attribute is reimplemented using a + multiprocessing. (For example, when an instance attribute is reimplemented using a descriptor that stores data in a global WeakKeyDictionary, pickled instances should be tested to ensure they are still working correctly.) But nothing like that applies here, because instance state is not involved. Although the situation is inherently @@ -35,8 +35,8 @@ A note on metaclass conflicts ============================= -The most important DeprecationWarning is for the code ``Git.USE_SHELL = True``, which is -a security risk. But this warning may not be possible to implement without a custom +The most important DeprecationWarning is for code like ``Git.USE_SHELL = True``, which +is a security risk. But this warning may not be possible to implement without a custom metaclass. This is because a descriptor in a class can customize all forms of attribute access on its instances, but can only customize getting an attribute on the class. Retrieving a descriptor from a class calls its ``__get__`` method (if defined), but From d7eb5e72e9e94b5d6a43ec3b2a0bce7cd8c51d61 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 27 Mar 2024 03:28:38 -0400 Subject: [PATCH 51/58] Test that USE_SHELL is unittest.mock.patch patchable --- test/deprecation/test_cmd_git.py | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index 2ebcb3a..5d710b0 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -56,6 +56,7 @@ import contextlib import sys from typing import Generator +import unittest.mock import warnings if sys.version_info >= (3, 11): @@ -250,6 +251,41 @@ def test_use_shell_cannot_set_on_instance( instance.USE_SHELL = value +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +@pytest.mark.parametrize("original_value", [False, True]) +def test_use_shell_is_mock_patchable_on_class_as_object_attribute( + original_value: bool, + restore_use_shell_state: None, +) -> None: + """Asymmetric patching looking up USE_SHELL in ``__dict__`` doesn't corrupt state. + + Code using GitPython may temporarily set Git.USE_SHELL to a different value. Ideally + it does not use unittest.mock.patch to do so, because that makes subtle assumptions + about the relationship between attributes and dictionaries. If the attribute can be + retrieved from the ``__dict__`` rather than directly, that value is assumed the + correct one to restore, even by a normal setattr. + + The effect is that some ways of simulating a class attribute with added behavior can + cause a descriptor, such as a property, to be set to its own backing attribute + during unpatching; then subsequent reads raise RecursionError. This happens if both + (a) setting it on the class is customized in a metaclass and (b) getting it on + instances is customized with a descriptor (such as a property) in the class itself. + + Although ideally code outside GitPython would not rely on being able to patch + Git.USE_SHELL with unittest.mock.patch, the technique is widespread. Thus, USE_SHELL + should be implemented in some way compatible with it. This test checks for that. + """ + Git.USE_SHELL = original_value + if Git.USE_SHELL is not original_value: + raise RuntimeError(f"Can't set up the test") + new_value = not original_value + + with unittest.mock.patch.object(Git, "USE_SHELL", new_value): + assert Git.USE_SHELL is new_value + + assert Git.USE_SHELL is original_value + + _EXPECTED_DIR_SUBSET = { "cat_file_all", "cat_file_header", From 158c2e6c61a4426809efd71a04620bcb29a8d701 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 27 Mar 2024 11:45:25 -0400 Subject: [PATCH 52/58] Make the restore_use_shell_state fixture more robust --- test/deprecation/test_cmd_git.py | 52 ++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index 5d710b0..4183018 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -54,6 +54,7 @@ """ import contextlib +import logging import sys from typing import Generator import unittest.mock @@ -75,6 +76,8 @@ _USE_SHELL_DANGEROUS_FRAGMENT = "Setting Git.USE_SHELL to True is unsafe and insecure" """Beginning text of USE_SHELL deprecation warnings when USE_SHELL is set True.""" +_logger = logging.getLogger(__name__) + @contextlib.contextmanager def _suppress_deprecation_warning() -> Generator[None, None, None]: @@ -85,22 +88,44 @@ def _suppress_deprecation_warning() -> Generator[None, None, None]: @pytest.fixture def restore_use_shell_state() -> Generator[None, None, None]: - """Fixture to attempt to restore state associated with the ``USE_SHELL`` attribute. + """Fixture to attempt to restore state associated with the USE_SHELL attribute. This is used to decrease the likelihood of state changes leaking out and affecting - other tests. But the goal is not to assert that ``_USE_SHELL`` is used, nor anything - about how or when it is used, which is an implementation detail subject to change. + other tests. But the goal is not to assert implementation details of USE_SHELL. + + This covers two of the common implementation strategies, for convenience in testing + both. USE_SHELL could be implemented in the metaclass: - This is possible but inelegant to do with pytest's monkeypatch fixture, which only - restores attributes that it has previously been used to change, create, or remove. + * With a separate _USE_SHELL backing attribute. If using a property or other + descriptor, this is the natural way to do it, but custom __getattribute__ and + __setattr__ logic, if it does more than adding warnings, may also use that. + * Like a simple attribute, using USE_SHELL itself, stored as usual in the class + dictionary, with custom __getattribute__/__setattr__ logic only to warn. + + This tries to save private state, tries to save the public attribute value, yields + to the test case, tries to restore the public attribute value, then tries to restore + private state. The idea is that if the getting or setting logic is wrong in the code + under test, the state will still most likely be reset successfully. """ no_value = object() + # Try to save the original private state. try: - old_backing_value = Git._USE_SHELL + old_private_value = Git._USE_SHELL except AttributeError: - old_backing_value = no_value + separate_backing_attribute = False + try: + old_private_value = type.__getattribute__(Git, "USE_SHELL") + except AttributeError: + old_private_value = no_value + _logger.error("Cannot retrieve old private _USE_SHELL or USE_SHELL value") + else: + separate_backing_attribute = True + try: + # Try to save the original public value. Rather than attempt to restore a state + # where the attribute is not set, if we cannot do this we allow AttributeError + # to propagate out of the fixture, erroring the test case before its code runs. with _suppress_deprecation_warning(): old_public_value = Git.USE_SHELL @@ -108,14 +133,15 @@ def restore_use_shell_state() -> Generator[None, None, None]: # during the yield. (The outer try-finally catches exceptions in this fixture.) yield + # Try to restore the original public value. with _suppress_deprecation_warning(): Git.USE_SHELL = old_public_value finally: - if old_backing_value is no_value: - with contextlib.suppress(AttributeError): - del Git._USE_SHELL - else: - Git._USE_SHELL = old_backing_value + # Try to restore the original private state. + if separate_backing_attribute: + Git._USE_SHELL = old_private_value + elif old_private_value is not no_value: + type.__setattr__(Git, "USE_SHELL", old_private_value) def test_cannot_access_undefined_on_git_class() -> None: @@ -277,7 +303,7 @@ def test_use_shell_is_mock_patchable_on_class_as_object_attribute( """ Git.USE_SHELL = original_value if Git.USE_SHELL is not original_value: - raise RuntimeError(f"Can't set up the test") + raise RuntimeError("Can't set up the test") new_value = not original_value with unittest.mock.patch.object(Git, "USE_SHELL", new_value): From 1379d232dd11f97dda4f10708819a2e1c405e7b4 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 27 Mar 2024 12:05:52 -0400 Subject: [PATCH 53/58] Add type: ignore in test we can't set USE_SHELL on instances As with other `type: ignore` comments in the deprecation tests, mypy will detect if it is superfluous (provided warn_unused_ignores is set to true), thereby enforcing that such code is a static type error. --- test/deprecation/test_cmd_git.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index 4183018..4925fad 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -274,7 +274,7 @@ def test_use_shell_cannot_set_on_instance( ) -> None: instance = Git() with pytest.raises(AttributeError): - instance.USE_SHELL = value + instance.USE_SHELL = value # type: ignore[misc] # Name not in __slots__. @pytest.mark.filterwarnings("ignore::DeprecationWarning") From 68390e6fd249beb910bc915caed90bd1972bb453 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 27 Mar 2024 12:09:15 -0400 Subject: [PATCH 54/58] Clarify unittest.mock.patch patchability test docstring --- test/deprecation/test_cmd_git.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index 4925fad..b792fdd 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -292,10 +292,11 @@ def test_use_shell_is_mock_patchable_on_class_as_object_attribute( correct one to restore, even by a normal setattr. The effect is that some ways of simulating a class attribute with added behavior can - cause a descriptor, such as a property, to be set to its own backing attribute - during unpatching; then subsequent reads raise RecursionError. This happens if both - (a) setting it on the class is customized in a metaclass and (b) getting it on - instances is customized with a descriptor (such as a property) in the class itself. + cause a descriptor, such as a property, to be set as the value of its own backing + attribute during unpatching; then subsequent reads raise RecursionError. This + happens if both (a) setting it on the class is customized in a metaclass and (b) + getting it on instances is customized with a descriptor (such as a property) in the + class itself. Although ideally code outside GitPython would not rely on being able to patch Git.USE_SHELL with unittest.mock.patch, the technique is widespread. Thus, USE_SHELL From 01cbfc610860f0d6a27c2304c3deb31ab58c50a9 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 27 Mar 2024 13:31:22 -0400 Subject: [PATCH 55/58] Test that Git.execute's own read of USE_SHELL does not warn + Use simplefilter where we can (separate from this test). --- test/deprecation/test_cmd_git.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index b792fdd..f17756f 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -82,7 +82,7 @@ @contextlib.contextmanager def _suppress_deprecation_warning() -> Generator[None, None, None]: with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + warnings.simplefilter("ignore", DeprecationWarning) yield @@ -313,6 +313,24 @@ class itself. assert Git.USE_SHELL is original_value +def test_execute_without_shell_arg_does_not_warn() -> None: + """No deprecation warning is issued from operations implemented using Git.execute(). + + When no ``shell`` argument is passed to Git.execute, which is when the value of + USE_SHELL is to be used, the way Git.execute itself accesses USE_SHELL does not + issue a deprecation warning. + """ + with warnings.catch_warnings(): + for category in DeprecationWarning, PendingDeprecationWarning: + warnings.filterwarnings( + action="error", + category=category, + module=r"git(?:\.|$)", + ) + + Git().version() + + _EXPECTED_DIR_SUBSET = { "cat_file_all", "cat_file_header", From fd2960d0252c93e35598803fa85374232880b1e2 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 27 Mar 2024 14:42:26 -0400 Subject: [PATCH 56/58] Suppress type errors in restore_use_shell_state _USE_SHELL branches These conditional branches are kept so alternative implementations can be examined, including if they need to be investigated to satisfy some future requirement. But to be unittest.mock.patch patchable, the approaches that would have a _USE_SHELL backing attribute would be difficult to implement in a straightforward way, which seems not to be needed or justified at this time. Since that is not anticipated (except as an intermediate step in development), these suppressions make sense, and they will also be reported by mypy if the implementation changes to benefit from them (so long as it is configured with warn_unused_ignores set to true). --- test/deprecation/test_cmd_git.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index f17756f..b6af5c7 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -111,7 +111,7 @@ def restore_use_shell_state() -> Generator[None, None, None]: # Try to save the original private state. try: - old_private_value = Git._USE_SHELL + old_private_value = Git._USE_SHELL # type: ignore[attr-defined] except AttributeError: separate_backing_attribute = False try: @@ -139,7 +139,7 @@ def restore_use_shell_state() -> Generator[None, None, None]: finally: # Try to restore the original private state. if separate_backing_attribute: - Git._USE_SHELL = old_private_value + Git._USE_SHELL = old_private_value # type: ignore[attr-defined] elif old_private_value is not no_value: type.__setattr__(Git, "USE_SHELL", old_private_value) From 9ed8e493a291040f8d4c2d332acf3a4b1a07942d Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Thu, 28 Mar 2024 15:33:47 -0400 Subject: [PATCH 57/58] Fix wrong/unclear grammar in test_instance_dir docstring --- test/deprecation/test_cmd_git.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index b6af5c7..d312b87 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -383,7 +383,7 @@ def test_class_dir() -> None: def test_instance_dir() -> None: """dir() on Git objects includes its statically known attributes. - This is like test_class_dir, but for Git instance rather than the class itself. + This is like test_class_dir, but for Git instances rather than the class itself. """ instance = Git() actual = set(dir(instance)) From 429524ae63792ac53c8daa263b7505a1c46d81d8 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 27 Mar 2024 16:39:20 -0400 Subject: [PATCH 58/58] Test GitMeta alias --- test/deprecation/test_cmd_git.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/deprecation/test_cmd_git.py b/test/deprecation/test_cmd_git.py index d312b87..ab37879 100644 --- a/test/deprecation/test_cmd_git.py +++ b/test/deprecation/test_cmd_git.py @@ -68,7 +68,7 @@ import pytest from pytest import WarningsRecorder -from git.cmd import Git +from git.cmd import Git, GitMeta _USE_SHELL_DEPRECATED_FRAGMENT = "Git.USE_SHELL is deprecated" """Text contained in all USE_SHELL deprecation warnings, and starting most of them.""" @@ -388,3 +388,15 @@ def test_instance_dir() -> None: instance = Git() actual = set(dir(instance)) assert _EXPECTED_DIR_SUBSET <= actual + + +def test_metaclass_alias() -> None: + """GitMeta aliases Git's metaclass, whether that is type or a custom metaclass.""" + + def accept_metaclass_instance(cls: GitMeta) -> None: + """Check that cls is statically recognizable as an instance of GitMeta.""" + + accept_metaclass_instance(Git) # assert_type would expect Type[Git], not GitMeta. + + # This comes after the static check, just in case it would affect the inference. + assert type(Git) is GitMeta pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy