scmrepo 1.4.0__py3-none-any.whl → 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of scmrepo might be problematic. Click here for more details.

@@ -3,13 +3,14 @@ import logging
3
3
  import os
4
4
  import stat
5
5
  from contextlib import contextmanager
6
- from io import BytesIO, StringIO
6
+ from io import BytesIO, StringIO, TextIOWrapper
7
7
  from typing import (
8
8
  TYPE_CHECKING,
9
9
  Callable,
10
10
  Dict,
11
11
  Generator,
12
12
  Iterable,
13
+ Iterator,
13
14
  List,
14
15
  Mapping,
15
16
  Optional,
@@ -20,8 +21,15 @@ from urllib.parse import urlparse
20
21
 
21
22
  from funcy import cached_property, reraise
22
23
 
23
- from scmrepo.exceptions import CloneError, MergeConflictError, RevError, SCMError
24
+ from scmrepo.exceptions import (
25
+ CloneError,
26
+ InvalidRemote,
27
+ MergeConflictError,
28
+ RevError,
29
+ SCMError,
30
+ )
24
31
  from scmrepo.git.backend.base import BaseGitBackend, SyncStatus
32
+ from scmrepo.git.config import Config
25
33
  from scmrepo.git.objects import GitCommit, GitObject, GitTag
26
34
  from scmrepo.utils import relpath
27
35
 
@@ -29,7 +37,8 @@ logger = logging.getLogger(__name__)
29
37
 
30
38
 
31
39
  if TYPE_CHECKING:
32
- from pygit2 import Signature
40
+ from pygit2 import Commit, Oid, Signature
41
+ from pygit2.config import Config as _Pygit2Config
33
42
  from pygit2.remote import Remote # type: ignore
34
43
  from pygit2.repository import Repository
35
44
 
@@ -37,13 +46,47 @@ if TYPE_CHECKING:
37
46
 
38
47
 
39
48
  class Pygit2Object(GitObject):
40
- def __init__(self, obj):
49
+ def __init__(self, obj, backend: Optional["Pygit2Backend"] = None):
41
50
  self.obj = obj
51
+ self.backend = backend
52
+
53
+ def open(
54
+ self,
55
+ mode: str = "r",
56
+ encoding: str = None,
57
+ key: Optional[Tuple[str, ...]] = None,
58
+ raw: bool = True,
59
+ rev: Optional[str] = None,
60
+ **kwargs,
61
+ ):
62
+ from pygit2 import BlobIO, GitError
42
63
 
43
- def open(self, mode: str = "r", encoding: str = None):
44
64
  if not encoding:
45
65
  encoding = locale.getpreferredencoding(False)
46
- data = self.obj.read_raw()
66
+ if self.backend is not None:
67
+ try:
68
+ if rev:
69
+ # pylint: disable-next=protected-access
70
+ commit, _ref = self.backend._resolve_refish(rev)
71
+ else:
72
+ pass
73
+ if raw:
74
+ blob_kwargs = {}
75
+ else:
76
+ assert key is not None
77
+ path = "/".join(key)
78
+ blob_kwargs = {
79
+ "as_path": path,
80
+ "commit_id": commit.oid,
81
+ }
82
+ blobio = BlobIO(self.obj, **blob_kwargs)
83
+ if mode == "rb":
84
+ return blobio
85
+ return TextIOWrapper(blobio, encoding=encoding)
86
+ except GitError as exc:
87
+ raise SCMError("failed to read git blob") from exc
88
+ else:
89
+ data = self.obj.read_raw()
47
90
  if mode == "rb":
48
91
  return BytesIO(data)
49
92
  return StringIO(data.decode(encoding))
@@ -74,7 +117,34 @@ class Pygit2Object(GitObject):
74
117
 
75
118
  def scandir(self) -> Iterable["Pygit2Object"]:
76
119
  for entry in self.obj: # noqa: B301
77
- yield Pygit2Object(entry)
120
+ yield Pygit2Object(entry, backend=self.backend)
121
+
122
+
123
+ class Pygit2Config(Config):
124
+ def __init__(self, config: "_Pygit2Config"):
125
+ self._config = config
126
+
127
+ def _key(self, section: Tuple[str, ...], name: str) -> str:
128
+ return ".".join(section + (name,))
129
+
130
+ def get(self, section: Tuple[str, ...], name: str) -> str:
131
+ return self._config[self._key(section, name)]
132
+
133
+ def get_bool(self, section: Tuple[str, ...], name: str) -> bool:
134
+ from pygit2 import GitError
135
+
136
+ try:
137
+ return self._config.get_bool(self._key(section, name))
138
+ except GitError as exc:
139
+ raise ValueError("invalid boolean config entry") from exc
140
+
141
+ def get_multivar(self, section: Tuple[str, ...], name: str) -> Iterator[str]:
142
+ from pygit2 import GitError
143
+
144
+ try:
145
+ yield from self._config.get_multivar(self._key(section, name))
146
+ except GitError as exc:
147
+ raise ValueError("invalid multivar config entry") from exc
78
148
 
79
149
 
80
150
  class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
@@ -83,6 +153,8 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
83
153
  ):
84
154
  import pygit2
85
155
 
156
+ from .filter import LFSFilter
157
+
86
158
  if search_parent_directories:
87
159
  ceiling_dirs = ""
88
160
  else:
@@ -99,6 +171,14 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
99
171
 
100
172
  self._stashes: dict = {}
101
173
 
174
+ try:
175
+ # NOTE: we want this init to be lazy so we do it on backend init.
176
+ # for subsequent backend instances, this call will error out since
177
+ # the filter is already registered
178
+ pygit2.filter_register("lfs", LFSFilter)
179
+ except ValueError:
180
+ pass
181
+
102
182
  def close(self):
103
183
  if hasattr(self, "_refdb"):
104
184
  del self._refdb
@@ -353,6 +433,15 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
353
433
  return self.repo.references["HEAD"].target[11:]
354
434
  return self.repo.head.shorthand
355
435
 
436
+ def active_branch_remote(self) -> str:
437
+ try:
438
+ upstream = self.repo.branches[self.active_branch()].upstream
439
+ if upstream:
440
+ return upstream.remote_name
441
+ except (KeyError, ValueError):
442
+ pass
443
+ raise SCMError("No active branch tracking remote")
444
+
356
445
  def list_branches(self) -> Iterable[str]:
357
446
  base = "refs/heads/"
358
447
  return sorted(ref[len(base) :] for ref in self.iter_refs(base))
@@ -366,7 +455,7 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
366
455
 
367
456
  def get_tree_obj(self, rev: str, **kwargs) -> Pygit2Object:
368
457
  tree = self.repo[rev].tree
369
- return Pygit2Object(tree)
458
+ return Pygit2Object(tree, backend=self)
370
459
 
371
460
  def get_rev(self) -> str:
372
461
  raise NotImplementedError
@@ -551,7 +640,8 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
551
640
  raise SCMError("Unknown merge analysis result")
552
641
 
553
642
  @contextmanager
554
- def get_remote(self, url: str) -> Generator["Remote", None, None]:
643
+ def _get_remote(self, url: str) -> Generator["Remote", None, None]:
644
+ """Return a pygit2.Remote suitable for the specified Git URL or remote name."""
555
645
  try:
556
646
  remote = self.repo.remotes[url]
557
647
  url = remote.url
@@ -577,57 +667,84 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
577
667
  progress: Callable[["GitProgressEvent"], None] = None,
578
668
  **kwargs,
579
669
  ) -> Mapping[str, SyncStatus]:
670
+ import fnmatch
671
+
580
672
  from pygit2 import GitError
581
673
 
582
674
  from .callbacks import RemoteCallbacks
583
675
 
584
- if isinstance(refspecs, str):
585
- refspecs = [refspecs]
676
+ refspecs = self._refspecs_list(refspecs, force=force)
586
677
 
587
- with self.get_remote(url) as remote:
588
- fetch_refspecs: List[str] = []
589
- for refspec in refspecs:
590
- if ":" in refspec:
591
- lh, rh = refspec.split(":")
592
- else:
593
- lh = rh = refspec
594
- if not rh.startswith("refs/"):
595
- rh = f"refs/heads/{rh}"
596
- if not lh.startswith("refs/"):
597
- lh = f"refs/heads/{lh}"
598
- rh = rh[len("refs/") :]
599
- refspec = f"+{lh}:refs/remotes/{remote.name}/{rh}"
600
- fetch_refspecs.append(refspec)
601
-
602
- logger.debug("fetch_refspecs: %s", fetch_refspecs)
678
+ # libgit2 rejects diverged refs but does not have a callback to notify
679
+ # when a ref was rejected so we have to determine whether no callback
680
+ # means up to date or rejected
681
+ def _default_status(
682
+ src: str, dst: str, remote_refs: Dict[str, "Oid"]
683
+ ) -> SyncStatus:
684
+ try:
685
+ if remote_refs[src] != self.repo.references[dst].target:
686
+ return SyncStatus.DIVERGED
687
+ except KeyError:
688
+ # remote_refs lookup is skipped when force is set, refs cannot
689
+ # be diverged on force
690
+ pass
691
+ return SyncStatus.UP_TO_DATE
692
+
693
+ with self._get_remote(url) as remote:
603
694
  with reraise(
604
695
  GitError,
605
696
  SCMError(f"Git failed to fetch ref from '{url}'"),
606
697
  ):
607
698
  with RemoteCallbacks(progress=progress) as cb:
699
+ remote_refs: Dict[str, "Oid"] = (
700
+ {
701
+ head["name"]: head["oid"]
702
+ for head in remote.ls_remotes(callbacks=cb)
703
+ }
704
+ if not force
705
+ else {}
706
+ )
608
707
  remote.fetch(
609
- refspecs=fetch_refspecs,
708
+ refspecs=refspecs,
610
709
  callbacks=cb,
710
+ message="fetch",
611
711
  )
612
712
 
613
713
  result: Dict[str, "SyncStatus"] = {}
614
- for refspec in fetch_refspecs:
615
- _, rh = refspec.split(":")
616
- if not rh.endswith("*"):
617
- refname = rh.split("/", 3)[-1]
618
- refname = f"refs/{refname}"
619
- result[refname] = self._merge_remote_branch(
620
- rh, refname, force, on_diverged
621
- )
622
- continue
623
- rh = rh.rstrip("*").rstrip("/") + "/"
624
- for branch in self.iter_refs(base=rh):
625
- refname = f"refs/{branch[len(rh):]}"
626
- result[refname] = self._merge_remote_branch(
627
- branch, refname, force, on_diverged
628
- )
714
+ for refspec in refspecs:
715
+ lh, rh = refspec.split(":")
716
+ if lh.endswith("*"):
717
+ assert rh.endswith("*")
718
+ lh_prefix = lh[:-1]
719
+ rh_prefix = rh[:-1]
720
+ for refname in remote_refs:
721
+ if fnmatch.fnmatch(refname, lh):
722
+ src = refname
723
+ dst = f"{rh_prefix}{refname[len(lh_prefix):]}"
724
+ result[dst] = cb.result.get(
725
+ src, _default_status(src, dst, remote_refs)
726
+ )
727
+ else:
728
+ result[rh] = cb.result.get(lh, _default_status(lh, rh, remote_refs))
729
+
629
730
  return result
630
731
 
732
+ @staticmethod
733
+ def _refspecs_list(
734
+ refspecs: Union[str, Iterable[str]],
735
+ force: bool = False,
736
+ ) -> List[str]:
737
+ if isinstance(refspecs, str):
738
+ if force and not refspecs.startswith("+"):
739
+ refspecs = f"+{refspecs}"
740
+ return [refspecs]
741
+ if force:
742
+ return [
743
+ (refspec if refspec.startswith("+") else f"+{refspec}")
744
+ for refspec in refspecs
745
+ ]
746
+ return list(refspecs)
747
+
631
748
  def _stash_iter(self, ref: str):
632
749
  raise NotImplementedError
633
750
 
@@ -940,6 +1057,12 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
940
1057
  def validate_git_remote(self, url: str, **kwargs):
941
1058
  raise NotImplementedError
942
1059
 
1060
+ def get_remote_url(self, remote: str) -> str:
1061
+ try:
1062
+ return self.repo.remotes[remote].url
1063
+ except KeyError as exc:
1064
+ raise InvalidRemote(remote) from exc
1065
+
943
1066
  def check_ref_format(self, refname: str):
944
1067
  raise NotImplementedError
945
1068
 
@@ -968,3 +1091,38 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
968
1091
  except KeyError:
969
1092
  pass
970
1093
  return str(ref.target)
1094
+
1095
+ def get_config(self, path: Optional[str] = None) -> "Config":
1096
+ from pygit2.config import Config as _Pygit2Config
1097
+
1098
+ if path:
1099
+ return Pygit2Config(_Pygit2Config(path))
1100
+ return Pygit2Config(self.repo.config)
1101
+
1102
+ def check_attr(
1103
+ self,
1104
+ path: str,
1105
+ attr: str,
1106
+ source: Optional[str] = None,
1107
+ ) -> Optional[Union[bool, str]]:
1108
+ from pygit2 import (
1109
+ GIT_ATTR_CHECK_FILE_THEN_INDEX,
1110
+ GIT_ATTR_CHECK_INCLUDE_COMMIT,
1111
+ GIT_ATTR_CHECK_INDEX_ONLY,
1112
+ GitError,
1113
+ )
1114
+
1115
+ commit: Optional["Commit"] = None
1116
+ flags = GIT_ATTR_CHECK_FILE_THEN_INDEX
1117
+ if source:
1118
+ try:
1119
+ commit, _ref = self._resolve_refish(source)
1120
+ flags = GIT_ATTR_CHECK_INDEX_ONLY | GIT_ATTR_CHECK_INCLUDE_COMMIT
1121
+ except (KeyError, GitError) as exc:
1122
+ raise SCMError(f"Invalid commit '{source}'") from exc
1123
+ try:
1124
+ return self.repo.get_attr(
1125
+ path, attr, flags=flags, commit=commit.id if commit else None
1126
+ )
1127
+ except GitError as exc:
1128
+ raise SCMError("Failed to check attribute") from exc
@@ -1,13 +1,15 @@
1
1
  from contextlib import AbstractContextManager
2
2
  from types import TracebackType
3
- from typing import TYPE_CHECKING, Callable, Optional, Type, Union
3
+ from typing import TYPE_CHECKING, Callable, Dict, Optional, Type, Union
4
4
 
5
5
  from pygit2 import RemoteCallbacks as _RemoteCallbacks
6
6
 
7
+ from scmrepo.git.backend.base import SyncStatus
7
8
  from scmrepo.git.credentials import Credential, CredentialNotFoundError
8
9
  from scmrepo.progress import GitProgressReporter
9
10
 
10
11
  if TYPE_CHECKING:
12
+ from pygit2 import Oid
11
13
  from pygit2.credentials import Keypair, Username, UserPass
12
14
 
13
15
  from scmrepo.progress import GitProgressEvent
@@ -27,6 +29,7 @@ class RemoteCallbacks(_RemoteCallbacks, AbstractContextManager):
27
29
  self.progress = GitProgressReporter(progress) if progress else None
28
30
  self._store_credentials: Optional["Credential"] = None
29
31
  self._tried_credentials = False
32
+ self.result: Dict[str, SyncStatus] = {}
30
33
 
31
34
  def __exit__(
32
35
  self,
@@ -66,3 +69,9 @@ class RemoteCallbacks(_RemoteCallbacks, AbstractContextManager):
66
69
  def _approve_credentials(self):
67
70
  if self._store_credentials:
68
71
  self._store_credentials.approve()
72
+
73
+ def update_tips(self, refname: str, old: "Oid", new: "Oid"):
74
+ if old == new:
75
+ self.result[refname] = SyncStatus.UP_TO_DATE
76
+ else:
77
+ self.result[refname] = SyncStatus.SUCCESS
@@ -0,0 +1,65 @@
1
+ import io
2
+ import logging
3
+ from typing import TYPE_CHECKING, Callable, List, Optional
4
+
5
+ from pygit2 import GIT_FILTER_CLEAN, Filter, Passthrough
6
+
7
+ if TYPE_CHECKING:
8
+ from pygit2 import FilterSource
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class LFSFilter(Filter):
14
+ attributes = "filter=*"
15
+
16
+ def __init__(self, *args, **kwargs):
17
+ self._smudge_buf: Optional[io.BytesIO] = None
18
+ self._smudge_root: Optional[str] = None
19
+
20
+ def check(self, src: "FilterSource", attr_values: List[str]):
21
+ if attr_values[0] == "lfs":
22
+ if src.mode != GIT_FILTER_CLEAN:
23
+ self._smudge_buf = io.BytesIO()
24
+ self._smudge_root = src.repo.workdir or src.repo.path
25
+ return
26
+ raise Passthrough
27
+
28
+ def write(
29
+ self, data: bytes, src: "FilterSource", write_next: Callable[[bytes], None]
30
+ ):
31
+ if src.mode == GIT_FILTER_CLEAN:
32
+ write_next(data)
33
+ return
34
+ if self._smudge_buf is None:
35
+ self._smudge_buf = io.BytesIO()
36
+ if self._smudge_root is None:
37
+ self._smudge_root = src.repo.workdir or src.repo.path
38
+ self._smudge_buf.write(data)
39
+
40
+ def close(self, write_next: Callable[[bytes], None]):
41
+ if self._smudge_buf is not None:
42
+ assert self._smudge_root
43
+ self._smudge(write_next)
44
+
45
+ def _smudge(self, write_next: Callable[[bytes], None]):
46
+ from scmrepo.exceptions import InvalidRemote
47
+ from scmrepo.git import Git
48
+ from scmrepo.git.lfs import smudge
49
+ from scmrepo.git.lfs.fetch import get_fetch_url
50
+
51
+ assert self._smudge_buf is not None
52
+ self._smudge_buf.seek(0)
53
+ with Git(self._smudge_root) as scm:
54
+ try:
55
+ url = get_fetch_url(scm)
56
+ except InvalidRemote:
57
+ url = None
58
+ fobj = smudge(scm.lfs_storage, self._smudge_buf, url=url)
59
+ data = fobj.read(io.DEFAULT_BUFFER_SIZE)
60
+ try:
61
+ while data:
62
+ write_next(data)
63
+ data = fobj.read(io.DEFAULT_BUFFER_SIZE)
64
+ except KeyboardInterrupt:
65
+ return
scmrepo/git/config.py ADDED
@@ -0,0 +1,35 @@
1
+ """git config convenience wrapper."""
2
+ import logging
3
+ from abc import ABC, abstractmethod
4
+ from typing import Iterator, Tuple
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class Config(ABC):
10
+ """Read-only Git config."""
11
+
12
+ @abstractmethod
13
+ def get(self, section: Tuple[str, ...], name: str) -> str:
14
+ """Return the specified setting as a string.
15
+
16
+ Raises:
17
+ KeyError: Option was not set.
18
+ """
19
+
20
+ @abstractmethod
21
+ def get_bool(self, section: Tuple[str, ...], name: str) -> bool:
22
+ """Return the specified setting as a boolean.
23
+
24
+ Raises:
25
+ KeyError: Option was not set.
26
+ ValueError: Option is not a valid boolean.
27
+ """
28
+
29
+ @abstractmethod
30
+ def get_multivar(self, section: Tuple[str, ...], name: str) -> Iterator[str]:
31
+ """Iterate over string values in the specified multivar setting.
32
+
33
+ Raises:
34
+ KeyError: Option was not set.
35
+ """
@@ -0,0 +1,8 @@
1
+ from .client import LFSClient
2
+ from .exceptions import LFSError
3
+ from .fetch import fetch
4
+ from .pointer import Pointer
5
+ from .smudge import smudge
6
+ from .storage import LFSStorage
7
+
8
+ __all__ = ["LFSClient", "LFSError", "LFSStorage", "Pointer", "fetch", "smudge"]