Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/git/util.py: 49%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

636 statements  

1# Copyright (C) 2008, 2009 Michael Trier (mtrier@gmail.com) and contributors 

2# 

3# This module is part of GitPython and is released under the 

4# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ 

5 

6import sys 

7 

8__all__ = [ 

9 "stream_copy", 

10 "join_path", 

11 "to_native_path_linux", 

12 "join_path_native", 

13 "Stats", 

14 "IndexFileSHA1Writer", 

15 "IterableObj", 

16 "IterableList", 

17 "BlockingLockFile", 

18 "LockFile", 

19 "Actor", 

20 "get_user_id", 

21 "assure_directory_exists", 

22 "RemoteProgress", 

23 "CallableRemoteProgress", 

24 "rmtree", 

25 "unbare_repo", 

26 "HIDE_WINDOWS_KNOWN_ERRORS", 

27] 

28 

29if sys.platform == "win32": 

30 __all__.append("to_native_path_windows") 

31 

32from abc import abstractmethod 

33import contextlib 

34from functools import wraps 

35import getpass 

36import logging 

37import ntpath 

38import os 

39import os.path as osp 

40from pathlib import Path 

41import platform 

42import re 

43import shutil 

44import stat 

45import subprocess 

46import time 

47from urllib.parse import urlsplit, urlunsplit 

48import warnings 

49 

50# NOTE: Unused imports can be improved now that CI testing has fully resumed. Some of 

51# these be used indirectly through other GitPython modules, which avoids having to write 

52# gitdb all the time in their imports. They are not in __all__, at least currently, 

53# because they could be removed or changed at any time, and so should not be considered 

54# conceptually public to code outside GitPython. Linters of course do not like it. 

55from gitdb.util import ( 

56 LazyMixin, # noqa: F401 

57 LockedFD, # noqa: F401 

58 bin_to_hex, # noqa: F401 

59 file_contents_ro, # noqa: F401 

60 file_contents_ro_filepath, # noqa: F401 

61 hex_to_bin, # noqa: F401 

62 make_sha, 

63 to_bin_sha, # noqa: F401 

64 to_hex_sha, # noqa: F401 

65) 

66 

67# typing --------------------------------------------------------- 

68 

69from typing import ( 

70 Any, 

71 AnyStr, 

72 Callable, 

73 Dict, 

74 Generator, 

75 IO, 

76 Iterator, 

77 List, 

78 Optional, 

79 Pattern, 

80 Sequence, 

81 Tuple, 

82 TYPE_CHECKING, 

83 Type, 

84 TypeVar, 

85 Union, 

86 cast, 

87 overload, 

88) 

89 

90if TYPE_CHECKING: 

91 from git.cmd import Git 

92 from git.config import GitConfigParser, SectionConstraint 

93 from git.remote import Remote 

94 from git.repo.base import Repo 

95 

96from git.types import ( 

97 Files_TD, 

98 Has_id_attribute, 

99 HSH_TD, 

100 Literal, 

101 PathLike, 

102 Protocol, 

103 SupportsIndex, 

104 SupportsRead, 

105 SupportsWrite, 

106 Total_TD, 

107 runtime_checkable, 

108) 

109 

110# --------------------------------------------------------------------- 

111 

112T_IterableObj = TypeVar("T_IterableObj", bound=Union["IterableObj", "Has_id_attribute"], covariant=True) 

113# So IterableList[Head] is subtype of IterableList[IterableObj]. 

114T_Actor = TypeVar("T_Actor", bound="Actor") 

115 

116_logger = logging.getLogger(__name__) 

117 

118 

119def _read_env_flag(name: str, default: bool) -> bool: 

120 """Read a boolean flag from an environment variable. 

121 

122 :return: 

123 The flag, or the `default` value if absent or ambiguous. 

124 """ 

125 try: 

126 value = os.environ[name] 

127 except KeyError: 

128 return default 

129 

130 _logger.warning( 

131 "The %s environment variable is deprecated. Its effect has never been documented and changes without warning.", 

132 name, 

133 ) 

134 

135 adjusted_value = value.strip().lower() 

136 

137 if adjusted_value in {"", "0", "false", "no"}: 

138 return False 

139 if adjusted_value in {"1", "true", "yes"}: 

140 return True 

141 _logger.warning("%s has unrecognized value %r, treating as %r.", name, value, default) 

142 return default 

143 

144 

145def _read_win_env_flag(name: str, default: bool) -> bool: 

146 """Read a boolean flag from an environment variable on Windows. 

147 

148 :return: 

149 On Windows, the flag, or the `default` value if absent or ambiguous. 

150 On all other operating systems, ``False``. 

151 

152 :note: 

153 This only accesses the environment on Windows. 

154 """ 

155 return sys.platform == "win32" and _read_env_flag(name, default) 

156 

157 

158#: We need an easy way to see if Appveyor TCs start failing, 

159#: so the errors marked with this var are considered "acknowledged" ones, awaiting remedy, 

160#: till then, we wish to hide them. 

161HIDE_WINDOWS_KNOWN_ERRORS = _read_win_env_flag("HIDE_WINDOWS_KNOWN_ERRORS", True) 

162HIDE_WINDOWS_FREEZE_ERRORS = _read_win_env_flag("HIDE_WINDOWS_FREEZE_ERRORS", True) 

163 

164# { Utility Methods 

165 

166T = TypeVar("T") 

167 

168 

169def unbare_repo(func: Callable[..., T]) -> Callable[..., T]: 

170 """Methods with this decorator raise :exc:`~git.exc.InvalidGitRepositoryError` if 

171 they encounter a bare repository.""" 

172 

173 from .exc import InvalidGitRepositoryError 

174 

175 @wraps(func) 

176 def wrapper(self: "Remote", *args: Any, **kwargs: Any) -> T: 

177 if self.repo.bare: 

178 raise InvalidGitRepositoryError("Method '%s' cannot operate on bare repositories" % func.__name__) 

179 # END bare method 

180 return func(self, *args, **kwargs) 

181 

182 # END wrapper 

183 

184 return wrapper 

185 

186 

187@contextlib.contextmanager 

188def cwd(new_dir: PathLike) -> Generator[PathLike, None, None]: 

189 """Context manager to temporarily change directory. 

190 

191 This is similar to :func:`contextlib.chdir` introduced in Python 3.11, but the 

192 context manager object returned by a single call to this function is not reentrant. 

193 """ 

194 old_dir = os.getcwd() 

195 os.chdir(new_dir) 

196 try: 

197 yield new_dir 

198 finally: 

199 os.chdir(old_dir) 

200 

201 

202@contextlib.contextmanager 

203def patch_env(name: str, value: str) -> Generator[None, None, None]: 

204 """Context manager to temporarily patch an environment variable.""" 

205 old_value = os.getenv(name) 

206 os.environ[name] = value 

207 try: 

208 yield 

209 finally: 

210 if old_value is None: 

211 del os.environ[name] 

212 else: 

213 os.environ[name] = old_value 

214 

215 

216def rmtree(path: PathLike) -> None: 

217 """Remove the given directory tree recursively. 

218 

219 :note: 

220 We use :func:`shutil.rmtree` but adjust its behaviour to see whether files that 

221 couldn't be deleted are read-only. Windows will not remove them in that case. 

222 """ 

223 

224 def handler(function: Callable[[str], Any], path: str, _excinfo: Any) -> None: 

225 """Callback for :func:`shutil.rmtree`. 

226 

227 This works as either a ``onexc`` or ``onerror`` style callback. 

228 """ 

229 # Is the error an access error? 

230 os.chmod(path, stat.S_IWUSR) 

231 

232 try: 

233 function(path) 

234 except PermissionError as ex: 

235 if HIDE_WINDOWS_KNOWN_ERRORS: 

236 from unittest import SkipTest 

237 

238 raise SkipTest(f"FIXME: fails with: PermissionError\n {ex}") from ex 

239 raise 

240 

241 if sys.platform != "win32": 

242 shutil.rmtree(path) 

243 elif sys.version_info >= (3, 12): 

244 shutil.rmtree(path, onexc=handler) 

245 else: 

246 shutil.rmtree(path, onerror=handler) 

247 

248 

249def rmfile(path: PathLike) -> None: 

250 """Ensure file deleted also on *Windows* where read-only files need special 

251 treatment.""" 

252 if osp.isfile(path): 

253 if sys.platform == "win32": 

254 os.chmod(path, 0o777) 

255 os.remove(path) 

256 

257 

258def stream_copy(source: SupportsRead[AnyStr], destination: SupportsWrite[AnyStr], chunk_size: int = 512 * 1024) -> int: 

259 """Copy all data from the `source` stream into the `destination` stream in chunks 

260 of size `chunk_size`. 

261 

262 :return: 

263 Number of bytes written 

264 """ 

265 br = 0 

266 while True: 

267 chunk = source.read(chunk_size) 

268 destination.write(chunk) 

269 br += len(chunk) 

270 if len(chunk) < chunk_size: 

271 break 

272 # END reading output stream 

273 return br 

274 

275 

276def join_path(a: PathLike, *p: PathLike) -> PathLike: 

277 R"""Join path tokens together similar to osp.join, but always use ``/`` instead of 

278 possibly ``\`` on Windows.""" 

279 path = os.fspath(a) 

280 for b in p: 

281 b = os.fspath(b) 

282 if not b: 

283 continue 

284 if b.startswith("/"): 

285 path += b[1:] 

286 elif path == "" or path.endswith("/"): 

287 path += b 

288 else: 

289 path += "/" + b 

290 # END for each path token to add 

291 return path 

292 

293 

294if sys.platform == "win32": 

295 

296 def to_native_path_windows(path: PathLike) -> str: 

297 path = os.fspath(path) 

298 return path.replace("/", "\\") 

299 

300 def to_native_path_linux(path: PathLike) -> str: 

301 path = os.fspath(path) 

302 return path.replace("\\", "/") 

303 

304 to_native_path = to_native_path_windows 

305else: 

306 # No need for any work on Linux. 

307 def to_native_path_linux(path: PathLike) -> str: 

308 return os.fspath(path) 

309 

310 to_native_path = to_native_path_linux 

311 

312 

313def join_path_native(a: PathLike, *p: PathLike) -> PathLike: 

314 R"""Like :func:`join_path`, but makes sure an OS native path is returned. 

315 

316 This is only needed to play it safe on Windows and to ensure nice paths that only 

317 use ``\``. 

318 """ 

319 return to_native_path(join_path(a, *p)) 

320 

321 

322def _is_path_rooted(path: PathLike) -> bool: 

323 r"""Whether ``path`` has a root, including one encoded in a UNC drive. 

324 

325 On Windows, ``\directory`` is rooted on the current drive without being 

326 absolute, while ``C:\directory`` has both a drive and a root. In contrast, 

327 ``directory`` and the drive-relative ``C:directory`` have no root. 

328 UNC paths are rooted: ``\\server\share`` stores the share in the drive 

329 returned by :func:`os.path.splitdrive`, while ``\\server\share\directory`` 

330 additionally has a rooted tail. 

331 On POSIX, which has no drive concept, this simply distinguishes absolute 

332 paths such as ``/directory`` from relative paths such as ``directory``. 

333 """ 

334 drive, tail = osp.splitdrive(os.fspath(path)) 

335 separators = (os.sep,) if os.altsep is None else (os.sep, os.altsep) 

336 return tail.startswith(separators) or drive.startswith(separators) 

337 

338 

339def _to_relative_path(root: PathLike, path: PathLike) -> str: 

340 r"""Return a normalized Git-style path confined to ``root``. 

341 

342 A Windows path such as ``\directory`` is rooted but not absolute. Resolve it 

343 against the drive of ``root`` rather than treating it as relative to ``root``. 

344 Drive-relative paths such as ``C:directory`` are rejected because their meaning 

345 depends on process-global per-drive state. 

346 

347 For example, with ``root`` set to ``C:\repo`` on Windows: 

348 

349 * ``directory\file`` -> ``directory/file`` 

350 * ``directory\`` -> ``directory/`` 

351 * ``C:\repo\directory\file`` -> ``directory/file`` 

352 * ``\repo\directory\file`` -> ``directory/file`` 

353 * ``C:directory\file`` -> :exc:`ValueError` 

354 * ``C:\other\file`` -> :exc:`ValueError` 

355 

356 On POSIX, ``/repo/directory/file`` under ``/repo`` similarly becomes 

357 ``directory/file``. A trailing separator is preserved as a Git-style ``/``. 

358 """ 

359 path_str = os.fspath(path) 

360 if not path_str: 

361 return path_str 

362 

363 drive, _tail = osp.splitdrive(path_str) 

364 rooted = _is_path_rooted(path_str) 

365 if drive and not rooted: 

366 raise ValueError("Drive-relative path %r is not supported" % path_str) 

367 

368 root_abs = osp.abspath(os.fspath(root)) 

369 path_abs = osp.abspath(osp.join(root_abs, path_str)) 

370 try: 

371 common_path = osp.commonpath([root_abs, path_abs]) 

372 except ValueError as e: 

373 raise ValueError("Path %r is not in repository at %r" % (path_str, root_abs)) from e 

374 if common_path != root_abs: 

375 raise ValueError("Path %r is not in repository at %r" % (path_str, root_abs)) 

376 

377 relative_path = to_native_path_linux(osp.relpath(path_abs, root_abs)) 

378 separators = (os.sep,) if os.altsep is None else (os.sep, os.altsep) 

379 if path_str.endswith(separators) and relative_path != "." and not relative_path.endswith("/"): 

380 relative_path += "/" 

381 return relative_path 

382 

383 

384_HFS_IGNORABLES = str.maketrans( 

385 "", "", "\u200c\u200d\u200e\u200f\u202a\u202b\u202c\u202d\u202e\u206a\u206b\u206c\u206d\u206e\u206f\ufeff" 

386) 

387 

388 

389def _validate_repo_path(path: PathLike) -> None: 

390 """Reject unsafe tree/index paths without normalizing away their components. 

391 

392 Protect Git metadata aliases on NTFS and HFS even when writing on another 

393 platform. Other POSIX filename characters, including newlines, remain valid. 

394 """ 

395 name = os.fspath(path) 

396 if not name or "\0" in name or ntpath.splitdrive(name)[0] or name.startswith("/"): 

397 raise ValueError("Invalid repository path %r" % name) 

398 if os.name == "nt" and "\\" in name: 

399 raise ValueError("Index paths must use '/' separators: %r" % name) 

400 for component in name.split("/"): 

401 if component in ("", ".", ".."): 

402 raise ValueError("Invalid repository path %r" % name) 

403 # NTFS recognizes backslashes and alternate data streams in metadata names. 

404 for part in component.split("\\"): 

405 ntfs_name = part.split(":", 1)[0].rstrip(" .").lower() 

406 hfs_name = part.translate(_HFS_IGNORABLES).lower() 

407 if ntfs_name in (".git", "git~1") or hfs_name == ".git": 

408 raise ValueError("Repository path aliases Git metadata: %r" % name) 

409 

410 

411def assure_directory_exists(path: PathLike, is_file: bool = False) -> bool: 

412 """Make sure that the directory pointed to by path exists. 

413 

414 :param is_file: 

415 If ``True``, `path` is assumed to be a file and handled correctly. 

416 Otherwise it must be a directory. 

417 

418 :return: 

419 ``True`` if the directory was created, ``False`` if it already existed. 

420 """ 

421 if is_file: 

422 path = osp.dirname(path) 

423 # END handle file 

424 if not osp.isdir(path): 

425 os.makedirs(path, exist_ok=True) 

426 return True 

427 return False 

428 

429 

430def _get_exe_extensions() -> Sequence[str]: 

431 PATHEXT = os.environ.get("PATHEXT", None) 

432 if PATHEXT: 

433 return tuple(p.upper() for p in PATHEXT.split(os.pathsep)) 

434 elif sys.platform == "win32": 

435 return (".BAT", ".COM", ".EXE") 

436 else: 

437 return () 

438 

439 

440def py_where(program: str, path: Optional[PathLike] = None) -> List[str]: 

441 """Perform a path search to assist :func:`is_cygwin_git`. 

442 

443 This is not robust for general use. It is an implementation detail of 

444 :func:`is_cygwin_git`. When a search following all shell rules is needed, 

445 :func:`shutil.which` can be used instead. 

446 

447 :note: 

448 Neither this function nor :func:`shutil.which` will predict the effect of an 

449 executable search on a native Windows system due to a :class:`subprocess.Popen` 

450 call without ``shell=True``, because shell and non-shell executable search on 

451 Windows differ considerably. 

452 """ 

453 # From: http://stackoverflow.com/a/377028/548792 

454 winprog_exts = _get_exe_extensions() 

455 

456 def is_exec(fpath: str) -> bool: 

457 return ( 

458 osp.isfile(fpath) 

459 and os.access(fpath, os.X_OK) 

460 and ( 

461 sys.platform != "win32" or not winprog_exts or any(fpath.upper().endswith(ext) for ext in winprog_exts) 

462 ) 

463 ) 

464 

465 progs = [] 

466 if not path: 

467 path = os.environ["PATH"] 

468 for folder in os.fspath(path).split(os.pathsep): 

469 folder = folder.strip('"') 

470 if folder: 

471 exe_path = osp.join(folder, program) 

472 for f in [exe_path] + ["%s%s" % (exe_path, e) for e in winprog_exts]: 

473 if is_exec(f): 

474 progs.append(f) 

475 return progs 

476 

477 

478def _cygexpath(drive: Optional[str], path: str, expand_vars: bool = True) -> str: 

479 if osp.isabs(path) and not drive: 

480 # Invoked from `cygpath()` directly with `D:Apps\123`? 

481 # It's an error, leave it alone just slashes) 

482 p = path # convert to str if AnyPath given 

483 else: 

484 p = path and osp.normpath(osp.expandvars(osp.expanduser(path)) if expand_vars else path) 

485 if osp.isabs(p): 

486 if drive: 

487 # Confusing, maybe a remote system should expand vars. 

488 p = path 

489 else: 

490 p = cygpath(p) 

491 elif drive: 

492 p = "/proc/cygdrive/%s/%s" % (drive.lower(), p) 

493 p_str = os.fspath(p) # ensure it is a str and not AnyPath 

494 return p_str.replace("\\", "/") 

495 

496 

497_cygpath_parsers: Tuple[Tuple[Pattern[str], Callable[..., str], bool], ...] = ( 

498 # See: https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx 

499 # and: https://www.cygwin.com/cygwin-ug-net/using.html#unc-paths 

500 ( 

501 re.compile(r"\\\\\?\\UNC\\([^\\]+)\\([^\\]+)(?:\\(.*))?"), 

502 (lambda server, share, rest_path: "//%s/%s/%s" % (server, share, rest_path.replace("\\", "/"))), 

503 False, 

504 ), 

505 (re.compile(r"\\\\\?\\(\w):[/\\](.*)"), (_cygexpath), False), 

506 (re.compile(r"(\w):[/\\](.*)"), (_cygexpath), False), 

507 (re.compile(r"file:(.*)", re.I), (lambda rest_path: rest_path), True), 

508 (re.compile(r"(\w{2,}:.*)"), (lambda url: url), False), # remote URL, do nothing 

509) 

510 

511 

512def cygpath(path: str, expand_vars: bool = True) -> str: 

513 """Use :meth:`git.cmd.Git.polish_url` instead, that works on any environment.""" 

514 path = os.fspath(path) # Ensure is str and not AnyPath. 

515 # Fix to use Paths when 3.5 dropped. Or to be just str if only for URLs? 

516 if not path.startswith(("/cygdrive", "//", "/proc/cygdrive")): 

517 for regex, parser, recurse in _cygpath_parsers: 

518 match = regex.match(path) 

519 if match: 

520 if parser is _cygexpath: 

521 path = parser(*match.groups(), expand_vars=expand_vars) 

522 else: 

523 path = parser(*match.groups()) 

524 if recurse: 

525 path = cygpath(path, expand_vars=expand_vars) 

526 break 

527 else: 

528 path = _cygexpath(None, path, expand_vars=expand_vars) 

529 

530 return path 

531 

532 

533_decygpath_regex = re.compile(r"(?:/proc)?/cygdrive/(\w)(/.*)?") 

534 

535 

536def decygpath(path: PathLike) -> str: 

537 path = os.fspath(path) 

538 m = _decygpath_regex.match(path) 

539 if m: 

540 drive, rest_path = m.groups() 

541 path = "%s:%s" % (drive.upper(), rest_path or "") 

542 

543 return path.replace("/", "\\") 

544 

545 

546#: Store boolean flags denoting if a specific Git executable 

547#: is from a Cygwin installation. TODO: use @functools.lru_cache(user_function) 

548_is_cygwin_cache: Dict[str, Optional[bool]] = {} 

549 

550 

551def _is_cygwin_git(git_executable: str) -> bool: 

552 is_cygwin = _is_cygwin_cache.get(git_executable) # type: Optional[bool] 

553 if is_cygwin is None: 

554 is_cygwin = False 

555 try: 

556 git_dir = osp.dirname(git_executable) 

557 if not git_dir: 

558 res = py_where(git_executable) 

559 git_dir = osp.dirname(res[0]) if res else "" 

560 

561 # Just a name given, not a real path. 

562 uname_cmd = osp.join(git_dir, "uname") 

563 

564 if not (Path(uname_cmd).is_file() and os.access(uname_cmd, os.X_OK)): 

565 _logger.debug(f"Failed checking if running in CYGWIN: {uname_cmd} is not an executable") 

566 _is_cygwin_cache[git_executable] = is_cygwin 

567 return is_cygwin 

568 

569 process = subprocess.Popen([uname_cmd], stdout=subprocess.PIPE, universal_newlines=True) 

570 uname_out, _ = process.communicate() 

571 # retcode = process.poll() 

572 is_cygwin = "CYGWIN" in uname_out 

573 except Exception as ex: 

574 _logger.debug("Failed checking if running in CYGWIN due to: %r", ex) 

575 _is_cygwin_cache[git_executable] = is_cygwin 

576 

577 return is_cygwin 

578 

579 

580@overload 

581def is_cygwin_git(git_executable: None) -> Literal[False]: ... 

582 

583 

584@overload 

585def is_cygwin_git(git_executable: PathLike) -> bool: ... 

586 

587 

588def is_cygwin_git(git_executable: Union[None, PathLike]) -> bool: 

589 _logger.debug(f"{sys.platform=}, {git_executable=}") 

590 if sys.platform != "cygwin": 

591 return False 

592 elif git_executable is None: 

593 return False 

594 else: 

595 return _is_cygwin_git(str(git_executable)) 

596 

597 

598def get_user_id() -> str: 

599 """:return: String identifying the currently active system user as ``name@node``""" 

600 return "%s@%s" % (getpass.getuser(), platform.node()) 

601 

602 

603def finalize_process(proc: Union["subprocess.Popen[Any]", "Git.AutoInterrupt"], **kwargs: Any) -> None: 

604 """Wait for the process (clone, fetch, pull or push) and handle its errors 

605 accordingly.""" 

606 # TODO: No close proc-streams?? 

607 proc.wait(**kwargs) 

608 

609 

610@overload 

611def expand_path(p: None, expand_vars: bool = ...) -> None: ... 

612 

613 

614@overload 

615def expand_path(p: PathLike, expand_vars: bool = ...) -> Optional[PathLike]: 

616 # TODO: Support for Python 3.5 has been dropped, so these overloads can be improved. 

617 ... 

618 

619 

620def expand_path(p: Union[None, PathLike], expand_vars: bool = True) -> Optional[PathLike]: 

621 if p is None: 

622 return None 

623 try: 

624 if isinstance(p, Path): 

625 return p.resolve() 

626 expanded_path = osp.expanduser(os.fspath(p)) 

627 if expand_vars: 

628 expanded_path = osp.expandvars(expanded_path) 

629 return osp.normpath(osp.abspath(expanded_path)) 

630 except Exception: 

631 return None 

632 

633 

634def remove_password_if_present(cmdline: Sequence[str]) -> List[str]: 

635 """Redact credentials in URLs and HTTP Authorization extra headers in a command line. 

636 

637 If nothing is found, this just returns the command line as-is. 

638 

639 This should be used for every log line that print a command line, as well as 

640 exception messages. 

641 """ 

642 new_cmdline = [] 

643 for index, to_parse in enumerate(cmdline): 

644 new_cmdline.append(to_parse) 

645 config_key, separator, header = to_parse.partition("=") 

646 header_name, colon, _ = header.partition(":") 

647 if ( 

648 separator 

649 and colon 

650 and config_key.lower().endswith(".extraheader") 

651 and header_name.strip().lower() == "authorization" 

652 ): 

653 new_cmdline[index] = "%s%s%s%s *****" % (config_key, separator, header_name, colon) 

654 continue 

655 try: 

656 url = urlsplit(to_parse) 

657 # Remove password from the URL if present. 

658 if url.password is None and url.username is None: 

659 continue 

660 

661 # Match urllib.parse's userinfo boundary. Keeping the raw hostinfo 

662 # preserves hostname case, IPv6 brackets, and port formatting. 

663 _, _, hostinfo = url.netloc.rpartition("@") 

664 redacted = "*****:*****" if url.password is not None else "*****" 

665 url = url._replace(netloc=f"{redacted}@{hostinfo}") 

666 new_cmdline[index] = urlunsplit(url) 

667 except ValueError: 

668 # This is not a valid URL. 

669 continue 

670 return new_cmdline 

671 

672 

673# } END utilities 

674 

675# { Classes 

676 

677 

678class RemoteProgress: 

679 """Handler providing an interface to parse progress information emitted by 

680 :manpage:`git-push(1)` and :manpage:`git-fetch(1)` and to dispatch callbacks 

681 allowing subclasses to react to the progress.""" 

682 

683 _num_op_codes: int = 9 

684 ( 

685 BEGIN, 

686 END, 

687 COUNTING, 

688 COMPRESSING, 

689 WRITING, 

690 RECEIVING, 

691 RESOLVING, 

692 FINDING_SOURCES, 

693 CHECKING_OUT, 

694 ) = [1 << x for x in range(_num_op_codes)] 

695 STAGE_MASK = BEGIN | END 

696 OP_MASK = ~STAGE_MASK 

697 

698 DONE_TOKEN = "done." 

699 TOKEN_SEPARATOR = ", " 

700 

701 __slots__ = ( 

702 "_cur_line", 

703 "_seen_ops", 

704 "error_lines", # Lines that started with 'error:' or 'fatal:'. 

705 "other_lines", # Lines not denoting progress (i.e.g. push-infos). 

706 ) 

707 re_op_absolute = re.compile(r"(remote: )?([\w\s]+):\s+()(\d+)()(.*)") 

708 re_op_relative = re.compile(r"(remote: )?([\w\s]+):\s+(\d+)% \((\d+)/(\d+)\)(.*)") 

709 

710 def __init__(self) -> None: 

711 self._seen_ops: List[int] = [] 

712 self._cur_line: Optional[str] = None 

713 self.error_lines: List[str] = [] 

714 self.other_lines: List[str] = [] 

715 

716 def _parse_progress_line(self, line: AnyStr) -> Optional[object]: 

717 """Parse progress information from the given line as retrieved by 

718 :manpage:`git-push(1)` or :manpage:`git-fetch(1)`. 

719 

720 - Lines that do not contain progress info are stored in :attr:`other_lines`. 

721 - Lines that seem to contain an error (i.e. start with ``error:`` or ``fatal:``) 

722 are stored in :attr:`error_lines`. 

723 

724 The base implementation returns ``None``. Subclasses may return another 

725 value for compatibility with existing overrides, but callers should treat 

726 the return value as unspecified. 

727 """ 

728 # handle 

729 # Counting objects: 4, done. 

730 # Compressing objects: 50% (1/2) 

731 # Compressing objects: 100% (2/2) 

732 # Compressing objects: 100% (2/2), done. 

733 if isinstance(line, bytes): # mypy argues about ternary assignment. 

734 line_str = line.decode("utf-8") 

735 else: 

736 line_str = line 

737 self._cur_line = line_str 

738 

739 if self._cur_line.startswith(("error:", "fatal:")): 

740 self.error_lines.append(self._cur_line) 

741 return None 

742 

743 cur_count, max_count = None, None 

744 match = self.re_op_relative.match(line_str) 

745 if match is None: 

746 match = self.re_op_absolute.match(line_str) 

747 

748 if not match: 

749 self.line_dropped(line_str) 

750 self.other_lines.append(line_str) 

751 return None 

752 # END could not get match 

753 

754 op_code = 0 

755 _remote, op_name, _percent, cur_count, max_count, message = match.groups() 

756 

757 # Get operation ID. 

758 if op_name == "Counting objects": 

759 op_code |= self.COUNTING 

760 elif op_name == "Compressing objects": 

761 op_code |= self.COMPRESSING 

762 elif op_name == "Writing objects": 

763 op_code |= self.WRITING 

764 elif op_name == "Receiving objects": 

765 op_code |= self.RECEIVING 

766 elif op_name == "Resolving deltas": 

767 op_code |= self.RESOLVING 

768 elif op_name == "Finding sources": 

769 op_code |= self.FINDING_SOURCES 

770 elif op_name == "Checking out files": 

771 op_code |= self.CHECKING_OUT 

772 else: 

773 # Note: On Windows it can happen that partial lines are sent. 

774 # Hence we get something like "CompreReceiving objects", which is 

775 # a blend of "Compressing objects" and "Receiving objects". 

776 # This can't really be prevented, so we drop the line verbosely 

777 # to make sure we get informed in case the process spits out new 

778 # commands at some point. 

779 self.line_dropped(line_str) 

780 # Note: Don't add this line to the other lines, as we have to silently 

781 # drop it. 

782 return None 

783 # END handle op code 

784 

785 # Figure out stage. 

786 if op_code not in self._seen_ops: 

787 self._seen_ops.append(op_code) 

788 op_code |= self.BEGIN 

789 # END begin opcode 

790 

791 if message is None: 

792 message = "" 

793 # END message handling 

794 

795 message = message.strip() 

796 if message.endswith(self.DONE_TOKEN): 

797 op_code |= self.END 

798 message = message[: -len(self.DONE_TOKEN)] 

799 # END end message handling 

800 message = message.strip(self.TOKEN_SEPARATOR) 

801 

802 self.update( 

803 op_code, 

804 cur_count and float(cur_count), 

805 max_count and float(max_count), 

806 message, 

807 ) 

808 return None 

809 

810 def new_message_handler(self) -> Callable[[str], None]: 

811 """ 

812 :return: 

813 A progress handler suitable for :func:`~git.cmd.handle_process_output`, 

814 passing lines on to this progress handler in a suitable format. 

815 """ 

816 

817 def handler(line: AnyStr) -> None: 

818 self._parse_progress_line(line.rstrip()) 

819 

820 # END handler 

821 

822 return handler 

823 

824 def line_dropped(self, line: str) -> None: 

825 """Called whenever a line could not be understood and was therefore dropped.""" 

826 pass 

827 

828 def update( 

829 self, 

830 op_code: int, 

831 cur_count: Union[str, float], 

832 max_count: Union[str, float, None] = None, 

833 message: str = "", 

834 ) -> None: 

835 """Called whenever the progress changes. 

836 

837 :param op_code: 

838 Integer allowing to be compared against Operation IDs and stage IDs. 

839 

840 Stage IDs are :const:`BEGIN` and :const:`END`. :const:`BEGIN` will only be 

841 set once for each Operation ID as well as :const:`END`. It may be that 

842 :const:`BEGIN` and :const:`END` are set at once in case only one progress 

843 message was emitted due to the speed of the operation. Between 

844 :const:`BEGIN` and :const:`END`, none of these flags will be set. 

845 

846 Operation IDs are all held within the :const:`OP_MASK`. Only one Operation 

847 ID will be active per call. 

848 

849 :param cur_count: 

850 Current absolute count of items. 

851 

852 :param max_count: 

853 The maximum count of items we expect. It may be ``None`` in case there is no 

854 maximum number of items or if it is (yet) unknown. 

855 

856 :param message: 

857 In case of the :const:`WRITING` operation, it contains the amount of bytes 

858 transferred. It may possibly be used for other purposes as well. 

859 

860 :note: 

861 You may read the contents of the current line in 

862 :attr:`self._cur_line <_cur_line>`. 

863 """ 

864 pass 

865 

866 

867class CallableRemoteProgress(RemoteProgress): 

868 """A :class:`RemoteProgress` implementation forwarding updates to any callable. 

869 

870 :note: 

871 Like direct instances of :class:`RemoteProgress`, instances of this 

872 :class:`CallableRemoteProgress` class are not themselves directly callable. 

873 Rather, instances of this class wrap a callable and forward to it. This should 

874 therefore not be confused with :class:`git.types.CallableProgress`. 

875 """ 

876 

877 __slots__ = ("_callable",) 

878 

879 def __init__(self, fn: Callable[..., Any]) -> None: 

880 self._callable = fn 

881 super().__init__() 

882 

883 def update(self, *args: Any, **kwargs: Any) -> None: 

884 self._callable(*args, **kwargs) 

885 

886 

887class _DeprecatedActorNameEmailRegex: 

888 _pattern = re.compile(r"(.*) <(.*?)>") 

889 

890 def __get__(self, _instance: Any, _owner: Any) -> Pattern[str]: 

891 warnings.warn( 

892 "Actor.name_email_regex is deprecated and will be removed in GitPython 4.0.0 because searching long " 

893 "malformed strings with it can take quadratic time. Use Actor.from_string() to parse actor identities, " 

894 "or Actor(name, email) when the fields are already separate.", 

895 DeprecationWarning, 

896 stacklevel=2, 

897 ) 

898 return self._pattern 

899 

900 

901class Actor: 

902 """Actors hold information about a person acting on the repository. They can be 

903 committers and authors or anything with a name and an email as mentioned in the git 

904 log entries.""" 

905 

906 name_email_regex = _DeprecatedActorNameEmailRegex() 

907 

908 # ENVIRONMENT VARIABLES 

909 # These are read when creating new commits. 

910 env_author_name = "GIT_AUTHOR_NAME" 

911 env_author_email = "GIT_AUTHOR_EMAIL" 

912 env_committer_name = "GIT_COMMITTER_NAME" 

913 env_committer_email = "GIT_COMMITTER_EMAIL" 

914 

915 # CONFIGURATION KEYS 

916 conf_name = "name" 

917 conf_email = "email" 

918 

919 __slots__ = ("name", "email") 

920 

921 def __init__(self, name: Optional[str], email: Optional[str]) -> None: 

922 self.name = name 

923 self.email = email 

924 

925 def __eq__(self, other: Any) -> bool: 

926 return self.name == other.name and self.email == other.email 

927 

928 def __ne__(self, other: Any) -> bool: 

929 return not (self == other) 

930 

931 def __hash__(self) -> int: 

932 return hash((self.name, self.email)) 

933 

934 def __str__(self) -> str: 

935 return self.name if self.name else "" 

936 

937 def __repr__(self) -> str: 

938 return '<git.Actor "%s <%s>">' % (self.name, self.email) 

939 

940 @classmethod 

941 def from_string(cls: Type[T_Actor], string: str) -> T_Actor: 

942 """Create an :class:`Actor` from a string. 

943 

944 :param string: 

945 The string, which is expected to be in regular git format:: 

946 

947 John Doe <jdoe@example.com> 

948 

949 :return: 

950 :class:`Actor` 

951 """ 

952 line = string.partition("\n")[0] 

953 left_bracket = line.find("<") 

954 right_bracket = line.find(">", left_bracket + 1) 

955 if left_bracket >= 0 and right_bracket >= 0: 

956 return cls(line[:left_bracket].rstrip(), line[left_bracket + 1 : right_bracket]) 

957 

958 # Assume the best and use the whole string as name. 

959 return cls(string, None) 

960 

961 _from_string = from_string 

962 

963 @classmethod 

964 def _main_actor( 

965 cls, 

966 env_name: str, 

967 env_email: str, 

968 config_reader: Union[None, "GitConfigParser", "SectionConstraint[GitConfigParser]"] = None, 

969 ) -> "Actor": 

970 actor = Actor("", "") 

971 user_id = None # We use this to avoid multiple calls to getpass.getuser(). 

972 

973 def default_email() -> str: 

974 nonlocal user_id 

975 if not user_id: 

976 user_id = get_user_id() 

977 return user_id 

978 

979 def default_name() -> str: 

980 return default_email().split("@")[0] 

981 

982 for attr, evar, cvar, default in ( 

983 ("name", env_name, cls.conf_name, default_name), 

984 ("email", env_email, cls.conf_email, default_email), 

985 ): 

986 try: 

987 val = os.environ[evar] 

988 setattr(actor, attr, val) 

989 except KeyError: 

990 if config_reader is not None: 

991 try: 

992 val = config_reader.get("user", cvar) 

993 except Exception: 

994 val = default() 

995 setattr(actor, attr, val) 

996 # END config-reader handling 

997 if not getattr(actor, attr): 

998 setattr(actor, attr, default()) 

999 # END handle name 

1000 # END for each item to retrieve 

1001 return actor 

1002 

1003 @classmethod 

1004 def committer( 

1005 cls, config_reader: Union[None, "GitConfigParser", "SectionConstraint[GitConfigParser]"] = None 

1006 ) -> "Actor": 

1007 """ 

1008 :return: 

1009 :class:`Actor` instance corresponding to the configured committer. It 

1010 behaves similar to the git implementation, such that the environment will 

1011 override configuration values of `config_reader`. If no value is set at all, 

1012 it will be generated. 

1013 

1014 :param config_reader: 

1015 ConfigReader to use to retrieve the values from in case they are not set in 

1016 the environment. 

1017 """ 

1018 return cls._main_actor(cls.env_committer_name, cls.env_committer_email, config_reader) 

1019 

1020 @classmethod 

1021 def author( 

1022 cls, config_reader: Union[None, "GitConfigParser", "SectionConstraint[GitConfigParser]"] = None 

1023 ) -> "Actor": 

1024 """Same as :meth:`committer`, but defines the main author. It may be specified 

1025 in the environment, but defaults to the committer.""" 

1026 return cls._main_actor(cls.env_author_name, cls.env_author_email, config_reader) 

1027 

1028 

1029class Stats: 

1030 """Represents stat information as presented by git at the end of a merge. It is 

1031 created from the output of a diff operation. 

1032 

1033 Example:: 

1034 

1035 c = Commit( sha1 ) 

1036 s = c.stats 

1037 s.total # full-stat-dict 

1038 s.files # dict( filepath : stat-dict ) 

1039 

1040 ``stat-dict`` 

1041 

1042 A dictionary with the following keys and values:: 

1043 

1044 deletions = number of deleted lines as int 

1045 insertions = number of inserted lines as int 

1046 lines = total number of lines changed as int, or deletions + insertions 

1047 change_type = type of change as str, A|C|D|M|R|T|U|X|B 

1048 

1049 ``full-stat-dict`` 

1050 

1051 In addition to the items in the stat-dict, it features additional information:: 

1052 

1053 files = number of changed files as int 

1054 """ 

1055 

1056 __slots__ = ("total", "files") 

1057 

1058 def __init__(self, total: Total_TD, files: Dict[PathLike, Files_TD]) -> None: 

1059 self.total = total 

1060 self.files = files 

1061 

1062 @classmethod 

1063 def _list_from_string(cls, repo: "Repo", text: str) -> "Stats": 

1064 """Create a :class:`Stats` object from output retrieved by 

1065 :manpage:`git-diff(1)`. 

1066 

1067 :return: 

1068 :class:`git.Stats` 

1069 """ 

1070 

1071 hsh: HSH_TD = { 

1072 "total": {"insertions": 0, "deletions": 0, "lines": 0, "files": 0}, 

1073 "files": {}, 

1074 } 

1075 for line in text.splitlines(): 

1076 (change_type, raw_insertions, raw_deletions, filename) = line.split("\t") 

1077 insertions = raw_insertions != "-" and int(raw_insertions) or 0 

1078 deletions = raw_deletions != "-" and int(raw_deletions) or 0 

1079 hsh["total"]["insertions"] += insertions 

1080 hsh["total"]["deletions"] += deletions 

1081 hsh["total"]["lines"] += insertions + deletions 

1082 hsh["total"]["files"] += 1 

1083 files_dict: Files_TD = { 

1084 "insertions": insertions, 

1085 "deletions": deletions, 

1086 "lines": insertions + deletions, 

1087 "change_type": change_type, 

1088 } 

1089 hsh["files"][filename.strip()] = files_dict 

1090 return Stats(hsh["total"], hsh["files"]) 

1091 

1092 

1093class IndexFileSHA1Writer: 

1094 """Wrapper around a file-like object that remembers the SHA1 of the data written to 

1095 it. It will write a sha when the stream is closed or if asked for explicitly using 

1096 :meth:`write_sha`. 

1097 

1098 Only useful to the index file. 

1099 

1100 :note: 

1101 Based on the dulwich project. 

1102 """ 

1103 

1104 __slots__ = ("f", "sha1") 

1105 

1106 def __init__(self, f: IO[bytes]) -> None: 

1107 self.f = f 

1108 self.sha1 = make_sha(b"") 

1109 

1110 def write(self, data: bytes) -> int: 

1111 self.sha1.update(data) 

1112 return self.f.write(data) 

1113 

1114 def write_sha(self) -> bytes: 

1115 sha = self.sha1.digest() 

1116 self.f.write(sha) 

1117 return sha 

1118 

1119 def close(self) -> bytes: 

1120 sha = self.write_sha() 

1121 self.f.close() 

1122 return sha 

1123 

1124 def tell(self) -> int: 

1125 return self.f.tell() 

1126 

1127 

1128class LockFile: 

1129 """Provides methods to obtain, check for, and release a file based lock which 

1130 should be used to handle concurrent access to the same file. 

1131 

1132 As we are a utility class to be derived from, we only use protected methods. 

1133 

1134 Locks will automatically be released on destruction. 

1135 """ 

1136 

1137 __slots__ = ("_file_path", "_owns_lock") 

1138 

1139 def __init__(self, file_path: PathLike) -> None: 

1140 self._file_path = file_path 

1141 self._owns_lock = False 

1142 

1143 def __del__(self) -> None: 

1144 self._release_lock() 

1145 

1146 def _lock_file_path(self) -> str: 

1147 """:return: Path to lockfile""" 

1148 return "%s.lock" % (self._file_path) 

1149 

1150 def _has_lock(self) -> bool: 

1151 """ 

1152 :return: 

1153 True if we have a lock and if the lockfile still exists 

1154 

1155 :raise AssertionError: 

1156 If our lock-file does not exist. 

1157 """ 

1158 return self._owns_lock 

1159 

1160 def _obtain_lock_or_raise(self) -> None: 

1161 """Create a lock file as flag for other instances, mark our instance as 

1162 lock-holder. 

1163 

1164 :raise IOError: 

1165 If a lock was already present or a lock file could not be written. 

1166 """ 

1167 if self._has_lock(): 

1168 return 

1169 lock_file = self._lock_file_path() 

1170 # Create the lock in one step. Checking for it first would allow another 

1171 # holder to create it between the check and the open. 

1172 try: 

1173 if sys.platform == "win32": 

1174 if "\0" in lock_file: 

1175 raise ValueError("embedded null character") 

1176 

1177 import ctypes 

1178 from ctypes import wintypes 

1179 

1180 # Unlike POSIX, Windows follows dangling symlinks even with O_EXCL. 

1181 # Open the reparse point itself so an existing link is rejected. 

1182 # Call the Unicode API directly: older _winapi.CreateFile wrappers 

1183 # use the ANSI API and can create a lock under the wrong filename. 

1184 kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) 

1185 create_file = kernel32.CreateFileW 

1186 create_file.argtypes = ( 

1187 wintypes.LPCWSTR, 

1188 wintypes.DWORD, 

1189 wintypes.DWORD, 

1190 wintypes.LPVOID, 

1191 wintypes.DWORD, 

1192 wintypes.DWORD, 

1193 wintypes.HANDLE, 

1194 ) 

1195 create_file.restype = wintypes.HANDLE 

1196 close_handle = kernel32.CloseHandle 

1197 close_handle.argtypes = (wintypes.HANDLE,) 

1198 close_handle.restype = wintypes.BOOL 

1199 handle = create_file( 

1200 lock_file, 

1201 0x40000000, # GENERIC_WRITE 

1202 0, 

1203 None, 

1204 1, # CREATE_NEW 

1205 0x00200000, # FILE_FLAG_OPEN_REPARSE_POINT 

1206 None, 

1207 ) 

1208 if handle == wintypes.HANDLE(-1).value: 

1209 raise ctypes.WinError(ctypes.get_last_error()) 

1210 if not close_handle(handle): 

1211 raise ctypes.WinError(ctypes.get_last_error()) 

1212 else: 

1213 fd = os.open(lock_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) 

1214 os.close(fd) 

1215 except FileExistsError as e: 

1216 raise OSError( 

1217 "Lock for file %r did already exist, delete %r in case the lock is illegal" 

1218 % (self._file_path, lock_file) 

1219 ) from e 

1220 except OSError as e: 

1221 raise OSError(str(e)) from e 

1222 

1223 self._owns_lock = True 

1224 

1225 def _obtain_lock(self) -> None: 

1226 """The default implementation will raise if a lock cannot be obtained. 

1227 

1228 Subclasses may override this method to provide a different implementation. 

1229 """ 

1230 return self._obtain_lock_or_raise() 

1231 

1232 def _release_lock(self) -> None: 

1233 """Release our lock if we have one.""" 

1234 if not self._has_lock(): 

1235 return 

1236 

1237 # If someone removed our file beforehand, lets just flag this issue instead of 

1238 # failing, to make it more usable. 

1239 lfp = self._lock_file_path() 

1240 try: 

1241 rmfile(lfp) 

1242 except OSError: 

1243 pass 

1244 self._owns_lock = False 

1245 

1246 

1247class BlockingLockFile(LockFile): 

1248 """The lock file will block until a lock could be obtained, or fail after a 

1249 specified timeout. 

1250 

1251 :note: 

1252 If the directory containing the lock was removed, an exception will be raised 

1253 during the blocking period, preventing hangs as the lock can never be obtained. 

1254 """ 

1255 

1256 __slots__ = ("_check_interval", "_max_block_time") 

1257 

1258 def __init__( 

1259 self, 

1260 file_path: PathLike, 

1261 check_interval_s: float = 0.3, 

1262 max_block_time_s: int = sys.maxsize, 

1263 ) -> None: 

1264 """Configure the instance. 

1265 

1266 :param check_interval_s: 

1267 Period of time to sleep until the lock is checked the next time. 

1268 By default, it waits a nearly unlimited time. 

1269 

1270 :param max_block_time_s: 

1271 Maximum amount of seconds we may lock. 

1272 """ 

1273 super().__init__(file_path) 

1274 self._check_interval = check_interval_s 

1275 self._max_block_time = max_block_time_s 

1276 

1277 def _obtain_lock(self) -> None: 

1278 """This method blocks until it obtained the lock, or raises :exc:`IOError` if it 

1279 ran out of time or if the parent directory was not available anymore. 

1280 

1281 If this method returns, you are guaranteed to own the lock. 

1282 """ 

1283 starttime = time.time() 

1284 maxtime = starttime + float(self._max_block_time) 

1285 while True: 

1286 try: 

1287 super()._obtain_lock() 

1288 except OSError as e: 

1289 # synity check: if the directory leading to the lockfile is not 

1290 # readable anymore, raise an exception 

1291 curtime = time.time() 

1292 if not osp.isdir(osp.dirname(self._lock_file_path())): 

1293 msg = "Directory containing the lockfile %r was not readable anymore after waiting %g seconds" % ( 

1294 self._lock_file_path(), 

1295 curtime - starttime, 

1296 ) 

1297 raise OSError(msg) from e 

1298 # END handle missing directory 

1299 

1300 if curtime >= maxtime: 

1301 msg = "Waited %g seconds for lock at %r" % ( 

1302 maxtime - starttime, 

1303 self._lock_file_path(), 

1304 ) 

1305 raise OSError(msg) from e 

1306 # END abort if we wait too long 

1307 time.sleep(self._check_interval) 

1308 else: 

1309 break 

1310 # END endless loop 

1311 

1312 

1313class IterableList(List[T_IterableObj]): # type: ignore[type-var] 

1314 """List of iterable objects allowing to query an object by id or by named index:: 

1315 

1316 heads = repo.heads 

1317 heads.master 

1318 heads['master'] 

1319 heads[0] 

1320 

1321 Iterable parent objects: 

1322 

1323 * :class:`Commit <git.objects.Commit>` 

1324 * :class:`Submodule <git.objects.submodule.base.Submodule>` 

1325 * :class:`Reference <git.refs.reference.Reference>` 

1326 * :class:`FetchInfo <git.remote.FetchInfo>` 

1327 * :class:`PushInfo <git.remote.PushInfo>` 

1328 

1329 Iterable via inheritance: 

1330 

1331 * :class:`Head <git.refs.head.Head>` 

1332 * :class:`TagReference <git.refs.tag.TagReference>` 

1333 * :class:`RemoteReference <git.refs.remote.RemoteReference>` 

1334 

1335 This requires an ``id_attribute`` name to be set which will be queried from its 

1336 contained items to have a means for comparison. 

1337 

1338 A prefix can be specified which is to be used in case the id returned by the items 

1339 always contains a prefix that does not matter to the user, so it can be left out. 

1340 """ 

1341 

1342 __slots__ = ("_id_attr", "_prefix") 

1343 

1344 def __new__(cls, id_attr: str, prefix: str = "") -> "IterableList[T_IterableObj]": 

1345 return super().__new__(cls) 

1346 

1347 def __init__(self, id_attr: str, prefix: str = "") -> None: 

1348 super().__init__() 

1349 self._id_attr = id_attr 

1350 self._prefix = prefix 

1351 

1352 def __contains__(self, attr: object) -> bool: 

1353 # First try identity match for performance. 

1354 try: 

1355 rval = list.__contains__(self, attr) 

1356 if rval: 

1357 return rval 

1358 except (AttributeError, TypeError): 

1359 pass 

1360 # END handle match 

1361 

1362 # Otherwise make a full name search. 

1363 try: 

1364 getattr(self, cast(str, attr)) # Use cast to silence mypy. 

1365 return True 

1366 except (AttributeError, TypeError): 

1367 return False 

1368 # END handle membership 

1369 

1370 def __getattr__(self, attr: str) -> T_IterableObj: 

1371 attr = self._prefix + attr 

1372 for item in self: 

1373 if getattr(item, self._id_attr) == attr: 

1374 return item 

1375 # END for each item 

1376 return list.__getattribute__(self, attr) 

1377 

1378 def __getitem__( # type: ignore[override] # pyright: ignore[reportIncompatibleMethodOverride] 

1379 self, index: Union[SupportsIndex, int, slice, str] 

1380 ) -> T_IterableObj: 

1381 if isinstance(index, int): 

1382 return list.__getitem__(self, index) 

1383 elif isinstance(index, slice): 

1384 raise ValueError("Index should be an int or str") 

1385 else: 

1386 try: 

1387 return getattr(self, cast(str, index)) 

1388 except AttributeError as e: 

1389 raise IndexError(f"No item found with id {self._prefix}{index}") from e 

1390 # END handle getattr 

1391 

1392 def __delitem__(self, index: Union[SupportsIndex, int, slice, str]) -> None: 

1393 delindex = cast(int, index) 

1394 if isinstance(index, str): 

1395 delindex = -1 

1396 name = self._prefix + index 

1397 for i, item in enumerate(self): 

1398 if getattr(item, self._id_attr) == name: 

1399 delindex = i 

1400 break 

1401 # END search index 

1402 # END for each item 

1403 if delindex == -1: 

1404 raise IndexError("Item with name %s not found" % name) 

1405 # END handle error 

1406 # END get index to delete 

1407 list.__delitem__(self, delindex) 

1408 

1409 

1410@runtime_checkable 

1411class IterableObj(Protocol): 

1412 """Defines an interface for iterable items, so there is a uniform way to retrieve 

1413 and iterate items within the git repository. 

1414 

1415 Subclasses: 

1416 

1417 * :class:`Submodule <git.objects.submodule.base.Submodule>` 

1418 * :class:`Commit <git.objects.Commit>` 

1419 * :class:`Reference <git.refs.reference.Reference>` 

1420 * :class:`PushInfo <git.remote.PushInfo>` 

1421 * :class:`FetchInfo <git.remote.FetchInfo>` 

1422 * :class:`Remote <git.remote.Remote>` 

1423 """ 

1424 

1425 __slots__ = () 

1426 

1427 _id_attribute_: str 

1428 

1429 @classmethod 

1430 @abstractmethod 

1431 def iter_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> Iterator[T_IterableObj]: 

1432 # Return-typed to be compatible with subtypes e.g. Remote. 

1433 """Find (all) items of this type. 

1434 

1435 Subclasses can specify `args` and `kwargs` differently, and may use them for 

1436 filtering. However, when the method is called with no additional positional or 

1437 keyword arguments, subclasses are obliged to to yield all items. 

1438 

1439 :return: 

1440 Iterator yielding Items 

1441 """ 

1442 raise NotImplementedError("To be implemented by Subclass") 

1443 

1444 @classmethod 

1445 def list_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> IterableList[T_IterableObj]: 

1446 """Find (all) items of this type and collect them into a list. 

1447 

1448 For more information about the arguments, see :meth:`iter_items`. 

1449 

1450 :note: 

1451 Favor the :meth:`iter_items` method as it will avoid eagerly collecting all 

1452 items. When there are many items, that can slow performance and increase 

1453 memory usage. 

1454 

1455 :return: 

1456 list(Item,...) list of item instances 

1457 """ 

1458 out_list: IterableList[T_IterableObj] = IterableList(cls._id_attribute_) 

1459 out_list.extend(cls.iter_items(repo, *args, **kwargs)) 

1460 return out_list 

1461 

1462 

1463class IterableClassWatcher(type): 

1464 """Metaclass that issues :exc:`DeprecationWarning` when :class:`git.util.Iterable` 

1465 is subclassed.""" 

1466 

1467 def __init__(cls, name: str, bases: Tuple[type, ...], clsdict: Dict[str, Any]) -> None: 

1468 super().__init__(name, bases, clsdict) 

1469 for base in bases: 

1470 if type(base) is IterableClassWatcher: 

1471 warnings.warn( 

1472 f"GitPython Iterable subclassed by {name}." 

1473 " Iterable is deprecated due to naming clash since v3.1.18" 

1474 " and will be removed in 4.0.0." 

1475 " Use IterableObj instead.", 

1476 DeprecationWarning, 

1477 stacklevel=2, 

1478 ) 

1479 

1480 

1481class Iterable(metaclass=IterableClassWatcher): 

1482 """Deprecated, use :class:`IterableObj` instead. 

1483 

1484 Defines an interface for iterable items, so there is a uniform way to retrieve 

1485 and iterate items within the git repository. 

1486 """ 

1487 

1488 __slots__ = () 

1489 

1490 _id_attribute_ = "attribute that most suitably identifies your instance" 

1491 

1492 @classmethod 

1493 def iter_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> Any: 

1494 """Deprecated, use :class:`IterableObj` instead. 

1495 

1496 Find (all) items of this type. 

1497 

1498 See :meth:`IterableObj.iter_items` for details on usage. 

1499 

1500 :return: 

1501 Iterator yielding Items 

1502 """ 

1503 raise NotImplementedError("To be implemented by Subclass") 

1504 

1505 @classmethod 

1506 def list_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> Any: 

1507 """Deprecated, use :class:`IterableObj` instead. 

1508 

1509 Find (all) items of this type and collect them into a list. 

1510 

1511 See :meth:`IterableObj.list_items` for details on usage. 

1512 

1513 :return: 

1514 list(Item,...) list of item instances 

1515 """ 

1516 out_list: Any = IterableList(cls._id_attribute_) 

1517 out_list.extend(cls.iter_items(repo, *args, **kwargs)) 

1518 return out_list 

1519 

1520 

1521# } END classes