From 09826f4ecf9cacc617627ad54452235d449345fc Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Tue, 24 Oct 2023 04:50:02 -0400 Subject: [PATCH 01/10] Revise test_util comment style --- test/test_util.py | 48 +++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/test/test_util.py b/test/test_util.py index f75231c98..5cac56f3b 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -1,4 +1,4 @@ -# test_utils.py +# test_util.py # Copyright (C) 2008, 2009 Michael Trier (mtrier@gmail.com) and contributors # # This module is part of GitPython and is released under @@ -275,14 +275,14 @@ def test_lock_file(self): my_file = tempfile.mktemp() lock_file = LockFile(my_file) assert not lock_file._has_lock() - # release lock we don't have - fine + # Release lock we don't have - fine. lock_file._release_lock() - # get lock + # Get lock. lock_file._obtain_lock_or_raise() assert lock_file._has_lock() - # concurrent access + # Concurrent access. other_lock_file = LockFile(my_file) assert not other_lock_file._has_lock() self.assertRaises(IOError, other_lock_file._obtain_lock_or_raise) @@ -293,7 +293,7 @@ def test_lock_file(self): other_lock_file._obtain_lock_or_raise() self.assertRaises(IOError, lock_file._obtain_lock_or_raise) - # auto-release on destruction + # Auto-release on destruction. del other_lock_file lock_file._obtain_lock_or_raise() lock_file._release_lock() @@ -303,7 +303,7 @@ def test_blocking_lock_file(self): lock_file = BlockingLockFile(my_file) lock_file._obtain_lock() - # next one waits for the lock + # Next one waits for the lock. start = time.time() wait_time = 0.1 wait_lock = BlockingLockFile(my_file, 0.05, wait_time) @@ -318,7 +318,7 @@ def test_user_id(self): self.assertIn("@", get_user_id()) def test_parse_date(self): - # parse_date(from_timestamp()) must return the tuple unchanged + # parse_date(from_timestamp()) must return the tuple unchanged. for timestamp, offset in ( (1522827734, -7200), (1522827734, 0), @@ -326,7 +326,7 @@ def test_parse_date(self): ): self.assertEqual(parse_date(from_timestamp(timestamp, offset)), (timestamp, offset)) - # test all supported formats + # Test all supported formats. def assert_rval(rval, veri_time, offset=0): self.assertEqual(len(rval), 2) self.assertIsInstance(rval[0], int) @@ -334,7 +334,7 @@ def assert_rval(rval, veri_time, offset=0): self.assertEqual(rval[0], veri_time) self.assertEqual(rval[1], offset) - # now that we are here, test our conversion functions as well + # Now that we are here, test our conversion functions as well. utctz = altz_to_utctz_str(offset) self.assertIsInstance(utctz, str) self.assertEqual(utctz_to_altz(verify_utctz(utctz)), offset) @@ -347,13 +347,13 @@ def assert_rval(rval, veri_time, offset=0): iso3 = ("2005.04.07 22:13:11 -0000", 0) alt = ("04/07/2005 22:13:11", 0) alt2 = ("07.04.2005 22:13:11", 0) - veri_time_utc = 1112911991 # the time this represents, in time since epoch, UTC + veri_time_utc = 1112911991 # The time this represents, in time since epoch, UTC. for date, offset in (rfc, iso, iso2, iso3, alt, alt2): assert_rval(parse_date(date), veri_time_utc, offset) # END for each date type - # and failure - self.assertRaises(ValueError, parse_date, datetime.now()) # non-aware datetime + # ...and failure. + self.assertRaises(ValueError, parse_date, datetime.now()) # Non-aware datetime. self.assertRaises(ValueError, parse_date, "invalid format") self.assertRaises(ValueError, parse_date, "123456789 -02000") self.assertRaises(ValueError, parse_date, " 123456789 -0200") @@ -362,7 +362,7 @@ def test_actor(self): for cr in (None, self.rorepo.config_reader()): self.assertIsInstance(Actor.committer(cr), Actor) self.assertIsInstance(Actor.author(cr), Actor) - # END assure config reader is handled + # END ensure config reader is handled @with_rw_repo("HEAD") @mock.patch("getpass.getuser") @@ -402,7 +402,7 @@ def test_actor_get_uid_laziness_called(self, mock_get_uid): mock_get_uid.return_value = "user" committer = Actor.committer(None) author = Actor.author(None) - # We can't test with `self.rorepo.config_reader()` here, as the uuid laziness + # We can't test with `self.rorepo.config_reader()` here, as the UUID laziness # depends on whether the user running the test has their global user.name config set. self.assertEqual(committer.name, "user") self.assertTrue(committer.email.startswith("user@")) @@ -436,30 +436,30 @@ def test_iterable_list(self, case): self.assertEqual(len(ilist), 2) - # contains works with name and identity + # Contains works with name and identity. self.assertIn(name1, ilist) self.assertIn(name2, ilist) self.assertIn(m2, ilist) self.assertIn(m2, ilist) self.assertNotIn("invalid", ilist) - # with string index + # With string index. self.assertIs(ilist[name1], m1) self.assertIs(ilist[name2], m2) - # with int index + # With int index. self.assertIs(ilist[0], m1) self.assertIs(ilist[1], m2) - # with getattr + # With getattr. self.assertIs(ilist.one, m1) self.assertIs(ilist.two, m2) - # test exceptions + # Test exceptions. self.assertRaises(AttributeError, getattr, ilist, "something") self.assertRaises(IndexError, ilist.__getitem__, "something") - # delete by name and index + # Delete by name and index. self.assertRaises(IndexError, ilist.__delitem__, "something") del ilist[name2] self.assertEqual(len(ilist), 1) @@ -494,21 +494,21 @@ def test_altz_to_utctz_str(self): self.assertEqual(altz_to_utctz_str(-59), "+0000") def test_from_timestamp(self): - # Correct offset: UTC+2, should return datetime + tzoffset(+2) + # Correct offset: UTC+2, should return datetime + tzoffset(+2). altz = utctz_to_altz("+0200") self.assertEqual( datetime.fromtimestamp(1522827734, tzoffset(altz)), from_timestamp(1522827734, altz), ) - # Wrong offset: UTC+58, should return datetime + tzoffset(UTC) + # Wrong offset: UTC+58, should return datetime + tzoffset(UTC). altz = utctz_to_altz("+5800") self.assertEqual( datetime.fromtimestamp(1522827734, tzoffset(0)), from_timestamp(1522827734, altz), ) - # Wrong offset: UTC-9000, should return datetime + tzoffset(UTC) + # Wrong offset: UTC-9000, should return datetime + tzoffset(UTC). altz = utctz_to_altz("-9000") self.assertEqual( datetime.fromtimestamp(1522827734, tzoffset(0)), @@ -538,7 +538,7 @@ def test_remove_password_from_command_line(self): redacted_cmd_1 = remove_password_if_present(cmd_1) assert username not in " ".join(redacted_cmd_1) assert password not in " ".join(redacted_cmd_1) - # Check that we use a copy + # Check that we use a copy. assert cmd_1 is not redacted_cmd_1 assert username in " ".join(cmd_1) assert password in " ".join(cmd_1) From e2fa5e2225fbcdd0f34108f1b9a6b4b1e8653555 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Tue, 24 Oct 2023 09:06:11 -0400 Subject: [PATCH 02/10] Pull rmtree tests out of TestUtils class And rework them as pure pytest tests (using pytest fixtures). This creates the TestRmtree class, which does not derive from TestCase. --- pyproject.toml | 13 +-- test-requirements.txt | 3 +- test/test_util.py | 203 ++++++++++++++++++++++-------------------- 3 files changed, 115 insertions(+), 104 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f4fc33fec..eae5943ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,10 +3,11 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] -python_files = 'test_*.py' -testpaths = 'test' # space separated list of paths from root e.g test tests doc/testing -addopts = '--cov=git --cov-report=term --disable-warnings' -filterwarnings = 'ignore::DeprecationWarning' +addopts = "--cov=git --cov-report=term --disable-warnings" +filterwarnings = "ignore::DeprecationWarning" +python_files = "test_*.py" +tmp_path_retention_policy = "failed" +testpaths = "test" # Space separated list of paths from root e.g test tests doc/testing. # --cov coverage # --cov-report term # send report to terminal term-missing -> terminal with line numbers html xml # --cov-report term-missing # to terminal with line numbers @@ -29,7 +30,7 @@ show_error_codes = true implicit_reexport = true # strict = true -# TODO: remove when 'gitdb' is fully annotated +# TODO: Remove when 'gitdb' is fully annotated. exclude = ["^git/ext/gitdb"] [[tool.mypy.overrides]] module = "gitdb.*" @@ -44,5 +45,5 @@ omit = ["*/git/ext/*"] [tool.black] line-length = 120 -target-version = ['py37'] +target-version = ["py37"] extend-exclude = "git/ext/gitdb" diff --git a/test-requirements.txt b/test-requirements.txt index a69181be1..8cadfb836 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,8 +4,9 @@ ddt >= 1.1.1, != 1.4.3 mock ; python_version < "3.8" mypy pre-commit -pytest +pytest >= 7.3.1 pytest-cov pytest-instafail +pytest-mock pytest-subtests pytest-sugar diff --git a/test/test_util.py b/test/test_util.py index 5cac56f3b..784dc9bfc 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -5,7 +5,6 @@ # the BSD License: https://opensource.org/license/bsd-3-clause/ import ast -import contextlib from datetime import datetime import os import pathlib @@ -15,7 +14,7 @@ import sys import tempfile import time -from unittest import SkipTest, mock, skipIf, skipUnless +from unittest import SkipTest, mock, skipUnless import ddt import pytest @@ -44,123 +43,133 @@ from test.lib import TestBase, with_rw_repo -class _Member: - """A member of an IterableList.""" - - __slots__ = ("name",) - - def __init__(self, name): - self.name = name - - def __repr__(self): - return f"{type(self).__name__}({self.name!r})" - - -@contextlib.contextmanager -def _tmpdir_to_force_permission_error(): - """Context manager to test permission errors in situations where they are not overcome.""" +@pytest.fixture +def permission_error_tmpdir(tmp_path): + """Fixture to test permissions errors situations where they are not overcome.""" if sys.platform == "cygwin": raise SkipTest("Cygwin can't set the permissions that make the test meaningful.") if sys.version_info < (3, 8): raise SkipTest("In 3.7, TemporaryDirectory doesn't clean up after weird permissions.") - with tempfile.TemporaryDirectory() as parent: - td = pathlib.Path(parent, "testdir") - td.mkdir() - (td / "x").write_bytes(b"") - (td / "x").chmod(stat.S_IRUSR) # Set up PermissionError on Windows. - td.chmod(stat.S_IRUSR | stat.S_IXUSR) # Set up PermissionError on Unix. - yield td + td = tmp_path / "testdir" + td.mkdir() + (td / "x").write_bytes(b"") + (td / "x").chmod(stat.S_IRUSR) # Set up PermissionError on Windows. + td.chmod(stat.S_IRUSR | stat.S_IXUSR) # Set up PermissionError on Unix. + yield td -@contextlib.contextmanager -def _tmpdir_for_file_not_found(): - """Context manager to test errors deleting a directory that are not due to permissions.""" - with tempfile.TemporaryDirectory() as parent: - yield pathlib.Path(parent, "testdir") # It is deliberately never created. +@pytest.fixture +def file_not_found_tmpdir(tmp_path): + """Fixture to test errors deleting a directory that are not due to permissions.""" + yield tmp_path / "testdir" # It is deliberately never created. -@ddt.ddt -class TestUtils(TestBase): - def test_rmtree_deletes_nested_dir_with_files(self): - with tempfile.TemporaryDirectory() as parent: - td = pathlib.Path(parent, "testdir") - for d in td, td / "q", td / "s": - d.mkdir() - for f in ( - td / "p", - td / "q" / "w", - td / "q" / "x", - td / "r", - td / "s" / "y", - td / "s" / "z", - ): - f.write_bytes(b"") +class TestRmtree: + """Tests for :func:`git.util.rmtree`.""" - try: - rmtree(td) - except SkipTest as ex: - self.fail(f"rmtree unexpectedly attempts skip: {ex!r}") + def test_deletes_nested_dir_with_files(self, tmp_path): + td = tmp_path / "testdir" - self.assertFalse(td.exists()) + for d in td, td / "q", td / "s": + d.mkdir() + for f in ( + td / "p", + td / "q" / "w", + td / "q" / "x", + td / "r", + td / "s" / "y", + td / "s" / "z", + ): + f.write_bytes(b"") - @skipIf( + try: + rmtree(td) + except SkipTest as ex: + pytest.fail(f"rmtree unexpectedly attempts skip: {ex!r}") + + assert not td.exists() + + @pytest.mark.skipif( sys.platform == "cygwin", - "Cygwin can't set the permissions that make the test meaningful.", + reason="Cygwin can't set the permissions that make the test meaningful.", ) - def test_rmtree_deletes_dir_with_readonly_files(self): + def test_deletes_dir_with_readonly_files(self, tmp_path): # Automatically works on Unix, but requires special handling on Windows. - # Not to be confused with what _tmpdir_to_force_permission_error sets up (see below). - with tempfile.TemporaryDirectory() as parent: - td = pathlib.Path(parent, "testdir") - for d in td, td / "sub": - d.mkdir() - for f in td / "x", td / "sub" / "y": - f.write_bytes(b"") - f.chmod(0) + # Not to be confused with what permission_error_tmpdir sets up (see below). + + td = tmp_path / "testdir" + + for d in td, td / "sub": + d.mkdir() + for f in td / "x", td / "sub" / "y": + f.write_bytes(b"") + f.chmod(0) + + try: + rmtree(td) + except SkipTest as ex: + self.fail(f"rmtree unexpectedly attempts skip: {ex!r}") + assert not td.exists() + + def test_wraps_perm_error_if_enabled(self, mocker, permission_error_tmpdir): + """rmtree wraps PermissionError when HIDE_WINDOWS_KNOWN_ERRORS is true.""" + # Access the module through sys.modules so it is unambiguous which module's + # attribute we patch: the original git.util, not git.index.util even though + # git.index.util "replaces" git.util and is what "import git.util" gives us. + mocker.patch.object(sys.modules["git.util"], "HIDE_WINDOWS_KNOWN_ERRORS", True) + + # Disable common chmod functions so the callback can't fix the problem. + mocker.patch.object(os, "chmod") + mocker.patch.object(pathlib.Path, "chmod") + + # Now we can see how an intractable PermissionError is treated. + with pytest.raises(SkipTest): + rmtree(permission_error_tmpdir) + + def test_does_not_wrap_perm_error_unless_enabled(self, mocker, permission_error_tmpdir): + """rmtree does not wrap PermissionError when HIDE_WINDOWS_KNOWN_ERRORS is false.""" + # See comments in test_wraps_perm_error_if_enabled for details about patching. + mocker.patch.object(sys.modules["git.util"], "HIDE_WINDOWS_KNOWN_ERRORS", False) + mocker.patch.object(os, "chmod") + mocker.patch.object(pathlib.Path, "chmod") + + with pytest.raises(PermissionError): + try: + rmtree(permission_error_tmpdir) + except SkipTest as ex: + pytest.fail(f"rmtree unexpectedly attempts skip: {ex!r}") + + @pytest.mark.parametrize("hide_windows_known_errors", [False, True]) + def test_does_not_wrap_other_errors(self, mocker, file_not_found_tmpdir, hide_windows_known_errors): + # See comments in test_wraps_perm_error_if_enabled for details about patching. + mocker.patch.object(sys.modules["git.util"], "HIDE_WINDOWS_KNOWN_ERRORS", hide_windows_known_errors) + mocker.patch.object(os, "chmod") + mocker.patch.object(pathlib.Path, "chmod") + + with pytest.raises(FileNotFoundError): try: - rmtree(td) + rmtree(file_not_found_tmpdir) except SkipTest as ex: self.fail(f"rmtree unexpectedly attempts skip: {ex!r}") - self.assertFalse(td.exists()) - def test_rmtree_can_wrap_exceptions(self): - """rmtree wraps PermissionError when HIDE_WINDOWS_KNOWN_ERRORS is true.""" - with _tmpdir_to_force_permission_error() as td: - # Access the module through sys.modules so it is unambiguous which module's - # attribute we patch: the original git.util, not git.index.util even though - # git.index.util "replaces" git.util and is what "import git.util" gives us. - with mock.patch.object(sys.modules["git.util"], "HIDE_WINDOWS_KNOWN_ERRORS", True): - # Disable common chmod functions so the callback can't fix the problem. - with mock.patch.object(os, "chmod"), mock.patch.object(pathlib.Path, "chmod"): - # Now we can see how an intractable PermissionError is treated. - with self.assertRaises(SkipTest): - rmtree(td) +class _Member: + """A member of an IterableList.""" - @ddt.data( - (False, PermissionError, _tmpdir_to_force_permission_error), - (False, FileNotFoundError, _tmpdir_for_file_not_found), - (True, FileNotFoundError, _tmpdir_for_file_not_found), - ) - def test_rmtree_does_not_wrap_unless_called_for(self, case): - """rmtree doesn't wrap non-PermissionError, nor if HIDE_WINDOWS_KNOWN_ERRORS is false.""" - hide_windows_known_errors, exception_type, tmpdir_context_factory = case - - with tmpdir_context_factory() as td: - # See comments in test_rmtree_can_wrap_exceptions regarding the patching done here. - with mock.patch.object( - sys.modules["git.util"], - "HIDE_WINDOWS_KNOWN_ERRORS", - hide_windows_known_errors, - ): - with mock.patch.object(os, "chmod"), mock.patch.object(pathlib.Path, "chmod"): - with self.assertRaises(exception_type): - try: - rmtree(td) - except SkipTest as ex: - self.fail(f"rmtree unexpectedly attempts skip: {ex!r}") + __slots__ = ("name",) + + def __init__(self, name): + self.name = name + + def __repr__(self): + return f"{type(self).__name__}({self.name!r})" + + +@ddt.ddt +class TestUtils(TestBase): + """Tests for utilities in :mod:`git.util` other than :func:`git.util.rmtree`.""" @ddt.data("HIDE_WINDOWS_KNOWN_ERRORS", "HIDE_WINDOWS_FREEZE_ERRORS") def test_env_vars_for_windows_tests(self, name): From c4da058ba99aafd16a01b0692b13bafb833969ac Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Tue, 24 Oct 2023 09:43:41 -0400 Subject: [PATCH 03/10] Pull HIDE_WINDOWS_*_ERRORS tests out of TestUtils And make them pure pytest tests. This creates the TestEnvParsing class, which does not inherit from TestCase. Because unlike @ddt.data (and @ddt.idata) @pytest.mark.parametrize can be nested (applied multiple times, creating tests for all combinations), that is used for the HIDE_WINDOWS_*_ERRORS tests instead of subtests. Because there were not any other uses of subtests in the test suite, the pytest-subtest plugin is accordingly removed from test-requirements. It may be put back anytime in the future. --- test-requirements.txt | 1 - test/test_util.py | 82 +++++++++++++++++++++++++------------------ 2 files changed, 48 insertions(+), 35 deletions(-) diff --git a/test-requirements.txt b/test-requirements.txt index 8cadfb836..7cfb977a1 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -8,5 +8,4 @@ pytest >= 7.3.1 pytest-cov pytest-instafail pytest-mock -pytest-subtests pytest-sugar diff --git a/test/test_util.py b/test/test_util.py index 784dc9bfc..c04f390d1 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -155,38 +155,26 @@ def test_does_not_wrap_other_errors(self, mocker, file_not_found_tmpdir, hide_wi self.fail(f"rmtree unexpectedly attempts skip: {ex!r}") -class _Member: - """A member of an IterableList.""" - - __slots__ = ("name",) - - def __init__(self, name): - self.name = name - - def __repr__(self): - return f"{type(self).__name__}({self.name!r})" - +class TestEnvParsing: + """Tests for environment variable parsing logic in :mod:`git.util`.""" + + @staticmethod + def _run_parse(name, value): + command = [ + sys.executable, + "-c", + f"from git.util import {name}; print(repr({name}))", + ] + output = subprocess.check_output( + command, + env=None if value is None else dict(os.environ, **{name: value}), + text=True, + ) + return ast.literal_eval(output) -@ddt.ddt -class TestUtils(TestBase): - """Tests for utilities in :mod:`git.util` other than :func:`git.util.rmtree`.""" - - @ddt.data("HIDE_WINDOWS_KNOWN_ERRORS", "HIDE_WINDOWS_FREEZE_ERRORS") - def test_env_vars_for_windows_tests(self, name): - def run_parse(value): - command = [ - sys.executable, - "-c", - f"from git.util import {name}; print(repr({name}))", - ] - output = subprocess.check_output( - command, - env=None if value is None else dict(os.environ, **{name: value}), - text=True, - ) - return ast.literal_eval(output) - - for env_var_value, expected_truth_value in ( + @pytest.mark.parametrize( + "env_var_value, expected_truth_value", + [ (None, os.name == "nt"), # True on Windows when the environment variable is unset. ("", False), (" ", False), @@ -202,9 +190,35 @@ def run_parse(value): ("YES", os.name == "nt"), (" no ", False), (" yes ", os.name == "nt"), - ): - with self.subTest(env_var_value=env_var_value): - self.assertIs(run_parse(env_var_value), expected_truth_value) + ], + ) + @pytest.mark.parametrize( + "name", + [ + "HIDE_WINDOWS_KNOWN_ERRORS", + "HIDE_WINDOWS_FREEZE_ERRORS", + ], + ) + def test_env_vars_for_windows_tests(self, name, env_var_value, expected_truth_value): + actual_parsed_value = self._run_parse(name, env_var_value) + assert actual_parsed_value is expected_truth_value + + +class _Member: + """A member of an IterableList.""" + + __slots__ = ("name",) + + def __init__(self, name): + self.name = name + + def __repr__(self): + return f"{type(self).__name__}({self.name!r})" + + +@ddt.ddt +class TestUtils(TestBase): + """Tests for most utilities in :mod:`git.util`.""" _norm_cygpath_pairs = ( (R"foo\bar", "foo/bar"), From aa1799f0de449c50fb5e35ba0befb4ff2fc6f98c Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Tue, 24 Oct 2023 09:51:40 -0400 Subject: [PATCH 04/10] Remove file_not_found_tmpdir fixture for TestRmtree Since its presence doesn't make things any simpler or more elegant. (It was left over from a previous approach where it was used in some @ddt parameters.) --- test/test_util.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/test/test_util.py b/test/test_util.py index c04f390d1..a02ca0cce 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -59,12 +59,6 @@ def permission_error_tmpdir(tmp_path): yield td -@pytest.fixture -def file_not_found_tmpdir(tmp_path): - """Fixture to test errors deleting a directory that are not due to permissions.""" - yield tmp_path / "testdir" # It is deliberately never created. - - class TestRmtree: """Tests for :func:`git.util.rmtree`.""" @@ -142,7 +136,9 @@ def test_does_not_wrap_perm_error_unless_enabled(self, mocker, permission_error_ pytest.fail(f"rmtree unexpectedly attempts skip: {ex!r}") @pytest.mark.parametrize("hide_windows_known_errors", [False, True]) - def test_does_not_wrap_other_errors(self, mocker, file_not_found_tmpdir, hide_windows_known_errors): + def test_does_not_wrap_other_errors(self, tmp_path, mocker, hide_windows_known_errors): + file_not_found_tmpdir = tmp_path / "testdir" # It is deliberately never created. + # See comments in test_wraps_perm_error_if_enabled for details about patching. mocker.patch.object(sys.modules["git.util"], "HIDE_WINDOWS_KNOWN_ERRORS", hide_windows_known_errors) mocker.patch.object(os, "chmod") From 2fb8c64a898e4c4bf93febadffa216c8a6ff2411 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Tue, 24 Oct 2023 09:57:05 -0400 Subject: [PATCH 05/10] Move permission_error_tmpdir skips to test cases This moves test skipping logic from the permission_error_tmpdir fixture to the two TestRmtree test case methods that use it. This produces some duplication, which is undesirable, but it makes it so that which tests are skipped under what conditions is immediately clear, easy to identify as skipping logic (which is usually achieved by decoration), and clear in its relationship to the skipping logic associated with other TestRmtree test cases. --- test/test_util.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/test/test_util.py b/test/test_util.py index a02ca0cce..1147676bc 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -46,11 +46,6 @@ @pytest.fixture def permission_error_tmpdir(tmp_path): """Fixture to test permissions errors situations where they are not overcome.""" - if sys.platform == "cygwin": - raise SkipTest("Cygwin can't set the permissions that make the test meaningful.") - if sys.version_info < (3, 8): - raise SkipTest("In 3.7, TemporaryDirectory doesn't clean up after weird permissions.") - td = tmp_path / "testdir" td.mkdir() (td / "x").write_bytes(b"") @@ -107,6 +102,14 @@ def test_deletes_dir_with_readonly_files(self, tmp_path): assert not td.exists() + @pytest.mark.skipif( + sys.platform == "cygwin", + reason="Cygwin can't set the permissions that make the test meaningful.", + ) + @pytest.mark.skipif( + sys.version_info < (3, 8), + reason="In 3.7, TemporaryDirectory doesn't clean up after weird permissions.", + ) def test_wraps_perm_error_if_enabled(self, mocker, permission_error_tmpdir): """rmtree wraps PermissionError when HIDE_WINDOWS_KNOWN_ERRORS is true.""" # Access the module through sys.modules so it is unambiguous which module's @@ -122,6 +125,14 @@ def test_wraps_perm_error_if_enabled(self, mocker, permission_error_tmpdir): with pytest.raises(SkipTest): rmtree(permission_error_tmpdir) + @pytest.mark.skipif( + sys.platform == "cygwin", + reason="Cygwin can't set the permissions that make the test meaningful.", + ) + @pytest.mark.skipif( + sys.version_info < (3, 8), + reason="In 3.7, TemporaryDirectory doesn't clean up after weird permissions.", + ) def test_does_not_wrap_perm_error_unless_enabled(self, mocker, permission_error_tmpdir): """rmtree does not wrap PermissionError when HIDE_WINDOWS_KNOWN_ERRORS is false.""" # See comments in test_wraps_perm_error_if_enabled for details about patching. From 1dccb8e3a90ad2227d0689171526212cd5740451 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Tue, 24 Oct 2023 11:16:31 -0400 Subject: [PATCH 06/10] Pull cygpath and decygpath tests out of TestUtils This turns them into pure pytest tests, in a new class TestCygpath. --- test/test_util.py | 98 ++++++++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/test/test_util.py b/test/test_util.py index 1147676bc..f5d716d4c 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -14,7 +14,7 @@ import sys import tempfile import time -from unittest import SkipTest, mock, skipUnless +from unittest import SkipTest, mock import ddt import pytest @@ -45,7 +45,7 @@ @pytest.fixture def permission_error_tmpdir(tmp_path): - """Fixture to test permissions errors situations where they are not overcome.""" + """Fixture to test permissions errors in situations where they are not overcome.""" td = tmp_path / "testdir" td.mkdir() (td / "x").write_bytes(b"") @@ -211,21 +211,9 @@ def test_env_vars_for_windows_tests(self, name, env_var_value, expected_truth_va assert actual_parsed_value is expected_truth_value -class _Member: - """A member of an IterableList.""" - - __slots__ = ("name",) - - def __init__(self, name): - self.name = name - - def __repr__(self): - return f"{type(self).__name__}({self.name!r})" - - -@ddt.ddt -class TestUtils(TestBase): - """Tests for most utilities in :mod:`git.util`.""" +@pytest.mark.skipif(sys.platform != "cygwin", reason="Paths specifically for Cygwin.") +class TestCygpath: + """Tests for :func:`git.util.cygpath` and :func:`git.util.decygpath`.""" _norm_cygpath_pairs = ( (R"foo\bar", "foo/bar"), @@ -248,54 +236,70 @@ class TestUtils(TestBase): (R"\\?\UNC\server\D$\Apps", "//server/D$/Apps"), ) - # FIXME: Mark only the /proc-prefixing cases xfail, somehow (or fix them). + # FIXME: Mark only the /proc-prefixing cases xfail (or fix them). @pytest.mark.xfail( reason="Many return paths prefixed /proc/cygdrive instead.", raises=AssertionError, ) - @skipUnless(sys.platform == "cygwin", "Paths specifically for Cygwin.") - @ddt.idata(_norm_cygpath_pairs + _unc_cygpath_pairs) - def test_cygpath_ok(self, case): - wpath, cpath = case + @pytest.mark.parametrize("wpath, cpath", _norm_cygpath_pairs + _unc_cygpath_pairs) + def test_cygpath_ok(self, wpath, cpath): cwpath = cygpath(wpath) - self.assertEqual(cwpath, cpath, wpath) + assert cwpath == cpath, wpath @pytest.mark.xfail( reason=R'2nd example r".\bar" -> "bar" fails, returns "./bar"', raises=AssertionError, ) - @skipUnless(sys.platform == "cygwin", "Paths specifically for Cygwin.") - @ddt.data( - (R"./bar", "bar"), - (R".\bar", "bar"), # FIXME: Mark only this one xfail, somehow (or fix it). - (R"../bar", "../bar"), - (R"..\bar", "../bar"), - (R"../bar/.\foo/../chu", "../bar/chu"), + @pytest.mark.parametrize( + "wpath, cpath", + [ + (R"./bar", "bar"), + (R".\bar", "bar"), # FIXME: Mark only this one xfail (or fix it). + (R"../bar", "../bar"), + (R"..\bar", "../bar"), + (R"../bar/.\foo/../chu", "../bar/chu"), + ], ) - def test_cygpath_norm_ok(self, case): - wpath, cpath = case + def test_cygpath_norm_ok(self, wpath, cpath): cwpath = cygpath(wpath) - self.assertEqual(cwpath, cpath or wpath, wpath) + assert cwpath == (cpath or wpath), wpath - @skipUnless(sys.platform == "cygwin", "Paths specifically for Cygwin.") - @ddt.data( - R"C:", - R"C:Relative", - R"D:Apps\123", - R"D:Apps/123", - R"\\?\a:rel", - R"\\share\a:rel", + @pytest.mark.parametrize( + "wpath", + [ + R"C:", + R"C:Relative", + R"D:Apps\123", + R"D:Apps/123", + R"\\?\a:rel", + R"\\share\a:rel", + ], ) def test_cygpath_invalids(self, wpath): cwpath = cygpath(wpath) - self.assertEqual(cwpath, wpath.replace("\\", "/"), wpath) + assert cwpath == wpath.replace("\\", "/"), wpath - @skipUnless(sys.platform == "cygwin", "Paths specifically for Cygwin.") - @ddt.idata(_norm_cygpath_pairs) - def test_decygpath(self, case): - wpath, cpath = case + @pytest.mark.parametrize("wpath, cpath", _norm_cygpath_pairs) + def test_decygpath(self, wpath, cpath): wcpath = decygpath(cpath) - self.assertEqual(wcpath, wpath.replace("/", "\\"), cpath) + assert wcpath == wpath.replace("/", "\\"), cpath + + +class _Member: + """A member of an IterableList.""" + + __slots__ = ("name",) + + def __init__(self, name): + self.name = name + + def __repr__(self): + return f"{type(self).__name__}({self.name!r})" + + +@ddt.ddt +class TestUtils(TestBase): + """Tests for most utilities in :mod:`git.util`.""" def test_it_should_dashify(self): self.assertEqual("this-is-my-argument", dashify("this_is_my_argument")) From 487bc8aa71ebfdfa455c4037d0bbcfd51eb4997c Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 25 Oct 2023 06:27:35 -0400 Subject: [PATCH 07/10] Start making TestCygpath xfail markings granular This marks only the one really expected failure case in in test_cygpath_norm_ok as xfail, preventing the others from giving "XPASS" results each time the tests are run. --- test/test_util.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/test_util.py b/test/test_util.py index f5d716d4c..39dbe5e56 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -211,6 +211,11 @@ def test_env_vars_for_windows_tests(self, name, env_var_value, expected_truth_va assert actual_parsed_value is expected_truth_value +def _xfail_param(*values, **xfail_kwargs): + """Build a pytest.mark.parametrize parameter that carries an xfail mark.""" + return pytest.param(*values, marks=pytest.mark.xfail(**xfail_kwargs)) + + @pytest.mark.skipif(sys.platform != "cygwin", reason="Paths specifically for Cygwin.") class TestCygpath: """Tests for :func:`git.util.cygpath` and :func:`git.util.decygpath`.""" @@ -246,15 +251,11 @@ def test_cygpath_ok(self, wpath, cpath): cwpath = cygpath(wpath) assert cwpath == cpath, wpath - @pytest.mark.xfail( - reason=R'2nd example r".\bar" -> "bar" fails, returns "./bar"', - raises=AssertionError, - ) @pytest.mark.parametrize( "wpath, cpath", [ (R"./bar", "bar"), - (R".\bar", "bar"), # FIXME: Mark only this one xfail (or fix it). + _xfail_param(R".\bar", "bar", reason=R'Returns: "./bar"', raises=AssertionError), (R"../bar", "../bar"), (R"..\bar", "../bar"), (R"../bar/.\foo/../chu", "../bar/chu"), From c3d3d1ecd3ce8a2ab547a7f1d94590a676f36d18 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Wed, 25 Oct 2023 10:24:44 -0400 Subject: [PATCH 08/10] Finish making TestCygpath xfail markings granular This marks only the (many, but not all) really expected failure cases in test_cygpath_ok as xfail, preventing the others from giving "XPASS" results each time the tests are run. This finishes making the xfail markings in TestCygpath granular, in the sense that now there should be no "XPASS" results. However, the approach taken here might still benefit from future reorganization or refactoring. --- test/test_util.py | 78 ++++++++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/test/test_util.py b/test/test_util.py index 39dbe5e56..a12d17db3 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -216,37 +216,59 @@ def _xfail_param(*values, **xfail_kwargs): return pytest.param(*values, marks=pytest.mark.xfail(**xfail_kwargs)) -@pytest.mark.skipif(sys.platform != "cygwin", reason="Paths specifically for Cygwin.") -class TestCygpath: - """Tests for :func:`git.util.cygpath` and :func:`git.util.decygpath`.""" +_norm_cygpath_pairs = ( + (R"foo\bar", "foo/bar"), + (R"foo/bar", "foo/bar"), + (R"C:\Users", "/cygdrive/c/Users"), + (R"C:\d/e", "/cygdrive/c/d/e"), + ("C:\\", "/cygdrive/c/"), + (R"\\server\C$\Users", "//server/C$/Users"), + (R"\\server\C$", "//server/C$"), + ("\\\\server\\c$\\", "//server/c$/"), + (R"\\server\BAR/", "//server/BAR/"), + (R"D:/Apps", "/cygdrive/d/Apps"), + (R"D:/Apps\fOO", "/cygdrive/d/Apps/fOO"), + (R"D:\Apps/123", "/cygdrive/d/Apps/123"), +) - _norm_cygpath_pairs = ( - (R"foo\bar", "foo/bar"), - (R"foo/bar", "foo/bar"), - (R"C:\Users", "/cygdrive/c/Users"), - (R"C:\d/e", "/cygdrive/c/d/e"), - ("C:\\", "/cygdrive/c/"), - (R"\\server\C$\Users", "//server/C$/Users"), - (R"\\server\C$", "//server/C$"), - ("\\\\server\\c$\\", "//server/c$/"), - (R"\\server\BAR/", "//server/BAR/"), - (R"D:/Apps", "/cygdrive/d/Apps"), - (R"D:/Apps\fOO", "/cygdrive/d/Apps/fOO"), - (R"D:\Apps/123", "/cygdrive/d/Apps/123"), - ) +_unc_cygpath_pairs = ( + (R"\\?\a:\com", "/cygdrive/a/com"), + (R"\\?\a:/com", "/cygdrive/a/com"), + (R"\\?\UNC\server\D$\Apps", "//server/D$/Apps"), +) - _unc_cygpath_pairs = ( - (R"\\?\a:\com", "/cygdrive/a/com"), - (R"\\?\a:/com", "/cygdrive/a/com"), - (R"\\?\UNC\server\D$\Apps", "//server/D$/Apps"), +# Mapping of expected failures for the test_cygpath_ok test. +_cygpath_ok_xfails = { + # From _norm_cygpath_pairs: + (R"C:\Users", "/cygdrive/c/Users"): "/proc/cygdrive/c/Users", + (R"C:\d/e", "/cygdrive/c/d/e"): "/proc/cygdrive/c/d/e", + ("C:\\", "/cygdrive/c/"): "/proc/cygdrive/c/", + (R"\\server\BAR/", "//server/BAR/"): "//server/BAR", + (R"D:/Apps", "/cygdrive/d/Apps"): "/proc/cygdrive/d/Apps", + (R"D:/Apps\fOO", "/cygdrive/d/Apps/fOO"): "/proc/cygdrive/d/Apps/fOO", + (R"D:\Apps/123", "/cygdrive/d/Apps/123"): "/proc/cygdrive/d/Apps/123", + # From _unc_cygpath_pairs: + (R"\\?\a:\com", "/cygdrive/a/com"): "/proc/cygdrive/a/com", + (R"\\?\a:/com", "/cygdrive/a/com"): "/proc/cygdrive/a/com", +} + + +# Parameter sets for the test_cygpath_ok test. +_cygpath_ok_params = [ + ( + _xfail_param(*case, reason=f"Returns: {_cygpath_ok_xfails[case]!r}", raises=AssertionError) + if case in _cygpath_ok_xfails + else case ) + for case in _norm_cygpath_pairs + _unc_cygpath_pairs +] - # FIXME: Mark only the /proc-prefixing cases xfail (or fix them). - @pytest.mark.xfail( - reason="Many return paths prefixed /proc/cygdrive instead.", - raises=AssertionError, - ) - @pytest.mark.parametrize("wpath, cpath", _norm_cygpath_pairs + _unc_cygpath_pairs) + +@pytest.mark.skipif(sys.platform != "cygwin", reason="Paths specifically for Cygwin.") +class TestCygpath: + """Tests for :func:`git.util.cygpath` and :func:`git.util.decygpath`.""" + + @pytest.mark.parametrize("wpath, cpath", _cygpath_ok_params) def test_cygpath_ok(self, wpath, cpath): cwpath = cygpath(wpath) assert cwpath == cpath, wpath @@ -255,7 +277,7 @@ def test_cygpath_ok(self, wpath, cpath): "wpath, cpath", [ (R"./bar", "bar"), - _xfail_param(R".\bar", "bar", reason=R'Returns: "./bar"', raises=AssertionError), + _xfail_param(R".\bar", "bar", reason="Returns: './bar'", raises=AssertionError), (R"../bar", "../bar"), (R"..\bar", "../bar"), (R"../bar/.\foo/../chu", "../bar/chu"), From 7d98f94a60b6108709fc1155cd23c3224880ec94 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Fri, 27 Oct 2023 01:10:53 -0400 Subject: [PATCH 09/10] Let all TestRmtree tests run on 3.7 They are no longer using TemporaryDirectory (because they use the pytest tmp_path fixture instead), so the limitations of TemporaryDirectory on Python 3.7 are no longer relevant. --- test/test_util.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/test_util.py b/test/test_util.py index a12d17db3..55f87ed9b 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -106,10 +106,6 @@ def test_deletes_dir_with_readonly_files(self, tmp_path): sys.platform == "cygwin", reason="Cygwin can't set the permissions that make the test meaningful.", ) - @pytest.mark.skipif( - sys.version_info < (3, 8), - reason="In 3.7, TemporaryDirectory doesn't clean up after weird permissions.", - ) def test_wraps_perm_error_if_enabled(self, mocker, permission_error_tmpdir): """rmtree wraps PermissionError when HIDE_WINDOWS_KNOWN_ERRORS is true.""" # Access the module through sys.modules so it is unambiguous which module's @@ -129,10 +125,6 @@ def test_wraps_perm_error_if_enabled(self, mocker, permission_error_tmpdir): sys.platform == "cygwin", reason="Cygwin can't set the permissions that make the test meaningful.", ) - @pytest.mark.skipif( - sys.version_info < (3, 8), - reason="In 3.7, TemporaryDirectory doesn't clean up after weird permissions.", - ) def test_does_not_wrap_perm_error_unless_enabled(self, mocker, permission_error_tmpdir): """rmtree does not wrap PermissionError when HIDE_WINDOWS_KNOWN_ERRORS is false.""" # See comments in test_wraps_perm_error_if_enabled for details about patching. From ba5bc45b3a570ef628dba97128ce4bfe9856d736 Mon Sep 17 00:00:00 2001 From: Eliah Kagan Date: Sun, 29 Oct 2023 03:34:03 -0400 Subject: [PATCH 10/10] Better explain the Windows and Unix cases Of PermissionError, in the rmtree tests. --- test/test_util.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/test_util.py b/test/test_util.py index 55f87ed9b..d345247b1 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -49,8 +49,13 @@ def permission_error_tmpdir(tmp_path): td = tmp_path / "testdir" td.mkdir() (td / "x").write_bytes(b"") - (td / "x").chmod(stat.S_IRUSR) # Set up PermissionError on Windows. - td.chmod(stat.S_IRUSR | stat.S_IXUSR) # Set up PermissionError on Unix. + + # Set up PermissionError on Windows, where we can't delete read-only files. + (td / "x").chmod(stat.S_IRUSR) + + # Set up PermissionError on Unix, where we can't delete files in read-only directories. + td.chmod(stat.S_IRUSR | stat.S_IXUSR) + yield td @@ -113,7 +118,7 @@ def test_wraps_perm_error_if_enabled(self, mocker, permission_error_tmpdir): # git.index.util "replaces" git.util and is what "import git.util" gives us. mocker.patch.object(sys.modules["git.util"], "HIDE_WINDOWS_KNOWN_ERRORS", True) - # Disable common chmod functions so the callback can't fix the problem. + # Disable common chmod functions so the callback can never fix the problem. mocker.patch.object(os, "chmod") mocker.patch.object(pathlib.Path, "chmod")