Skip to content

Commit d66edad

Browse files
Byroncodex
andcommitted
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 <codex@openai.com>
1 parent fa93137 commit d66edad

20 files changed

Lines changed: 772 additions & 1049 deletions

File tree

.basedpyright/baseline.json

Lines changed: 500 additions & 964 deletions
Large diffs are not rendered by default.

git/cmd.py

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100

101101

102102
def handle_process_output(
103-
process: "Git.AutoInterrupt" | Popen,
103+
process: Union["Git.AutoInterrupt", Popen],
104104
stdout_handler: Union[
105105
None,
106106
Callable[[AnyStr], None],
@@ -395,9 +395,7 @@ def wait(self, stderr: Union[None, str, bytes] = b"") -> int:
395395
:raise git.exc.GitCommandError:
396396
If the return status is not 0.
397397
"""
398-
if stderr is None:
399-
stderr_b = b""
400-
stderr_b = force_bytes(data=stderr, encoding="utf-8")
398+
stderr_b = force_bytes(data=stderr, encoding="utf-8") or b""
401399
status: Union[int, None]
402400
if self.proc is not None:
403401
status = self.proc.wait()
@@ -1180,52 +1178,112 @@ def version_info(self) -> Tuple[int, ...]:
11801178
def execute(
11811179
self,
11821180
command: Union[str, Sequence[Any]],
1181+
istream: Union[None, int, BinaryIO] = None,
11831182
*,
11841183
as_process: Literal[True],
1184+
**subprocess_kwargs: Any,
11851185
) -> "AutoInterrupt": ...
11861186

11871187
@overload
11881188
def execute(
11891189
self,
11901190
command: Union[str, Sequence[Any]],
1191+
istream: Union[None, int, BinaryIO] = None,
11911192
*,
11921193
as_process: Literal[False] = False,
1193-
stdout_as_string: Literal[True],
1194-
) -> Union[str, Tuple[int, str, str]]: ...
1194+
with_extended_output: Literal[False] = False,
1195+
stdout_as_string: Literal[True] = True,
1196+
with_stdout: Literal[True] = True,
1197+
**subprocess_kwargs: Any,
1198+
) -> str: ...
11951199

11961200
@overload
11971201
def execute(
11981202
self,
11991203
command: Union[str, Sequence[Any]],
1204+
istream: Union[None, int, BinaryIO] = None,
12001205
*,
12011206
as_process: Literal[False] = False,
1202-
stdout_as_string: Literal[False] = False,
1203-
) -> Union[bytes, Tuple[int, bytes, str]]: ...
1207+
with_extended_output: Literal[False] = False,
1208+
stdout_as_string: Literal[False],
1209+
universal_newlines: Literal[False] = False,
1210+
with_stdout: Literal[True] = True,
1211+
**subprocess_kwargs: Any,
1212+
) -> bytes: ...
12041213

12051214
@overload
12061215
def execute(
12071216
self,
12081217
command: Union[str, Sequence[Any]],
1218+
istream: Union[None, int, BinaryIO] = None,
12091219
*,
1210-
with_extended_output: Literal[False],
1211-
as_process: Literal[False],
1212-
stdout_as_string: Literal[True],
1213-
) -> str: ...
1220+
as_process: Literal[False] = False,
1221+
with_extended_output: Literal[True],
1222+
stdout_as_string: Literal[True] = True,
1223+
with_stdout: Literal[True] = True,
1224+
**subprocess_kwargs: Any,
1225+
) -> Tuple[int, str, str]: ...
12141226

12151227
@overload
12161228
def execute(
12171229
self,
12181230
command: Union[str, Sequence[Any]],
1231+
istream: Union[None, int, BinaryIO] = None,
12191232
*,
1220-
with_extended_output: Literal[False],
1221-
as_process: Literal[False],
1233+
as_process: Literal[False] = False,
1234+
with_extended_output: Literal[True],
12221235
stdout_as_string: Literal[False],
1223-
) -> bytes: ...
1236+
universal_newlines: Literal[False] = False,
1237+
with_stdout: Literal[True] = True,
1238+
**subprocess_kwargs: Any,
1239+
) -> Tuple[int, bytes, str]: ...
1240+
1241+
@overload
1242+
def execute(
1243+
self,
1244+
command: Union[str, Sequence[Any]],
1245+
istream: Union[None, int, BinaryIO] = None,
1246+
*,
1247+
as_process: Literal[False] = False,
1248+
with_extended_output: Literal[True],
1249+
**subprocess_kwargs: Any,
1250+
) -> Tuple[int, Union[str, bytes, None], str]: ...
1251+
1252+
@overload
1253+
def execute(
1254+
self,
1255+
command: Union[str, Sequence[Any]],
1256+
istream: Union[None, int, BinaryIO] = None,
1257+
*,
1258+
as_process: Literal[False] = False,
1259+
with_extended_output: Literal[False] = False,
1260+
**subprocess_kwargs: Any,
1261+
) -> Union[str, bytes, None]: ...
1262+
1263+
@overload
1264+
def execute(
1265+
self,
1266+
command: Union[str, Sequence[Any]],
1267+
istream: Union[None, int, BinaryIO] = None,
1268+
with_extended_output: bool = False,
1269+
with_exceptions: bool = True,
1270+
as_process: bool = False,
1271+
output_stream: Union[None, BinaryIO] = None,
1272+
stdout_as_string: bool = True,
1273+
kill_after_timeout: Union[None, float] = None,
1274+
with_stdout: bool = True,
1275+
universal_newlines: bool = False,
1276+
shell: Union[None, bool] = None,
1277+
env: Union[None, Mapping[str, str]] = None,
1278+
max_chunk_size: int = io.DEFAULT_BUFFER_SIZE,
1279+
strip_newline_in_stdout: bool = True,
1280+
**subprocess_kwargs: Any,
1281+
) -> Union[None, str, bytes, Tuple[int, Union[str, bytes, None], str], AutoInterrupt]: ...
12241282

12251283
def execute(
12261284
self,
12271285
command: Union[str, Sequence[Any]],
1228-
istream: Union[None, BinaryIO] = None,
1286+
istream: Union[None, int, BinaryIO] = None,
12291287
with_extended_output: bool = False,
12301288
with_exceptions: bool = True,
12311289
as_process: bool = False,
@@ -1239,7 +1297,7 @@ def execute(
12391297
max_chunk_size: int = io.DEFAULT_BUFFER_SIZE,
12401298
strip_newline_in_stdout: bool = True,
12411299
**subprocess_kwargs: Any,
1242-
) -> Union[str, bytes, Tuple[int, Union[str, bytes], str], AutoInterrupt]:
1300+
) -> Union[None, str, bytes, Tuple[int, Union[str, bytes, None], str], AutoInterrupt]:
12431301
R"""Handle executing the command, and consume and return the returned
12441302
information (stdout).
12451303
@@ -1503,7 +1561,7 @@ def make_timeout_error() -> Union[str, bytes]:
15031561
err = f'Timeout: the command "{" ".join(redacted_command)}" did not complete in {timeout:g} secs.'
15041562
return err if universal_newlines else err.encode(defenc)
15051563

1506-
def communicate() -> Tuple[AnyStr, AnyStr]:
1564+
def communicate() -> Tuple[Union[str, bytes, None], Union[str, bytes, None]]:
15071565
assert watchdog is not None
15081566
assert kill_check is not None
15091567
watchdog.start()
@@ -1523,8 +1581,8 @@ def communicate() -> Tuple[AnyStr, AnyStr]:
15231581

15241582
# Wait for the process to return.
15251583
status = 0
1526-
stdout_value: Union[str, bytes] = b""
1527-
stderr_value: Union[str, bytes] = b""
1584+
stdout_value: Union[str, bytes, None] = b""
1585+
stderr_value: Union[str, bytes, None] = b""
15281586
newline = "\n" if universal_newlines else b"\n"
15291587
try:
15301588
if output_stream is None:
@@ -1566,7 +1624,7 @@ def communicate() -> Tuple[AnyStr, AnyStr]:
15661624
if self.GIT_PYTHON_TRACE == "full":
15671625
cmdstr = " ".join(redacted_command)
15681626

1569-
def as_text(stdout_value: Union[bytes, str]) -> str:
1627+
def as_text(stdout_value: Union[bytes, str, None]) -> str:
15701628
return not output_stream and safe_decode(stdout_value) or "<OUTPUT_STREAM>"
15711629

15721630
# END as_text
@@ -1591,6 +1649,8 @@ def as_text(stdout_value: Union[bytes, str]) -> str:
15911649
if isinstance(stdout_value, bytes) and stdout_as_string: # Could also be output_stream.
15921650
stdout_value = safe_decode(stdout_value)
15931651

1652+
# stderr is always captured through PIPE.
1653+
assert stderr_value is not None
15941654
# Allow access to the command's status code.
15951655
if with_extended_output:
15961656
return (status, stdout_value, safe_decode(stderr_value))
@@ -1829,7 +1889,7 @@ def _parse_object_header(self, header_line: str) -> Tuple[str, str, int]:
18291889
raise ValueError("Failed to parse header: %r" % header_line)
18301890
return (tokens[0], tokens[1], int(tokens[2]))
18311891

1832-
def _prepare_ref(self, ref: AnyStr) -> bytes:
1892+
def _prepare_ref(self, ref: object) -> bytes:
18331893
# Required for command to separate refs on stdin, as bytes.
18341894
if isinstance(ref, bytes):
18351895
# Assume 40 bytes hexsha - bin-to-ascii for some reason returns bytes, not text.
@@ -1856,15 +1916,15 @@ def _get_persistent_cmd(self, attr_name: str, cmd_name: str, *args: Any, **kwarg
18561916
cmd = cast("Git.AutoInterrupt", cmd)
18571917
return cmd
18581918

1859-
def __get_object_header(self, cmd: "Git.AutoInterrupt", ref: AnyStr) -> Tuple[str, str, int]:
1919+
def __get_object_header(self, cmd: "Git.AutoInterrupt", ref: Union[str, bytes]) -> Tuple[str, str, int]:
18601920
if cmd.stdin and cmd.stdout:
18611921
cmd.stdin.write(self._prepare_ref(ref))
18621922
cmd.stdin.flush()
18631923
return self._parse_object_header(cmd.stdout.readline())
18641924
else:
18651925
raise ValueError("cmd stdin was empty")
18661926

1867-
def get_object_header(self, ref: str) -> Tuple[str, str, int]:
1927+
def get_object_header(self, ref: Union[str, bytes]) -> Tuple[str, str, int]:
18681928
"""Use this method to quickly examine the type and size of the object behind the
18691929
given ref.
18701930
@@ -1878,7 +1938,7 @@ def get_object_header(self, ref: str) -> Tuple[str, str, int]:
18781938
cmd = self._get_persistent_cmd("cat_file_header", "cat_file", batch_check=True)
18791939
return self.__get_object_header(cmd, ref)
18801940

1881-
def get_object_data(self, ref: str) -> Tuple[str, str, int, bytes]:
1941+
def get_object_data(self, ref: Union[str, bytes]) -> Tuple[str, str, int, bytes]:
18821942
"""Similar to :meth:`get_object_header`, but returns object data as well.
18831943
18841944
:return:
@@ -1892,7 +1952,7 @@ def get_object_data(self, ref: str) -> Tuple[str, str, int, bytes]:
18921952
del stream
18931953
return (hexsha, typename, size, data)
18941954

1895-
def stream_object_data(self, ref: str) -> Tuple[str, str, int, "Git.CatFileContentStream"]:
1955+
def stream_object_data(self, ref: Union[str, bytes]) -> Tuple[str, str, int, "Git.CatFileContentStream"]:
18961956
"""Similar to :meth:`get_object_data`, but returns the data as a stream.
18971957
18981958
:return:

git/index/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1235,7 +1235,7 @@ def _read_commit_editmsg(self) -> str:
12351235
def _commit_editmsg_filepath(self) -> str:
12361236
return osp.join(self.repo.common_dir, "COMMIT_EDITMSG")
12371237

1238-
def _flush_stdin_and_wait(cls, proc: "Popen[bytes]", ignore_stdout: bool = False) -> bytes:
1238+
def _flush_stdin_and_wait(self, proc: "Popen[bytes]", ignore_stdout: bool = False) -> bytes:
12391239
stdin_IO = proc.stdin
12401240
if stdin_IO:
12411241
stdin_IO.flush()

git/index/fun.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from git.types import PathLike
4747

4848
if TYPE_CHECKING:
49-
from git.db import GitCmdObjectDB
49+
from gitdb.db.base import ObjectDBR, ObjectDBW
5050
from git.objects.tree import TreeCacheTup
5151

5252
from .base import IndexFile
@@ -412,7 +412,7 @@ def read_cache(
412412

413413

414414
def write_tree_from_cache(
415-
entries: List[IndexEntry], odb: "GitCmdObjectDB", sl: slice, si: int = 0
415+
entries: List[IndexEntry], odb: "ObjectDBW", sl: slice, si: int = 0
416416
) -> Tuple[bytes, List["TreeCacheTup"]]:
417417
R"""Create a tree from the given sorted list of entries and put the respective
418418
trees into the given object database.
@@ -484,7 +484,7 @@ def _tree_entry_to_baseindexentry(tree_entry: "TreeCacheTup", stage: int) -> Bas
484484
return BaseIndexEntry((tree_entry[1], tree_entry[0], stage << CE_STAGESHIFT, tree_entry[2]))
485485

486486

487-
def aggressive_tree_merge(odb: "GitCmdObjectDB", tree_shas: Sequence[bytes]) -> List[BaseIndexEntry]:
487+
def aggressive_tree_merge(odb: "ObjectDBR", tree_shas: Sequence[bytes]) -> List[BaseIndexEntry]:
488488
R"""
489489
:return:
490490
List of :class:`~git.index.typ.BaseIndexEntry`\s representing the aggressive

git/index/typ.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,21 @@
99
from pathlib import Path
1010

1111
from git.objects import Blob
12+
from git.objects.base import IndexObject
1213

1314
from .util import pack, unpack
1415

1516
# typing ----------------------------------------------------------------------
1617

17-
from typing import NamedTuple, Sequence, TYPE_CHECKING, Tuple, Union, cast
18+
from typing import NamedTuple, Sequence, TYPE_CHECKING, Tuple, Type, TypeVar, Union, cast
1819

1920
from git.types import PathLike
2021

2122
if TYPE_CHECKING:
2223
from git.repo import Repo
2324

2425
StageType = int
26+
_T_IndexEntry = TypeVar("_T_IndexEntry", bound="BaseIndexEntry")
2527

2628
# ---------------------------------------------------------------------------------
2729

@@ -104,15 +106,20 @@ class BaseIndexEntry(BaseIndexEntryHelper):
104106
"""
105107

106108
def __new__(
107-
cls,
109+
cls: Type[_T_IndexEntry],
108110
inp_tuple: Union[
109111
Tuple[int, bytes, int, PathLike],
112+
Tuple[int, bytes, int, PathLike, bytes, bytes, int, int, int, int, int],
110113
Tuple[int, bytes, int, PathLike, bytes, bytes, int, int, int, int, int, int],
111114
],
112-
) -> "BaseIndexEntry":
115+
) -> _T_IndexEntry:
113116
"""Override ``__new__`` to allow construction from a tuple for backwards
114117
compatibility."""
115-
return super().__new__(cls, *inp_tuple)
118+
if len(inp_tuple) == 4:
119+
return BaseIndexEntryHelper.__new__(cls, *inp_tuple)
120+
if len(inp_tuple) == 11:
121+
return BaseIndexEntryHelper.__new__(cls, *inp_tuple)
122+
return BaseIndexEntryHelper.__new__(cls, *inp_tuple)
116123

117124
def __str__(self) -> str:
118125
return "%o %s %i\t%s" % (self.mode, self.hexsha, self.stage, self.path)
@@ -148,7 +155,7 @@ def intent_to_add(self) -> bool:
148155
return (self.extended_flags & CE_EXT_INTENT_TO_ADD) > 0
149156

150157
@classmethod
151-
def from_blob(cls, blob: Blob, stage: int = 0) -> "BaseIndexEntry":
158+
def from_blob(cls, blob: IndexObject, stage: int = 0) -> "BaseIndexEntry":
152159
""":return: Fully equipped BaseIndexEntry at the given stage"""
153160
return cls((blob.mode, blob.binsha, stage << CE_STAGESHIFT, blob.path))
154161

@@ -192,10 +199,10 @@ def from_base(cls, base: "BaseIndexEntry") -> "IndexEntry":
192199
Instance of type :class:`BaseIndexEntry`.
193200
"""
194201
time = pack(">LL", 0, 0)
195-
return IndexEntry((base.mode, base.binsha, base.flags, base.path, time, time, 0, 0, 0, 0, 0)) # type: ignore[arg-type]
202+
return IndexEntry((base.mode, base.binsha, base.flags, base.path, time, time, 0, 0, 0, 0, 0))
196203

197204
@classmethod
198-
def from_blob(cls, blob: Blob, stage: int = 0) -> "IndexEntry":
205+
def from_blob(cls, blob: IndexObject, stage: int = 0) -> "IndexEntry":
199206
""":return: Minimal entry resembling the given blob object"""
200207
time = pack(">LL", 0, 0)
201208
return IndexEntry(
@@ -211,5 +218,5 @@ def from_blob(cls, blob: Blob, stage: int = 0) -> "IndexEntry":
211218
0,
212219
0,
213220
blob.size,
214-
) # type: ignore[arg-type]
221+
)
215222
)

git/objects/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from typing import Any, TYPE_CHECKING, Union
2020

21-
from git.types import AnyGitObject, GitObjectTypeString, PathLike
21+
from git.types import AnyGitObject, GitObjectTypeString, PathLike, SupportsWrite
2222

2323
if TYPE_CHECKING:
2424
from gitdb.base import OStream
@@ -200,7 +200,7 @@ def data_stream(self) -> "OStream":
200200
"""
201201
return self.repo.odb.stream(self.binsha)
202202

203-
def stream_data(self, ostream: "OStream") -> "Object":
203+
def stream_data(self, ostream: SupportsWrite[bytes]) -> "Object":
204204
"""Write our data directly to the given output stream.
205205
206206
:param ostream:

git/objects/commit.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ def _interpret_trailers(
502502
) -> str:
503503
message_bytes = message if isinstance(message, bytes) else message.encode(encoding, errors="strict")
504504
cmd = [repo.git.GIT_PYTHON_GIT_EXECUTABLE, "interpret-trailers", *trailer_args]
505-
proc: Git.AutoInterrupt = repo.git.execute( # type: ignore[call-overload]
505+
proc: Git.AutoInterrupt = repo.git.execute(
506506
cmd,
507507
as_process=True,
508508
istream=PIPE,

0 commit comments

Comments
 (0)