From ad4079dde47ce721e7652f56a81a28063052a166 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Sun, 28 Feb 2021 20:56:27 +0000 Subject: [PATCH 01/16] add types to base.py and fun.py --- git/repo/base.py | 271 ++++++++++++++++++++++++++++------------------- git/repo/fun.py | 37 ++++--- 2 files changed, 187 insertions(+), 121 deletions(-) diff --git a/git/repo/base.py b/git/repo/base.py index 8f1ef0a6e..253631063 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -4,7 +4,11 @@ # This module is part of GitPython and is released under # the BSD License: http://www.opensource.org/licenses/bsd-license.php -from collections import namedtuple + +from git.objects.tag import TagObject +from git.objects.blob import Blob +from git.objects.tree import Tree +from git.refs.symbolic import SymbolicReference import logging import os import re @@ -26,23 +30,34 @@ 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.util import Actor, finalize_process, decygpath, hex_to_bin, expand_path +from git.util import Actor, IterableList, finalize_process, decygpath, hex_to_bin, expand_path import os.path as osp from .fun import rev_parse, is_git_dir, find_submodule_git_dir, touch, find_worktree_git_dir import gc import gitdb -try: - import pathlib -except ImportError: - pathlib = None +# Typing ------------------------------------------------------------------- +from typing import (Any, BinaryIO, Callable, Dict, Iterator, List, Mapping, Optional, + TextIO, Tuple, Type, Union, NamedTuple, cast,) +from typing_extensions import Literal +from git.types import PathLike, TBD -log = logging.getLogger(__name__) +Lit_config_levels = Literal['system', 'global', 'user', 'repository'] + + +# -------------------------------------------------------------------------- -BlameEntry = namedtuple('BlameEntry', ['commit', 'linenos', 'orig_path', 'orig_linenos']) +class BlameEntry(NamedTuple): + commit: Dict[str, TBD] # Any == 'Commit' type? + linenos: range + orig_path: Optional[str] + orig_linenos: range + + +log = logging.getLogger(__name__) __all__ = ('Repo',) @@ -63,11 +78,11 @@ class Repo(object): 'git_dir' is the .git repository directory, which is always set.""" DAEMON_EXPORT_FILE = 'git-daemon-export-ok' - git = None # Must exist, or __del__ will fail in case we raise on `__init__()` - working_dir = None - _working_tree_dir = None - git_dir = None - _common_dir = None + git = cast('Git', None) # Must exist, or __del__ will fail in case we raise on `__init__()` + working_dir = None # type: Optional[PathLike] + _working_tree_dir = None # type: Optional[PathLike] + git_dir = None # type: Optional[PathLike] + _common_dir = None # type: Optional[PathLike] # precompiled regex re_whitespace = re.compile(r'\s+') @@ -79,13 +94,14 @@ class Repo(object): # invariants # represents the configuration level of a configuration file - config_level = ("system", "user", "global", "repository") + config_level = ("system", "user", "global", "repository") # type: Tuple[Lit_config_levels, ...] # Subclass configuration # Subclasses may easily bring in their own custom types by placing a constructor or type here GitCommandWrapperType = Git - def __init__(self, path=None, odbt=GitCmdObjectDB, search_parent_directories=False, expand_vars=True): + def __init__(self, path: Optional[PathLike] = None, odbt: Type[GitCmdObjectDB] = GitCmdObjectDB, + search_parent_directories: bool = False, expand_vars: bool = True) -> None: """Create a new Repo instance :param path: @@ -126,8 +142,9 @@ def __init__(self, path=None, odbt=GitCmdObjectDB, search_parent_directories=Fal warnings.warn("The use of environment variables in paths is deprecated" + "\nfor security reasons and may be removed in the future!!") epath = expand_path(epath, expand_vars) - if not os.path.exists(epath): - raise NoSuchPathError(epath) + if epath is not None: + if not os.path.exists(epath): + raise NoSuchPathError(epath) ## Walk up the path to find the `.git` dir. # @@ -178,6 +195,7 @@ def __init__(self, path=None, odbt=GitCmdObjectDB, search_parent_directories=Fal # END while curpath if self.git_dir is None: + self.git_dir = cast(PathLike, self.git_dir) raise InvalidGitRepositoryError(epath) self._bare = False @@ -190,7 +208,7 @@ def __init__(self, path=None, odbt=GitCmdObjectDB, search_parent_directories=Fal try: common_dir = open(osp.join(self.git_dir, 'commondir'), 'rt').readlines()[0].strip() self._common_dir = osp.join(self.git_dir, common_dir) - except (OSError, IOError): + except OSError: self._common_dir = None # adjust the wd in case we are actually bare - we didn't know that @@ -199,28 +217,28 @@ def __init__(self, path=None, odbt=GitCmdObjectDB, search_parent_directories=Fal self._working_tree_dir = None # END working dir handling - self.working_dir = self._working_tree_dir or self.common_dir + self.working_dir = self._working_tree_dir or self.common_dir # type: Optional[PathLike] self.git = self.GitCommandWrapperType(self.working_dir) # special handling, in special times - args = [osp.join(self.common_dir, 'objects')] + args = [osp.join(self.common_dir, 'objects')] # type: List[Union[str, Git]] if issubclass(odbt, GitCmdObjectDB): args.append(self.git) self.odb = odbt(*args) - def __enter__(self): + def __enter__(self) -> 'Repo': return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, exc_type: TBD, exc_value: TBD, traceback: TBD) -> None: self.close() - def __del__(self): + def __del__(self) -> None: try: self.close() except Exception: pass - def close(self): + def close(self) -> None: if self.git: self.git.clear_cache() # Tempfiles objects on Windows are holding references to @@ -235,25 +253,26 @@ def close(self): if is_win: gc.collect() - def __eq__(self, rhs): - if isinstance(rhs, Repo): + def __eq__(self, rhs: object) -> bool: + if isinstance(rhs, Repo) and self.git_dir: return self.git_dir == rhs.git_dir return False - def __ne__(self, rhs): + def __ne__(self, rhs: object) -> bool: return not self.__eq__(rhs) - def __hash__(self): + def __hash__(self) -> int: return hash(self.git_dir) # Description property - def _get_description(self): - filename = osp.join(self.git_dir, 'description') + def _get_description(self) -> str: + filename = osp.join(self.git_dir, 'description') if self.git_dir else "" with open(filename, 'rb') as fp: return fp.read().rstrip().decode(defenc) - def _set_description(self, descr): - filename = osp.join(self.git_dir, 'description') + def _set_description(self, descr: str) -> None: + + filename = osp.join(self.git_dir, 'description') if self.git_dir else "" with open(filename, 'wb') as fp: fp.write((descr + '\n').encode(defenc)) @@ -263,25 +282,31 @@ def _set_description(self, descr): del _set_description @property - def working_tree_dir(self): + def working_tree_dir(self) -> Optional[PathLike]: """:return: The working tree directory of our git repository. If this is a bare repository, None is returned. """ return self._working_tree_dir @property - def common_dir(self): + def common_dir(self) -> PathLike: """ :return: The git dir that holds everything except possibly HEAD, FETCH_HEAD, ORIG_HEAD, COMMIT_EDITMSG, index, and logs/.""" - return self._common_dir or self.git_dir + if self._common_dir: + return self._common_dir + elif self.git_dir: + return self.git_dir + else: + # or could return "" + raise InvalidGitRepositoryError() @property - def bare(self): + def bare(self) -> bool: """:return: True if the repository is bare""" return self._bare @property - def heads(self): + def heads(self) -> IterableList: """A list of ``Head`` objects representing the branch heads in this repo @@ -289,7 +314,7 @@ def heads(self): return Head.list_items(self) @property - def references(self): + def references(self) -> IterableList: """A list of Reference objects representing tags, heads and remote references. :return: IterableList(Reference, ...)""" @@ -302,24 +327,24 @@ def references(self): branches = heads @property - def index(self): + def index(self) -> IndexFile: """:return: IndexFile representing this repository's index. :note: This property can be expensive, as the returned ``IndexFile`` will be reinitialized. It's recommended to re-use the object.""" return IndexFile(self) @property - def head(self): + def head(self) -> HEAD: """:return: HEAD Object pointing to the current head reference""" return HEAD(self, 'HEAD') @property - def remotes(self): + def remotes(self) -> IterableList: """A list of Remote objects allowing to access and manipulate remotes :return: ``git.IterableList(Remote, ...)``""" return Remote.list_items(self) - def remote(self, name='origin'): + def remote(self, name: str = 'origin') -> 'Remote': """:return: Remote with the specified name :raise ValueError: if no remote with such a name exists""" r = Remote(self, name) @@ -330,13 +355,13 @@ def remote(self, name='origin'): #{ Submodules @property - def submodules(self): + def submodules(self) -> IterableList: """ :return: git.IterableList(Submodule, ...) of direct submodules available from the current head""" return Submodule.list_items(self) - def submodule(self, name): + def submodule(self, name: str) -> IterableList: """ :return: Submodule with the given name :raise ValueError: If no such submodule exists""" try: @@ -345,7 +370,7 @@ def submodule(self, name): raise ValueError("Didn't find submodule named %r" % name) from e # END exception handling - def create_submodule(self, *args, **kwargs): + def create_submodule(self, *args: Any, **kwargs: Any) -> Submodule: """Create a new submodule :note: See the documentation of Submodule.add for a description of the @@ -353,13 +378,13 @@ def create_submodule(self, *args, **kwargs): :return: created submodules""" return Submodule.add(self, *args, **kwargs) - def iter_submodules(self, *args, **kwargs): + def iter_submodules(self, *args: Any, **kwargs: Any) -> Iterator: """An iterator yielding Submodule instances, see Traversable interface for a description of args and kwargs :return: Iterator""" return RootModule(self).traverse(*args, **kwargs) - def submodule_update(self, *args, **kwargs): + def submodule_update(self, *args: Any, **kwargs: Any) -> Iterator: """Update the submodules, keeping the repository consistent as it will take the previous state into consideration. For more information, please see the documentation of RootModule.update""" @@ -368,41 +393,45 @@ def submodule_update(self, *args, **kwargs): #}END submodules @property - def tags(self): + def tags(self) -> IterableList: """A list of ``Tag`` objects that are available in this repo :return: ``git.IterableList(TagReference, ...)`` """ return TagReference.list_items(self) - def tag(self, path): + def tag(self, path: PathLike) -> TagReference: """:return: TagReference Object, reference pointing to a Commit or Tag :param path: path to the tag reference, i.e. 0.1.5 or tags/0.1.5 """ return TagReference(self, path) - def create_head(self, path, commit='HEAD', force=False, logmsg=None): + def create_head(self, path: PathLike, commit: str = 'HEAD', + force: bool = False, logmsg: Optional[str] = None + ) -> SymbolicReference: """Create a new head within the repository. For more documentation, please see the Head.create method. :return: newly created Head Reference""" return Head.create(self, path, commit, force, logmsg) - def delete_head(self, *heads, **kwargs): + def delete_head(self, *heads: HEAD, **kwargs: Any) -> None: """Delete the given heads :param kwargs: Additional keyword arguments to be passed to git-branch""" return Head.delete(self, *heads, **kwargs) - def create_tag(self, path, ref='HEAD', message=None, force=False, **kwargs): + def create_tag(self, path: PathLike, ref: str = 'HEAD', + message: Optional[str] = None, force: bool = False, **kwargs: Any + ) -> TagReference: """Create a new tag reference. For more documentation, please see the TagReference.create method. :return: TagReference object """ return TagReference.create(self, path, ref, message, force, **kwargs) - def delete_tag(self, *tags): + def delete_tag(self, *tags: TBD) -> None: """Delete the given tag references""" return TagReference.delete(self, *tags) - def create_remote(self, name, url, **kwargs): + def create_remote(self, name: str, url: PathLike, **kwargs: Any) -> Remote: """Create a new remote. For more information, please see the documentation of the Remote.create @@ -411,11 +440,11 @@ def create_remote(self, name, url, **kwargs): :return: Remote reference""" return Remote.create(self, name, url, **kwargs) - def delete_remote(self, remote): + def delete_remote(self, remote: 'Remote') -> Type['Remote']: """Delete the given remote.""" return Remote.remove(self, remote) - def _get_config_path(self, config_level): + def _get_config_path(self, config_level: Lit_config_levels) -> str: # we do not support an absolute path of the gitconfig on windows , # use the global config instead if is_win and config_level == "system": @@ -429,11 +458,16 @@ def _get_config_path(self, config_level): elif config_level == "global": return osp.normpath(osp.expanduser("~/.gitconfig")) elif config_level == "repository": - return osp.normpath(osp.join(self._common_dir or self.git_dir, "config")) + if self._common_dir: + return osp.normpath(osp.join(self._common_dir, "config")) + elif self.git_dir: + return osp.normpath(osp.join(self.git_dir, "config")) + else: + raise NotADirectoryError raise ValueError("Invalid configuration level: %r" % config_level) - def config_reader(self, config_level=None): + def config_reader(self, config_level: Optional[Lit_config_levels] = None) -> GitConfigParser: """ :return: GitConfigParser allowing to read the full git configuration, but not to write it @@ -454,7 +488,7 @@ def config_reader(self, config_level=None): files = [self._get_config_path(config_level)] return GitConfigParser(files, read_only=True, repo=self) - def config_writer(self, config_level="repository"): + def config_writer(self, config_level: Lit_config_levels = "repository") -> GitConfigParser: """ :return: GitConfigParser allowing to write values of the specified configuration file level. @@ -469,7 +503,7 @@ def config_writer(self, config_level="repository"): repository = configuration file for this repository only""" return GitConfigParser(self._get_config_path(config_level), read_only=False, repo=self) - def commit(self, rev=None): + def commit(self, rev: Optional[TBD] = None,) -> Union[SymbolicReference, Commit, TagObject, Blob, Tree, None]: """The Commit object for the specified revision :param rev: revision specifier, see git-rev-parse for viable options. @@ -479,12 +513,12 @@ def commit(self, rev=None): return self.head.commit return self.rev_parse(str(rev) + "^0") - def iter_trees(self, *args, **kwargs): + def iter_trees(self, *args: Any, **kwargs: Any) -> Iterator['Tree']: """:return: Iterator yielding Tree objects :note: Takes all arguments known to iter_commits method""" return (c.tree for c in self.iter_commits(*args, **kwargs)) - def tree(self, rev=None): + def tree(self, rev: Union['Commit', 'Tree', None] = None) -> 'Tree': """The Tree object for the given treeish revision Examples:: @@ -501,7 +535,8 @@ def tree(self, rev=None): return self.head.commit.tree return self.rev_parse(str(rev) + "^{tree}") - def iter_commits(self, rev=None, paths='', **kwargs): + def iter_commits(self, rev: Optional[TBD] = None, paths: Union[PathLike, List[PathLike]] = '', + **kwargs: Any,) -> Iterator[Commit]: """A list of Commit objects representing the history of a given ref/commit :param rev: @@ -525,7 +560,8 @@ def iter_commits(self, rev=None, paths='', **kwargs): return Commit.iter_items(self, rev, paths, **kwargs) - def merge_base(self, *rev, **kwargs): + def merge_base(self, *rev: TBD, **kwargs: Any, + ) -> List[Union[SymbolicReference, Commit, TagObject, Blob, Tree, None]]: """Find the closest common ancestor for the given revision (e.g. Commits, Tags, References, etc) :param rev: At least two revs to find the common ancestor for. @@ -538,9 +574,9 @@ def merge_base(self, *rev, **kwargs): raise ValueError("Please specify at least two revs, got only %i" % len(rev)) # end handle input - res = [] + res = [] # type: List[Union[SymbolicReference, Commit, TagObject, Blob, Tree, None]] try: - lines = self.git.merge_base(*rev, **kwargs).splitlines() + lines = self.git.merge_base(*rev, **kwargs).splitlines() # List[str] except GitCommandError as err: if err.status == 128: raise @@ -556,7 +592,7 @@ def merge_base(self, *rev, **kwargs): return res - def is_ancestor(self, ancestor_rev, rev): + def is_ancestor(self, ancestor_rev: 'Commit', rev: 'Commit') -> bool: """Check if a commit is an ancestor of another :param ancestor_rev: Rev which should be an ancestor @@ -571,12 +607,12 @@ def is_ancestor(self, ancestor_rev, rev): raise return True - def _get_daemon_export(self): - filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) + def _get_daemon_export(self) -> bool: + filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) if self.git_dir else "" return osp.exists(filename) - def _set_daemon_export(self, value): - filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) + def _set_daemon_export(self, value: object) -> None: + filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) if self.git_dir else "" fileexists = osp.exists(filename) if value and not fileexists: touch(filename) @@ -588,11 +624,11 @@ def _set_daemon_export(self, value): del _get_daemon_export del _set_daemon_export - def _get_alternates(self): + def _get_alternates(self) -> List[str]: """The list of alternates for this repo from which objects can be retrieved :return: list of strings being pathnames of alternates""" - alternates_path = osp.join(self.git_dir, 'objects', 'info', 'alternates') + alternates_path = osp.join(self.git_dir, 'objects', 'info', 'alternates') if self.git_dir else "" if osp.exists(alternates_path): with open(alternates_path, 'rb') as f: @@ -600,7 +636,7 @@ def _get_alternates(self): return alts.strip().splitlines() return [] - def _set_alternates(self, alts): + def _set_alternates(self, alts: List[str]) -> None: """Sets the alternates :param alts: @@ -622,8 +658,8 @@ def _set_alternates(self, alts): alternates = property(_get_alternates, _set_alternates, doc="Retrieve a list of alternates paths or set a list paths to be used as alternates") - def is_dirty(self, index=True, working_tree=True, untracked_files=False, - submodules=True, path=None): + def is_dirty(self, index: bool = True, working_tree: bool = True, untracked_files: bool = False, + submodules: bool = True, path: Optional[PathLike] = None) -> bool: """ :return: ``True``, the repository is considered dirty. By default it will react @@ -639,7 +675,7 @@ def is_dirty(self, index=True, working_tree=True, untracked_files=False, if not submodules: default_args.append('--ignore-submodules') if path: - default_args.extend(["--", path]) + default_args.extend(["--", str(path)]) if index: # diff index against HEAD if osp.isfile(self.index.path) and \ @@ -658,7 +694,7 @@ def is_dirty(self, index=True, working_tree=True, untracked_files=False, return False @property - def untracked_files(self): + def untracked_files(self) -> List[str]: """ :return: list(str,...) @@ -673,7 +709,7 @@ def untracked_files(self): consider caching it yourself.""" return self._get_untracked_files() - def _get_untracked_files(self, *args, **kwargs): + def _get_untracked_files(self, *args: Any, **kwargs: Any) -> List[str]: # make sure we get all files, not only untracked directories proc = self.git.status(*args, porcelain=True, @@ -697,7 +733,7 @@ def _get_untracked_files(self, *args, **kwargs): finalize_process(proc) return untracked_files - def ignored(self, *paths): + def ignored(self, *paths: PathLike) -> List[PathLike]: """Checks if paths are ignored via .gitignore Doing so using the "git check-ignore" method. @@ -711,13 +747,13 @@ def ignored(self, *paths): return proc.replace("\\\\", "\\").replace('"', "").split("\n") @property - def active_branch(self): + def active_branch(self) -> 'SymbolicReference': """The name of the currently active branch. :return: Head to the active branch""" return self.head.reference - def blame_incremental(self, rev, file, **kwargs): + def blame_incremental(self, rev: TBD, file: TBD, **kwargs: Any) -> Optional[Iterator['BlameEntry']]: """Iterator for blame information for the given file at the given revision. Unlike .blame(), this does not return the actual file's contents, only @@ -791,22 +827,24 @@ def blame_incremental(self, rev, file, **kwargs): safe_decode(orig_filename), range(orig_lineno, orig_lineno + num_lines)) - def blame(self, rev, file, incremental=False, **kwargs): + def blame(self, rev: TBD, file: TBD, incremental: bool = False, **kwargs: Any + ) -> Union[List[List[Union[Optional['Commit'], List[str]]]], Optional[Iterator[BlameEntry]]]: """The blame information for the given file at the given revision. :param rev: revision specifier, see git-rev-parse for viable options. :return: list: [git.Commit, list: [<line>]] - A list of tuples associating a Commit object with a list of lines that + A list of lists associating a Commit object with a list of lines that changed within the given commit. The Commit objects will be given in order of appearance.""" if incremental: return self.blame_incremental(rev, file, **kwargs) data = self.git.blame(rev, '--', file, p=True, stdout_as_string=False, **kwargs) - commits = {} - blames = [] - info = None + commits = {} # type: Dict[str, Any] + blames = [] # type: List[List[Union[Optional['Commit'], List[str]]]] + + info = {} # type: Dict[str, Any] # use Any until TypedDict available keepends = True for line in data.splitlines(keepends): @@ -833,10 +871,12 @@ def blame(self, rev, file, incremental=False, **kwargs): digits = parts[-1].split(" ") if len(digits) == 3: info = {'id': firstpart} - blames.append([None, []]) - elif info['id'] != firstpart: + blames.append([None, [""]]) + elif not info or info['id'] != firstpart: info = {'id': firstpart} - blames.append([commits.get(firstpart), []]) + commits_firstpart = commits.get(firstpart) + blames.append([commits_firstpart, []]) + # END blame data initialization else: m = self.re_author_committer_start.search(firstpart) @@ -891,7 +931,10 @@ def blame(self, rev, file, incremental=False, **kwargs): pass # end handle line contents blames[-1][0] = c - blames[-1][1].append(line) + if blames[-1][1] is not None: + blames[-1][1].append(line) + else: + blames[-1][1] = [line] info = {'id': sha} # END if we collected commit info # END distinguish filename,summary,rest @@ -900,7 +943,8 @@ def blame(self, rev, file, incremental=False, **kwargs): return blames @classmethod - def init(cls, path=None, mkdir=True, odbt=GitCmdObjectDB, expand_vars=True, **kwargs): + def init(cls, path: PathLike = None, mkdir: bool = True, odbt: Type[GitCmdObjectDB] = GitCmdObjectDB, + expand_vars: bool = True, **kwargs: Any,) -> 'Repo': """Initialize a git repository at the given path if specified :param path: @@ -938,9 +982,12 @@ def init(cls, path=None, mkdir=True, odbt=GitCmdObjectDB, expand_vars=True, **kw return cls(path, odbt=odbt) @classmethod - def _clone(cls, git, url, path, odb_default_type, progress, multi_options=None, **kwargs): + def _clone(cls, git: 'Git', url: PathLike, path: PathLike, odb_default_type: Type[GitCmdObjectDB], + progress: Optional[Callable], + multi_options: Optional[List[str]] = None, **kwargs: Any, + ) -> 'Repo': if progress is not None: - progress = to_progress_instance(progress) + progress_checked = to_progress_instance(progress) odbt = kwargs.pop('odbt', odb_default_type) @@ -964,9 +1011,10 @@ def _clone(cls, git, url, path, odb_default_type, progress, multi_options=None, if multi_options: multi = ' '.join(multi_options).split(' ') proc = git.clone(multi, Git.polish_url(url), clone_path, with_extended_output=True, as_process=True, - v=True, universal_newlines=True, **add_progress(kwargs, git, progress)) - if progress: - handle_process_output(proc, None, progress.new_message_handler(), finalize_process, decode_streams=False) + v=True, universal_newlines=True, **add_progress(kwargs, git, progress_checked)) + if progress_checked: + handle_process_output(proc, None, progress_checked.new_message_handler(), + finalize_process, decode_streams=False) else: (stdout, stderr) = proc.communicate() log.debug("Cmd(%s)'s unused stdout: %s", getattr(proc, 'args', ''), stdout) @@ -974,8 +1022,8 @@ def _clone(cls, git, url, path, odb_default_type, progress, multi_options=None, # our git command could have a different working dir than our actual # environment, hence we prepend its working dir if required - if not osp.isabs(path) and git.working_dir: - path = osp.join(git._working_dir, path) + if not osp.isabs(path): + path = osp.join(git._working_dir, path) if git._working_dir is not None else path repo = cls(path, odbt=odbt) @@ -993,7 +1041,8 @@ def _clone(cls, git, url, path, odb_default_type, progress, multi_options=None, # END handle remote repo return repo - def clone(self, path, progress=None, multi_options=None, **kwargs): + def clone(self, path: PathLike, progress: Optional[Callable] = None, + multi_options: Optional[List[str]] = None, **kwargs: Any) -> 'Repo': """Create a clone from this repository. :param path: is the full path of the new repo (traditionally ends with ./<name>.git). @@ -1011,7 +1060,9 @@ def clone(self, path, progress=None, multi_options=None, **kwargs): return self._clone(self.git, self.common_dir, path, type(self.odb), progress, multi_options, **kwargs) @classmethod - def clone_from(cls, url, to_path, progress=None, env=None, multi_options=None, **kwargs): + def clone_from(cls, url: PathLike, to_path: PathLike, progress: Optional[Callable] = None, + env: Optional[Mapping[str, Any]] = None, + multi_options: Optional[List[str]] = None, **kwargs: Any) -> 'Repo': """Create a clone from the given URL :param url: valid git url, see http://www.kernel.org/pub/software/scm/git/docs/git-clone.html#URLS @@ -1031,7 +1082,8 @@ def clone_from(cls, url, to_path, progress=None, env=None, multi_options=None, * git.update_environment(**env) return cls._clone(git, url, to_path, GitCmdObjectDB, progress, multi_options, **kwargs) - def archive(self, ostream, treeish=None, prefix=None, **kwargs): + def archive(self, ostream: Union[TextIO, BinaryIO], treeish: Optional[str] = None, + prefix: Optional[str] = None, **kwargs: Any) -> 'Repo': """Archive the tree at the given revision. :param ostream: file compatible stream object to which the archive will be written as bytes @@ -1052,14 +1104,14 @@ def archive(self, ostream, treeish=None, prefix=None, **kwargs): kwargs['prefix'] = prefix kwargs['output_stream'] = ostream path = kwargs.pop('path', []) + path = cast(Union[PathLike, List[PathLike], Tuple[PathLike, ...]], path) if not isinstance(path, (tuple, list)): path = [path] # end assure paths is list - self.git.archive(treeish, *path, **kwargs) return self - def has_separate_working_tree(self): + def has_separate_working_tree(self) -> bool: """ :return: True if our git_dir is not at the root of our working_tree_dir, but a .git file with a platform agnositic symbolic link. Our git_dir will be wherever the .git file points to @@ -1067,21 +1119,24 @@ def has_separate_working_tree(self): """ if self.bare: return False - return osp.isfile(osp.join(self.working_tree_dir, '.git')) + if self.working_tree_dir: + return osp.isfile(osp.join(self.working_tree_dir, '.git')) + else: + return False # or raise Error? rev_parse = rev_parse - def __repr__(self): + def __repr__(self) -> str: clazz = self.__class__ return '<%s.%s %r>' % (clazz.__module__, clazz.__name__, self.git_dir) - def currently_rebasing_on(self): + def currently_rebasing_on(self) -> Union[SymbolicReference, Commit, TagObject, Blob, Tree, None]: """ :return: The commit which is currently being replayed while rebasing. None if we are not currently rebasing. """ - rebase_head_file = osp.join(self.git_dir, "REBASE_HEAD") + rebase_head_file = osp.join(self.git_dir, "REBASE_HEAD") if self.git_dir else "" if not osp.isfile(rebase_head_file): return None return self.commit(open(rebase_head_file, "rt").readline().strip()) diff --git a/git/repo/fun.py b/git/repo/fun.py index 714d41221..b81845932 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -1,4 +1,5 @@ """Package with general repository related functions""" +from git.refs.tag import Tag import os import stat from string import digits @@ -15,18 +16,27 @@ import os.path as osp from git.cmd import Git +# Typing ---------------------------------------------------------------------- + +from .base import Repo +from git.db import GitCmdObjectDB +from git.objects import Commit, TagObject, Blob, Tree +from typing import AnyStr, Union, Optional, cast +from git.types import PathLike + +# ---------------------------------------------------------------------------- __all__ = ('rev_parse', 'is_git_dir', 'touch', 'find_submodule_git_dir', 'name_to_object', 'short_to_long', 'deref_tag', 'to_commit', 'find_worktree_git_dir') -def touch(filename): +def touch(filename: str) -> str: with open(filename, "ab"): pass return filename -def is_git_dir(d): +def is_git_dir(d: PathLike) -> bool: """ This is taken from the git setup.c:is_git_directory function. @@ -48,7 +58,7 @@ def is_git_dir(d): return False -def find_worktree_git_dir(dotgit): +def find_worktree_git_dir(dotgit: PathLike) -> Optional[str]: """Search for a gitdir for this worktree.""" try: statbuf = os.stat(dotgit) @@ -67,7 +77,7 @@ def find_worktree_git_dir(dotgit): return None -def find_submodule_git_dir(d): +def find_submodule_git_dir(d: PathLike) -> Optional[PathLike]: """Search for a submodule repo.""" if is_git_dir(d): return d @@ -75,7 +85,7 @@ def find_submodule_git_dir(d): try: with open(d) as fp: content = fp.read().rstrip() - except (IOError, OSError): + except IOError: # it's probably not a file pass else: @@ -92,7 +102,7 @@ def find_submodule_git_dir(d): return None -def short_to_long(odb, hexsha): +def short_to_long(odb: GitCmdObjectDB, hexsha: AnyStr) -> Optional[bytes]: """:return: long hexadecimal sha1 from the given less-than-40 byte hexsha or None if no candidate could be found. :param hexsha: hexsha with less than 40 byte""" @@ -103,14 +113,15 @@ def short_to_long(odb, hexsha): # END exception handling -def name_to_object(repo, name, return_ref=False): +def name_to_object(repo: Repo, name: str, return_ref: bool = False, + ) -> Union[SymbolicReference, Commit, TagObject, Blob, Tree]: """ :return: object specified by the given name, hexshas ( short and long ) as well as references are supported :param return_ref: if name specifies a reference, we will return the reference instead of the object. Otherwise it will raise BadObject or BadName """ - hexsha = None + hexsha = None # type: Union[None, str, bytes] # is it a hexsha ? Try the most common ones, which is 7 to 40 if repo.re_hexsha_shortened.match(name): @@ -150,7 +161,7 @@ def name_to_object(repo, name, return_ref=False): return Object.new_from_sha(repo, hex_to_bin(hexsha)) -def deref_tag(tag): +def deref_tag(tag: Tag) -> TagObject: """Recursively dereference a tag and return the resulting object""" while True: try: @@ -161,7 +172,7 @@ def deref_tag(tag): return tag -def to_commit(obj): +def to_commit(obj: Object) -> Union[Commit, TagObject]: """Convert the given object to a commit if possible and return it""" if obj.type == 'tag': obj = deref_tag(obj) @@ -172,7 +183,7 @@ def to_commit(obj): return obj -def rev_parse(repo, rev): +def rev_parse(repo: Repo, rev: str) -> Union[Commit, Tag, Tree, Blob]: """ :return: Object at the given revision, either Commit, Tag, Tree or Blob :param rev: git-rev-parse compatible revision specification as string, please see @@ -188,7 +199,7 @@ def rev_parse(repo, rev): raise NotImplementedError("commit by message search ( regex )") # END handle search - obj = None + obj = cast(Object, None) # not ideal. Should use guards ref = None output_type = "commit" start = 0 @@ -238,7 +249,7 @@ def rev_parse(repo, rev): pass # error raised later # END exception handling elif output_type in ('', 'blob'): - if obj.type == 'tag': + if obj and obj.type == 'tag': obj = deref_tag(obj) else: # cannot do anything for non-tags From 5b0028e1e75e1ee0eea63ba78cb3160d49c1f3a3 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Sun, 28 Feb 2021 21:16:14 +0000 Subject: [PATCH 02/16] start add types to util.py --- git/cmd.py | 13 +-- git/compat.py | 21 +++-- git/config.py | 4 +- git/refs/symbolic.py | 2 +- git/util.py | 192 ++++++++++++++++++++++++------------------- 5 files changed, 135 insertions(+), 97 deletions(-) diff --git a/git/cmd.py b/git/cmd.py index 050efaedf..bac162176 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -19,6 +19,7 @@ import threading from collections import OrderedDict from textwrap import dedent +from typing import Any, Dict, List, Optional from git.compat import ( defenc, @@ -39,6 +40,8 @@ stream_copy, ) +from .types import PathLike + execute_kwargs = {'istream', 'with_extended_output', 'with_exceptions', 'as_process', 'stdout_as_string', 'output_stream', 'with_stdout', 'kill_after_timeout', @@ -516,7 +519,7 @@ def __del__(self): self._stream.read(bytes_left + 1) # END handle incomplete read - def __init__(self, working_dir=None): + def __init__(self, working_dir: Optional[PathLike]=None) -> None: """Initialize this instance with: :param working_dir: @@ -525,12 +528,12 @@ def __init__(self, working_dir=None): It is meant to be the working tree directory if available, or the .git directory in case of bare repositories.""" super(Git, self).__init__() - self._working_dir = expand_path(working_dir) + self._working_dir = expand_path(working_dir) if working_dir is not None else None self._git_options = () - self._persistent_git_options = [] + self._persistent_git_options = [] # type: List[str] # Extra environment variables to pass to git commands - self._environment = {} + self._environment = {} # type: Dict[str, Any] # cached command slots self.cat_file_header = None @@ -544,7 +547,7 @@ def __getattr__(self, name): return LazyMixin.__getattr__(self, name) return lambda *args, **kwargs: self._call_process(name, *args, **kwargs) - def set_persistent_git_options(self, **kwargs): + def set_persistent_git_options(self, **kwargs) -> None: """Specify command line options to the git executable for subsequent subcommand calls diff --git a/git/compat.py b/git/compat.py index de8a238ba..8d9e551d4 100644 --- a/git/compat.py +++ b/git/compat.py @@ -10,6 +10,7 @@ import locale import os import sys +from typing import AnyStr, Optional, Type from gitdb.utils.encoding import ( @@ -18,33 +19,38 @@ ) -is_win = (os.name == 'nt') +is_win = (os.name == 'nt') # type: bool is_posix = (os.name == 'posix') is_darwin = (os.name == 'darwin') defenc = sys.getfilesystemencoding() -def safe_decode(s): +def safe_decode(s: Optional[AnyStr]) -> Optional[str]: """Safely decodes a binary string to unicode""" if isinstance(s, str): return s elif isinstance(s, bytes): return s.decode(defenc, 'surrogateescape') - elif s is not None: + elif s is None: + return None + else: raise TypeError('Expected bytes or text, but got %r' % (s,)) -def safe_encode(s): - """Safely decodes a binary string to unicode""" + +def safe_encode(s: Optional[AnyStr]) -> Optional[bytes]: + """Safely encodes a binary string to unicode""" if isinstance(s, str): return s.encode(defenc) elif isinstance(s, bytes): return s - elif s is not None: + elif s is None: + return None + else: raise TypeError('Expected bytes or text, but got %r' % (s,)) -def win_encode(s): +def win_encode(s: Optional[AnyStr]) -> Optional[bytes]: """Encode unicodes for process arguments on Windows.""" if isinstance(s, str): return s.encode(locale.getpreferredencoding(False)) @@ -52,6 +58,7 @@ def win_encode(s): return s elif s is not None: raise TypeError('Expected bytes or text, but got %r' % (s,)) + return None def with_metaclass(meta, *bases): diff --git a/git/config.py b/git/config.py index 9f09efe2b..ffbbfab40 100644 --- a/git/config.py +++ b/git/config.py @@ -16,6 +16,8 @@ import fnmatch from collections import OrderedDict +from typing_extensions import Literal + from git.compat import ( defenc, force_text, @@ -194,7 +196,7 @@ def items_all(self): return [(k, self.getall(k)) for k in self] -def get_config_path(config_level): +def get_config_path(config_level: Literal['system','global','user','repository']) -> str: # we do not support an absolute path of the gitconfig on windows , # use the global config instead diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 60cfe554e..fb9b4f84b 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -513,7 +513,7 @@ def _create(cls, repo, path, resolve, reference, force, logmsg=None): return ref @classmethod - def create(cls, repo, path, reference='HEAD', force=False, logmsg=None): + def create(cls, repo, path, reference='HEAD', force=False, logmsg=None, **kwargs): """Create a new symbolic reference, hence a reference pointing to another reference. :param repo: diff --git a/git/util.py b/git/util.py index 04c967891..16c3e62a2 100644 --- a/git/util.py +++ b/git/util.py @@ -15,8 +15,16 @@ import stat from sys import maxsize import time +from typing import Any, AnyStr, Callable, Dict, Generator, List, NoReturn, Optional, Pattern, Sequence, Tuple, Union, cast from unittest import SkipTest +import typing_extensions +from .types import PathLike, TBD +from pathlib import Path + +from typing_extensions import Literal + + from gitdb.util import (# NOQA @IgnorePep8 make_sha, LockedFD, # @UnusedImport @@ -29,7 +37,7 @@ hex_to_bin, # @UnusedImport ) -from git.compat import is_win +from .compat import is_win import os.path as osp from .exc import InvalidGitRepositoryError @@ -47,6 +55,9 @@ log = logging.getLogger(__name__) +# types############################################################ + + #: We need an easy way to see if Appveyor TCs start failing, #: so the errors marked with this var are considered "acknowledged" ones, awaiting remedy, #: till then, we wish to hide them. @@ -56,22 +67,23 @@ #{ Utility Methods -def unbare_repo(func): +def unbare_repo(func: Callable) -> Callable: """Methods with this decorator raise InvalidGitRepositoryError if they encounter a bare repository""" @wraps(func) - def wrapper(self, *args, **kwargs): + def wrapper(self, *args: Any, **kwargs: Any) -> Callable: if self.repo.bare: raise InvalidGitRepositoryError("Method '%s' cannot operate on bare repositories" % func.__name__) # END bare method return func(self, *args, **kwargs) # END wrapper + return wrapper @contextlib.contextmanager -def cwd(new_dir): +def cwd(new_dir: PathLike) -> Generator[PathLike, None, None]: old_dir = os.getcwd() os.chdir(new_dir) try: @@ -80,7 +92,7 @@ def cwd(new_dir): os.chdir(old_dir) -def rmtree(path): +def rmtree(path: PathLike) -> None: """Remove the given recursively. :note: we use shutil rmtree but adjust its behaviour to see whether files that @@ -100,7 +112,7 @@ def onerror(func, path, exc_info): return shutil.rmtree(path, False, onerror) -def rmfile(path): +def rmfile(path: PathLike) -> None: """Ensure file deleted also on *Windows* where read-only files need special treatment.""" if osp.isfile(path): if is_win: @@ -108,7 +120,7 @@ def rmfile(path): os.remove(path) -def stream_copy(source, destination, chunk_size=512 * 1024): +def stream_copy(source, destination, chunk_size: int = 512 * 1024) -> int: """Copy all data from the source stream into the destination stream in chunks of size chunk_size @@ -165,7 +177,7 @@ def join_path_native(a, *p): return to_native_path(join_path(a, *p)) -def assure_directory_exists(path, is_file=False): +def assure_directory_exists(path: PathLike, is_file: bool = False) -> bool: """Assure that the directory pointed to by path exists. :param is_file: If True, path is assumed to be a file and handled correctly. @@ -180,18 +192,18 @@ def assure_directory_exists(path, is_file=False): return False -def _get_exe_extensions(): +def _get_exe_extensions() -> Sequence[str]: PATHEXT = os.environ.get('PATHEXT', None) - return tuple(p.upper() for p in PATHEXT.split(os.pathsep)) \ - if PATHEXT \ - else (('.BAT', 'COM', '.EXE') if is_win else ()) + return tuple(p.upper() for p in PATHEXT.split(os.pathsep)) if PATHEXT \ + else ('.BAT', 'COM', '.EXE') if is_win \ + else ('') -def py_where(program, path=None): +def py_where(program, path: Optional[PathLike]=None) -> List[str]: # From: http://stackoverflow.com/a/377028/548792 winprog_exts = _get_exe_extensions() - def is_exec(fpath): + def is_exec(fpath: str) -> bool: return osp.isfile(fpath) and os.access(fpath, os.X_OK) and ( os.name != 'nt' or not winprog_exts or any(fpath.upper().endswith(ext) for ext in winprog_exts)) @@ -199,7 +211,7 @@ def is_exec(fpath): progs = [] if not path: path = os.environ["PATH"] - for folder in path.split(os.pathsep): + for folder in str(path).split(os.pathsep): folder = folder.strip('"') if folder: exe_path = osp.join(folder, program) @@ -209,11 +221,11 @@ def is_exec(fpath): return progs -def _cygexpath(drive, path): +def _cygexpath(drive: Optional[str], path: PathLike) -> str: if osp.isabs(path) and not drive: ## Invoked from `cygpath()` directly with `D:Apps\123`? # It's an error, leave it alone just slashes) - p = path + p = path # convert to str if AnyPath given else: p = path and osp.normpath(osp.expandvars(osp.expanduser(path))) if osp.isabs(p): @@ -224,8 +236,8 @@ def _cygexpath(drive, path): p = cygpath(p) elif drive: p = '/cygdrive/%s/%s' % (drive.lower(), p) - - return p.replace('\\', '/') + p_str = str(p) # ensure it is a str and not AnyPath + return p_str.replace('\\', '/') _cygpath_parsers = ( @@ -237,27 +249,31 @@ def _cygexpath(drive, path): ), (re.compile(r"\\\\\?\\(\w):[/\\](.*)"), - _cygexpath, + (_cygexpath), False ), (re.compile(r"(\w):[/\\](.*)"), - _cygexpath, + (_cygexpath), False ), (re.compile(r"file:(.*)", re.I), (lambda rest_path: rest_path), - True), + True + ), (re.compile(r"(\w{2,}:.*)"), # remote URL, do nothing (lambda url: url), - False), -) + False + ), +) # type: Tuple[Tuple[Pattern[str], Callable, bool], ...] -def cygpath(path): + +def cygpath(path: PathLike) -> PathLike: """Use :meth:`git.cmd.Git.polish_url()` instead, that works on any environment.""" + path = str(path) # ensure is str and not AnyPath if not path.startswith(('/cygdrive', '//')): for regex, parser, recurse in _cygpath_parsers: match = regex.match(path) @@ -275,7 +291,8 @@ def cygpath(path): _decygpath_regex = re.compile(r"/cygdrive/(\w)(/.*)?") -def decygpath(path): +def decygpath(path: PathLike) -> str: + path = str(Path) m = _decygpath_regex.match(path) if m: drive, rest_path = m.groups() @@ -286,16 +303,16 @@ def decygpath(path): #: Store boolean flags denoting if a specific Git executable #: is from a Cygwin installation (since `cache_lru()` unsupported on PY2). -_is_cygwin_cache = {} +_is_cygwin_cache = {} # type: Dict[str, Optional[bool]] -def is_cygwin_git(git_executable): +def is_cygwin_git(git_executable) -> bool: if not is_win: return False #from subprocess import check_output - is_cygwin = _is_cygwin_cache.get(git_executable) + is_cygwin = _is_cygwin_cache.get(git_executable) # type: Optional[bool] if is_cygwin is None: is_cygwin = False try: @@ -318,18 +335,18 @@ def is_cygwin_git(git_executable): return is_cygwin -def get_user_id(): +def get_user_id() -> str: """:return: string identifying the currently active system user as name@node""" return "%s@%s" % (getpass.getuser(), platform.node()) -def finalize_process(proc, **kwargs): +def finalize_process(proc: TBD, **kwargs: Any) -> None: """Wait for the process (clone, fetch, pull or push) and handle its errors accordingly""" ## TODO: No close proc-streams?? proc.wait(**kwargs) -def expand_path(p, expand_vars=True): +def expand_path(p: PathLike, expand_vars: bool=True) -> Optional[PathLike]: try: p = osp.expanduser(p) if expand_vars: @@ -364,13 +381,13 @@ class RemoteProgress(object): re_op_absolute = re.compile(r"(remote: )?([\w\s]+):\s+()(\d+)()(.*)") re_op_relative = re.compile(r"(remote: )?([\w\s]+):\s+(\d+)% \((\d+)/(\d+)\)(.*)") - def __init__(self): + def __init__(self) -> None: self._seen_ops = [] - self._cur_line = None + self._cur_line = None # type: Optional[str] self.error_lines = [] self.other_lines = [] - def _parse_progress_line(self, line): + def _parse_progress_line(self, line: AnyStr) -> None: """Parse progress information from the given line as retrieved by git-push or git-fetch. @@ -382,7 +399,12 @@ def _parse_progress_line(self, line): # Compressing objects: 50% (1/2) # Compressing objects: 100% (2/2) # Compressing objects: 100% (2/2), done. - self._cur_line = line = line.decode('utf-8') if isinstance(line, bytes) else line + if isinstance(line, bytes): # mypy argues about ternary assignment + line_str = line.decode('utf-8') + else: + line_str = line + self._cur_line = line_str + if self.error_lines or self._cur_line.startswith(('error:', 'fatal:')): self.error_lines.append(self._cur_line) return @@ -390,25 +412,25 @@ def _parse_progress_line(self, line): # find escape characters and cut them away - regex will not work with # them as they are non-ascii. As git might expect a tty, it will send them last_valid_index = None - for i, c in enumerate(reversed(line)): + for i, c in enumerate(reversed(line_str)): if ord(c) < 32: # its a slice index last_valid_index = -i - 1 # END character was non-ascii # END for each character in line if last_valid_index is not None: - line = line[:last_valid_index] + line_str = line_str[:last_valid_index] # END cut away invalid part - line = line.rstrip() + line_str = line_str.rstrip() cur_count, max_count = None, None - match = self.re_op_relative.match(line) + match = self.re_op_relative.match(line_str) if match is None: - match = self.re_op_absolute.match(line) + match = self.re_op_absolute.match(line_str) if not match: - self.line_dropped(line) - self.other_lines.append(line) + self.line_dropped(line_str) + self.other_lines.append(line_str) return # END could not get match @@ -437,7 +459,7 @@ def _parse_progress_line(self, line): # This can't really be prevented, so we drop the line verbosely # to make sure we get informed in case the process spits out new # commands at some point. - self.line_dropped(line) + self.line_dropped(line_str) # Note: Don't add this line to the other lines, as we have to silently # drop it return @@ -465,7 +487,7 @@ def _parse_progress_line(self, line): max_count and float(max_count), message) - def new_message_handler(self): + def new_message_handler(self) -> Callable[[str], None]: """ :return: a progress handler suitable for handle_process_output(), passing lines on to this Progress @@ -510,7 +532,7 @@ class CallableRemoteProgress(RemoteProgress): """An implementation forwarding updates to any callable""" __slots__ = ('_callable') - def __init__(self, fn): + def __init__(self, fn: Callable) -> None: self._callable = fn super(CallableRemoteProgress, self).__init__() @@ -539,27 +561,27 @@ class Actor(object): __slots__ = ('name', 'email') - def __init__(self, name, email): + def __init__(self, name: Optional[str], email: Optional[str]) -> None: self.name = name self.email = email - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return self.name == other.name and self.email == other.email - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: return not (self == other) - def __hash__(self): + def __hash__(self) -> int: return hash((self.name, self.email)) - def __str__(self): + def __str__(self) -> str: return self.name - def __repr__(self): + def __repr__(self) -> str: return '<git.Actor "%s <%s>">' % (self.name, self.email) @classmethod - def _from_string(cls, string): + def _from_string(cls, string: str) -> 'Actor': """Create an Actor from a string. :param string: is the string, which is expected to be in regular git format @@ -580,17 +602,17 @@ def _from_string(cls, string): # END handle name/email matching @classmethod - def _main_actor(cls, env_name, env_email, config_reader=None): + def _main_actor(cls, env_name: str, env_email: str, config_reader: Optional[TBD]=None) -> 'Actor': actor = Actor('', '') user_id = None # We use this to avoid multiple calls to getpass.getuser() - def default_email(): + def default_email() -> str: nonlocal user_id if not user_id: user_id = get_user_id() return user_id - def default_name(): + def default_name() -> str: return default_email().split('@')[0] for attr, evar, cvar, default in (('name', env_name, cls.conf_name, default_name), @@ -609,7 +631,7 @@ def default_name(): return actor @classmethod - def committer(cls, config_reader=None): + def committer(cls, config_reader: Optional[TBD] = None) -> 'Actor': """ :return: Actor instance corresponding to the configured committer. It behaves similar to the git implementation, such that the environment will override @@ -620,7 +642,7 @@ def committer(cls, config_reader=None): return cls._main_actor(cls.env_committer_name, cls.env_committer_email, config_reader) @classmethod - def author(cls, config_reader=None): + def author(cls, config_reader: Optional[TBD] = None): """Same as committer(), but defines the main author. It may be specified in the environment, but defaults to the committer""" return cls._main_actor(cls.env_author_name, cls.env_author_email, config_reader) @@ -654,16 +676,18 @@ class Stats(object): files = number of changed files as int""" __slots__ = ("total", "files") - def __init__(self, total, files): + def __init__(self, total: Dict[str, Dict[str, int]], files: Dict[str, Dict[str, int]]): self.total = total self.files = files @classmethod - def _list_from_string(cls, repo, text): + def _list_from_string(cls, repo, text: str) -> 'Stats': """Create a Stat object from output retrieved by git-diff. :return: git.Stat""" - hsh = {'total': {'insertions': 0, 'deletions': 0, 'lines': 0, 'files': 0}, 'files': {}} + hsh = {'total': {'insertions': 0, 'deletions': 0, 'lines': 0, 'files': 0}, + 'files': {} + } # type: Dict[str, Dict[str, TBD]] ## need typeddict or refactor for mypy for line in text.splitlines(): (raw_insertions, raw_deletions, filename) = line.split("\t") insertions = raw_insertions != '-' and int(raw_insertions) or 0 @@ -689,7 +713,7 @@ class IndexFileSHA1Writer(object): :note: Based on the dulwich project""" __slots__ = ("f", "sha1") - def __init__(self, f): + def __init__(self, f) -> None: self.f = f self.sha1 = make_sha(b"") @@ -697,12 +721,12 @@ def write(self, data): self.sha1.update(data) return self.f.write(data) - def write_sha(self): + def write_sha(self) -> bytes: sha = self.sha1.digest() self.f.write(sha) return sha - def close(self): + def close(self) -> bytes: sha = self.write_sha() self.f.close() return sha @@ -721,23 +745,23 @@ class LockFile(object): Locks will automatically be released on destruction""" __slots__ = ("_file_path", "_owns_lock") - def __init__(self, file_path): + def __init__(self, file_path: PathLike) -> None: self._file_path = file_path self._owns_lock = False - def __del__(self): + def __del__(self) -> None: self._release_lock() - def _lock_file_path(self): + def _lock_file_path(self) -> str: """:return: Path to lockfile""" return "%s.lock" % (self._file_path) - def _has_lock(self): + def _has_lock(self) -> bool: """:return: True if we have a lock and if the lockfile still exists :raise AssertionError: if our lock-file does not exist""" return self._owns_lock - def _obtain_lock_or_raise(self): + def _obtain_lock_or_raise(self) -> None: """Create a lock file as flag for other instances, mark our instance as lock-holder :raise IOError: if a lock was already present or a lock file could not be written""" @@ -759,12 +783,12 @@ def _obtain_lock_or_raise(self): self._owns_lock = True - def _obtain_lock(self): + def _obtain_lock(self) -> None: """The default implementation will raise if a lock cannot be obtained. Subclasses may override this method to provide a different implementation""" return self._obtain_lock_or_raise() - def _release_lock(self): + def _release_lock(self) -> None: """Release our lock if we have one""" if not self._has_lock(): return @@ -789,7 +813,7 @@ class BlockingLockFile(LockFile): can never be obtained.""" __slots__ = ("_check_interval", "_max_block_time") - def __init__(self, file_path, check_interval_s=0.3, max_block_time_s=maxsize): + def __init__(self, file_path: PathLike, check_interval_s: float=0.3, max_block_time_s: int=maxsize) -> None: """Configure the instance :param check_interval_s: @@ -801,7 +825,7 @@ def __init__(self, file_path, check_interval_s=0.3, max_block_time_s=maxsize): self._check_interval = check_interval_s self._max_block_time = max_block_time_s - def _obtain_lock(self): + def _obtain_lock(self) -> None: """This method blocks until it obtained the lock, or raises IOError if it ran out of time or if the parent directory was not available anymore. If this method returns, you are guaranteed to own the lock""" @@ -851,11 +875,11 @@ class IterableList(list): def __new__(cls, id_attr, prefix=''): return super(IterableList, cls).__new__(cls) - def __init__(self, id_attr, prefix=''): + def __init__(self, id_attr: str, prefix: str='') -> None: self._id_attr = id_attr self._prefix = prefix - def __contains__(self, attr): + def __contains__(self, attr: object) -> bool: # first try identity match for performance try: rval = list.__contains__(self, attr) @@ -867,13 +891,13 @@ def __contains__(self, attr): # otherwise make a full name search try: - getattr(self, attr) + getattr(self, cast(str, attr)) # use cast to silence mypy return True except (AttributeError, TypeError): return False # END handle membership - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> object: attr = self._prefix + attr for item in self: if getattr(item, self._id_attr) == attr: @@ -881,20 +905,22 @@ def __getattr__(self, attr): # END for each item return list.__getattribute__(self, attr) - def __getitem__(self, index): + def __getitem__(self, index: Union[int, slice, str]) -> Any: if isinstance(index, int): return list.__getitem__(self, index) + assert not isinstance(index, slice) try: return getattr(self, index) except AttributeError as e: raise IndexError("No item found with id %r" % (self._prefix + index)) from e # END handle getattr - def __delitem__(self, index): - delindex = index + def __delitem__(self, index: Union[int, str, slice]) -> None: + if not isinstance(index, int): delindex = -1 + assert not isinstance(index, slice) name = self._prefix + index for i, item in enumerate(self): if getattr(item, self._id_attr) == name: @@ -917,7 +943,7 @@ class Iterable(object): _id_attribute_ = "attribute that most suitably identifies your instance" @classmethod - def list_items(cls, repo, *args, **kwargs): + def list_items(cls, repo, *args, **kwargs) -> 'IterableList': """ Find all items of this type - subclasses can specify args and kwargs differently. If no args are given, subclasses are obliged to return all items if no additional @@ -931,7 +957,7 @@ def list_items(cls, repo, *args, **kwargs): return out_list @classmethod - def iter_items(cls, repo, *args, **kwargs): + def iter_items(cls, repo, *args, **kwargs) -> NoReturn: """For more information about the arguments, see list_items :return: iterator yielding Items""" raise NotImplementedError("To be implemented by Subclass") @@ -940,5 +966,5 @@ def iter_items(cls, repo, *args, **kwargs): class NullHandler(logging.Handler): - def emit(self, record): + def emit(self, record) -> None: pass From a094ac1808f7c5fa0653ac075074bb2232223ac1 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 1 Mar 2021 20:18:01 +0000 Subject: [PATCH 03/16] add types to git.util and git.__init__ --- git/__init__.py | 9 ++-- git/remote.py | 5 ++- git/util.py | 111 +++++++++++++++++++++++++----------------------- 3 files changed, 68 insertions(+), 57 deletions(-) diff --git a/git/__init__.py b/git/__init__.py index 534408308..e2f960db7 100644 --- a/git/__init__.py +++ b/git/__init__.py @@ -8,15 +8,18 @@ import inspect import os import sys - import os.path as osp +from typing import Optional +from git.types import PathLike __version__ = 'git' + + #{ Initialization -def _init_externals(): +def _init_externals() -> None: """Initialize external projects by putting them into the path""" if __version__ == 'git' and 'PYOXIDIZER' not in os.environ: sys.path.insert(1, osp.join(osp.dirname(__file__), 'ext', 'gitdb')) @@ -65,7 +68,7 @@ def _init_externals(): #{ Initialize git executable path GIT_OK = None -def refresh(path=None): +def refresh(path:Optional[PathLike]=None) -> None: """Convenience method for setting the git executable path.""" global GIT_OK GIT_OK = False diff --git a/git/remote.py b/git/remote.py index 659166149..53349ce70 100644 --- a/git/remote.py +++ b/git/remote.py @@ -34,6 +34,9 @@ TagReference ) +# typing------------------------------------------------------- + +from git.repo.Base import Repo log = logging.getLogger('git.remote') log.addHandler(logging.NullHandler()) @@ -403,7 +406,7 @@ def __init__(self, repo, name): :param repo: The repository we are a remote of :param name: the name of the remote, i.e. 'origin'""" - self.repo = repo + self.repo = repo # type: 'Repo' self.name = name if is_win: diff --git a/git/util.py b/git/util.py index 16c3e62a2..b5cce59db 100644 --- a/git/util.py +++ b/git/util.py @@ -3,6 +3,8 @@ # # This module is part of GitPython and is released under # the BSD License: http://www.opensource.org/licenses/bsd-license.php +from git.remote import Remote +from _typeshed import ReadableBuffer import contextlib from functools import wraps import getpass @@ -15,17 +17,16 @@ import stat from sys import maxsize import time -from typing import Any, AnyStr, Callable, Dict, Generator, List, NoReturn, Optional, Pattern, Sequence, Tuple, Union, cast from unittest import SkipTest -import typing_extensions -from .types import PathLike, TBD -from pathlib import Path - +from typing import (Any, AnyStr, BinaryIO, Callable, Dict, Generator, IO, List, NoReturn, Optional, Pattern, + Sequence, TextIO, Tuple, Union, cast) from typing_extensions import Literal +from git.repo.base import Repo +from .types import PathLike, TBD -from gitdb.util import (# NOQA @IgnorePep8 +from gitdb.util import ( # NOQA @IgnorePep8 make_sha, LockedFD, # @UnusedImport file_contents_ro, # @UnusedImport @@ -72,7 +73,7 @@ def unbare_repo(func: Callable) -> Callable: encounter a bare repository""" @wraps(func) - def wrapper(self, *args: Any, **kwargs: Any) -> Callable: + def wrapper(self: Remote, *args: Any, **kwargs: Any) -> TBD: if self.repo.bare: raise InvalidGitRepositoryError("Method '%s' cannot operate on bare repositories" % func.__name__) # END bare method @@ -98,7 +99,7 @@ def rmtree(path: PathLike) -> None: :note: we use shutil rmtree but adjust its behaviour to see whether files that couldn't be deleted are read-only. Windows will not remove them in that case""" - def onerror(func, path, exc_info): + def onerror(func: Callable, path: PathLike, exc_info: TBD) -> None: # Is the error an access error ? os.chmod(path, stat.S_IWUSR) @@ -120,7 +121,7 @@ def rmfile(path: PathLike) -> None: os.remove(path) -def stream_copy(source, destination, chunk_size: int = 512 * 1024) -> int: +def stream_copy(source: BinaryIO, destination: BinaryIO, chunk_size: int = 512 * 1024) -> int: """Copy all data from the source stream into the destination stream in chunks of size chunk_size @@ -136,11 +137,12 @@ def stream_copy(source, destination, chunk_size: int = 512 * 1024) -> int: return br -def join_path(a, *p): +def join_path(a: PathLike, *p: PathLike) -> PathLike: """Join path tokens together similar to osp.join, but always use '/' instead of possibly '\' on windows.""" - path = a + path = str(a) for b in p: + b = str(b) if not b: continue if b.startswith('/'): @@ -154,22 +156,24 @@ def join_path(a, *p): if is_win: - def to_native_path_windows(path): + def to_native_path_windows(path: PathLike) -> PathLike: + path = str(path) return path.replace('/', '\\') - def to_native_path_linux(path): + def to_native_path_linux(path: PathLike) -> PathLike: + path = str(path) return path.replace('\\', '/') __all__.append("to_native_path_windows") to_native_path = to_native_path_windows else: # no need for any work on linux - def to_native_path_linux(path): + def to_native_path_linux(path: PathLike) -> PathLike: return path to_native_path = to_native_path_linux -def join_path_native(a, *p): +def join_path_native(a: PathLike, *p: PathLike) -> PathLike: """ As join path, but makes sure an OS native path is returned. This is only needed to play it safe on my dear windows and to assure nice paths that only @@ -199,7 +203,7 @@ def _get_exe_extensions() -> Sequence[str]: else ('') -def py_where(program, path: Optional[PathLike]=None) -> List[str]: +def py_where(program: str, path: Optional[PathLike] = None) -> List[str]: # From: http://stackoverflow.com/a/377028/548792 winprog_exts = _get_exe_extensions() @@ -249,7 +253,7 @@ def _cygexpath(drive: Optional[str], path: PathLike) -> str: ), (re.compile(r"\\\\\?\\(\w):[/\\](.*)"), - (_cygexpath), + (_cygexpath), False ), @@ -270,7 +274,6 @@ def _cygexpath(drive: Optional[str], path: PathLike) -> str: ) # type: Tuple[Tuple[Pattern[str], Callable, bool], ...] - def cygpath(path: PathLike) -> PathLike: """Use :meth:`git.cmd.Git.polish_url()` instead, that works on any environment.""" path = str(path) # ensure is str and not AnyPath @@ -292,7 +295,7 @@ def cygpath(path: PathLike) -> PathLike: def decygpath(path: PathLike) -> str: - path = str(Path) + path = str(path) m = _decygpath_regex.match(path) if m: drive, rest_path = m.groups() @@ -306,12 +309,12 @@ def decygpath(path: PathLike) -> str: _is_cygwin_cache = {} # type: Dict[str, Optional[bool]] -def is_cygwin_git(git_executable) -> bool: +def is_cygwin_git(git_executable: PathLike) -> bool: if not is_win: return False #from subprocess import check_output - + git_executable = str(git_executable) is_cygwin = _is_cygwin_cache.get(git_executable) # type: Optional[bool] if is_cygwin is None: is_cygwin = False @@ -319,7 +322,7 @@ def is_cygwin_git(git_executable) -> bool: git_dir = osp.dirname(git_executable) if not git_dir: res = py_where(git_executable) - git_dir = osp.dirname(res[0]) if res else None + git_dir = osp.dirname(res[0]) if res else "" ## Just a name given, not a real path. uname_cmd = osp.join(git_dir, 'uname') @@ -346,7 +349,7 @@ def finalize_process(proc: TBD, **kwargs: Any) -> None: proc.wait(**kwargs) -def expand_path(p: PathLike, expand_vars: bool=True) -> Optional[PathLike]: +def expand_path(p: PathLike, expand_vars: bool = True) -> Optional[PathLike]: try: p = osp.expanduser(p) if expand_vars: @@ -382,10 +385,10 @@ class RemoteProgress(object): re_op_relative = re.compile(r"(remote: )?([\w\s]+):\s+(\d+)% \((\d+)/(\d+)\)(.*)") def __init__(self) -> None: - self._seen_ops = [] + self._seen_ops = [] # type: List[TBD] self._cur_line = None # type: Optional[str] - self.error_lines = [] - self.other_lines = [] + self.error_lines = [] # type: List[str] + self.other_lines = [] # type: List[str] def _parse_progress_line(self, line: AnyStr) -> None: """Parse progress information from the given line as retrieved by git-push @@ -404,7 +407,7 @@ def _parse_progress_line(self, line: AnyStr) -> None: else: line_str = line self._cur_line = line_str - + if self.error_lines or self._cur_line.startswith(('error:', 'fatal:')): self.error_lines.append(self._cur_line) return @@ -462,7 +465,7 @@ def _parse_progress_line(self, line: AnyStr) -> None: self.line_dropped(line_str) # Note: Don't add this line to the other lines, as we have to silently # drop it - return + return None # END handle op code # figure out stage @@ -492,16 +495,17 @@ def new_message_handler(self) -> Callable[[str], None]: :return: a progress handler suitable for handle_process_output(), passing lines on to this Progress handler in a suitable format""" - def handler(line): + def handler(line: AnyStr) -> None: return self._parse_progress_line(line.rstrip()) # end return handler - def line_dropped(self, line): + def line_dropped(self, line: str) -> None: """Called whenever a line could not be understood and was therefore dropped.""" pass - def update(self, op_code, cur_count, max_count=None, message=''): + def update(self, op_code: int, cur_count: Union[str, float], max_count: Union[str, float, None] = None, + message: str = '',) -> None: """Called whenever the progress changes :param op_code: @@ -536,7 +540,7 @@ def __init__(self, fn: Callable) -> None: self._callable = fn super(CallableRemoteProgress, self).__init__() - def update(self, *args, **kwargs): + def update(self, *args: Any, **kwargs: Any) -> None: self._callable(*args, **kwargs) @@ -575,7 +579,7 @@ def __hash__(self) -> int: return hash((self.name, self.email)) def __str__(self) -> str: - return self.name + return self.name if self.name else "" def __repr__(self) -> str: return '<git.Actor "%s <%s>">' % (self.name, self.email) @@ -602,7 +606,7 @@ def _from_string(cls, string: str) -> 'Actor': # END handle name/email matching @classmethod - def _main_actor(cls, env_name: str, env_email: str, config_reader: Optional[TBD]=None) -> 'Actor': + def _main_actor(cls, env_name: str, env_email: str, config_reader: Optional[TBD] = None) -> 'Actor': actor = Actor('', '') user_id = None # We use this to avoid multiple calls to getpass.getuser() @@ -642,7 +646,7 @@ def committer(cls, config_reader: Optional[TBD] = None) -> 'Actor': return cls._main_actor(cls.env_committer_name, cls.env_committer_email, config_reader) @classmethod - def author(cls, config_reader: Optional[TBD] = None): + def author(cls, config_reader: Optional[TBD] = None) -> 'Actor': """Same as committer(), but defines the main author. It may be specified in the environment, but defaults to the committer""" return cls._main_actor(cls.env_author_name, cls.env_author_email, config_reader) @@ -681,11 +685,11 @@ def __init__(self, total: Dict[str, Dict[str, int]], files: Dict[str, Dict[str, self.files = files @classmethod - def _list_from_string(cls, repo, text: str) -> 'Stats': + def _list_from_string(cls, repo: Repo, text: str) -> 'Stats': """Create a Stat object from output retrieved by git-diff. :return: git.Stat""" - hsh = {'total': {'insertions': 0, 'deletions': 0, 'lines': 0, 'files': 0}, + hsh = {'total': {'insertions': 0, 'deletions': 0, 'lines': 0, 'files': 0}, 'files': {} } # type: Dict[str, Dict[str, TBD]] ## need typeddict or refactor for mypy for line in text.splitlines(): @@ -713,11 +717,11 @@ class IndexFileSHA1Writer(object): :note: Based on the dulwich project""" __slots__ = ("f", "sha1") - def __init__(self, f) -> None: + def __init__(self, f: IO) -> None: self.f = f self.sha1 = make_sha(b"") - def write(self, data): + def write(self, data: AnyStr) -> int: self.sha1.update(data) return self.f.write(data) @@ -731,7 +735,7 @@ def close(self) -> bytes: self.f.close() return sha - def tell(self): + def tell(self) -> int: return self.f.tell() @@ -813,7 +817,7 @@ class BlockingLockFile(LockFile): can never be obtained.""" __slots__ = ("_check_interval", "_max_block_time") - def __init__(self, file_path: PathLike, check_interval_s: float=0.3, max_block_time_s: int=maxsize) -> None: + def __init__(self, file_path: PathLike, check_interval_s: float = 0.3, max_block_time_s: int = maxsize) -> None: """Configure the instance :param check_interval_s: @@ -872,10 +876,10 @@ class IterableList(list): can be left out.""" __slots__ = ('_id_attr', '_prefix') - def __new__(cls, id_attr, prefix=''): + def __new__(cls, id_attr: str, prefix: str = '') -> 'IterableList': return super(IterableList, cls).__new__(cls) - def __init__(self, id_attr: str, prefix: str='') -> None: + def __init__(self, id_attr: str, prefix: str = '') -> None: self._id_attr = id_attr self._prefix = prefix @@ -897,7 +901,7 @@ def __contains__(self, attr: object) -> bool: return False # END handle membership - def __getattr__(self, attr: str) -> object: + def __getattr__(self, attr: str) -> Any: attr = self._prefix + attr for item in self: if getattr(item, self._id_attr) == attr: @@ -908,12 +912,13 @@ def __getattr__(self, attr: str) -> object: def __getitem__(self, index: Union[int, slice, str]) -> Any: if isinstance(index, int): return list.__getitem__(self, index) - - assert not isinstance(index, slice) - try: - return getattr(self, index) - except AttributeError as e: - raise IndexError("No item found with id %r" % (self._prefix + index)) from e + elif isinstance(index, slice): + raise ValueError("Index should be an int or str") + else: + try: + return getattr(self, index) + except AttributeError as e: + raise IndexError("No item found with id %r" % (self._prefix + index)) from e # END handle getattr def __delitem__(self, index: Union[int, str, slice]) -> None: @@ -943,7 +948,7 @@ class Iterable(object): _id_attribute_ = "attribute that most suitably identifies your instance" @classmethod - def list_items(cls, repo, *args, **kwargs) -> 'IterableList': + def list_items(cls, repo: Repo, *args: Any, **kwargs: Any) -> 'IterableList': """ Find all items of this type - subclasses can specify args and kwargs differently. If no args are given, subclasses are obliged to return all items if no additional @@ -957,7 +962,7 @@ def list_items(cls, repo, *args, **kwargs) -> 'IterableList': return out_list @classmethod - def iter_items(cls, repo, *args, **kwargs) -> NoReturn: + def iter_items(cls, repo: Repo, *args: Any, **kwargs: Any) -> NoReturn: """For more information about the arguments, see list_items :return: iterator yielding Items""" raise NotImplementedError("To be implemented by Subclass") @@ -966,5 +971,5 @@ def iter_items(cls, repo, *args, **kwargs) -> NoReturn: class NullHandler(logging.Handler): - def emit(self, record) -> None: + def emit(self, record: object) -> None: pass From 71e28b8e2ac1b8bc8990454721740b2073829110 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 1 Mar 2021 20:55:08 +0000 Subject: [PATCH 04/16] add types to git.db and git.exc --- git/db.py | 20 ++++++++++++-------- git/exc.py | 39 +++++++++++++++++++++++++++------------ mypy.ini | 2 ++ 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/git/db.py b/git/db.py index de2e99910..e2d3910d8 100644 --- a/git/db.py +++ b/git/db.py @@ -6,12 +6,16 @@ ) from gitdb.db import GitDB # @UnusedImport from gitdb.db import LooseObjectDB +from gitdb.exc import BadObject -from .exc import ( - GitCommandError, - BadObject -) +from .exc import GitCommandError + +# typing------------------------------------------------- + +from .cmd import Git +from .types import PathLike, TBD +# -------------------------------------------------------- __all__ = ('GitCmdObjectDB', 'GitDB') @@ -28,23 +32,23 @@ class GitCmdObjectDB(LooseObjectDB): have packs and the other implementations """ - def __init__(self, root_path, git): + def __init__(self, root_path: PathLike, git: Git) -> None: """Initialize this instance with the root and a git command""" super(GitCmdObjectDB, self).__init__(root_path) self._git = git - def info(self, sha): + def info(self, sha: bytes) -> OInfo: hexsha, typename, size = self._git.get_object_header(bin_to_hex(sha)) return OInfo(hex_to_bin(hexsha), typename, size) - def stream(self, sha): + def stream(self, sha: bytes) -> OStream: """For now, all lookup is done by git itself""" hexsha, typename, size, stream = self._git.stream_object_data(bin_to_hex(sha)) return OStream(hex_to_bin(hexsha), typename, size, stream) # { Interface - def partial_to_complete_sha_hex(self, partial_hexsha): + def partial_to_complete_sha_hex(self, partial_hexsha: str) -> bytes: """:return: Full binary 20 byte sha from the given partial hexsha :raise AmbiguousObjectName: :raise BadObject: diff --git a/git/exc.py b/git/exc.py index 71a40bdfd..bd019c7fd 100644 --- a/git/exc.py +++ b/git/exc.py @@ -8,6 +8,13 @@ from gitdb.exc import * # NOQA @UnusedWildImport skipcq: PYL-W0401, PYL-W0614 from git.compat import safe_decode +# typing ---------------------------------------------------- + +from git.repo.base import Repo +from git.types import PathLike +from typing import IO, List, Optional, Sequence, Tuple, Union + +# ------------------------------------------------------------------ class GitError(Exception): """ Base class for all package exceptions """ @@ -37,7 +44,9 @@ class CommandError(GitError): #: "'%s' failed%s" _msg = "Cmd('%s') failed%s" - def __init__(self, command, status=None, stderr=None, stdout=None): + def __init__(self, command: Union[List[str], Tuple[str, ...], str], + status: Union[str, None, Exception] = None, + stderr: Optional[IO[str]] = None, stdout: Optional[IO[str]] = None) -> None: if not isinstance(command, (tuple, list)): command = command.split() self.command = command @@ -53,12 +62,12 @@ def __init__(self, command, status=None, stderr=None, stdout=None): status = "'%s'" % s if isinstance(status, str) else s self._cmd = safe_decode(command[0]) - self._cmdline = ' '.join(safe_decode(i) for i in command) + self._cmdline = ' '.join(str(safe_decode(i)) for i in command) self._cause = status and " due to: %s" % status or "!" - self.stdout = stdout and "\n stdout: '%s'" % safe_decode(stdout) or '' - self.stderr = stderr and "\n stderr: '%s'" % safe_decode(stderr) or '' + self.stdout = stdout and "\n stdout: '%s'" % safe_decode(str(stdout)) or '' + self.stderr = stderr and "\n stderr: '%s'" % safe_decode(str(stderr)) or '' - def __str__(self): + def __str__(self) -> str: return (self._msg + "\n cmdline: %s%s%s") % ( self._cmd, self._cause, self._cmdline, self.stdout, self.stderr) @@ -66,7 +75,8 @@ def __str__(self): class GitCommandNotFound(CommandError): """Thrown if we cannot find the `git` executable in the PATH or at the path given by the GIT_PYTHON_GIT_EXECUTABLE environment variable""" - def __init__(self, command, cause): + + def __init__(self, command: Union[List[str], Tuple[str], str], cause: Union[str, Exception]) -> None: super(GitCommandNotFound, self).__init__(command, cause) self._msg = "Cmd('%s') not found%s" @@ -74,7 +84,11 @@ def __init__(self, command, cause): class GitCommandError(CommandError): """ Thrown if execution of the git command fails with non-zero status code. """ - def __init__(self, command, status, stderr=None, stdout=None): + def __init__(self, command: Union[List[str], Tuple[str, ...], str], + status: Union[str, None, Exception] = None, + stderr: Optional[IO[str]] = None, + stdout: Optional[IO[str]] = None, + ) -> None: super(GitCommandError, self).__init__(command, status, stderr, stdout) @@ -92,13 +106,13 @@ class CheckoutError(GitError): were checked out successfully and hence match the version stored in the index""" - def __init__(self, message, failed_files, valid_files, failed_reasons): + def __init__(self, message: str, failed_files: List[PathLike], valid_files: List[PathLike], failed_reasons: List[str]) -> None: Exception.__init__(self, message) self.failed_files = failed_files self.failed_reasons = failed_reasons self.valid_files = valid_files - def __str__(self): + def __str__(self) -> str: return Exception.__str__(self) + ":%s" % self.failed_files @@ -116,7 +130,8 @@ class HookExecutionError(CommandError): """Thrown if a hook exits with a non-zero exit code. It provides access to the exit code and the string returned via standard output""" - def __init__(self, command, status, stderr=None, stdout=None): + def __init__(self, command: Union[List[str], Tuple[str, ...], str], status: Optional[str], + stderr: Optional[IO[str]] = None, stdout: Optional[IO[str]] = None) -> None: super(HookExecutionError, self).__init__(command, status, stderr, stdout) self._msg = "Hook('%s') failed%s" @@ -124,9 +139,9 @@ def __init__(self, command, status, stderr=None, stdout=None): class RepositoryDirtyError(GitError): """Thrown whenever an operation on a repository fails as it has uncommitted changes that would be overwritten""" - def __init__(self, repo, message): + def __init__(self, repo: Repo, message: str) -> None: self.repo = repo self.message = message - def __str__(self): + def __str__(self) -> str: return "Operation cannot be performed on %r: %s" % (self.repo, self.message) diff --git a/mypy.ini b/mypy.ini index 349266b77..47c0fb0c0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,3 +2,5 @@ [mypy] disallow_untyped_defs = True + +mypy_path = 'git' From 2fd9f6ee5c8b4ae4e01a40dc398e2768d838210d Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Tue, 2 Mar 2021 21:46:17 +0000 Subject: [PATCH 05/16] add types to git.compat and git.diff --- git/compat.py | 16 ++++--- git/db.py | 5 ++- git/diff.py | 114 +++++++++++++++++++++++++++++--------------------- git/exc.py | 11 +++-- git/util.py | 9 ++-- 5 files changed, 91 insertions(+), 64 deletions(-) diff --git a/git/compat.py b/git/compat.py index 8d9e551d4..4fe394ae0 100644 --- a/git/compat.py +++ b/git/compat.py @@ -10,14 +10,15 @@ import locale import os import sys -from typing import AnyStr, Optional, Type - from gitdb.utils.encoding import ( force_bytes, # @UnusedImport force_text # @UnusedImport ) +from typing import Any, AnyStr, Dict, Optional, Type +from git.types import TBD + is_win = (os.name == 'nt') # type: bool is_posix = (os.name == 'posix') @@ -61,14 +62,17 @@ def win_encode(s: Optional[AnyStr]) -> Optional[bytes]: return None -def with_metaclass(meta, *bases): +def with_metaclass(meta: Type[Any], *bases: Any) -> 'metaclass': # type: ignore ## mypy cannot understand dynamic class creation """copied from https://github.com/Byron/bcore/blob/master/src/python/butility/future.py#L15""" - class metaclass(meta): + + class metaclass(meta): # type: ignore __call__ = type.__call__ - __init__ = type.__init__ + __init__ = type.__init__ # type: ignore - def __new__(cls, name, nbases, d): + def __new__(cls, name: str, nbases: Optional[int], d: Dict[str, Any]) -> TBD: if nbases is None: return type.__new__(cls, name, (), d) return meta(name, bases, d) + return metaclass(meta.__name__ + 'Helper', None, {}) + diff --git a/git/db.py b/git/db.py index e2d3910d8..ef2b0b2ef 100644 --- a/git/db.py +++ b/git/db.py @@ -1,4 +1,5 @@ """Module with our own gitdb implementation - it uses the git command""" +from typing import AnyStr from git.util import bin_to_hex, hex_to_bin from gitdb.base import ( OInfo, @@ -13,7 +14,7 @@ # typing------------------------------------------------- from .cmd import Git -from .types import PathLike, TBD +from .types import PathLike # -------------------------------------------------------- @@ -48,7 +49,7 @@ def stream(self, sha: bytes) -> OStream: # { Interface - def partial_to_complete_sha_hex(self, partial_hexsha: str) -> bytes: + def partial_to_complete_sha_hex(self, partial_hexsha: AnyStr) -> bytes: """:return: Full binary 20 byte sha from the given partial hexsha :raise AmbiguousObjectName: :raise BadObject: diff --git a/git/diff.py b/git/diff.py index a9dc4b572..b25aadc76 100644 --- a/git/diff.py +++ b/git/diff.py @@ -3,8 +3,8 @@ # # This module is part of GitPython and is released under # the BSD License: http://www.opensource.org/licenses/bsd-license.php -import re +import re from git.cmd import handle_process_output from git.compat import defenc from git.util import finalize_process, hex_to_bin @@ -13,22 +13,33 @@ from .objects.util import mode_str_to_int +# typing ------------------------------------------------------------------ + +from .objects.tree import Tree +from git.repo.base import Repo +from typing_extensions import Final, Literal +from git.types import TBD +from typing import Any, Iterator, List, Match, Optional, Tuple, Type, Union +Lit_change_type = Literal['A', 'D', 'M', 'R', 'T'] + +# ------------------------------------------------------------------------ + __all__ = ('Diffable', 'DiffIndex', 'Diff', 'NULL_TREE') # Special object to compare against the empty tree in diffs -NULL_TREE = object() +NULL_TREE: Final[object] = object() _octal_byte_re = re.compile(b'\\\\([0-9]{3})') -def _octal_repl(matchobj): +def _octal_repl(matchobj: Match) -> bytes: value = matchobj.group(1) value = int(value, 8) value = bytes(bytearray((value,))) return value -def decode_path(path, has_ab_prefix=True): +def decode_path(path: bytes, has_ab_prefix: bool = True) -> Optional[bytes]: if path == b'/dev/null': return None @@ -60,7 +71,7 @@ class Diffable(object): class Index(object): pass - def _process_diff_args(self, args): + def _process_diff_args(self, args: List[Union[str, 'Diffable', object]]) -> List[Union[str, 'Diffable', object]]: """ :return: possibly altered version of the given args list. @@ -68,7 +79,9 @@ def _process_diff_args(self, args): Subclasses can use it to alter the behaviour of the superclass""" return args - def diff(self, other=Index, paths=None, create_patch=False, **kwargs): + def diff(self, other: Union[Type[Index], Type[Tree], object, None, str] = Index, + paths: Union[str, List[str], Tuple[str, ...], None] = None, + create_patch: bool = False, **kwargs: Any) -> 'DiffIndex': """Creates diffs between two items being trees, trees and index or an index and the working tree. It will detect renames automatically. @@ -99,7 +112,7 @@ def diff(self, other=Index, paths=None, create_patch=False, **kwargs): :note: On a bare repository, 'other' needs to be provided as Index or as as Tree/Commit, or a git command error will occur""" - args = [] + args = [] # type: List[Union[str, Diffable, object]] args.append("--abbrev=40") # we need full shas args.append("--full-index") # get full index paths, not only filenames @@ -117,6 +130,9 @@ def diff(self, other=Index, paths=None, create_patch=False, **kwargs): if paths is not None and not isinstance(paths, (tuple, list)): paths = [paths] + if hasattr(self, 'repo'): # else raise Error? + self.repo = self.repo # type: 'Repo' + diff_cmd = self.repo.git.diff if other is self.Index: args.insert(0, '--cached') @@ -163,7 +179,7 @@ class DiffIndex(list): # T = Changed in the type change_type = ("A", "C", "D", "R", "M", "T") - def iter_change_type(self, change_type): + def iter_change_type(self, change_type: Lit_change_type) -> Iterator['Diff']: """ :return: iterator yielding Diff instances that match the given change_type @@ -180,7 +196,7 @@ def iter_change_type(self, change_type): if change_type not in self.change_type: raise ValueError("Invalid change type: %s" % change_type) - for diff in self: + for diff in self: # type: 'Diff' if diff.change_type == change_type: yield diff elif change_type == "A" and diff.new_file: @@ -255,22 +271,21 @@ class Diff(object): "new_file", "deleted_file", "copied_file", "raw_rename_from", "raw_rename_to", "diff", "change_type", "score") - def __init__(self, repo, a_rawpath, b_rawpath, a_blob_id, b_blob_id, a_mode, - b_mode, new_file, deleted_file, copied_file, raw_rename_from, - raw_rename_to, diff, change_type, score): - - self.a_mode = a_mode - self.b_mode = b_mode + def __init__(self, repo: Repo, + a_rawpath: Optional[bytes], b_rawpath: Optional[bytes], + a_blob_id: Union[str, bytes, None], b_blob_id: Union[str, bytes, None], + a_mode: Union[bytes, str, None], b_mode: Union[bytes, str, None], + new_file: bool, deleted_file: bool, copied_file: bool, + raw_rename_from: Optional[bytes], raw_rename_to: Optional[bytes], + diff: Union[str, bytes, None], change_type: Optional[str], score: Optional[int]) -> None: assert a_rawpath is None or isinstance(a_rawpath, bytes) assert b_rawpath is None or isinstance(b_rawpath, bytes) self.a_rawpath = a_rawpath self.b_rawpath = b_rawpath - if self.a_mode: - self.a_mode = mode_str_to_int(self.a_mode) - if self.b_mode: - self.b_mode = mode_str_to_int(self.b_mode) + self.a_mode = mode_str_to_int(a_mode) if a_mode else None + self.b_mode = mode_str_to_int(b_mode) if b_mode else None # Determine whether this diff references a submodule, if it does then # we need to overwrite "repo" to the corresponding submodule's repo instead @@ -305,27 +320,27 @@ def __init__(self, repo, a_rawpath, b_rawpath, a_blob_id, b_blob_id, a_mode, self.change_type = change_type self.score = score - def __eq__(self, other): + def __eq__(self, other: object) -> bool: for name in self.__slots__: if getattr(self, name) != getattr(other, name): return False # END for each name return True - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return not (self == other) - def __hash__(self): + def __hash__(self) -> int: return hash(tuple(getattr(self, n) for n in self.__slots__)) - def __str__(self): - h = "%s" + def __str__(self) -> str: + h = "%s" # type: str if self.a_blob: h %= self.a_blob.path elif self.b_blob: h %= self.b_blob.path - msg = '' + msg = '' # type: str line = None # temp line line_length = 0 # line length for b, n in zip((self.a_blob, self.b_blob), ('lhs', 'rhs')): @@ -354,7 +369,7 @@ def __str__(self): if self.diff: msg += '\n---' try: - msg += self.diff.decode(defenc) + msg += self.diff.decode(defenc) if isinstance(self.diff, bytes) else self.diff except UnicodeDecodeError: msg += 'OMITTED BINARY DATA' # end handle encoding @@ -368,36 +383,36 @@ def __str__(self): return res @property - def a_path(self): + def a_path(self) -> Optional[str]: return self.a_rawpath.decode(defenc, 'replace') if self.a_rawpath else None @property - def b_path(self): + def b_path(self) -> Optional[str]: return self.b_rawpath.decode(defenc, 'replace') if self.b_rawpath else None @property - def rename_from(self): + def rename_from(self) -> Optional[str]: return self.raw_rename_from.decode(defenc, 'replace') if self.raw_rename_from else None @property - def rename_to(self): + def rename_to(self) -> Optional[str]: return self.raw_rename_to.decode(defenc, 'replace') if self.raw_rename_to else None @property - def renamed(self): + def renamed(self) -> bool: """:returns: True if the blob of our diff has been renamed :note: This property is deprecated, please use ``renamed_file`` instead. """ return self.renamed_file @property - def renamed_file(self): + def renamed_file(self) -> bool: """:returns: True if the blob of our diff has been renamed """ return self.rename_from != self.rename_to @classmethod - def _pick_best_path(cls, path_match, rename_match, path_fallback_match): + def _pick_best_path(cls, path_match: bytes, rename_match: bytes, path_fallback_match: bytes) -> Optional[bytes]: if path_match: return decode_path(path_match) @@ -410,21 +425,23 @@ def _pick_best_path(cls, path_match, rename_match, path_fallback_match): return None @classmethod - def _index_from_patch_format(cls, repo, proc): + def _index_from_patch_format(cls, repo: Repo, proc: TBD) -> DiffIndex: """Create a new DiffIndex from the given text which must be in patch format :param repo: is the repository we are operating on - it is required :param stream: result of 'git diff' as a stream (supporting file protocol) :return: git.DiffIndex """ ## FIXME: Here SLURPING raw, need to re-phrase header-regexes linewise. - text = [] - handle_process_output(proc, text.append, None, finalize_process, decode_streams=False) + text_list = [] # type: List[bytes] + handle_process_output(proc, text_list.append, None, finalize_process, decode_streams=False) # for now, we have to bake the stream - text = b''.join(text) + text = b''.join(text_list) index = DiffIndex() previous_header = None header = None + a_path, b_path = None, None # for mypy + a_mode, b_mode = None, None # for mypy for _header in cls.re_header.finditer(text): a_path_fallback, b_path_fallback, \ old_mode, new_mode, \ @@ -464,14 +481,14 @@ def _index_from_patch_format(cls, repo, proc): previous_header = _header header = _header # end for each header we parse - if index: + if index and header: index[-1].diff = text[header.end():] # end assign last diff return index @classmethod - def _index_from_raw_format(cls, repo, proc): + def _index_from_raw_format(cls, repo: 'Repo', proc: TBD) -> DiffIndex: """Create a new DiffIndex from the given stream which must be in raw format. :return: git.DiffIndex""" # handles @@ -479,12 +496,13 @@ def _index_from_raw_format(cls, repo, proc): index = DiffIndex() - def handle_diff_line(lines): - lines = lines.decode(defenc) + def handle_diff_line(lines_bytes: bytes) -> None: + lines = lines_bytes.decode(defenc) for line in lines.split(':')[1:]: meta, _, path = line.partition('\x00') path = path.rstrip('\x00') + a_blob_id, b_blob_id = None, None # Type: Optional[str] old_mode, new_mode, a_blob_id, b_blob_id, _change_type = meta.split(None, 4) # Change type can be R100 # R: status letter @@ -504,20 +522,20 @@ def handle_diff_line(lines): # NOTE: We cannot conclude from the existence of a blob to change type # as diffs with the working do not have blobs yet if change_type == 'D': - b_blob_id = None + b_blob_id = None # Optional[str] deleted_file = True elif change_type == 'A': a_blob_id = None new_file = True elif change_type == 'C': copied_file = True - a_path, b_path = path.split('\x00', 1) - a_path = a_path.encode(defenc) - b_path = b_path.encode(defenc) + a_path_str, b_path_str = path.split('\x00', 1) + a_path = a_path_str.encode(defenc) + b_path = b_path_str.encode(defenc) elif change_type == 'R': - a_path, b_path = path.split('\x00', 1) - a_path = a_path.encode(defenc) - b_path = b_path.encode(defenc) + a_path_str, b_path_str = path.split('\x00', 1) + a_path = a_path_str.encode(defenc) + b_path = b_path_str.encode(defenc) rename_from, rename_to = a_path, b_path elif change_type == 'T': # Nothing to do diff --git a/git/exc.py b/git/exc.py index bd019c7fd..c02b2b3a3 100644 --- a/git/exc.py +++ b/git/exc.py @@ -12,10 +12,11 @@ from git.repo.base import Repo from git.types import PathLike -from typing import IO, List, Optional, Sequence, Tuple, Union +from typing import IO, List, Optional, Tuple, Union # ------------------------------------------------------------------ + class GitError(Exception): """ Base class for all package exceptions """ @@ -44,7 +45,7 @@ class CommandError(GitError): #: "'%s' failed%s" _msg = "Cmd('%s') failed%s" - def __init__(self, command: Union[List[str], Tuple[str, ...], str], + def __init__(self, command: Union[List[str], Tuple[str, ...], str], status: Union[str, None, Exception] = None, stderr: Optional[IO[str]] = None, stdout: Optional[IO[str]] = None) -> None: if not isinstance(command, (tuple, list)): @@ -84,7 +85,7 @@ def __init__(self, command: Union[List[str], Tuple[str], str], cause: Union[str, class GitCommandError(CommandError): """ Thrown if execution of the git command fails with non-zero status code. """ - def __init__(self, command: Union[List[str], Tuple[str, ...], str], + def __init__(self, command: Union[List[str], Tuple[str, ...], str], status: Union[str, None, Exception] = None, stderr: Optional[IO[str]] = None, stdout: Optional[IO[str]] = None, @@ -106,7 +107,9 @@ class CheckoutError(GitError): were checked out successfully and hence match the version stored in the index""" - def __init__(self, message: str, failed_files: List[PathLike], valid_files: List[PathLike], failed_reasons: List[str]) -> None: + def __init__(self, message: str, failed_files: List[PathLike], valid_files: List[PathLike], + failed_reasons: List[str]) -> None: + Exception.__init__(self, message) self.failed_files = failed_files self.failed_reasons = failed_reasons diff --git a/git/util.py b/git/util.py index b5cce59db..2b0c81715 100644 --- a/git/util.py +++ b/git/util.py @@ -4,7 +4,6 @@ # This module is part of GitPython and is released under # the BSD License: http://www.opensource.org/licenses/bsd-license.php from git.remote import Remote -from _typeshed import ReadableBuffer import contextlib from functools import wraps import getpass @@ -19,12 +18,14 @@ import time from unittest import SkipTest -from typing import (Any, AnyStr, BinaryIO, Callable, Dict, Generator, IO, List, NoReturn, Optional, Pattern, - Sequence, TextIO, Tuple, Union, cast) -from typing_extensions import Literal +# typing --------------------------------------------------------- +from typing import (Any, AnyStr, BinaryIO, Callable, Dict, Generator, IO, List, + NoReturn, Optional, Pattern, Sequence, Tuple, Union, cast) from git.repo.base import Repo from .types import PathLike, TBD +# --------------------------------------------------------------------- + from gitdb.util import ( # NOQA @IgnorePep8 make_sha, From 6752fad0e93d1d2747f56be30a52fea212bd15d6 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 3 May 2021 15:59:07 +0100 Subject: [PATCH 06/16] add initial types to remote.py --- .github/FUNDING.yml | 1 + CONTRIBUTING.md | 3 +- MANIFEST.in | 7 +- Makefile | 4 +- VERSION | 2 +- doc/source/changes.rst | 13 ++- git/__init__.py | 9 +- git/cmd.py | 56 +++++----- git/{compat.py => compat/__init__.py} | 39 ++++++- git/compat/typing.py | 13 +++ git/config.py | 7 +- git/db.py | 17 +-- git/diff.py | 126 ++++++++++----------- git/exc.py | 17 ++- git/objects/__init__.py | 4 +- git/objects/base.py | 3 +- git/refs/reference.py | 4 +- git/refs/symbolic.py | 2 +- git/remote.py | 37 +++---- git/repo/base.py | 151 +++++++++++++------------- git/repo/fun.py | 21 ++-- git/types.py | 18 ++- git/util.py | 47 ++++++-- mypy.ini | 7 +- requirements.txt | 1 + test-requirements.txt | 2 + test/fixtures/diff_file_with_colon | Bin 0 -> 351 bytes test/test_clone.py | 32 ++++++ test/test_diff.py | 7 ++ test/test_repo.py | 15 +++ test/test_util.py | 20 +++- tox.ini | 11 +- 32 files changed, 453 insertions(+), 243 deletions(-) create mode 100644 .github/FUNDING.yml rename git/{compat.py => compat/__init__.py} (75%) create mode 100644 git/compat/typing.py create mode 100644 test/fixtures/diff_file_with_colon create mode 100644 test/test_clone.py diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..80819f5d8 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: byron diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4217cbaf9..f685e7e72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,7 @@ The following is a short step-by-step rundown of what one typically would do to * [fork this project](https://github.com/gitpython-developers/GitPython/fork) on GitHub. * For setting up the environment to run the self tests, please look at `.travis.yml`. * Please try to **write a test that fails unless the contribution is present.** -* Feel free to add yourself to AUTHORS file. +* Try to avoid massive commits and prefer to take small steps, with one commit for each. +* Feel free to add yourself to AUTHORS file. * Create a pull request. diff --git a/MANIFEST.in b/MANIFEST.in index 5fd771db3..f02721fc6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,10 +1,11 @@ -include VERSION -include LICENSE -include CHANGES include AUTHORS +include CHANGES include CONTRIBUTING.md +include LICENSE include README.md +include VERSION include requirements.txt +include test-requirements.txt recursive-include doc * recursive-exclude test * diff --git a/Makefile b/Makefile index 709813ff2..f5d8a1089 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ release: clean make force_release force_release: clean - git push --tags origin master + git push --tags origin main python3 setup.py sdist bdist_wheel twine upload -s -i 27C50E7F590947D7273A741E85194C08421980C9 dist/* @@ -24,7 +24,7 @@ docker-build: test: docker-build # NOTE!!! - # NOTE!!! If you are not running from master or have local changes then tests will fail + # NOTE!!! If you are not running from main or have local changes then tests will fail # NOTE!!! docker run --rm -v ${CURDIR}:/src -w /src -t gitpython:xenial tox diff --git a/VERSION b/VERSION index 55f20a1a9..b5f785d2d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.13 +3.1.15 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 405179d0c..1b916f30f 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,7 +2,15 @@ Changelog ========= -3.1.?? +3.1.15 +====== + +* add deprectation warning for python 3.5 + +See the following for details: +https://github.com/gitpython-developers/gitpython/milestone/47?closed=1 + +3.1.14 ====== * git.Commit objects now have a ``replace`` method that will return a @@ -10,6 +18,9 @@ Changelog * Add python 3.9 support * Drop python 3.4 support +See the following for details: +https://github.com/gitpython-developers/gitpython/milestone/46?closed=1 + 3.1.13 ====== diff --git a/git/__init__.py b/git/__init__.py index e2f960db7..ae9254a26 100644 --- a/git/__init__.py +++ b/git/__init__.py @@ -5,6 +5,7 @@ # the BSD License: http://www.opensource.org/licenses/bsd-license.php # flake8: noqa #@PydevCodeAnalysisIgnore +from git.exc import * # @NoMove @IgnorePep8 import inspect import os import sys @@ -16,8 +17,6 @@ __version__ = 'git' - - #{ Initialization def _init_externals() -> None: """Initialize external projects by putting them into the path""" @@ -32,13 +31,13 @@ def _init_externals() -> None: #} END initialization + ################# _init_externals() ################# #{ Imports -from git.exc import * # @NoMove @IgnorePep8 try: from git.config import GitConfigParser # @NoMove @IgnorePep8 from git.objects import * # @NoMove @IgnorePep8 @@ -68,7 +67,8 @@ def _init_externals() -> None: #{ Initialize git executable path GIT_OK = None -def refresh(path:Optional[PathLike]=None) -> None: + +def refresh(path: Optional[PathLike] = None) -> None: """Convenience method for setting the git executable path.""" global GIT_OK GIT_OK = False @@ -81,6 +81,7 @@ def refresh(path:Optional[PathLike]=None) -> None: GIT_OK = True #} END initialize git executable path + ################# try: refresh() diff --git a/git/cmd.py b/git/cmd.py index bac162176..ac3ca2ec1 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -19,7 +19,7 @@ import threading from collections import OrderedDict from textwrap import dedent -from typing import Any, Dict, List, Optional +import warnings from git.compat import ( defenc, @@ -29,7 +29,7 @@ is_win, ) from git.exc import CommandError -from git.util import is_cygwin_git, cygpath, expand_path +from git.util import is_cygwin_git, cygpath, expand_path, remove_password_if_present from .exc import ( GitCommandError, @@ -40,8 +40,6 @@ stream_copy, ) -from .types import PathLike - execute_kwargs = {'istream', 'with_extended_output', 'with_exceptions', 'as_process', 'stdout_as_string', 'output_stream', 'with_stdout', 'kill_after_timeout', @@ -85,8 +83,8 @@ def pump_stream(cmdline, name, stream, is_decode, handler): line = line.decode(defenc) handler(line) except Exception as ex: - log.error("Pumping %r of cmd(%s) failed due to: %r", name, cmdline, ex) - raise CommandError(['<%s-pump>' % name] + cmdline, ex) from ex + log.error("Pumping %r of cmd(%s) failed due to: %r", name, remove_password_if_present(cmdline), ex) + raise CommandError(['<%s-pump>' % name] + remove_password_if_present(cmdline), ex) from ex finally: stream.close() @@ -105,7 +103,7 @@ def pump_stream(cmdline, name, stream, is_decode, handler): for name, stream, handler in pumps: t = threading.Thread(target=pump_stream, args=(cmdline, name, stream, decode_streams, handler)) - t.setDaemon(True) + t.daemon = True t.start() threads.append(t) @@ -140,7 +138,7 @@ def dict_to_slots_and__excluded_are_none(self, d, excluded=()): ## CREATE_NEW_PROCESS_GROUP is needed to allow killing it afterwards, # see https://docs.python.org/3/library/subprocess.html#subprocess.Popen.send_signal -PROC_CREATIONFLAGS = (CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP +PROC_CREATIONFLAGS = (CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] if is_win else 0) @@ -212,7 +210,7 @@ def refresh(cls, path=None): # - a GitCommandNotFound error is spawned by ourselves # - a PermissionError is spawned if the git executable provided # cannot be executed for whatever reason - + has_git = False try: cls().version() @@ -408,7 +406,7 @@ def read_all_from_possibly_closed_stream(stream): if status != 0: errstr = read_all_from_possibly_closed_stream(self.proc.stderr) log.debug('AutoInterrupt wait stderr: %r' % (errstr,)) - raise GitCommandError(self.args, status, errstr) + raise GitCommandError(remove_password_if_present(self.args), status, errstr) # END status handling return status # END auto interrupt @@ -500,7 +498,7 @@ def readlines(self, size=-1): # skipcq: PYL-E0301 def __iter__(self): return self - + def __next__(self): return self.next() @@ -519,7 +517,7 @@ def __del__(self): self._stream.read(bytes_left + 1) # END handle incomplete read - def __init__(self, working_dir: Optional[PathLike]=None) -> None: + def __init__(self, working_dir=None): """Initialize this instance with: :param working_dir: @@ -528,12 +526,12 @@ def __init__(self, working_dir: Optional[PathLike]=None) -> None: It is meant to be the working tree directory if available, or the .git directory in case of bare repositories.""" super(Git, self).__init__() - self._working_dir = expand_path(working_dir) if working_dir is not None else None + self._working_dir = expand_path(working_dir) self._git_options = () - self._persistent_git_options = [] # type: List[str] + self._persistent_git_options = [] # Extra environment variables to pass to git commands - self._environment = {} # type: Dict[str, Any] + self._environment = {} # cached command slots self.cat_file_header = None @@ -547,7 +545,7 @@ def __getattr__(self, name): return LazyMixin.__getattr__(self, name) return lambda *args, **kwargs: self._call_process(name, *args, **kwargs) - def set_persistent_git_options(self, **kwargs) -> None: + def set_persistent_git_options(self, **kwargs): """Specify command line options to the git executable for subsequent subcommand calls @@ -641,7 +639,7 @@ def execute(self, command, :param env: A dictionary of environment variables to be passed to `subprocess.Popen`. - + :param max_chunk_size: Maximum number of bytes in one chunk of data passed to the output_stream in one invocation of write() method. If the given number is not positive then @@ -685,8 +683,10 @@ def execute(self, command, :note: If you add additional keyword arguments to the signature of this method, you must update the execute_kwargs tuple housed in this module.""" + # Remove password for the command if present + redacted_command = remove_password_if_present(command) if self.GIT_PYTHON_TRACE and (self.GIT_PYTHON_TRACE != 'full' or as_process): - log.info(' '.join(command)) + log.info(' '.join(redacted_command)) # Allow the user to have the command executed in their working dir. cwd = self._working_dir or os.getcwd() @@ -707,7 +707,7 @@ def execute(self, command, if is_win: cmd_not_found_exception = OSError if kill_after_timeout: - raise GitCommandError(command, '"kill_after_timeout" feature is not supported on Windows.') + raise GitCommandError(redacted_command, '"kill_after_timeout" feature is not supported on Windows.') else: if sys.version_info[0] > 2: cmd_not_found_exception = FileNotFoundError # NOQA # exists, flake8 unknown @UndefinedVariable @@ -722,7 +722,7 @@ def execute(self, command, if istream: istream_ok = "<valid stream>" log.debug("Popen(%s, cwd=%s, universal_newlines=%s, shell=%s, istream=%s)", - command, cwd, universal_newlines, shell, istream_ok) + redacted_command, cwd, universal_newlines, shell, istream_ok) try: proc = Popen(command, env=env, @@ -738,7 +738,7 @@ def execute(self, command, **subprocess_kwargs ) except cmd_not_found_exception as err: - raise GitCommandNotFound(command, err) from err + raise GitCommandNotFound(redacted_command, err) from err if as_process: return self.AutoInterrupt(proc, command) @@ -788,7 +788,7 @@ def _kill_process(pid): watchdog.cancel() if kill_check.isSet(): stderr_value = ('Timeout: the command "%s" did not complete in %d ' - 'secs.' % (" ".join(command), kill_after_timeout)) + 'secs.' % (" ".join(redacted_command), kill_after_timeout)) if not universal_newlines: stderr_value = stderr_value.encode(defenc) # strip trailing "\n" @@ -812,7 +812,7 @@ def _kill_process(pid): proc.stderr.close() if self.GIT_PYTHON_TRACE == 'full': - cmdstr = " ".join(command) + cmdstr = " ".join(redacted_command) def as_text(stdout_value): return not output_stream and safe_decode(stdout_value) or '<OUTPUT_STREAM>' @@ -828,7 +828,7 @@ def as_text(stdout_value): # END handle debug printing if with_exceptions and status != 0: - raise GitCommandError(command, status, stderr_value, stdout_value) + raise GitCommandError(redacted_command, status, stderr_value, stdout_value) if isinstance(stdout_value, bytes) and stdout_as_string: # could also be output_stream stdout_value = safe_decode(stdout_value) @@ -905,8 +905,14 @@ def transform_kwarg(self, name, value, split_single_char_options): def transform_kwargs(self, split_single_char_options=True, **kwargs): """Transforms Python style kwargs into git command line options.""" + # Python 3.6 preserves the order of kwargs and thus has a stable + # order. For older versions sort the kwargs by the key to get a stable + # order. + if sys.version_info[:2] < (3, 6): + kwargs = OrderedDict(sorted(kwargs.items(), key=lambda x: x[0])) + warnings.warn("Python 3.5 support is deprecated and will be removed 2021-09-05.\n" + + "It does not preserve the order for key-word arguments and enforce lexical sorting instead.") args = [] - kwargs = OrderedDict(sorted(kwargs.items(), key=lambda x: x[0])) for k, v in kwargs.items(): if isinstance(v, (list, tuple)): for value in v: diff --git a/git/compat.py b/git/compat/__init__.py similarity index 75% rename from git/compat.py rename to git/compat/__init__.py index 4fe394ae0..c4bd2aa36 100644 --- a/git/compat.py +++ b/git/compat/__init__.py @@ -16,9 +16,22 @@ force_text # @UnusedImport ) -from typing import Any, AnyStr, Dict, Optional, Type +# typing -------------------------------------------------------------------- + +from typing import ( + Any, + AnyStr, + Dict, + IO, + Optional, + Type, + Union, + overload, +) from git.types import TBD +# --------------------------------------------------------------------------- + is_win = (os.name == 'nt') # type: bool is_posix = (os.name == 'posix') @@ -26,7 +39,13 @@ defenc = sys.getfilesystemencoding() -def safe_decode(s: Optional[AnyStr]) -> Optional[str]: +@overload +def safe_decode(s: None) -> None: ... + +@overload +def safe_decode(s: Union[IO[str], AnyStr]) -> str: ... + +def safe_decode(s: Union[IO[str], AnyStr, None]) -> Optional[str]: """Safely decodes a binary string to unicode""" if isinstance(s, str): return s @@ -38,6 +57,11 @@ def safe_decode(s: Optional[AnyStr]) -> Optional[str]: raise TypeError('Expected bytes or text, but got %r' % (s,)) +@overload +def safe_encode(s: None) -> None: ... + +@overload +def safe_encode(s: AnyStr) -> bytes: ... def safe_encode(s: Optional[AnyStr]) -> Optional[bytes]: """Safely encodes a binary string to unicode""" @@ -51,6 +75,12 @@ def safe_encode(s: Optional[AnyStr]) -> Optional[bytes]: raise TypeError('Expected bytes or text, but got %r' % (s,)) +@overload +def win_encode(s: None) -> None: ... + +@overload +def win_encode(s: AnyStr) -> bytes: ... + def win_encode(s: Optional[AnyStr]) -> Optional[bytes]: """Encode unicodes for process arguments on Windows.""" if isinstance(s, str): @@ -62,9 +92,9 @@ def win_encode(s: Optional[AnyStr]) -> Optional[bytes]: return None -def with_metaclass(meta: Type[Any], *bases: Any) -> 'metaclass': # type: ignore ## mypy cannot understand dynamic class creation +def with_metaclass(meta: Type[Any], *bases: Any) -> 'metaclass': # type: ignore ## mypy cannot understand dynamic class creation """copied from https://github.com/Byron/bcore/blob/master/src/python/butility/future.py#L15""" - + class metaclass(meta): # type: ignore __call__ = type.__call__ __init__ = type.__init__ # type: ignore @@ -75,4 +105,3 @@ def __new__(cls, name: str, nbases: Optional[int], d: Dict[str, Any]) -> TBD: return meta(name, bases, d) return metaclass(meta.__name__ + 'Helper', None, {}) - diff --git a/git/compat/typing.py b/git/compat/typing.py new file mode 100644 index 000000000..925c5ba2e --- /dev/null +++ b/git/compat/typing.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# config.py +# Copyright (C) 2021 Michael Trier (mtrier@gmail.com) and contributors +# +# This module is part of GitPython and is released under +# the BSD License: http://www.opensource.org/licenses/bsd-license.php + +import sys + +if sys.version_info[:2] >= (3, 8): + from typing import Final, Literal # noqa: F401 +else: + from typing_extensions import Final, Literal # noqa: F401 diff --git a/git/config.py b/git/config.py index ffbbfab40..0c8d975db 100644 --- a/git/config.py +++ b/git/config.py @@ -16,14 +16,13 @@ import fnmatch from collections import OrderedDict -from typing_extensions import Literal - from git.compat import ( defenc, force_text, with_metaclass, is_win, ) +from git.compat.typing import Literal from git.util import LockFile import os.path as osp @@ -196,7 +195,7 @@ def items_all(self): return [(k, self.getall(k)) for k in self] -def get_config_path(config_level: Literal['system','global','user','repository']) -> str: +def get_config_path(config_level: Literal['system', 'global', 'user', 'repository']) -> str: # we do not support an absolute path of the gitconfig on windows , # use the global config instead @@ -216,7 +215,7 @@ def get_config_path(config_level: Literal['system','global','user','repository'] raise ValueError("Invalid configuration level: %r" % config_level) -class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, object)): +class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, object)): # type: ignore ## mypy does not understand dynamic class creation # noqa: E501 """Implements specifics required to read git style configuration files. diff --git a/git/db.py b/git/db.py index ef2b0b2ef..dc60c5552 100644 --- a/git/db.py +++ b/git/db.py @@ -1,5 +1,4 @@ """Module with our own gitdb implementation - it uses the git command""" -from typing import AnyStr from git.util import bin_to_hex, hex_to_bin from gitdb.base import ( OInfo, @@ -7,21 +6,23 @@ ) from gitdb.db import GitDB # @UnusedImport from gitdb.db import LooseObjectDB -from gitdb.exc import BadObject -from .exc import GitCommandError +from gitdb.exc import BadObject +from git.exc import GitCommandError # typing------------------------------------------------- -from .cmd import Git -from .types import PathLike +from typing import TYPE_CHECKING, AnyStr +from git.types import PathLike + +if TYPE_CHECKING: + from git.cmd import Git + # -------------------------------------------------------- __all__ = ('GitCmdObjectDB', 'GitDB') -# class GitCmdObjectDB(CompoundDB, ObjectDBW): - class GitCmdObjectDB(LooseObjectDB): @@ -33,7 +34,7 @@ class GitCmdObjectDB(LooseObjectDB): have packs and the other implementations """ - def __init__(self, root_path: PathLike, git: Git) -> None: + def __init__(self, root_path: PathLike, git: 'Git') -> None: """Initialize this instance with the root and a git command""" super(GitCmdObjectDB, self).__init__(root_path) self._git = git diff --git a/git/diff.py b/git/diff.py index b25aadc76..943916ea8 100644 --- a/git/diff.py +++ b/git/diff.py @@ -15,11 +15,14 @@ # typing ------------------------------------------------------------------ -from .objects.tree import Tree -from git.repo.base import Repo -from typing_extensions import Final, Literal +from typing import Any, Iterator, List, Match, Optional, Tuple, Type, Union, TYPE_CHECKING +from git.compat.typing import Final, Literal from git.types import TBD -from typing import Any, Iterator, List, Match, Optional, Tuple, Type, Union + +if TYPE_CHECKING: + from .objects.tree import Tree + from git.repo.base import Repo + Lit_change_type = Literal['A', 'D', 'M', 'R', 'T'] # ------------------------------------------------------------------------ @@ -27,7 +30,7 @@ __all__ = ('Diffable', 'DiffIndex', 'Diff', 'NULL_TREE') # Special object to compare against the empty tree in diffs -NULL_TREE: Final[object] = object() +NULL_TREE = object() # type: Final[object] _octal_byte_re = re.compile(b'\\\\([0-9]{3})') @@ -79,7 +82,7 @@ def _process_diff_args(self, args: List[Union[str, 'Diffable', object]]) -> List Subclasses can use it to alter the behaviour of the superclass""" return args - def diff(self, other: Union[Type[Index], Type[Tree], object, None, str] = Index, + def diff(self, other: Union[Type[Index], Type['Tree'], object, None, str] = Index, paths: Union[str, List[str], Tuple[str, ...], None] = None, create_patch: bool = False, **kwargs: Any) -> 'DiffIndex': """Creates diffs between two items being trees, trees and index or an @@ -271,7 +274,7 @@ class Diff(object): "new_file", "deleted_file", "copied_file", "raw_rename_from", "raw_rename_to", "diff", "change_type", "score") - def __init__(self, repo: Repo, + def __init__(self, repo: 'Repo', a_rawpath: Optional[bytes], b_rawpath: Optional[bytes], a_blob_id: Union[str, bytes, None], b_blob_id: Union[str, bytes, None], a_mode: Union[bytes, str, None], b_mode: Union[bytes, str, None], @@ -425,7 +428,7 @@ def _pick_best_path(cls, path_match: bytes, rename_match: bytes, path_fallback_m return None @classmethod - def _index_from_patch_format(cls, repo: Repo, proc: TBD) -> DiffIndex: + def _index_from_patch_format(cls, repo: 'Repo', proc: TBD) -> DiffIndex: """Create a new DiffIndex from the given text which must be in patch format :param repo: is the repository we are operating on - it is required :param stream: result of 'git diff' as a stream (supporting file protocol) @@ -487,6 +490,58 @@ def _index_from_patch_format(cls, repo: Repo, proc: TBD) -> DiffIndex: return index + @staticmethod + def _handle_diff_line(lines_bytes: bytes, repo: 'Repo', index: TBD) -> None: + lines = lines_bytes.decode(defenc) + + for line in lines.split(':')[1:]: + meta, _, path = line.partition('\x00') + path = path.rstrip('\x00') + a_blob_id, b_blob_id = None, None # Type: Optional[str] + old_mode, new_mode, a_blob_id, b_blob_id, _change_type = meta.split(None, 4) + # Change type can be R100 + # R: status letter + # 100: score (in case of copy and rename) + change_type = _change_type[0] + score_str = ''.join(_change_type[1:]) + score = int(score_str) if score_str.isdigit() else None + path = path.strip() + a_path = path.encode(defenc) + b_path = path.encode(defenc) + deleted_file = False + new_file = False + copied_file = False + rename_from = None + rename_to = None + + # NOTE: We cannot conclude from the existence of a blob to change type + # as diffs with the working do not have blobs yet + if change_type == 'D': + b_blob_id = None # Optional[str] + deleted_file = True + elif change_type == 'A': + a_blob_id = None + new_file = True + elif change_type == 'C': + copied_file = True + a_path_str, b_path_str = path.split('\x00', 1) + a_path = a_path_str.encode(defenc) + b_path = b_path_str.encode(defenc) + elif change_type == 'R': + a_path_str, b_path_str = path.split('\x00', 1) + a_path = a_path_str.encode(defenc) + b_path = b_path_str.encode(defenc) + rename_from, rename_to = a_path, b_path + elif change_type == 'T': + # Nothing to do + pass + # END add/remove handling + + diff = Diff(repo, a_path, b_path, a_blob_id, b_blob_id, old_mode, new_mode, + new_file, deleted_file, copied_file, rename_from, rename_to, + '', change_type, score) + index.append(diff) + @classmethod def _index_from_raw_format(cls, repo: 'Repo', proc: TBD) -> DiffIndex: """Create a new DiffIndex from the given stream which must be in raw format. @@ -495,58 +550,7 @@ def _index_from_raw_format(cls, repo: 'Repo', proc: TBD) -> DiffIndex: # :100644 100644 687099101... 37c5e30c8... M .gitignore index = DiffIndex() - - def handle_diff_line(lines_bytes: bytes) -> None: - lines = lines_bytes.decode(defenc) - - for line in lines.split(':')[1:]: - meta, _, path = line.partition('\x00') - path = path.rstrip('\x00') - a_blob_id, b_blob_id = None, None # Type: Optional[str] - old_mode, new_mode, a_blob_id, b_blob_id, _change_type = meta.split(None, 4) - # Change type can be R100 - # R: status letter - # 100: score (in case of copy and rename) - change_type = _change_type[0] - score_str = ''.join(_change_type[1:]) - score = int(score_str) if score_str.isdigit() else None - path = path.strip() - a_path = path.encode(defenc) - b_path = path.encode(defenc) - deleted_file = False - new_file = False - copied_file = False - rename_from = None - rename_to = None - - # NOTE: We cannot conclude from the existence of a blob to change type - # as diffs with the working do not have blobs yet - if change_type == 'D': - b_blob_id = None # Optional[str] - deleted_file = True - elif change_type == 'A': - a_blob_id = None - new_file = True - elif change_type == 'C': - copied_file = True - a_path_str, b_path_str = path.split('\x00', 1) - a_path = a_path_str.encode(defenc) - b_path = b_path_str.encode(defenc) - elif change_type == 'R': - a_path_str, b_path_str = path.split('\x00', 1) - a_path = a_path_str.encode(defenc) - b_path = b_path_str.encode(defenc) - rename_from, rename_to = a_path, b_path - elif change_type == 'T': - # Nothing to do - pass - # END add/remove handling - - diff = Diff(repo, a_path, b_path, a_blob_id, b_blob_id, old_mode, new_mode, - new_file, deleted_file, copied_file, rename_from, rename_to, - '', change_type, score) - index.append(diff) - - handle_process_output(proc, handle_diff_line, None, finalize_process, decode_streams=False) + handle_process_output(proc, lambda bytes: cls._handle_diff_line( + bytes, repo, index), None, finalize_process, decode_streams=False) return index diff --git a/git/exc.py b/git/exc.py index c02b2b3a3..6e646921c 100644 --- a/git/exc.py +++ b/git/exc.py @@ -5,14 +5,17 @@ # the BSD License: http://www.opensource.org/licenses/bsd-license.php """ Module containing all exceptions thrown throughout the git package, """ +from gitdb.exc import BadName # NOQA @UnusedWildImport skipcq: PYL-W0401, PYL-W0614 from gitdb.exc import * # NOQA @UnusedWildImport skipcq: PYL-W0401, PYL-W0614 from git.compat import safe_decode # typing ---------------------------------------------------- -from git.repo.base import Repo +from typing import IO, List, Optional, Tuple, Union, TYPE_CHECKING from git.types import PathLike -from typing import IO, List, Optional, Tuple, Union + +if TYPE_CHECKING: + from git.repo.base import Repo # ------------------------------------------------------------------ @@ -63,10 +66,12 @@ def __init__(self, command: Union[List[str], Tuple[str, ...], str], status = "'%s'" % s if isinstance(status, str) else s self._cmd = safe_decode(command[0]) - self._cmdline = ' '.join(str(safe_decode(i)) for i in command) + self._cmdline = ' '.join(safe_decode(i) for i in command) self._cause = status and " due to: %s" % status or "!" - self.stdout = stdout and "\n stdout: '%s'" % safe_decode(str(stdout)) or '' - self.stderr = stderr and "\n stderr: '%s'" % safe_decode(str(stderr)) or '' + stdout_decode = safe_decode(stdout) + stderr_decode = safe_decode(stderr) + self.stdout = stdout_decode and "\n stdout: '%s'" % stdout_decode or '' + self.stderr = stderr_decode and "\n stderr: '%s'" % stderr_decode or '' def __str__(self) -> str: return (self._msg + "\n cmdline: %s%s%s") % ( @@ -142,7 +147,7 @@ def __init__(self, command: Union[List[str], Tuple[str, ...], str], status: Opti class RepositoryDirtyError(GitError): """Thrown whenever an operation on a repository fails as it has uncommitted changes that would be overwritten""" - def __init__(self, repo: Repo, message: str) -> None: + def __init__(self, repo: 'Repo', message: str) -> None: self.repo = repo self.message = message diff --git a/git/objects/__init__.py b/git/objects/__init__.py index 23b2416ae..897eb98fa 100644 --- a/git/objects/__init__.py +++ b/git/objects/__init__.py @@ -16,8 +16,8 @@ from .tree import * # Fix import dependency - add IndexObject to the util module, so that it can be # imported by the submodule.base -smutil.IndexObject = IndexObject -smutil.Object = Object +smutil.IndexObject = IndexObject # type: ignore[attr-defined] +smutil.Object = Object # type: ignore[attr-defined] del(smutil) # must come after submodule was made available diff --git a/git/objects/base.py b/git/objects/base.py index cccb5ec66..59f0e8368 100644 --- a/git/objects/base.py +++ b/git/objects/base.py @@ -7,6 +7,7 @@ import gitdb.typ as dbtyp import os.path as osp +from typing import Optional # noqa: F401 unused import from .util import get_object_type_by_name @@ -24,7 +25,7 @@ class Object(LazyMixin): TYPES = (dbtyp.str_blob_type, dbtyp.str_tree_type, dbtyp.str_commit_type, dbtyp.str_tag_type) __slots__ = ("repo", "binsha", "size") - type = None # to be set by subclass + type = None # type: Optional[str] # to be set by subclass def __init__(self, repo, binsha): """Initialize an object by identifying it by its binary sha. diff --git a/git/refs/reference.py b/git/refs/reference.py index aaa9b63fe..9014f5558 100644 --- a/git/refs/reference.py +++ b/git/refs/reference.py @@ -103,7 +103,7 @@ def iter_items(cls, repo, common_path=None): #{ Remote Interface - @property + @property # type: ignore ## mypy cannot deal with properties with an extra decorator (2021-04-21) @require_remote_ref_path def remote_name(self): """ @@ -114,7 +114,7 @@ def remote_name(self): # /refs/remotes/<remote name>/<branch_name> return tokens[2] - @property + @property # type: ignore ## mypy cannot deal with properties with an extra decorator (2021-04-21) @require_remote_ref_path def remote_head(self): """:return: Name of the remote head itself, i.e. master. diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index fb9b4f84b..22d9c1d51 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -87,7 +87,7 @@ def _iter_packed_refs(cls, repo): """Returns an iterator yielding pairs of sha1/path pairs (as bytes) for the corresponding refs. :note: The packed refs file will be kept open as long as we iterate""" try: - with open(cls._get_packed_refs_path(repo), 'rt') as fp: + with open(cls._get_packed_refs_path(repo), 'rt', encoding='UTF-8') as fp: for line in fp: line = line.strip() if not line: diff --git a/git/remote.py b/git/remote.py index 53349ce70..20b5a5514 100644 --- a/git/remote.py +++ b/git/remote.py @@ -9,7 +9,7 @@ import re from git.cmd import handle_process_output, Git -from git.compat import (defenc, force_text, is_win) +from git.compat import (defenc, force_text) from git.exc import GitCommandError from git.util import ( LazyMixin, @@ -36,7 +36,15 @@ # typing------------------------------------------------------- -from git.repo.Base import Repo +from typing import Any, Optional, Set, TYPE_CHECKING, Union + +from git.types import PathLike + +if TYPE_CHECKING: + from git.repo.base import Repo + from git.objects.commit import Commit + +# ------------------------------------------------------------- log = logging.getLogger('git.remote') log.addHandler(logging.NullHandler()) @@ -47,7 +55,7 @@ #{ Utilities -def add_progress(kwargs, git, progress): +def add_progress(kwargs: Any, git: Git, progress: RemoteProgress) -> Any: """Add the --progress flag to the given kwargs dict if supported by the git command. If the actual progress in the given progress instance is not given, we do not request any progress @@ -63,7 +71,7 @@ def add_progress(kwargs, git, progress): #} END utilities -def to_progress_instance(progress): +def to_progress_instance(progress: Optional[RemoteProgress]) -> Union[RemoteProgress, CallableRemoteProgress]: """Given the 'progress' return a suitable object derived from RemoteProgress(). """ @@ -224,7 +232,7 @@ class FetchInfo(object): } @classmethod - def refresh(cls): + def refresh(cls) -> bool: """This gets called by the refresh function (see the top level __init__). """ @@ -247,7 +255,8 @@ def refresh(cls): return True - def __init__(self, ref, flags, note='', old_commit=None, remote_ref_path=None): + def __init__(self, ref: SymbolicReference, flags: Set[int], note: str = '', old_commit: Optional[Commit] = None, + remote_ref_path: Optional[PathLike] = None): """ Initialize a new instance """ @@ -257,16 +266,16 @@ def __init__(self, ref, flags, note='', old_commit=None, remote_ref_path=None): self.old_commit = old_commit self.remote_ref_path = remote_ref_path - def __str__(self): + def __str__(self) -> str: return self.name @property - def name(self): + def name(self) -> str: """:return: Name of our remote ref""" return self.ref.name @property - def commit(self): + def commit(self) -> 'Commit': """:return: Commit of our remote ref""" return self.ref.commit @@ -409,16 +418,6 @@ def __init__(self, repo, name): self.repo = repo # type: 'Repo' self.name = name - if is_win: - # some oddity: on windows, python 2.5, it for some reason does not realize - # that it has the config_writer property, but instead calls __getattr__ - # which will not yield the expected results. 'pinging' the members - # with a dir call creates the config_writer property that we require - # ... bugs like these make me wonder whether python really wants to be used - # for production. It doesn't happen on linux though. - dir(self) - # END windows special handling - def __getattr__(self, attr): """Allows to call this instance like remote.special( \\*args, \\*\\*kwargs) to call git-remote special self.name""" diff --git a/git/repo/base.py b/git/repo/base.py index 253631063..ed0a810e4 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -4,11 +4,6 @@ # This module is part of GitPython and is released under # the BSD License: http://www.opensource.org/licenses/bsd-license.php - -from git.objects.tag import TagObject -from git.objects.blob import Blob -from git.objects.tree import Tree -from git.refs.symbolic import SymbolicReference import logging import os import re @@ -30,38 +25,44 @@ 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.util import Actor, IterableList, finalize_process, decygpath, hex_to_bin, expand_path +from git.util import Actor, finalize_process, decygpath, hex_to_bin, expand_path, remove_password_if_present import os.path as osp from .fun import rev_parse, is_git_dir, find_submodule_git_dir, touch, find_worktree_git_dir import gc import gitdb -# Typing ------------------------------------------------------------------- - -from typing import (Any, BinaryIO, Callable, Dict, Iterator, List, Mapping, Optional, - TextIO, Tuple, Type, Union, NamedTuple, cast,) -from typing_extensions import Literal -from git.types import PathLike, TBD - -Lit_config_levels = Literal['system', 'global', 'user', 'repository'] - +# typing ------------------------------------------------------ -# -------------------------------------------------------------------------- +from git.compat.typing import Literal +from git.types import TBD, PathLike +from typing import (Any, BinaryIO, Callable, Dict, + Iterator, List, Mapping, Optional, + TextIO, Tuple, Type, Union, + NamedTuple, cast, TYPE_CHECKING) +if TYPE_CHECKING: # only needed for types + from git.util import IterableList + from git.refs.symbolic import SymbolicReference + from git.objects import TagObject, Blob, Tree # NOQA: F401 -class BlameEntry(NamedTuple): - commit: Dict[str, TBD] # Any == 'Commit' type? - linenos: range - orig_path: Optional[str] - orig_linenos: range +Lit_config_levels = Literal['system', 'global', 'user', 'repository'] +# ----------------------------------------------------------- log = logging.getLogger(__name__) __all__ = ('Repo',) +BlameEntry = NamedTuple('BlameEntry', [ + ('commit', Dict[str, TBD]), + ('linenos', range), + ('orig_path', Optional[str]), + ('orig_linenos', range)] +) + + class Repo(object): """Represents a git repository and allows you to query references, gather commit information, generate diffs, create and clone repositories query @@ -221,10 +222,11 @@ def __init__(self, path: Optional[PathLike] = None, odbt: Type[GitCmdObjectDB] = self.git = self.GitCommandWrapperType(self.working_dir) # special handling, in special times - args = [osp.join(self.common_dir, 'objects')] # type: List[Union[str, Git]] + rootpath = osp.join(self.common_dir, 'objects') if issubclass(odbt, GitCmdObjectDB): - args.append(self.git) - self.odb = odbt(*args) + self.odb = odbt(rootpath, self.git) + else: + self.odb = odbt(rootpath) def __enter__(self) -> 'Repo': return self @@ -266,13 +268,14 @@ def __hash__(self) -> int: # Description property def _get_description(self) -> str: - filename = osp.join(self.git_dir, 'description') if self.git_dir else "" + if self.git_dir: + filename = osp.join(self.git_dir, 'description') with open(filename, 'rb') as fp: return fp.read().rstrip().decode(defenc) def _set_description(self, descr: str) -> None: - - filename = osp.join(self.git_dir, 'description') if self.git_dir else "" + if self.git_dir: + filename = osp.join(self.git_dir, 'description') with open(filename, 'wb') as fp: fp.write((descr + '\n').encode(defenc)) @@ -306,7 +309,7 @@ def bare(self) -> bool: return self._bare @property - def heads(self) -> IterableList: + def heads(self) -> 'IterableList': """A list of ``Head`` objects representing the branch heads in this repo @@ -314,7 +317,7 @@ def heads(self) -> IterableList: return Head.list_items(self) @property - def references(self) -> IterableList: + def references(self) -> 'IterableList': """A list of Reference objects representing tags, heads and remote references. :return: IterableList(Reference, ...)""" @@ -327,19 +330,19 @@ def references(self) -> IterableList: branches = heads @property - def index(self) -> IndexFile: + def index(self) -> 'IndexFile': """:return: IndexFile representing this repository's index. :note: This property can be expensive, as the returned ``IndexFile`` will be reinitialized. It's recommended to re-use the object.""" return IndexFile(self) @property - def head(self) -> HEAD: + def head(self) -> 'HEAD': """:return: HEAD Object pointing to the current head reference""" return HEAD(self, 'HEAD') @property - def remotes(self) -> IterableList: + def remotes(self) -> 'IterableList': """A list of Remote objects allowing to access and manipulate remotes :return: ``git.IterableList(Remote, ...)``""" return Remote.list_items(self) @@ -355,13 +358,13 @@ def remote(self, name: str = 'origin') -> 'Remote': #{ Submodules @property - def submodules(self) -> IterableList: + def submodules(self) -> 'IterableList': """ :return: git.IterableList(Submodule, ...) of direct submodules available from the current head""" return Submodule.list_items(self) - def submodule(self, name: str) -> IterableList: + def submodule(self, name: str) -> 'IterableList': """ :return: Submodule with the given name :raise ValueError: If no such submodule exists""" try: @@ -393,7 +396,7 @@ def submodule_update(self, *args: Any, **kwargs: Any) -> Iterator: #}END submodules @property - def tags(self) -> IterableList: + def tags(self) -> 'IterableList': """A list of ``Tag`` objects that are available in this repo :return: ``git.IterableList(TagReference, ...)`` """ return TagReference.list_items(self) @@ -405,14 +408,14 @@ def tag(self, path: PathLike) -> TagReference: def create_head(self, path: PathLike, commit: str = 'HEAD', force: bool = False, logmsg: Optional[str] = None - ) -> SymbolicReference: + ) -> 'SymbolicReference': """Create a new head within the repository. For more documentation, please see the Head.create method. :return: newly created Head Reference""" return Head.create(self, path, commit, force, logmsg) - def delete_head(self, *heads: HEAD, **kwargs: Any) -> None: + def delete_head(self, *heads: 'SymbolicReference', **kwargs: Any) -> None: """Delete the given heads :param kwargs: Additional keyword arguments to be passed to git-branch""" @@ -458,12 +461,11 @@ def _get_config_path(self, config_level: Lit_config_levels) -> str: elif config_level == "global": return osp.normpath(osp.expanduser("~/.gitconfig")) elif config_level == "repository": - if self._common_dir: - return osp.normpath(osp.join(self._common_dir, "config")) - elif self.git_dir: - return osp.normpath(osp.join(self.git_dir, "config")) - else: + repo_dir = self._common_dir or self.git_dir + if not repo_dir: raise NotADirectoryError + else: + return osp.normpath(osp.join(repo_dir, "config")) raise ValueError("Invalid configuration level: %r" % config_level) @@ -503,7 +505,8 @@ def config_writer(self, config_level: Lit_config_levels = "repository") -> GitCo repository = configuration file for this repository only""" return GitConfigParser(self._get_config_path(config_level), read_only=False, repo=self) - def commit(self, rev: Optional[TBD] = None,) -> Union[SymbolicReference, Commit, TagObject, Blob, Tree, None]: + def commit(self, rev: Optional[TBD] = None + ) -> Union['SymbolicReference', Commit, 'TagObject', 'Blob', 'Tree']: """The Commit object for the specified revision :param rev: revision specifier, see git-rev-parse for viable options. @@ -536,7 +539,7 @@ def tree(self, rev: Union['Commit', 'Tree', None] = None) -> 'Tree': return self.rev_parse(str(rev) + "^{tree}") def iter_commits(self, rev: Optional[TBD] = None, paths: Union[PathLike, List[PathLike]] = '', - **kwargs: Any,) -> Iterator[Commit]: + **kwargs: Any) -> Iterator[Commit]: """A list of Commit objects representing the history of a given ref/commit :param rev: @@ -560,8 +563,8 @@ def iter_commits(self, rev: Optional[TBD] = None, paths: Union[PathLike, List[Pa return Commit.iter_items(self, rev, paths, **kwargs) - def merge_base(self, *rev: TBD, **kwargs: Any, - ) -> List[Union[SymbolicReference, Commit, TagObject, Blob, Tree, None]]: + def merge_base(self, *rev: TBD, **kwargs: Any + ) -> List[Union['SymbolicReference', Commit, 'TagObject', 'Blob', 'Tree', None]]: """Find the closest common ancestor for the given revision (e.g. Commits, Tags, References, etc) :param rev: At least two revs to find the common ancestor for. @@ -574,7 +577,7 @@ def merge_base(self, *rev: TBD, **kwargs: Any, raise ValueError("Please specify at least two revs, got only %i" % len(rev)) # end handle input - res = [] # type: List[Union[SymbolicReference, Commit, TagObject, Blob, Tree, None]] + res = [] # type: List[Union['SymbolicReference', Commit, 'TagObject', 'Blob', 'Tree', None]] try: lines = self.git.merge_base(*rev, **kwargs).splitlines() # List[str] except GitCommandError as err: @@ -608,11 +611,13 @@ def is_ancestor(self, ancestor_rev: 'Commit', rev: 'Commit') -> bool: return True def _get_daemon_export(self) -> bool: - filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) if self.git_dir else "" + if self.git_dir: + filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) return osp.exists(filename) def _set_daemon_export(self, value: object) -> None: - filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) if self.git_dir else "" + if self.git_dir: + filename = osp.join(self.git_dir, self.DAEMON_EXPORT_FILE) fileexists = osp.exists(filename) if value and not fileexists: touch(filename) @@ -628,7 +633,8 @@ def _get_alternates(self) -> List[str]: """The list of alternates for this repo from which objects can be retrieved :return: list of strings being pathnames of alternates""" - alternates_path = osp.join(self.git_dir, 'objects', 'info', 'alternates') if self.git_dir else "" + if self.git_dir: + alternates_path = osp.join(self.git_dir, 'objects', 'info', 'alternates') if osp.exists(alternates_path): with open(alternates_path, 'rb') as f: @@ -768,7 +774,7 @@ def blame_incremental(self, rev: TBD, file: TBD, **kwargs: Any) -> Optional[Iter should get a continuous range spanning all line numbers in the file. """ data = self.git.blame(rev, '--', file, p=True, incremental=True, stdout_as_string=False, **kwargs) - commits = {} + commits = {} # type: Dict[str, TBD] stream = (line for line in data.split(b'\n') if line) while True: @@ -776,10 +782,11 @@ def blame_incremental(self, rev: TBD, file: TBD, **kwargs: Any) -> Optional[Iter line = next(stream) # when exhausted, causes a StopIteration, terminating this function except StopIteration: return - hexsha, orig_lineno, lineno, num_lines = line.split() - lineno = int(lineno) - num_lines = int(num_lines) - orig_lineno = int(orig_lineno) + split_line = line.split() # type: Tuple[str, str, str, str] + hexsha, orig_lineno_str, lineno_str, num_lines_str = split_line + lineno = int(lineno_str) + num_lines = int(num_lines_str) + orig_lineno = int(orig_lineno_str) if hexsha not in commits: # Now read the next few lines and build up a dict of properties # for this commit @@ -871,12 +878,10 @@ def blame(self, rev: TBD, file: TBD, incremental: bool = False, **kwargs: Any digits = parts[-1].split(" ") if len(digits) == 3: info = {'id': firstpart} - blames.append([None, [""]]) - elif not info or info['id'] != firstpart: + blames.append([None, []]) + elif info['id'] != firstpart: info = {'id': firstpart} - commits_firstpart = commits.get(firstpart) - blames.append([commits_firstpart, []]) - + blames.append([commits.get(firstpart), []]) # END blame data initialization else: m = self.re_author_committer_start.search(firstpart) @@ -933,8 +938,6 @@ def blame(self, rev: TBD, file: TBD, incremental: bool = False, **kwargs: Any blames[-1][0] = c if blames[-1][1] is not None: blames[-1][1].append(line) - else: - blames[-1][1] = [line] info = {'id': sha} # END if we collected commit info # END distinguish filename,summary,rest @@ -944,7 +947,7 @@ def blame(self, rev: TBD, file: TBD, incremental: bool = False, **kwargs: Any @classmethod def init(cls, path: PathLike = None, mkdir: bool = True, odbt: Type[GitCmdObjectDB] = GitCmdObjectDB, - expand_vars: bool = True, **kwargs: Any,) -> 'Repo': + expand_vars: bool = True, **kwargs: Any) -> 'Repo': """Initialize a git repository at the given path if specified :param path: @@ -983,12 +986,8 @@ def init(cls, path: PathLike = None, mkdir: bool = True, odbt: Type[GitCmdObject @classmethod def _clone(cls, git: 'Git', url: PathLike, path: PathLike, odb_default_type: Type[GitCmdObjectDB], - progress: Optional[Callable], - multi_options: Optional[List[str]] = None, **kwargs: Any, + progress: Optional[Callable], multi_options: Optional[List[str]] = None, **kwargs: Any ) -> 'Repo': - if progress is not None: - progress_checked = to_progress_instance(progress) - odbt = kwargs.pop('odbt', odb_default_type) # when pathlib.Path or other classbased path is passed @@ -1011,13 +1010,16 @@ def _clone(cls, git: 'Git', url: PathLike, path: PathLike, odb_default_type: Typ if multi_options: multi = ' '.join(multi_options).split(' ') proc = git.clone(multi, Git.polish_url(url), clone_path, with_extended_output=True, as_process=True, - v=True, universal_newlines=True, **add_progress(kwargs, git, progress_checked)) - if progress_checked: - handle_process_output(proc, None, progress_checked.new_message_handler(), + v=True, universal_newlines=True, **add_progress(kwargs, git, progress)) + if progress: + handle_process_output(proc, None, to_progress_instance(progress).new_message_handler(), finalize_process, decode_streams=False) else: (stdout, stderr) = proc.communicate() - log.debug("Cmd(%s)'s unused stdout: %s", getattr(proc, 'args', ''), stdout) + cmdline = getattr(proc, 'args', '') + cmdline = remove_password_if_present(cmdline) + + log.debug("Cmd(%s)'s unused stdout: %s", cmdline, stdout) finalize_process(proc, stderr=stderr) # our git command could have a different working dir than our actual @@ -1130,13 +1132,14 @@ def __repr__(self) -> str: clazz = self.__class__ return '<%s.%s %r>' % (clazz.__module__, clazz.__name__, self.git_dir) - def currently_rebasing_on(self) -> Union[SymbolicReference, Commit, TagObject, Blob, Tree, None]: + def currently_rebasing_on(self) -> Union['SymbolicReference', Commit, 'TagObject', 'Blob', 'Tree', None]: """ :return: The commit which is currently being replayed while rebasing. None if we are not currently rebasing. """ - rebase_head_file = osp.join(self.git_dir, "REBASE_HEAD") if self.git_dir else "" + if self.git_dir: + rebase_head_file = osp.join(self.git_dir, "REBASE_HEAD") if not osp.isfile(rebase_head_file): return None return self.commit(open(rebase_head_file, "rt").readline().strip()) diff --git a/git/repo/fun.py b/git/repo/fun.py index b81845932..703940819 100644 --- a/git/repo/fun.py +++ b/git/repo/fun.py @@ -18,11 +18,12 @@ # Typing ---------------------------------------------------------------------- -from .base import Repo -from git.db import GitCmdObjectDB -from git.objects import Commit, TagObject, Blob, Tree -from typing import AnyStr, Union, Optional, cast +from typing import AnyStr, Union, Optional, cast, TYPE_CHECKING from git.types import PathLike +if TYPE_CHECKING: + from .base import Repo + from git.db import GitCmdObjectDB + from git.objects import Commit, TagObject, Blob, Tree # ---------------------------------------------------------------------------- @@ -102,7 +103,7 @@ def find_submodule_git_dir(d: PathLike) -> Optional[PathLike]: return None -def short_to_long(odb: GitCmdObjectDB, hexsha: AnyStr) -> Optional[bytes]: +def short_to_long(odb: 'GitCmdObjectDB', hexsha: AnyStr) -> Optional[bytes]: """:return: long hexadecimal sha1 from the given less-than-40 byte hexsha or None if no candidate could be found. :param hexsha: hexsha with less than 40 byte""" @@ -113,8 +114,8 @@ def short_to_long(odb: GitCmdObjectDB, hexsha: AnyStr) -> Optional[bytes]: # END exception handling -def name_to_object(repo: Repo, name: str, return_ref: bool = False, - ) -> Union[SymbolicReference, Commit, TagObject, Blob, Tree]: +def name_to_object(repo: 'Repo', name: str, return_ref: bool = False + ) -> Union[SymbolicReference, 'Commit', 'TagObject', 'Blob', 'Tree']: """ :return: object specified by the given name, hexshas ( short and long ) as well as references are supported @@ -161,7 +162,7 @@ def name_to_object(repo: Repo, name: str, return_ref: bool = False, return Object.new_from_sha(repo, hex_to_bin(hexsha)) -def deref_tag(tag: Tag) -> TagObject: +def deref_tag(tag: Tag) -> 'TagObject': """Recursively dereference a tag and return the resulting object""" while True: try: @@ -172,7 +173,7 @@ def deref_tag(tag: Tag) -> TagObject: return tag -def to_commit(obj: Object) -> Union[Commit, TagObject]: +def to_commit(obj: Object) -> Union['Commit', 'TagObject']: """Convert the given object to a commit if possible and return it""" if obj.type == 'tag': obj = deref_tag(obj) @@ -183,7 +184,7 @@ def to_commit(obj: Object) -> Union[Commit, TagObject]: return obj -def rev_parse(repo: Repo, rev: str) -> Union[Commit, Tag, Tree, Blob]: +def rev_parse(repo: 'Repo', rev: str) -> Union['Commit', 'Tag', 'Tree', 'Blob']: """ :return: Object at the given revision, either Commit, Tag, Tree or Blob :param rev: git-rev-parse compatible revision specification as string, please see diff --git a/git/types.py b/git/types.py index dc44c1231..3e33ae0c9 100644 --- a/git/types.py +++ b/git/types.py @@ -1,6 +1,20 @@ -import os # @UnusedImport ## not really unused, is in type string +# -*- coding: utf-8 -*- +# This module is part of GitPython and is released under +# the BSD License: http://www.opensource.org/licenses/bsd-license.php + +import os +import sys from typing import Union, Any TBD = Any -PathLike = Union[str, 'os.PathLike[str]'] + +if sys.version_info[:2] < (3, 6): + # os.PathLike (PEP-519) only got introduced with Python 3.6 + PathLike = str +elif sys.version_info[:2] < (3, 9): + # Python >= 3.6, < 3.9 + PathLike = Union[str, os.PathLike] +elif sys.version_info[:2] >= (3, 9): + # os.PathLike only becomes subscriptable from Python 3.9 onwards + PathLike = Union[str, os.PathLike[str]] diff --git a/git/util.py b/git/util.py index 2b0c81715..af4990286 100644 --- a/git/util.py +++ b/git/util.py @@ -3,7 +3,7 @@ # # This module is part of GitPython and is released under # the BSD License: http://www.opensource.org/licenses/bsd-license.php -from git.remote import Remote + import contextlib from functools import wraps import getpass @@ -17,11 +17,15 @@ from sys import maxsize import time from unittest import SkipTest +from urllib.parse import urlsplit, urlunsplit # typing --------------------------------------------------------- + from typing import (Any, AnyStr, BinaryIO, Callable, Dict, Generator, IO, List, - NoReturn, Optional, Pattern, Sequence, Tuple, Union, cast) -from git.repo.base import Repo + NoReturn, Optional, Pattern, Sequence, Tuple, Union, cast, TYPE_CHECKING) +if TYPE_CHECKING: + from git.remote import Remote + from git.repo.base import Repo from .types import PathLike, TBD # --------------------------------------------------------------------- @@ -74,7 +78,7 @@ def unbare_repo(func: Callable) -> Callable: encounter a bare repository""" @wraps(func) - def wrapper(self: Remote, *args: Any, **kwargs: Any) -> TBD: + def wrapper(self: 'Remote', *args: Any, **kwargs: Any) -> TBD: if self.repo.bare: raise InvalidGitRepositoryError("Method '%s' cannot operate on bare repositories" % func.__name__) # END bare method @@ -359,6 +363,34 @@ def expand_path(p: PathLike, expand_vars: bool = True) -> Optional[PathLike]: except Exception: return None + +def remove_password_if_present(cmdline): + """ + Parse any command line argument and if on of the element is an URL with a + password, replace it by stars (in-place). + + If nothing found just returns the command line as-is. + + This should be used for every log line that print a command line. + """ + new_cmdline = [] + for index, to_parse in enumerate(cmdline): + new_cmdline.append(to_parse) + try: + url = urlsplit(to_parse) + # Remove password from the URL if present + if url.password is None: + continue + + edited_url = url._replace( + netloc=url.netloc.replace(url.password, "*****")) + new_cmdline[index] = urlunsplit(edited_url) + except ValueError: + # This is not a valid URL + continue + return new_cmdline + + #} END utilities #{ Classes @@ -686,7 +718,7 @@ def __init__(self, total: Dict[str, Dict[str, int]], files: Dict[str, Dict[str, self.files = files @classmethod - def _list_from_string(cls, repo: Repo, text: str) -> 'Stats': + def _list_from_string(cls, repo: 'Repo', text: str) -> 'Stats': """Create a Stat object from output retrieved by git-diff. :return: git.Stat""" @@ -924,6 +956,7 @@ def __getitem__(self, index: Union[int, slice, str]) -> Any: def __delitem__(self, index: Union[int, str, slice]) -> None: + delindex = cast(int, index) if not isinstance(index, int): delindex = -1 assert not isinstance(index, slice) @@ -949,7 +982,7 @@ class Iterable(object): _id_attribute_ = "attribute that most suitably identifies your instance" @classmethod - def list_items(cls, repo: Repo, *args: Any, **kwargs: Any) -> 'IterableList': + def list_items(cls, repo: 'Repo', *args: Any, **kwargs: Any) -> 'IterableList': """ Find all items of this type - subclasses can specify args and kwargs differently. If no args are given, subclasses are obliged to return all items if no additional @@ -963,7 +996,7 @@ def list_items(cls, repo: Repo, *args: Any, **kwargs: Any) -> 'IterableList': return out_list @classmethod - def iter_items(cls, repo: Repo, *args: Any, **kwargs: Any) -> NoReturn: + def iter_items(cls, repo: 'Repo', *args: Any, **kwargs: Any) -> NoReturn: """For more information about the arguments, see list_items :return: iterator yielding Items""" raise NotImplementedError("To be implemented by Subclass") diff --git a/mypy.ini b/mypy.ini index 47c0fb0c0..b63d68fd3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,9 @@ [mypy] -disallow_untyped_defs = True +# TODO: enable when we've fully annotated everything +#disallow_untyped_defs = True -mypy_path = 'git' +# TODO: remove when 'gitdb' is fully annotated +[mypy-gitdb.*] +ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt index c4e8340d8..d980f6682 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ gitdb>=4.0.1,<5 +typing-extensions>=3.7.4.0;python_version<"3.8" diff --git a/test-requirements.txt b/test-requirements.txt index abda95cf0..e06d2be14 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,3 +4,5 @@ flake8 tox virtualenv nose +gitdb>=4.0.1,<5 +typing-extensions>=3.7.4.0;python_version<"3.8" diff --git a/test/fixtures/diff_file_with_colon b/test/fixtures/diff_file_with_colon new file mode 100644 index 0000000000000000000000000000000000000000..4058b1715bd164ef8c13c73a458426bc43dcc5d0 GIT binary patch literal 351 zcmY+9L2g4a2nBN#pCG{o^G)v1GgM%p{ZiCa>X(|{zOIx_Suo3abFBbORGtVHk0xf# zt1}_luqGPY*44*sL1T85T9COlPLmCE!c-N<>|J<MgNjJnX(U5=Ipuzf^1UDL<yz+b zcK<jZkK=T$4C~4hK|LKdX%V6_HYLx-ffXhDM60kRfV2XrhFYCq0y(P?HIdn>8`qgK z10mULiQo3)5|87u=(dFaN+(aTwH5}_u&!;nb*vEUE4_8K%$Smeu+|L?+-VFY9wIF} c#^^1?Cph;RUU3PJ_&P3s@74Fr^XJd$7YhDgzW@LL literal 0 HcmV?d00001 diff --git a/test/test_clone.py b/test/test_clone.py new file mode 100644 index 000000000..e9f6714d3 --- /dev/null +++ b/test/test_clone.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This module is part of GitPython and is released under +# the BSD License: http://www.opensource.org/licenses/bsd-license.php + +from pathlib import Path +import re + +import git + +from .lib import ( + TestBase, + with_rw_directory, +) + + +class TestClone(TestBase): + @with_rw_directory + def test_checkout_in_non_empty_dir(self, rw_dir): + non_empty_dir = Path(rw_dir) + garbage_file = non_empty_dir / 'not-empty' + garbage_file.write_text('Garbage!') + + # Verify that cloning into the non-empty dir fails while complaining about + # the target directory not being empty/non-existent + try: + self.rorepo.clone(non_empty_dir) + except git.GitCommandError as exc: + self.assertTrue(exc.stderr, "GitCommandError's 'stderr' is unexpectedly empty") + expr = re.compile(r'(?is).*\bfatal:\s+destination\s+path\b.*\bexists\b.*\bnot\b.*\bempty\s+directory\b') + self.assertTrue(expr.search(exc.stderr), '"%s" does not match "%s"' % (expr.pattern, exc.stderr)) + else: + self.fail("GitCommandError not raised") diff --git a/test/test_diff.py b/test/test_diff.py index c6c9b67a0..9b20893a4 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -7,6 +7,7 @@ import ddt import shutil import tempfile +import unittest from git import ( Repo, GitCommandError, @@ -220,6 +221,12 @@ def test_diff_index_raw_format(self): self.assertIsNotNone(res[0].deleted_file) self.assertIsNone(res[0].b_path,) + @unittest.skip("This currently fails and would need someone to improve diff parsing") + def test_diff_file_with_colon(self): + output = fixture('diff_file_with_colon') + res = [] + Diff._handle_diff_line(output, None, res) + def test_diff_initial_commit(self): initial_commit = self.rorepo.commit('33ebe7acec14b25c5f84f35a664803fcab2f7781') diff --git a/test/test_repo.py b/test/test_repo.py index d5ea8664a..8dc178337 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -238,6 +238,21 @@ def test_clone_from_with_path_contains_unicode(self): except UnicodeEncodeError: self.fail('Raised UnicodeEncodeError') + @with_rw_directory + def test_leaking_password_in_clone_logs(self, rw_dir): + password = "fakepassword1234" + try: + Repo.clone_from( + url="https://fakeuser:{}@fakerepo.example.com/testrepo".format( + password), + to_path=rw_dir) + except GitCommandError as err: + assert password not in str(err), "The error message '%s' should not contain the password" % err + # Working example from a blank private project + Repo.clone_from( + url="https://gitlab+deploy-token-392045:mLWhVus7bjLsy8xj8q2V@gitlab.com/mercierm/test_git_python", + to_path=rw_dir) + @with_rw_repo('HEAD') def test_max_chunk_size(self, repo): class TestOutputStream(TestBase): diff --git a/test/test_util.py b/test/test_util.py index 5eba6c500..ddc5f628f 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -30,7 +30,8 @@ Actor, IterableList, cygpath, - decygpath + decygpath, + remove_password_if_present, ) @@ -322,3 +323,20 @@ def test_pickle_tzoffset(self): t2 = pickle.loads(pickle.dumps(t1)) self.assertEqual(t1._offset, t2._offset) self.assertEqual(t1._name, t2._name) + + def test_remove_password_from_command_line(self): + password = "fakepassword1234" + url_with_pass = "https://fakeuser:{}@fakerepo.example.com/testrepo".format(password) + url_without_pass = "https://fakerepo.example.com/testrepo" + + cmd_1 = ["git", "clone", "-v", url_with_pass] + cmd_2 = ["git", "clone", "-v", url_without_pass] + cmd_3 = ["no", "url", "in", "this", "one"] + + redacted_cmd_1 = remove_password_if_present(cmd_1) + assert password not in " ".join(redacted_cmd_1) + # Check that we use a copy + assert cmd_1 is not redacted_cmd_1 + assert password in " ".join(cmd_1) + assert cmd_2 == remove_password_if_present(cmd_2) + assert cmd_3 == remove_password_if_present(cmd_3) diff --git a/tox.ini b/tox.ini index ad126ed4e..a0cb1c9f1 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,14 @@ commands = coverage run --omit="git/test/*" -m unittest --buffer {posargs} [testenv:flake8] commands = flake8 --ignore=W293,E265,E266,W503,W504,E731 {posargs} +[testenv:type] +description = type check ourselves +deps = + {[testenv]deps} + mypy +commands = + mypy -p git + [testenv:venv] commands = {posargs} @@ -23,6 +31,7 @@ commands = {posargs} # E266 = too many leading '#' for block comment # E731 = do not assign a lambda expression, use a def # W293 = Blank line contains whitespace -ignore = E265,W293,E266,E731 +# W504 = Line break after operator +ignore = E265,W293,E266,E731, W504 max-line-length = 120 exclude = .tox,.venv,build,dist,doc,git/ext/ From 18b75d9e63f513e972cbc09c06b040bcdb15aa05 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 3 May 2021 16:02:11 +0100 Subject: [PATCH 07/16] copy sys.version checks for literal and final to git.types --- git/types.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/git/types.py b/git/types.py index 3e33ae0c9..40d4f7885 100644 --- a/git/types.py +++ b/git/types.py @@ -6,6 +6,11 @@ import sys from typing import Union, Any +if sys.version_info[:2] >= (3, 8): + from typing import Final, Literal # noqa: F401 +else: + from typing_extensions import Final, Literal # noqa: F401 + TBD = Any From a1fa8506d177fa49552ffa84527c35d32f193abe Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 3 May 2021 16:05:09 +0100 Subject: [PATCH 08/16] update type of FetchInfo.refresh() to use Literal --- git/remote.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/git/remote.py b/git/remote.py index 20b5a5514..7d6c0f6f6 100644 --- a/git/remote.py +++ b/git/remote.py @@ -38,7 +38,7 @@ from typing import Any, Optional, Set, TYPE_CHECKING, Union -from git.types import PathLike +from git.types import PathLike, Literal if TYPE_CHECKING: from git.repo.base import Repo @@ -232,7 +232,7 @@ class FetchInfo(object): } @classmethod - def refresh(cls) -> bool: + def refresh(cls) -> Literal[True]: """This gets called by the refresh function (see the top level __init__). """ From c08f592cc0238054ec57b6024521a04cf70e692f Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 3 May 2021 16:16:14 +0100 Subject: [PATCH 09/16] add types to PushInfo.__init__() .remote_ref() and .old_commit() --- git/remote.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/git/remote.py b/git/remote.py index 7d6c0f6f6..8404b1907 100644 --- a/git/remote.py +++ b/git/remote.py @@ -115,8 +115,8 @@ class PushInfo(object): '=': UP_TO_DATE, '!': ERROR} - def __init__(self, flags, local_ref, remote_ref_string, remote, old_commit=None, - summary=''): + def __init__(self, flags: Set[int], local_ref: SymbolicReference, remote_ref_string: str, remote, + old_commit: Optional[bytes] = None, summary: str = '') -> None: """ Initialize a new instance """ self.flags = flags self.local_ref = local_ref @@ -126,11 +126,11 @@ def __init__(self, flags, local_ref, remote_ref_string, remote, old_commit=None, self.summary = summary @property - def old_commit(self): + def old_commit(self) -> Optional[bool]: return self._old_commit_sha and self._remote.repo.commit(self._old_commit_sha) or None @property - def remote_ref(self): + def remote_ref(self) -> Union[RemoteReference, TagReference]: """ :return: Remote Reference or TagReference in the local repository corresponding From baec2e293158ccffd5657abf4acdae18256c6c90 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 3 May 2021 16:35:06 +0100 Subject: [PATCH 10/16] make progress types more general --- git/remote.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/git/remote.py b/git/remote.py index 8404b1907..8093fa9d2 100644 --- a/git/remote.py +++ b/git/remote.py @@ -36,7 +36,7 @@ # typing------------------------------------------------------- -from typing import Any, Optional, Set, TYPE_CHECKING, Union +from typing import Any, Callable, Optional, Set, TYPE_CHECKING, Union from git.types import PathLike, Literal @@ -55,7 +55,7 @@ #{ Utilities -def add_progress(kwargs: Any, git: Git, progress: RemoteProgress) -> Any: +def add_progress(kwargs: Any, git: Git, progress: Optional[Callable[..., Any]]) -> Any: """Add the --progress flag to the given kwargs dict if supported by the git command. If the actual progress in the given progress instance is not given, we do not request any progress @@ -71,7 +71,7 @@ def add_progress(kwargs: Any, git: Git, progress: RemoteProgress) -> Any: #} END utilities -def to_progress_instance(progress: Optional[RemoteProgress]) -> Union[RemoteProgress, CallableRemoteProgress]: +def to_progress_instance(progress: Callable[..., Any]) -> Union[RemoteProgress, CallableRemoteProgress]: """Given the 'progress' return a suitable object derived from RemoteProgress(). """ From e37ebaa5407408ee73479a12ada0c4a75e602092 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 3 May 2021 16:40:21 +0100 Subject: [PATCH 11/16] change a type (Commit) to a forward ref --- git/remote.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/remote.py b/git/remote.py index 8093fa9d2..5b6b29a72 100644 --- a/git/remote.py +++ b/git/remote.py @@ -255,7 +255,7 @@ def refresh(cls) -> Literal[True]: return True - def __init__(self, ref: SymbolicReference, flags: Set[int], note: str = '', old_commit: Optional[Commit] = None, + def __init__(self, ref: SymbolicReference, flags: Set[int], note: str = '', old_commit: Optional['Commit'] = None, remote_ref_path: Optional[PathLike] = None): """ Initialize a new instance From f97d37881d50da8f9702681bc1928a8d44119e88 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 3 May 2021 17:18:11 +0100 Subject: [PATCH 12/16] change flags type to int --- git/remote.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/git/remote.py b/git/remote.py index 5b6b29a72..d73da7d4b 100644 --- a/git/remote.py +++ b/git/remote.py @@ -36,7 +36,7 @@ # typing------------------------------------------------------- -from typing import Any, Callable, Optional, Set, TYPE_CHECKING, Union +from typing import Any, Callable, Optional, TYPE_CHECKING, Union from git.types import PathLike, Literal @@ -115,7 +115,7 @@ class PushInfo(object): '=': UP_TO_DATE, '!': ERROR} - def __init__(self, flags: Set[int], local_ref: SymbolicReference, remote_ref_string: str, remote, + def __init__(self, flags: int, local_ref: SymbolicReference, remote_ref_string: str, remote, old_commit: Optional[bytes] = None, summary: str = '') -> None: """ Initialize a new instance """ self.flags = flags @@ -255,8 +255,8 @@ def refresh(cls) -> Literal[True]: return True - def __init__(self, ref: SymbolicReference, flags: Set[int], note: str = '', old_commit: Optional['Commit'] = None, - remote_ref_path: Optional[PathLike] = None): + def __init__(self, ref: SymbolicReference, flags: int, note: str = '', old_commit: Optional['Commit'] = None, + remote_ref_path: Optional[PathLike] = None) -> None: """ Initialize a new instance """ From 90fefb0a8cc5dc793d40608e2d6a2398acecef12 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 3 May 2021 17:49:36 +0100 Subject: [PATCH 13/16] add overloads to to_progress_instance() --- git/remote.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/git/remote.py b/git/remote.py index d73da7d4b..0071b9237 100644 --- a/git/remote.py +++ b/git/remote.py @@ -36,7 +36,7 @@ # typing------------------------------------------------------- -from typing import Any, Callable, Optional, TYPE_CHECKING, Union +from typing import Any, Callable, Optional, TYPE_CHECKING, Union, overload from git.types import PathLike, Literal @@ -71,7 +71,23 @@ def add_progress(kwargs: Any, git: Git, progress: Optional[Callable[..., Any]]) #} END utilities -def to_progress_instance(progress: Callable[..., Any]) -> Union[RemoteProgress, CallableRemoteProgress]: +@overload +def to_progress_instance(progress: None) -> RemoteProgress: + ... + + +@overload +def to_progress_instance(progress: Callable[..., Any]) -> CallableRemoteProgress: + ... + + +@overload +def to_progress_instance(progress: RemoteProgress) -> RemoteProgress: + ... + + +def to_progress_instance(progress: Union[Callable[..., Any], RemoteProgress, None] + ) -> Union[RemoteProgress, CallableRemoteProgress]: """Given the 'progress' return a suitable object derived from RemoteProgress(). """ From 559ddb3b60e36a1b9c4a145d7a00a295a37d46a8 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 3 May 2021 18:15:58 +0100 Subject: [PATCH 14/16] add types to _from_line() --- git/remote.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/git/remote.py b/git/remote.py index 0071b9237..8aa390ff6 100644 --- a/git/remote.py +++ b/git/remote.py @@ -131,9 +131,10 @@ class PushInfo(object): '=': UP_TO_DATE, '!': ERROR} - def __init__(self, flags: int, local_ref: SymbolicReference, remote_ref_string: str, remote, - old_commit: Optional[bytes] = None, summary: str = '') -> None: - """ Initialize a new instance """ + def __init__(self, flags: int, local_ref: Union[SymbolicReference, None], remote_ref_string: str, remote, + old_commit: Optional[str] = None, summary: str = '') -> None: + """ Initialize a new instance + local_ref: HEAD | Head | RemoteReference | TagReference | Reference | SymbolicReference | None """ self.flags = flags self.local_ref = local_ref self.remote_ref_string = remote_ref_string @@ -162,7 +163,7 @@ def remote_ref(self) -> Union[RemoteReference, TagReference]: # END @classmethod - def _from_line(cls, remote, line): + def _from_line(cls, remote, line: str) -> 'PushInfo': """Create a new PushInfo instance as parsed from line which is expected to be like refs/heads/master:refs/heads/master 05d2687..1d0568e as bytes""" control_character, from_to, summary = line.split('\t', 3) @@ -178,7 +179,7 @@ def _from_line(cls, remote, line): # from_to handling from_ref_string, to_ref_string = from_to.split(':') if flags & cls.DELETED: - from_ref = None + from_ref = None # type: Union[SymbolicReference, None] else: if from_ref_string == "(delete)": from_ref = None @@ -186,7 +187,7 @@ def _from_line(cls, remote, line): from_ref = Reference.from_path(remote.repo, from_ref_string) # commit handling, could be message or commit info - old_commit = None + old_commit = None # type: Optional[str] if summary.startswith('['): if "[rejected]" in summary: flags |= cls.REJECTED From 1b16037a4ff17f0e25add382c3550323373c4398 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 3 May 2021 21:17:25 +0100 Subject: [PATCH 15/16] second pass of adding types --- git/refs/symbolic.py | 2 +- git/remote.py | 36 +++++++++++++++++++++--------------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 22d9c1d51..64a6591aa 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -45,7 +45,7 @@ class SymbolicReference(object): _remote_common_path_default = "refs/remotes" _id_attribute_ = "name" - def __init__(self, repo, path): + def __init__(self, repo, path, check_path=None): self.repo = repo self.path = path diff --git a/git/remote.py b/git/remote.py index 8aa390ff6..34d653e63 100644 --- a/git/remote.py +++ b/git/remote.py @@ -36,14 +36,18 @@ # typing------------------------------------------------------- -from typing import Any, Callable, Optional, TYPE_CHECKING, Union, overload +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Union, cast, overload from git.types import PathLike, Literal if TYPE_CHECKING: from git.repo.base import Repo from git.objects.commit import Commit + from git.objects.blob import Blob + from git.objects.tree import Tree + from git.objects.tag import TagObject +flagKeyLiteral = Literal[' ', '!', '+', '-', '*', '=', 't'] # ------------------------------------------------------------- log = logging.getLogger('git.remote') @@ -131,7 +135,7 @@ class PushInfo(object): '=': UP_TO_DATE, '!': ERROR} - def __init__(self, flags: int, local_ref: Union[SymbolicReference, None], remote_ref_string: str, remote, + def __init__(self, flags: int, local_ref: Union[SymbolicReference, None], remote_ref_string: str, remote: 'Remote', old_commit: Optional[str] = None, summary: str = '') -> None: """ Initialize a new instance local_ref: HEAD | Head | RemoteReference | TagReference | Reference | SymbolicReference | None """ @@ -143,7 +147,7 @@ def __init__(self, flags: int, local_ref: Union[SymbolicReference, None], remote self.summary = summary @property - def old_commit(self) -> Optional[bool]: + def old_commit(self) -> Union[str, SymbolicReference, 'Commit', 'TagObject', 'Blob', 'Tree', None]: return self._old_commit_sha and self._remote.repo.commit(self._old_commit_sha) or None @property @@ -246,7 +250,7 @@ class FetchInfo(object): '=': HEAD_UPTODATE, ' ': FAST_FORWARD, '-': TAG_UPDATE, - } + } # type: Dict[flagKeyLiteral, int] @classmethod def refresh(cls) -> Literal[True]: @@ -297,7 +301,7 @@ def commit(self) -> 'Commit': return self.ref.commit @classmethod - def _from_line(cls, repo, line, fetch_line): + def _from_line(cls, repo: Repo, line: str, fetch_line) -> 'FetchInfo': """Parse information from the given line as returned by git-fetch -v and return a new FetchInfo object representing this information. @@ -319,7 +323,9 @@ def _from_line(cls, repo, line, fetch_line): raise ValueError("Failed to parse line: %r" % line) # parse lines - control_character, operation, local_remote_ref, remote_local_ref, note = match.groups() + control_character, operation, local_remote_ref, remote_local_ref_str, note = match.groups() + control_character = cast(flagKeyLiteral, control_character) # can do this neater once 3.5 dropped + try: _new_hex_sha, _fetch_operation, fetch_note = fetch_line.split("\t") ref_type_name, fetch_note = fetch_note.split(' ', 1) @@ -359,7 +365,7 @@ def _from_line(cls, repo, line, fetch_line): # the fetch result is stored in FETCH_HEAD which destroys the rule we usually # have. In that case we use a symbolic reference which is detached ref_type = None - if remote_local_ref == "FETCH_HEAD": + if remote_local_ref_str == "FETCH_HEAD": ref_type = SymbolicReference elif ref_type_name == "tag" or is_tag_operation: # the ref_type_name can be branch, whereas we are still seeing a tag operation. It happens during @@ -387,21 +393,21 @@ def _from_line(cls, repo, line, fetch_line): # by the 'ref/' prefix. Otherwise even a tag could be in refs/remotes, which is when it will have the # 'tags/' subdirectory in its path. # We don't want to test for actual existence, but try to figure everything out analytically. - ref_path = None - remote_local_ref = remote_local_ref.strip() - if remote_local_ref.startswith(Reference._common_path_default + "/"): + ref_path = None # type: Optional[PathLike] + remote_local_ref_str = remote_local_ref_str.strip() + if remote_local_ref_str.startswith(Reference._common_path_default + "/"): # always use actual type if we get absolute paths # Will always be the case if something is fetched outside of refs/remotes (if its not a tag) - ref_path = remote_local_ref + ref_path = remote_local_ref_str if ref_type is not TagReference and not \ - remote_local_ref.startswith(RemoteReference._common_path_default + "/"): + remote_local_ref_str.startswith(RemoteReference._common_path_default + "/"): ref_type = Reference # END downgrade remote reference - elif ref_type is TagReference and 'tags/' in remote_local_ref: + elif ref_type is TagReference and 'tags/' in remote_local_ref_str: # even though its a tag, it is located in refs/remotes - ref_path = join_path(RemoteReference._common_path_default, remote_local_ref) + ref_path = join_path(RemoteReference._common_path_default, remote_local_ref_str) else: - ref_path = join_path(ref_type._common_path_default, remote_local_ref) + ref_path = join_path(ref_type._common_path_default, remote_local_ref_str) # END obtain refpath # even though the path could be within the git conventions, we make From 96f8f17d5d63c0e0c044ac3f56e94a1aa2e45ec3 Mon Sep 17 00:00:00 2001 From: yobmod <yobmod@gmail.com> Date: Mon, 3 May 2021 21:20:29 +0100 Subject: [PATCH 16/16] fix Repo forward ref --- git/remote.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git/remote.py b/git/remote.py index 34d653e63..2eeafcc41 100644 --- a/git/remote.py +++ b/git/remote.py @@ -301,7 +301,7 @@ def commit(self) -> 'Commit': return self.ref.commit @classmethod - def _from_line(cls, repo: Repo, line: str, fetch_line) -> 'FetchInfo': + def _from_line(cls, repo: 'Repo', line: str, fetch_line: str) -> 'FetchInfo': """Parse information from the given line as returned by git-fetch -v and return a new FetchInfo object representing this information.