From fa931374b841fd01f11114b42ca0fdb4d87633dc Mon Sep 17 00:00:00 2001 From: Mingyang Wu <129849514+aprylewu@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:02:32 +0800 Subject: [PATCH 1/2] Fix timeout child-process lookup on non-GNU systems Git.execute used ps --ppid to find direct children before enforcing kill_after_timeout. On macOS this option is rejected: the parent is killed, but a child can continue running and hold captured output pipes open. Use pgrep -P for the child lookup, with POSIX ps PID/PPID output as a fallback when pgrep is absent. Filter the fallback by the original parent PID and reap the lookup subprocess in both paths. Keep the existing parent-first SIGKILL order, direct-child scope, and Windows guard, and update the documented command requirements. Systems without either lookup facility and the existing PID-reuse race remain limitations. Add real-process regressions for native pgrep and the ps fallback, plus a test that excludes unrelated processes and grandchildren from the fallback. Both real-process cases failed on the original code on macOS. The command module now passes 105 tests with 1 skip on macOS 27.0 / Python 3.13.5. Ruff check and format, codespell, mypy (45 files), basedpyright, and diff whitespace checks pass. Linux and Cygwin were not run locally; Cygwin's default ps lacks the required options, so the real-process cases skip it. Fixes #1756 Signed-off-by: Mingyang Wu <129849514+aprylewu@users.noreply.github.com> --- git/cmd.py | 30 ++++++++++++++++--------- test/test_git.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 193dfd4f6..9de2f6e8a 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -1303,9 +1303,9 @@ def execute( carefully considered, due to the following limitations: 1. This feature is not supported at all on Windows. - 2. Effectiveness may vary by operating system. ``ps --ppid`` is used to - enumerate child processes, which is available on most GNU/Linux systems - but not most others. + 2. Enumerating child processes requires ``pgrep -P``, or a ``ps`` command + supporting the POSIX ``-A`` and ``-o`` options if ``pgrep`` is not + installed. Effectiveness may vary on systems without these commands. 3. Deeper descendants do not receive signals, though they may sometimes terminate as a consequence of their parent processes being killed. 4. `kill_after_timeout` uses ``SIGKILL``, which can have negative side @@ -1465,14 +1465,24 @@ def kill_process(pid: int) -> None: This callback implementation would be ineffective and unsafe on Windows. """ - p = Popen(["ps", "--ppid", str(pid)], stdout=PIPE) child_pids = [] - if p.stdout is not None: - for line in p.stdout: - if len(line.split()) > 0: - local_pid = (line.split())[0] - if local_pid.isdigit(): - child_pids.append(int(local_pid)) + try: + p = Popen(["pgrep", "-P", str(pid)], stdout=PIPE) + except FileNotFoundError: + # POSIX ps does not support selecting by parent PID. + with Popen(["ps", "-A", "-o", "pid=", "-o", "ppid="], stdout=PIPE) as p: + if p.stdout is not None: + for line in p.stdout: + fields = line.split() + if len(fields) == 2 and all(field.isdigit() for field in fields): + if int(fields[1]) == pid: + child_pids.append(int(fields[0])) + else: + with p: + if p.stdout is not None: + for line in p.stdout: + if line.strip().isdigit(): + child_pids.append(int(line)) try: os.kill(pid, signal.SIGKILL) for child_pid in child_pids: diff --git a/test/test_git.py b/test/test_git.py index a88d980fb..b19652363 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -14,6 +14,7 @@ import pickle import re import shutil +import signal import subprocess import sys import tempfile @@ -332,6 +333,63 @@ def test_it_honors_kill_after_timeout_with_output_stream(self): self.assertEqual(output_stream.getvalue(), b"started\n") self.assertIn("Timeout: the command", stderr) + @skipUnless( + sys.platform not in ("win32", "cygwin"), + "child process lookup requires pgrep or POSIX ps", + ) + @ddt.data(False, True) + def test_timeout_kills_direct_child(self, without_pgrep): + with tempfile.TemporaryDirectory() as directory: + marker = Path(directory, "child-survived") + child_code = ( + "import pathlib, sys, time; time.sleep(2); " + "pathlib.Path(sys.argv[1]).write_text('survived', encoding='utf-8')" + ) + parent_code = ( + "import subprocess, sys, time; " + "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]]); " + "time.sleep(30)" + ) + popen = cmd.Popen + + def portable_popen(args, **kwargs): + if without_pgrep and args[0] == "pgrep": + raise FileNotFoundError("pgrep is not installed") + return popen(args, **kwargs) + + with mock.patch.object(cmd, "Popen", side_effect=portable_popen): + status, _, stderr = self.git.execute( + [sys.executable, "-c", parent_code, child_code, str(marker)], + kill_after_timeout=1, + with_exceptions=False, + with_extended_output=True, + ) + + self.assertNotEqual(status, 0) + self.assertIn("Timeout: the command", stderr) + self.assertFalse(marker.exists(), "the direct child survived the timeout") + + @skipUnless(sys.platform != "win32", "kill_after_timeout is not supported on Windows") + def test_timeout_ps_fallback_selects_only_direct_children(self): + process = mock.MagicMock() + process.pid = 1234 + process.communicate.return_value = (b"", b"") + process.returncode = -signal.SIGKILL + ps = mock.MagicMock() + ps.__enter__.return_value = ps + ps.stdout = io.BytesIO(b"PID PPID\n 321 1\n 5678 1234\n 9012 5678\n\n") + + with contextlib.ExitStack() as stack: + stack.enter_context(mock.patch.object(cmd, "safer_popen", return_value=process)) + stack.enter_context(mock.patch.object(cmd, "Popen", side_effect=[FileNotFoundError, ps])) + kill = stack.enter_context(mock.patch.object(cmd.os, "kill")) + timer = stack.enter_context(mock.patch.object(cmd.threading, "Timer")) + # Run the timeout callback synchronously, with no real processes or signals. + timer.return_value.start.side_effect = lambda: timer.call_args.args[1](1234) + self.git.execute(["git", "version"], kill_after_timeout=1, with_exceptions=False) + + self.assertEqual(kill.call_args_list, [mock.call(1234, signal.SIGKILL), mock.call(5678, signal.SIGKILL)]) + def test_it_executes_git_without_stdout_redirect(self): returncode, stdout, stderr = self.git.execute( ["git", "version"], From d66edad65ef7c6a42a3040c4c5287e53a16b6b68 Mon Sep 17 00:00:00 2001 From: Byron Date: Sun, 13 Sep 2026 04:44:16 +0200 Subject: [PATCH 2/2] Correct public API typing and add portable runtime checks Several public annotations rejected supported inputs or lost the relationship between input options and return types. Describe Git.execute process, text, bytes, and extended-output results with overloads, accept stdin file descriptors, and account for absent stdout. Correct remote-removal, object, database, blame, and index-entry types, preserve entry subclasses and the supported tuple shapes, and accept streams with only the required read or write methods. Normalize absent previous stderr before appending process errors in AutoInterrupt.wait. Add runtime and static regressions for these interfaces, include them in mypy and basedpyright, make required imports explicit, and reduce the basedpyright baseline to the remaining diagnostics. Keep mock available to typecheck the Python 3.7 import branches. Make the output-type regression emit its payload without a newline. Its original print call produced CRLF on Windows; Git.execute strips the final LF as documented, leaving a carriage return that failed the test in all 11 Windows jobs. Omitting the newline keeps the same text, bytes, and tuple assertions independent of platform newline translation. Validation on macOS with Python 3.12.14: all four test/test_typing.py tests pass. Forcing the child stdout to translate newlines to CRLF reproduces the old failure and passes with the corrected command. Mypy passes for 46 source files; basedpyright --warnings reports no errors or warnings; Ruff lint and format checks for test/test_typing.py and git diff --check pass. Native Windows validation is delegated to the PR CI matrix. Assisted-by: GPT 6.0 Co-authored-by: GPT 6.0 --- .basedpyright/baseline.json | 1464 +++++++++++---------------------- git/cmd.py | 112 ++- git/index/base.py | 2 +- git/index/fun.py | 6 +- git/index/typ.py | 23 +- git/objects/base.py | 4 +- git/objects/commit.py | 2 +- git/objects/fun.py | 6 +- git/objects/submodule/base.py | 4 +- git/objects/tag.py | 4 +- git/refs/reference.py | 2 +- git/remote.py | 16 +- git/repo/base.py | 13 +- git/repo/fun.py | 19 +- git/types.py | 16 + git/util.py | 5 +- pyproject.toml | 4 +- test-requirements.txt | 2 +- test/test_git.py | 26 +- test/test_typing.py | 91 ++ 20 files changed, 772 insertions(+), 1049 deletions(-) create mode 100644 test/test_typing.py diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index 14bf1024e..79d35235e 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -1,966 +1,502 @@ { - "files": { - "./git/cmd.py": [ - { - "code": "reportGeneralTypeIssues", - "range": { - "startColumn": 13, - "endColumn": 32, - "lineCount": 1 - } - }, - { - "code": "reportOptionalOperand", - "range": { - "startColumn": 27, - "endColumn": 35, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 28, - "endColumn": 31, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 28, - "endColumn": 40, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 19, - "endColumn": 68, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 19, - "endColumn": 31, - "lineCount": 1 - } - } - ], - "./git/config.py": [ - { - "code": "reportGeneralTypeIssues", - "range": { - "startColumn": 11, - "endColumn": 26, - "lineCount": 1 - } - }, - { - "code": "reportInvalidTypeVarUse", - "range": { - "startColumn": 43, - "endColumn": 45, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 41, - "endColumn": 48, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 32, - "endColumn": 39, - "lineCount": 1 - } - }, - { - "code": "reportCallIssue", - "range": { - "startColumn": 25, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 30, - "endColumn": 39, - "lineCount": 1 - } - } - ], - "./git/db.py": [ - { - "code": "reportIncompatibleMethodOverride", - "range": { - "startColumn": 8, - "endColumn": 12, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 61, - "endColumn": 79, - "lineCount": 1 - } - }, - { - "code": "reportIncompatibleMethodOverride", - "range": { - "startColumn": 8, - "endColumn": 14, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 70, - "endColumn": 88, - "lineCount": 1 - } - } - ], - "./git/index/base.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 30, - "endColumn": 36, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 28, - "endColumn": 34, - "lineCount": 1 - } - }, - { - "code": "reportAssignmentType", - "range": { - "startColumn": 38, - "endColumn": 76, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 45, - "endColumn": 53, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 60, - "endColumn": 63, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 56, - "endColumn": 60, - "lineCount": 1 - } - }, - { - "code": "reportSelfClsParameterName", - "range": { - "startColumn": 30, - "endColumn": 33, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 51, - "endColumn": 57, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 55, - "endColumn": 61, - "lineCount": 1 - } - }, - { - "code": "reportIncompatibleMethodOverride", - "range": { - "startColumn": 8, - "endColumn": 12, - "lineCount": 1 - } - }, - { - "code": "reportAssignmentType", - "range": { - "startColumn": 20, - "endColumn": 46, - "lineCount": 1 - } - } - ], - "./git/index/fun.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 8, - "endColumn": 36, - "lineCount": 1 - } - } - ], - "./git/index/typ.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 46, - "lineCount": 1 - } - } - ], - "./git/objects/base.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 20, - "endColumn": 27, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 29, - "endColumn": 36, - "lineCount": 1 - } - } - ], - "./git/objects/blob.py": [ - { - "code": "reportIncompatibleVariableOverride", - "range": { - "startColumn": 4, - "endColumn": 8, - "lineCount": 1 - } - } - ], - "./git/objects/commit.py": [ - { - "code": "reportIncompatibleVariableOverride", - "range": { - "startColumn": 4, - "endColumn": 8, - "lineCount": 1 - } - }, - { - "code": "reportIncompatibleMethodOverride", - "range": { - "startColumn": 8, - "endColumn": 31, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 20, - "endColumn": 33, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 20, - "endColumn": 30, - "lineCount": 1 - } - } - ], - "./git/objects/submodule/base.py": [ - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 19, - "endColumn": 24, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 19, - "endColumn": 24, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 23, - "endColumn": 25, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 23, - "endColumn": 25, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 41, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 52, - "endColumn": 84, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 20, - "endColumn": 40, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 35, - "endColumn": 42, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 15, - "endColumn": 20, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 15, - "endColumn": 20, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 19, - "endColumn": 33, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 19, - "endColumn": 33, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 15, - "endColumn": 27, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 15, - "endColumn": 27, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 15, - "endColumn": 19, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 15, - "endColumn": 19, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 18, - "endColumn": 20, - "lineCount": 1 - } - } - ], - "./git/objects/tag.py": [ - { - "code": "reportIncompatibleVariableOverride", - "range": { - "startColumn": 4, - "endColumn": 8, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 72, - "endColumn": 78, - "lineCount": 1 - } - } - ], - "./git/objects/tree.py": [ - { - "code": "reportIncompatibleVariableOverride", - "range": { - "startColumn": 4, - "endColumn": 8, - "lineCount": 1 - } - }, - { - "code": "reportIncompatibleMethodOverride", - "range": { - "startColumn": 8, - "endColumn": 31, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 19, - "endColumn": 83, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 15, - "endColumn": 54, - "lineCount": 1 - } - } - ], - "./git/objects/util.py": [ - { - "code": "reportAssignmentType", - "range": { - "startColumn": 23, - "endColumn": 34, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 22, - "endColumn": 26, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 30, - "endColumn": 34, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 15, - "endColumn": 54, - "lineCount": 1 - } - } - ], - "./git/refs/log.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 30, - "endColumn": 34, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 17, - "endColumn": 22, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 28, - "endColumn": 30, - "lineCount": 1 - } - } - ], - "./git/refs/reference.py": [ - { - "code": "reportInvalidTypeVarUse", - "range": { - "startColumn": 22, - "endColumn": 34, - "lineCount": 1 - } - }, - { - "code": "reportIncompatibleVariableOverride", - "range": { - "startColumn": 13, - "endColumn": 17, - "lineCount": 1 - } - } - ], - "./git/refs/symbolic.py": [ - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 15, - "endColumn": 20, - "lineCount": 1 - } - } - ], - "./git/refs/tag.py": [ - { - "code": "reportIncompatibleMethodOverride", - "range": { - "startColumn": 8, - "endColumn": 14, - "lineCount": 1 - } - }, - { - "code": "reportIncompatibleMethodOverride", - "range": { - "startColumn": 8, - "endColumn": 14, - "lineCount": 1 - } - } - ], - "./git/remote.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 25, - "endColumn": 36, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 29, - "endColumn": 40, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 26, - "endColumn": 38, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 26, - "endColumn": 38, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 26, - "endColumn": 38, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 26, - "endColumn": 38, - "lineCount": 1 - } - } - ], - "./git/repo/base.py": [ - { - "code": "reportRedeclaration", - "range": { - "startColumn": 4, - "endColumn": 15, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 18, - "endColumn": 22, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 35, - "endColumn": 41, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 15, - "endColumn": 46, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 15, - "endColumn": 51, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 15, - "endColumn": 28, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 16, - "endColumn": 31, - "lineCount": 1 - } - }, - { - "code": "reportTypedDictNotRequiredAccess", - "range": { - "startColumn": 21, - "endColumn": 31, - "lineCount": 1 - } - }, - { - "code": "reportTypedDictNotRequiredAccess", - "range": { - "startColumn": 34, - "endColumn": 44, - "lineCount": 1 - } - }, - { - "code": "reportTypedDictNotRequiredAccess", - "range": { - "startColumn": 65, - "endColumn": 79, - "lineCount": 1 - } - }, - { - "code": "reportTypedDictNotRequiredAccess", - "range": { - "startColumn": 82, - "endColumn": 102, - "lineCount": 1 - } - }, - { - "code": "reportTypedDictNotRequiredAccess", - "range": { - "startColumn": 50, - "endColumn": 69, - "lineCount": 1 - } - }, - { - "code": "reportTypedDictNotRequiredAccess", - "range": { - "startColumn": 68, - "endColumn": 85, - "lineCount": 1 - } - }, - { - "code": "reportTypedDictNotRequiredAccess", - "range": { - "startColumn": 88, - "endColumn": 111, - "lineCount": 1 - } - }, - { - "code": "reportTypedDictNotRequiredAccess", - "range": { - "startColumn": 51, - "endColumn": 73, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 12, - "endColumn": 26, - "lineCount": 1 - } - }, - { - "code": "reportAssignmentType", - "range": { - "startColumn": 22, - "endColumn": 38, - "lineCount": 1 - } - } - ], - "./git/repo/fun.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 25, - "endColumn": 33, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 35, - "endColumn": 43, - "lineCount": 1 - } - }, - { - "code": "reportAssignmentType", - "range": { - "startColumn": 18, - "endColumn": 28, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 11, - "endColumn": 14, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 24, - "endColumn": 27, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 11, - "endColumn": 14, - "lineCount": 1 - } - }, - { - "code": "reportReturnType", - "range": { - "startColumn": 11, - "endColumn": 20, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 25, - "endColumn": 28, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 24, - "endColumn": 27, - "lineCount": 1 - } - } - ], - "./test/deprecation/test_basic.py": [ - { - "code": "reportUnusedExpression", - "range": { - "startColumn": 12, - "endColumn": 62, - "lineCount": 1 - } - } - ] - } + "files": { + "./git/config.py": [ + { + "code": "reportGeneralTypeIssues", + "range": { + "startColumn": 11, + "endColumn": 26, + "lineCount": 1 + } + }, + { + "code": "reportInvalidTypeVarUse", + "range": { + "startColumn": 43, + "endColumn": 45, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 41, + "endColumn": 48, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 32, + "endColumn": 39, + "lineCount": 1 + } + }, + { + "code": "reportCallIssue", + "range": { + "startColumn": 25, + "endColumn": 46, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 30, + "endColumn": 39, + "lineCount": 1 + } + } + ], + "./git/db.py": [ + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 12, + "lineCount": 1 + } + }, + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 14, + "lineCount": 1 + } + } + ], + "./git/index/base.py": [ + { + "code": "reportArgumentType", + "range": { + "startColumn": 30, + "endColumn": 36, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 28, + "endColumn": 34, + "lineCount": 1 + } + }, + { + "code": "reportAssignmentType", + "range": { + "startColumn": 38, + "endColumn": 76, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 51, + "endColumn": 57, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 55, + "endColumn": 61, + "lineCount": 1 + } + }, + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 12, + "lineCount": 1 + } + }, + { + "code": "reportAssignmentType", + "range": { + "startColumn": 20, + "endColumn": 46, + "lineCount": 1 + } + } + ], + "./git/objects/blob.py": [ + { + "code": "reportIncompatibleVariableOverride", + "range": { + "startColumn": 4, + "endColumn": 8, + "lineCount": 1 + } + } + ], + "./git/objects/commit.py": [ + { + "code": "reportIncompatibleVariableOverride", + "range": { + "startColumn": 4, + "endColumn": 8, + "lineCount": 1 + } + }, + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 31, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 20, + "endColumn": 33, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 20, + "endColumn": 30, + "lineCount": 1 + } + } + ], + "./git/objects/submodule/base.py": [ + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 19, + "endColumn": 24, + "lineCount": 1 + } + }, + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 19, + "endColumn": 24, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 23, + "endColumn": 25, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 23, + "endColumn": 25, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 52, + "endColumn": 84, + "lineCount": 1 + } + } + ], + "./git/objects/tag.py": [ + { + "code": "reportIncompatibleVariableOverride", + "range": { + "startColumn": 4, + "endColumn": 8, + "lineCount": 1 + } + } + ], + "./git/objects/tree.py": [ + { + "code": "reportIncompatibleVariableOverride", + "range": { + "startColumn": 4, + "endColumn": 8, + "lineCount": 1 + } + }, + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 31, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 19, + "endColumn": 83, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 15, + "endColumn": 54, + "lineCount": 1 + } + } + ], + "./git/objects/util.py": [ + { + "code": "reportAssignmentType", + "range": { + "startColumn": 23, + "endColumn": 34, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 22, + "endColumn": 26, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 30, + "endColumn": 34, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 15, + "endColumn": 54, + "lineCount": 1 + } + } + ], + "./git/refs/log.py": [ + { + "code": "reportArgumentType", + "range": { + "startColumn": 30, + "endColumn": 34, + "lineCount": 1 + } + }, + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 17, + "endColumn": 22, + "lineCount": 1 + } + }, + { + "code": "reportArgumentType", + "range": { + "startColumn": 28, + "endColumn": 30, + "lineCount": 1 + } + } + ], + "./git/refs/reference.py": [ + { + "code": "reportIncompatibleVariableOverride", + "range": { + "startColumn": 13, + "endColumn": 17, + "lineCount": 1 + } + } + ], + "./git/refs/symbolic.py": [ + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 15, + "endColumn": 20, + "lineCount": 1 + } + } + ], + "./git/refs/tag.py": [ + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 14, + "lineCount": 1 + } + }, + { + "code": "reportIncompatibleMethodOverride", + "range": { + "startColumn": 8, + "endColumn": 14, + "lineCount": 1 + } + } + ], + "./git/remote.py": [ + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 26, + "endColumn": 38, + "lineCount": 1 + } + }, + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 26, + "endColumn": 38, + "lineCount": 1 + } + }, + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 26, + "endColumn": 38, + "lineCount": 1 + } + }, + { + "code": "reportAttributeAccessIssue", + "range": { + "startColumn": 26, + "endColumn": 38, + "lineCount": 1 + } + } + ], + "./git/repo/base.py": [ + { + "code": "reportReturnType", + "range": { + "startColumn": 15, + "endColumn": 46, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 15, + "endColumn": 51, + "lineCount": 1 + } + }, + { + "code": "reportReturnType", + "range": { + "startColumn": 15, + "endColumn": 28, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 21, + "endColumn": 31, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 34, + "endColumn": 44, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 65, + "endColumn": 79, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 82, + "endColumn": 102, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 50, + "endColumn": 69, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 68, + "endColumn": 85, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 88, + "endColumn": 111, + "lineCount": 1 + } + }, + { + "code": "reportTypedDictNotRequiredAccess", + "range": { + "startColumn": 51, + "endColumn": 73, + "lineCount": 1 + } + } + ], + "./git/repo/fun.py": [ + { + "code": "reportReturnType", + "range": { + "startColumn": 11, + "endColumn": 20, + "lineCount": 1 + } + } + ], + "./test/deprecation/test_basic.py": [ + { + "code": "reportUnusedExpression", + "range": { + "startColumn": 12, + "endColumn": 62, + "lineCount": 1 + } + } + ] + } } diff --git a/git/cmd.py b/git/cmd.py index 9de2f6e8a..3d7446906 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -100,7 +100,7 @@ def handle_process_output( - process: "Git.AutoInterrupt" | Popen, + process: Union["Git.AutoInterrupt", Popen], stdout_handler: Union[ None, Callable[[AnyStr], None], @@ -395,9 +395,7 @@ def wait(self, stderr: Union[None, str, bytes] = b"") -> int: :raise git.exc.GitCommandError: If the return status is not 0. """ - if stderr is None: - stderr_b = b"" - stderr_b = force_bytes(data=stderr, encoding="utf-8") + stderr_b = force_bytes(data=stderr, encoding="utf-8") or b"" status: Union[int, None] if self.proc is not None: status = self.proc.wait() @@ -1180,52 +1178,112 @@ def version_info(self) -> Tuple[int, ...]: def execute( self, command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, *, as_process: Literal[True], + **subprocess_kwargs: Any, ) -> "AutoInterrupt": ... @overload def execute( self, command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, *, as_process: Literal[False] = False, - stdout_as_string: Literal[True], - ) -> Union[str, Tuple[int, str, str]]: ... + with_extended_output: Literal[False] = False, + stdout_as_string: Literal[True] = True, + with_stdout: Literal[True] = True, + **subprocess_kwargs: Any, + ) -> str: ... @overload def execute( self, command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, *, as_process: Literal[False] = False, - stdout_as_string: Literal[False] = False, - ) -> Union[bytes, Tuple[int, bytes, str]]: ... + with_extended_output: Literal[False] = False, + stdout_as_string: Literal[False], + universal_newlines: Literal[False] = False, + with_stdout: Literal[True] = True, + **subprocess_kwargs: Any, + ) -> bytes: ... @overload def execute( self, command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, *, - with_extended_output: Literal[False], - as_process: Literal[False], - stdout_as_string: Literal[True], - ) -> str: ... + as_process: Literal[False] = False, + with_extended_output: Literal[True], + stdout_as_string: Literal[True] = True, + with_stdout: Literal[True] = True, + **subprocess_kwargs: Any, + ) -> Tuple[int, str, str]: ... @overload def execute( self, command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, *, - with_extended_output: Literal[False], - as_process: Literal[False], + as_process: Literal[False] = False, + with_extended_output: Literal[True], stdout_as_string: Literal[False], - ) -> bytes: ... + universal_newlines: Literal[False] = False, + with_stdout: Literal[True] = True, + **subprocess_kwargs: Any, + ) -> Tuple[int, bytes, str]: ... + + @overload + def execute( + self, + command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, + *, + as_process: Literal[False] = False, + with_extended_output: Literal[True], + **subprocess_kwargs: Any, + ) -> Tuple[int, Union[str, bytes, None], str]: ... + + @overload + def execute( + self, + command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, + *, + as_process: Literal[False] = False, + with_extended_output: Literal[False] = False, + **subprocess_kwargs: Any, + ) -> Union[str, bytes, None]: ... + + @overload + def execute( + self, + command: Union[str, Sequence[Any]], + istream: Union[None, int, BinaryIO] = None, + with_extended_output: bool = False, + with_exceptions: bool = True, + as_process: bool = False, + output_stream: Union[None, BinaryIO] = None, + stdout_as_string: bool = True, + kill_after_timeout: Union[None, float] = None, + with_stdout: bool = True, + universal_newlines: bool = False, + shell: Union[None, bool] = None, + env: Union[None, Mapping[str, str]] = None, + max_chunk_size: int = io.DEFAULT_BUFFER_SIZE, + strip_newline_in_stdout: bool = True, + **subprocess_kwargs: Any, + ) -> Union[None, str, bytes, Tuple[int, Union[str, bytes, None], str], AutoInterrupt]: ... def execute( self, command: Union[str, Sequence[Any]], - istream: Union[None, BinaryIO] = None, + istream: Union[None, int, BinaryIO] = None, with_extended_output: bool = False, with_exceptions: bool = True, as_process: bool = False, @@ -1239,7 +1297,7 @@ def execute( max_chunk_size: int = io.DEFAULT_BUFFER_SIZE, strip_newline_in_stdout: bool = True, **subprocess_kwargs: Any, - ) -> Union[str, bytes, Tuple[int, Union[str, bytes], str], AutoInterrupt]: + ) -> Union[None, str, bytes, Tuple[int, Union[str, bytes, None], str], AutoInterrupt]: R"""Handle executing the command, and consume and return the returned information (stdout). @@ -1503,7 +1561,7 @@ def make_timeout_error() -> Union[str, bytes]: err = f'Timeout: the command "{" ".join(redacted_command)}" did not complete in {timeout:g} secs.' return err if universal_newlines else err.encode(defenc) - def communicate() -> Tuple[AnyStr, AnyStr]: + def communicate() -> Tuple[Union[str, bytes, None], Union[str, bytes, None]]: assert watchdog is not None assert kill_check is not None watchdog.start() @@ -1523,8 +1581,8 @@ def communicate() -> Tuple[AnyStr, AnyStr]: # Wait for the process to return. status = 0 - stdout_value: Union[str, bytes] = b"" - stderr_value: Union[str, bytes] = b"" + stdout_value: Union[str, bytes, None] = b"" + stderr_value: Union[str, bytes, None] = b"" newline = "\n" if universal_newlines else b"\n" try: if output_stream is None: @@ -1566,7 +1624,7 @@ def communicate() -> Tuple[AnyStr, AnyStr]: if self.GIT_PYTHON_TRACE == "full": cmdstr = " ".join(redacted_command) - def as_text(stdout_value: Union[bytes, str]) -> str: + def as_text(stdout_value: Union[bytes, str, None]) -> str: return not output_stream and safe_decode(stdout_value) or "" # END as_text @@ -1591,6 +1649,8 @@ def as_text(stdout_value: Union[bytes, str]) -> str: if isinstance(stdout_value, bytes) and stdout_as_string: # Could also be output_stream. stdout_value = safe_decode(stdout_value) + # stderr is always captured through PIPE. + assert stderr_value is not None # Allow access to the command's status code. if with_extended_output: return (status, stdout_value, safe_decode(stderr_value)) @@ -1829,7 +1889,7 @@ def _parse_object_header(self, header_line: str) -> Tuple[str, str, int]: raise ValueError("Failed to parse header: %r" % header_line) return (tokens[0], tokens[1], int(tokens[2])) - def _prepare_ref(self, ref: AnyStr) -> bytes: + def _prepare_ref(self, ref: object) -> bytes: # Required for command to separate refs on stdin, as bytes. if isinstance(ref, bytes): # Assume 40 bytes hexsha - bin-to-ascii for some reason returns bytes, not text. @@ -1856,7 +1916,7 @@ def _get_persistent_cmd(self, attr_name: str, cmd_name: str, *args: Any, **kwarg cmd = cast("Git.AutoInterrupt", cmd) return cmd - def __get_object_header(self, cmd: "Git.AutoInterrupt", ref: AnyStr) -> Tuple[str, str, int]: + def __get_object_header(self, cmd: "Git.AutoInterrupt", ref: Union[str, bytes]) -> Tuple[str, str, int]: if cmd.stdin and cmd.stdout: cmd.stdin.write(self._prepare_ref(ref)) cmd.stdin.flush() @@ -1864,7 +1924,7 @@ def __get_object_header(self, cmd: "Git.AutoInterrupt", ref: AnyStr) -> Tuple[st else: raise ValueError("cmd stdin was empty") - def get_object_header(self, ref: str) -> Tuple[str, str, int]: + def get_object_header(self, ref: Union[str, bytes]) -> Tuple[str, str, int]: """Use this method to quickly examine the type and size of the object behind the given ref. @@ -1878,7 +1938,7 @@ def get_object_header(self, ref: str) -> Tuple[str, str, int]: cmd = self._get_persistent_cmd("cat_file_header", "cat_file", batch_check=True) return self.__get_object_header(cmd, ref) - def get_object_data(self, ref: str) -> Tuple[str, str, int, bytes]: + def get_object_data(self, ref: Union[str, bytes]) -> Tuple[str, str, int, bytes]: """Similar to :meth:`get_object_header`, but returns object data as well. :return: @@ -1892,7 +1952,7 @@ def get_object_data(self, ref: str) -> Tuple[str, str, int, bytes]: del stream return (hexsha, typename, size, data) - def stream_object_data(self, ref: str) -> Tuple[str, str, int, "Git.CatFileContentStream"]: + def stream_object_data(self, ref: Union[str, bytes]) -> Tuple[str, str, int, "Git.CatFileContentStream"]: """Similar to :meth:`get_object_data`, but returns the data as a stream. :return: diff --git a/git/index/base.py b/git/index/base.py index a3c915242..560fc5e2c 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -1235,7 +1235,7 @@ def _read_commit_editmsg(self) -> str: def _commit_editmsg_filepath(self) -> str: return osp.join(self.repo.common_dir, "COMMIT_EDITMSG") - def _flush_stdin_and_wait(cls, proc: "Popen[bytes]", ignore_stdout: bool = False) -> bytes: + def _flush_stdin_and_wait(self, proc: "Popen[bytes]", ignore_stdout: bool = False) -> bytes: stdin_IO = proc.stdin if stdin_IO: stdin_IO.flush() diff --git a/git/index/fun.py b/git/index/fun.py index 886e10de9..45c18ace4 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -46,7 +46,7 @@ from git.types import PathLike if TYPE_CHECKING: - from git.db import GitCmdObjectDB + from gitdb.db.base import ObjectDBR, ObjectDBW from git.objects.tree import TreeCacheTup from .base import IndexFile @@ -412,7 +412,7 @@ def read_cache( def write_tree_from_cache( - entries: List[IndexEntry], odb: "GitCmdObjectDB", sl: slice, si: int = 0 + entries: List[IndexEntry], odb: "ObjectDBW", sl: slice, si: int = 0 ) -> Tuple[bytes, List["TreeCacheTup"]]: R"""Create a tree from the given sorted list of entries and put the respective trees into the given object database. @@ -484,7 +484,7 @@ def _tree_entry_to_baseindexentry(tree_entry: "TreeCacheTup", stage: int) -> Bas return BaseIndexEntry((tree_entry[1], tree_entry[0], stage << CE_STAGESHIFT, tree_entry[2])) -def aggressive_tree_merge(odb: "GitCmdObjectDB", tree_shas: Sequence[bytes]) -> List[BaseIndexEntry]: +def aggressive_tree_merge(odb: "ObjectDBR", tree_shas: Sequence[bytes]) -> List[BaseIndexEntry]: R""" :return: List of :class:`~git.index.typ.BaseIndexEntry`\s representing the aggressive diff --git a/git/index/typ.py b/git/index/typ.py index 927633a9f..78f7c8ea5 100644 --- a/git/index/typ.py +++ b/git/index/typ.py @@ -9,12 +9,13 @@ from pathlib import Path from git.objects import Blob +from git.objects.base import IndexObject from .util import pack, unpack # typing ---------------------------------------------------------------------- -from typing import NamedTuple, Sequence, TYPE_CHECKING, Tuple, Union, cast +from typing import NamedTuple, Sequence, TYPE_CHECKING, Tuple, Type, TypeVar, Union, cast from git.types import PathLike @@ -22,6 +23,7 @@ from git.repo import Repo StageType = int +_T_IndexEntry = TypeVar("_T_IndexEntry", bound="BaseIndexEntry") # --------------------------------------------------------------------------------- @@ -104,15 +106,20 @@ class BaseIndexEntry(BaseIndexEntryHelper): """ def __new__( - cls, + cls: Type[_T_IndexEntry], inp_tuple: Union[ Tuple[int, bytes, int, PathLike], + Tuple[int, bytes, int, PathLike, bytes, bytes, int, int, int, int, int], Tuple[int, bytes, int, PathLike, bytes, bytes, int, int, int, int, int, int], ], - ) -> "BaseIndexEntry": + ) -> _T_IndexEntry: """Override ``__new__`` to allow construction from a tuple for backwards compatibility.""" - return super().__new__(cls, *inp_tuple) + if len(inp_tuple) == 4: + return BaseIndexEntryHelper.__new__(cls, *inp_tuple) + if len(inp_tuple) == 11: + return BaseIndexEntryHelper.__new__(cls, *inp_tuple) + return BaseIndexEntryHelper.__new__(cls, *inp_tuple) def __str__(self) -> str: return "%o %s %i\t%s" % (self.mode, self.hexsha, self.stage, self.path) @@ -148,7 +155,7 @@ def intent_to_add(self) -> bool: return (self.extended_flags & CE_EXT_INTENT_TO_ADD) > 0 @classmethod - def from_blob(cls, blob: Blob, stage: int = 0) -> "BaseIndexEntry": + def from_blob(cls, blob: IndexObject, stage: int = 0) -> "BaseIndexEntry": """:return: Fully equipped BaseIndexEntry at the given stage""" return cls((blob.mode, blob.binsha, stage << CE_STAGESHIFT, blob.path)) @@ -192,10 +199,10 @@ def from_base(cls, base: "BaseIndexEntry") -> "IndexEntry": Instance of type :class:`BaseIndexEntry`. """ time = pack(">LL", 0, 0) - return IndexEntry((base.mode, base.binsha, base.flags, base.path, time, time, 0, 0, 0, 0, 0)) # type: ignore[arg-type] + return IndexEntry((base.mode, base.binsha, base.flags, base.path, time, time, 0, 0, 0, 0, 0)) @classmethod - def from_blob(cls, blob: Blob, stage: int = 0) -> "IndexEntry": + def from_blob(cls, blob: IndexObject, stage: int = 0) -> "IndexEntry": """:return: Minimal entry resembling the given blob object""" time = pack(">LL", 0, 0) return IndexEntry( @@ -211,5 +218,5 @@ def from_blob(cls, blob: Blob, stage: int = 0) -> "IndexEntry": 0, 0, blob.size, - ) # type: ignore[arg-type] + ) ) diff --git a/git/objects/base.py b/git/objects/base.py index faf600c6b..1188ec0c9 100644 --- a/git/objects/base.py +++ b/git/objects/base.py @@ -18,7 +18,7 @@ from typing import Any, TYPE_CHECKING, Union -from git.types import AnyGitObject, GitObjectTypeString, PathLike +from git.types import AnyGitObject, GitObjectTypeString, PathLike, SupportsWrite if TYPE_CHECKING: from gitdb.base import OStream @@ -200,7 +200,7 @@ def data_stream(self) -> "OStream": """ return self.repo.odb.stream(self.binsha) - def stream_data(self, ostream: "OStream") -> "Object": + def stream_data(self, ostream: SupportsWrite[bytes]) -> "Object": """Write our data directly to the given output stream. :param ostream: diff --git a/git/objects/commit.py b/git/objects/commit.py index 45843eac2..0348d3299 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -502,7 +502,7 @@ def _interpret_trailers( ) -> str: message_bytes = message if isinstance(message, bytes) else message.encode(encoding, errors="strict") cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args] - proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload] + proc: Git.AutoInterrupt = repo.git.execute( cmd, as_process=True, istream=PIPE, diff --git a/git/objects/fun.py b/git/objects/fun.py index ad5fbd59b..7ef229990 100644 --- a/git/objects/fun.py +++ b/git/objects/fun.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: from _typeshed import ReadableBuffer - from git import GitCmdObjectDB + from gitdb.db.base import ObjectDBR EntryTup = Tuple[bytes, int, str] # Same as TreeCacheTup in tree.py. EntryTupOrNone = Union[EntryTup, None] @@ -166,7 +166,7 @@ def _to_full_path(item: EntryTupOrNone, path_prefix: str) -> EntryTupOrNone: def traverse_trees_recursive( - odb: "GitCmdObjectDB", tree_shas: Sequence[Union[bytes, None]], path_prefix: str + odb: "ObjectDBR", tree_shas: Sequence[Union[bytes, None]], path_prefix: str ) -> List[Tuple[EntryTupOrNone, ...]]: """ :return: @@ -253,7 +253,7 @@ def traverse_trees_recursive( return out -def traverse_tree_recursive(odb: "GitCmdObjectDB", tree_sha: bytes, path_prefix: str) -> List[EntryTup]: +def traverse_tree_recursive(odb: "ObjectDBR", tree_sha: bytes, path_prefix: str) -> List[EntryTup]: """ :return: List of entries of the tree pointed to by the binary `tree_sha`. diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 8308e4459..ba281c499 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -14,7 +14,7 @@ import stat import sys import uuid -import urllib +import urllib.parse import git from git.cmd import Git @@ -1769,7 +1769,7 @@ def iter_items( # END handle critical error # Make sure we are looking at a submodule object. - if type(sm) is not git.objects.submodule.base.Submodule: + if type(sm) is not Submodule: continue # Fill in remaining info - saves time as it doesn't have to be parsed again. diff --git a/git/objects/tag.py b/git/objects/tag.py index 88671d316..18b4a9ca4 100644 --- a/git/objects/tag.py +++ b/git/objects/tag.py @@ -23,6 +23,8 @@ from typing import List, TYPE_CHECKING, Union +from git.types import AnyGitObject + if sys.version_info >= (3, 8): from typing import Literal else: @@ -61,7 +63,7 @@ def __init__( self, repo: "Repo", binsha: bytes, - object: Union[None, base.Object] = None, + object: Union[None, AnyGitObject] = None, tag: Union[None, str] = None, tagger: Union[None, Actor] = None, tagged_date: Union[int, None] = None, diff --git a/git/refs/reference.py b/git/refs/reference.py index 0c4327225..7d6c62cf5 100644 --- a/git/refs/reference.py +++ b/git/refs/reference.py @@ -26,7 +26,7 @@ def require_remote_ref_path(func: Callable[..., _T]) -> Callable[..., _T]: """A decorator raising :exc:`ValueError` if we are not a valid remote, based on the path.""" - def wrapper(self: T_References, *args: Any) -> _T: + def wrapper(self: SymbolicReference, *args: Any) -> _T: if not self.is_remote(): raise ValueError("ref path does not point to a remote reference: %s" % self.path) return func(self, *args) diff --git a/git/remote.py b/git/remote.py index e2d5cbc1d..2ddf11af0 100644 --- a/git/remote.py +++ b/git/remote.py @@ -38,6 +38,7 @@ Sequence, TYPE_CHECKING, Type, + TypeVar, Union, cast, overload, @@ -50,6 +51,8 @@ from git.objects.submodule.base import UpdateProgress from git.repo.base import Repo +_T_RemoteName = TypeVar("_T_RemoteName", bound=Union[str, "Remote"]) + flagKeyLiteral = Literal[" ", "!", "+", "-", "*", "=", "t", "?"] # ------------------------------------------------------------- @@ -820,19 +823,20 @@ def add(cls, repo: "Repo", name: str, url: str, **kwargs: Any) -> "Remote": return cls.create(repo, name, url, **kwargs) @classmethod - def remove(cls, repo: "Repo", name: str) -> str: + def remove(cls, repo: "Repo", name: _T_RemoteName) -> _T_RemoteName: """Remove the remote with the given name. :return: The passed remote name to remove """ repo.git.remote("rm", name) - if isinstance(name, cls): - name._clear_cache() + remote = name + if isinstance(remote, cls): + remote._clear_cache() return name @classmethod - def rm(cls, repo: "Repo", name: str) -> str: + def rm(cls, repo: "Repo", name: _T_RemoteName) -> _T_RemoteName: """Alias of remove. Remove the remote with the given name. @@ -901,7 +905,7 @@ def _get_fetch_info_from_stderr( kill_after_timeout=kill_after_timeout, ) - stderr_text = progress.error_lines and "\n".join(progress.error_lines) or "" + stderr_text = "\n".join(progress.error_lines) proc.wait(stderr=stderr_text) if stderr_text: _logger.warning("Error lines received while fetching: %s", stderr_text) @@ -973,7 +977,7 @@ def stdout_handler(line: str) -> None: decode_streams=False, kill_after_timeout=kill_after_timeout, ) - stderr_text = progress.error_lines and "\n".join(progress.error_lines) or "" + stderr_text = "\n".join(progress.error_lines) try: proc.wait(stderr=stderr_text) except Exception as e: diff --git a/git/repo/base.py b/git/repo/base.py index f326266d6..29922e118 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -18,6 +18,7 @@ import warnings import gitdb +import gitdb.util from gitdb.db.loose import LooseObjectDB from gitdb.exc import BadObject @@ -33,7 +34,7 @@ from git.index import IndexFile from git.objects import Submodule, RootModule, Commit from git.refs import HEAD, Head, Reference, TagReference -from git.remote import Remote, add_progress, to_progress_instance +from git.remote import Remote, _T_RemoteName, add_progress, to_progress_instance from git.util import ( Actor, cygpath, @@ -95,7 +96,7 @@ class BlameEntry(NamedTuple): - commit: Dict[str, Commit] + commit: Commit linenos: range orig_path: Optional[str] orig_linenos: range @@ -396,7 +397,7 @@ def __init__( self._working_tree_dir = None # END working dir handling - self.working_dir: PathLike = self._working_tree_dir or self.common_dir + self.working_dir = self._working_tree_dir or self.common_dir self.git = self.GitCommandWrapperType(self.working_dir) if common_dir_env is not None: self.git.update_environment(GIT_DIR=os.fspath(self.git_dir), GIT_COMMON_DIR=os.fspath(self.common_dir)) @@ -718,7 +719,7 @@ def create_remote(self, name: str, url: str, **kwargs: Any) -> Remote: """ return Remote.create(self, name, url, **kwargs) - def delete_remote(self, remote: "Remote") -> str: + def delete_remote(self, remote: _T_RemoteName) -> _T_RemoteName: """Delete the given remote.""" return Remote.remove(self, remote) @@ -1504,7 +1505,7 @@ def _clone( git: "Git", url: PathLike, path: PathLike, - odb_default_type: Type[GitCmdObjectDB], + odb_default_type: Type[LooseObjectDB], progress: Union["RemoteProgress", "UpdateProgress", Callable[..., "RemoteProgress"], None] = None, multi_options: Optional[List[str]] = None, allow_unsafe_protocols: bool = False, @@ -1711,7 +1712,7 @@ def clone_from( def archive( self, ostream: Union[TextIO, BinaryIO], - treeish: Optional[str] = None, + treeish: Union[str, Commit, None] = None, prefix: Optional[str] = None, allow_unsafe_options: bool = False, allow_unsafe_protocols: bool = False, diff --git a/git/repo/fun.py b/git/repo/fun.py index eb0d8075a..a565054bc 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -40,11 +40,10 @@ from git.types import AnyGitObject, Literal, PathLike if TYPE_CHECKING: - from git.db import GitCmdObjectDB + from gitdb.db import CompoundDB, LooseObjectDB from git.objects import Commit from git.refs.reference import Reference from git.refs.log import RefLog, RefLogEntry - from git.refs.tag import Tag from .base import Repo @@ -158,7 +157,7 @@ def find_submodule_git_dir(d: PathLike) -> Optional[PathLike]: return path if is_git_dir(path) else None -def short_to_long(odb: "GitCmdObjectDB", hexsha: str) -> Optional[bytes]: +def short_to_long(odb: Union["CompoundDB", "LooseObjectDB"], hexsha: str) -> Optional[bytes]: """ :return: Long hexadecimal sha1 from the given less than 40 byte hexsha, or ``None`` if no @@ -261,7 +260,7 @@ def name_to_object(repo: "Repo", name: str, return_ref: bool = False) -> Union[A return Object.new_from_sha(repo, hex_to_bin(hexsha)) -def deref_tag(tag: "Tag") -> AnyGitObject: +def deref_tag(tag: AnyGitObject) -> AnyGitObject: """Recursively dereference a tag and return the resulting object.""" while True: try: @@ -272,7 +271,7 @@ def deref_tag(tag: "Tag") -> AnyGitObject: return tag -def to_commit(obj: Object) -> "Commit": +def to_commit(obj: AnyGitObject) -> "Commit": """Convert the given object to a commit if possible and return it.""" if obj.type == "tag": obj = deref_tag(obj) @@ -521,7 +520,7 @@ def _find_commit_by_message( if rev is None: commits = _all_ref_commits(repo) else: - commits = _reachable_commits([to_commit(cast(Object, rev))]) + commits = _reachable_commits([to_commit(rev)]) # END handle starting point for commit in commits: @@ -541,7 +540,7 @@ def _all_ref_commits(repo: "Repo") -> Iterator["Commit"]: starts = [] for ref in repo.references: try: - starts.append(to_commit(cast(Object, ref.object))) + starts.append(to_commit(ref.object)) except (BadName, ValueError): pass # END skip refs that do not point to commits @@ -589,7 +588,7 @@ def _index_lookup(repo: "Repo", spec: str) -> AnyGitObject: def _tree_lookup(obj: AnyGitObject, path: str) -> AnyGitObject: if obj.type != "tree": - obj = to_commit(cast(Object, obj)).tree + obj = to_commit(obj).tree # END get tree if not path: return obj @@ -604,9 +603,9 @@ def _peel(obj: AnyGitObject, output_type: str, repo: "Repo", rev: str) -> AnyGit if output_type == "object": return obj if output_type == "commit": - return to_commit(cast(Object, obj)) + return to_commit(obj) if output_type == "tree": - return to_commit(cast(Object, obj)).tree if obj.type != "tree" else obj + return to_commit(obj).tree if obj.type != "tree" else obj if output_type == "blob": obj = deref_tag(obj) if obj.type == "tag" else obj if obj.type == output_type: diff --git a/git/types.py b/git/types.py index 100fff43f..31d40bf3b 100644 --- a/git/types.py +++ b/git/types.py @@ -48,6 +48,22 @@ _T = TypeVar("_T") """Type variable used internally in GitPython.""" +_T_Stream_co = TypeVar("_T_Stream_co", str, bytes, covariant=True) +_T_Stream_contra = TypeVar("_T_Stream_contra", str, bytes, contravariant=True) + + +class SupportsRead(Protocol[_T_Stream_co]): + """A stream supporting reads, without requiring the full IO interface.""" + + def read(self, __size: int = -1) -> _T_Stream_co: ... + + +class SupportsWrite(Protocol[_T_Stream_contra]): + """A stream supporting writes, including writers that return None.""" + + def write(self, __data: _T_Stream_contra) -> object: ... + + AnyGitObject = Union["Commit", "Tree", "TagObject", "Blob"] """Union of the :class:`~git.objects.base.Object`-based types that represent actual git object types. diff --git a/git/util.py b/git/util.py index a80e667c7..f72e3d7c1 100644 --- a/git/util.py +++ b/git/util.py @@ -68,7 +68,6 @@ from typing import ( Any, AnyStr, - BinaryIO, Callable, Dict, Generator, @@ -101,6 +100,8 @@ PathLike, Protocol, SupportsIndex, + SupportsRead, + SupportsWrite, Total_TD, runtime_checkable, ) @@ -253,7 +254,7 @@ def rmfile(path: PathLike) -> None: os.remove(path) -def stream_copy(source: BinaryIO, destination: BinaryIO, chunk_size: int = 512 * 1024) -> int: +def stream_copy(source: SupportsRead[AnyStr], destination: SupportsWrite[AnyStr], chunk_size: int = 512 * 1024) -> int: """Copy all data from the `source` stream into the `destination` stream in chunks of size `chunk_size`. diff --git a/pyproject.toml b/pyproject.toml index b7c437bf3..fbcde3611 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ testpaths = "test" # Space separated list of paths from root e.g test tests doc # filterwarnings ignore::WarningType # ignores those warnings [tool.mypy] -files = ["git/", "test/deprecation/"] +files = ["git/", "test/deprecation/", "test/test_typing.py"] disallow_untyped_defs = true no_implicit_optional = true warn_redundant_casts = true @@ -40,6 +40,8 @@ pythonVersion = "3.7" include = [ "git", "test/deprecation", + "test/test_typing.py", + "test/test_git.py", ] extraPaths = [ "gitdb", diff --git a/test-requirements.txt b/test-requirements.txt index e2443825c..0fabac809 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,7 +1,7 @@ coverage[toml] basedpyright==1.39.9 ; python_version >= "3.9" and sys_platform != "cygwin" ddt >= 1.1.1, != 1.4.3 -mock ; python_version < "3.8" +mock # Also needed to typecheck the Python 3.7 import branches. mypy==1.18.2 ; python_version >= "3.9" # pin mypy version to avoid new errors pre-commit pytest >= 7.3.1 diff --git a/test/test_git.py b/test/test_git.py index b19652363..14fc7dfc3 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -137,24 +137,24 @@ def test_it_raises_errors(self): self.assertRaises(GitCommandError, self.git.this_does_not_exist) def test_it_transforms_kwargs_into_git_command_arguments(self): - self.assertEqual(["-s"], self.git.transform_kwargs(**{"s": True})) - self.assertEqual(["-s", "5"], self.git.transform_kwargs(**{"s": 5})) - self.assertEqual([], self.git.transform_kwargs(**{"s": None})) + self.assertEqual(["-s"], self.git.transform_kwargs(s=True)) + self.assertEqual(["-s", "5"], self.git.transform_kwargs(s=5)) + self.assertEqual([], self.git.transform_kwargs(s=None)) - self.assertEqual(["--max-count"], self.git.transform_kwargs(**{"max_count": True})) - self.assertEqual(["--max-count=5"], self.git.transform_kwargs(**{"max_count": 5})) - self.assertEqual(["--max-count=0"], self.git.transform_kwargs(**{"max_count": 0})) - self.assertEqual([], self.git.transform_kwargs(**{"max_count": None})) + self.assertEqual(["--max-count"], self.git.transform_kwargs(max_count=True)) + self.assertEqual(["--max-count=5"], self.git.transform_kwargs(max_count=5)) + self.assertEqual(["--max-count=0"], self.git.transform_kwargs(max_count=0)) + self.assertEqual([], self.git.transform_kwargs(max_count=None)) # Multiple args are supported by using lists/tuples. self.assertEqual( ["-L", "1-3", "-L", "12-18"], - self.git.transform_kwargs(**{"L": ("1-3", "12-18")}), + self.git.transform_kwargs(L=("1-3", "12-18")), ) - self.assertEqual(["-C", "-C"], self.git.transform_kwargs(**{"C": [True, True, None, False]})) + self.assertEqual(["-C", "-C"], self.git.transform_kwargs(C=[True, True, None, False])) # Order is undefined. - res = self.git.transform_kwargs(**{"s": True, "t": True}) + res = self.git.transform_kwargs(s=True, t=True) self.assertEqual({"-s", "-t"}, set(res)) def test_check_unsafe_options_normalizes_kwargs(self): @@ -221,7 +221,9 @@ def test_option_candidates_include_falsey_non_boolean_values(self): candidates = Git._option_candidates(kwargs=kwargs) self.assertEqual(candidates, ["--pathspec-from-file"]) - self.assertEqual(self.git.transform_kwargs(**kwargs), ["--pathspec-from-file=0"]) + self.assertEqual( + self.git.transform_kwargs(split_single_char_options=True, **kwargs), ["--pathspec-from-file=0"] + ) with self.assertRaises(UnsafeOptionError): Git.check_unsafe_options( options=candidates, @@ -634,6 +636,7 @@ def test_initial_refresh_from_bad_git_path_env_warn(self, case): with mock.patch.dict(os.environ, env_vars): with self.assertLogs(cmd.__name__, logging.CRITICAL) as ctx: refresh() + assert ctx is not None self.assertEqual(len(ctx.records), 1) message = ctx.records[0].getMessage() self.assertRegex(message, r"\ABad git executable.\n") @@ -753,6 +756,7 @@ def test_refresh_with_good_absolute_git_path_arg(self): def test_refresh_with_good_relative_git_path_arg(self): """Good relative path arg is resolved to absolute path and set.""" absolute_path = shutil.which("git") + assert absolute_path is not None dirname, basename = osp.split(absolute_path) with cwd(dirname): diff --git a/test/test_typing.py b/test/test_typing.py new file mode 100644 index 000000000..101c05520 --- /dev/null +++ b/test/test_typing.py @@ -0,0 +1,91 @@ +# This module is part of GitPython and is released under the +# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ + +"""Runtime and static checks for public APIs with related input/output types.""" + +from io import BytesIO, StringIO +import subprocess +import sys +from typing import List, Tuple, TYPE_CHECKING + +import pytest + +from git import Git +from git.exc import GitCommandError +from git.index.typ import BaseIndexEntry, IndexEntry +from git.util import stream_copy + + +def test_index_entry_constructor_shapes() -> None: + class DerivedEntry(IndexEntry): + pass + + short = (0o100644, b"\0" * 20, 0, "file") + full = short + (b"\0" * 8, b"\0" * 8, 1, 2, 3, 4, 5) + entries: List[IndexEntry] = [IndexEntry(short), IndexEntry(full), IndexEntry(full + (0x4000,))] + derived: DerivedEntry = DerivedEntry(short) + + assert all(type(entry) is IndexEntry for entry in entries) + assert [entry.size for entry in entries] == [0, 5, 5] + assert [entry.skip_worktree for entry in entries] == [False, False, True] + assert type(derived) is DerivedEntry + assert IndexEntry.from_base(BaseIndexEntry(short)) == entries[0] + + +def test_stream_copy_minimal_writer() -> None: + class Writer: + def __init__(self) -> None: + self.data = b"" + + def write(self, data: bytes) -> None: + self.data += data + + writer = Writer() + assert stream_copy(BytesIO(b"payload"), writer, chunk_size=3) == 7 + assert writer.data == b"payload" + text = StringIO() + assert stream_copy(StringIO("payload"), text, chunk_size=3) == 7 + assert text.getvalue() == "payload" + + +def test_process_wait_with_no_previous_stderr() -> None: + process = Git().execute( + [sys.executable, "-c", "import sys; sys.stderr.write('failure'); sys.exit(1)"], + as_process=True, + istream=subprocess.DEVNULL, + shell=False, + ) + with pytest.raises(GitCommandError, match="failure"): + process.wait(stderr=None) + + +def test_execute_output_types() -> None: + git = Git() + command = [sys.executable, "-c", "print('payload', end='')"] + text: str = git.execute(command, with_exceptions=False, shell=False) + binary: bytes = git.execute(command, stdout_as_string=False, shell=False) + extended_text: Tuple[int, str, str] = git.execute(command, with_extended_output=True, shell=False) + extended_binary: Tuple[int, bytes, str] = git.execute( + command, with_extended_output=True, stdout_as_string=False, shell=False + ) + assert text == "payload" + assert binary == b"payload" + assert extended_text == (0, text, "") + assert extended_binary == (0, binary, "") + + +if TYPE_CHECKING: + from git import Remote, Repo + from git.repo.base import BlameEntry + from git.objects import Commit, Submodule + + repo = Repo() + remote = Remote(repo, "origin") + removed_names: List[str] = [Remote.remove(repo, "origin"), Remote.rm(repo, "origin"), repo.delete_remote("origin")] + removed_remotes: List[Remote] = [Remote.remove(repo, remote), Remote.rm(repo, remote), repo.delete_remote(remote)] + blame = BlameEntry(repo.head.commit, range(1), "file", range(1)) + commit: Commit = blame.commit + submodule_entry: IndexEntry = IndexEntry.from_blob(Submodule(repo, b"\0" * 20)) + Git().get_object_header(b"HEAD") + Git().get_object_data(b"HEAD") + Git().stream_object_data(b"HEAD")