scmrepo 1.4.1__tar.gz → 1.5.0__tar.gz

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.

Files changed (70) hide show
  1. {scmrepo-1.4.1 → scmrepo-1.5.0}/PKG-INFO +4 -2
  2. {scmrepo-1.4.1 → scmrepo-1.5.0}/pyproject.toml +1 -0
  3. {scmrepo-1.4.1 → scmrepo-1.5.0}/setup.cfg +3 -1
  4. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/base.py +5 -1
  5. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/fs.py +4 -3
  6. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/__init__.py +13 -0
  7. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/backend/base.py +40 -0
  8. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/backend/dulwich/__init__.py +67 -1
  9. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/backend/gitpython.py +56 -1
  10. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/backend/pygit2/__init__.py +138 -8
  11. scmrepo-1.5.0/src/scmrepo/git/backend/pygit2/filter.py +65 -0
  12. scmrepo-1.5.0/src/scmrepo/git/config.py +35 -0
  13. scmrepo-1.5.0/src/scmrepo/git/lfs/__init__.py +8 -0
  14. scmrepo-1.5.0/src/scmrepo/git/lfs/client.py +223 -0
  15. scmrepo-1.5.0/src/scmrepo/git/lfs/exceptions.py +5 -0
  16. scmrepo-1.5.0/src/scmrepo/git/lfs/fetch.py +162 -0
  17. scmrepo-1.5.0/src/scmrepo/git/lfs/object.py +15 -0
  18. scmrepo-1.5.0/src/scmrepo/git/lfs/pointer.py +109 -0
  19. scmrepo-1.5.0/src/scmrepo/git/lfs/progress.py +61 -0
  20. scmrepo-1.5.0/src/scmrepo/git/lfs/smudge.py +51 -0
  21. scmrepo-1.5.0/src/scmrepo/git/lfs/storage.py +74 -0
  22. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/objects.py +3 -2
  23. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo.egg-info/PKG-INFO +4 -2
  24. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo.egg-info/SOURCES.txt +12 -0
  25. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo.egg-info/requires.txt +3 -1
  26. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/test_fs.py +9 -5
  27. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/test_git.py +29 -0
  28. scmrepo-1.5.0/tests/test_lfs.py +76 -0
  29. {scmrepo-1.4.1 → scmrepo-1.5.0}/.coveragerc +0 -0
  30. {scmrepo-1.4.1 → scmrepo-1.5.0}/.cruft.json +0 -0
  31. {scmrepo-1.4.1 → scmrepo-1.5.0}/.gitattributes +0 -0
  32. {scmrepo-1.4.1 → scmrepo-1.5.0}/.github/dependabot.yml +0 -0
  33. {scmrepo-1.4.1 → scmrepo-1.5.0}/.github/workflows/release.yaml +0 -0
  34. {scmrepo-1.4.1 → scmrepo-1.5.0}/.github/workflows/tests.yaml +0 -0
  35. {scmrepo-1.4.1 → scmrepo-1.5.0}/.github/workflows/update-template.yaml +0 -0
  36. {scmrepo-1.4.1 → scmrepo-1.5.0}/.gitignore +0 -0
  37. {scmrepo-1.4.1 → scmrepo-1.5.0}/.pre-commit-config.yaml +0 -0
  38. {scmrepo-1.4.1 → scmrepo-1.5.0}/CODE_OF_CONDUCT.rst +0 -0
  39. {scmrepo-1.4.1 → scmrepo-1.5.0}/CONTRIBUTING.rst +0 -0
  40. {scmrepo-1.4.1 → scmrepo-1.5.0}/LICENSE +0 -0
  41. {scmrepo-1.4.1 → scmrepo-1.5.0}/README.rst +0 -0
  42. {scmrepo-1.4.1 → scmrepo-1.5.0}/noxfile.py +0 -0
  43. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/__init__.py +0 -0
  44. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/asyn.py +0 -0
  45. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/exceptions.py +0 -0
  46. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/backend/__init__.py +0 -0
  47. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/backend/dulwich/asyncssh_vendor.py +0 -0
  48. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/backend/dulwich/client.py +0 -0
  49. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/backend/pygit2/callbacks.py +0 -0
  50. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/credentials.py +0 -0
  51. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/git/stash.py +0 -0
  52. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/noscm.py +0 -0
  53. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/progress.py +0 -0
  54. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/py.typed +0 -0
  55. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo/utils.py +0 -0
  56. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo.egg-info/dependency_links.txt +0 -0
  57. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo.egg-info/not-zip-safe +0 -0
  58. {scmrepo-1.4.1 → scmrepo-1.5.0}/src/scmrepo.egg-info/top_level.txt +0 -0
  59. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/__init__.py +0 -0
  60. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/conftest.py +0 -0
  61. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/docker-compose.yml +0 -0
  62. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/git-init/git.sh +0 -0
  63. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/test_credentials.py +0 -0
  64. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/test_dulwich.py +0 -0
  65. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/test_noscm.py +0 -0
  66. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/test_pygit2.py +0 -0
  67. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/test_scmrepo.py +0 -0
  68. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/test_stash.py +0 -0
  69. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/user.key +0 -0
  70. {scmrepo-1.4.1 → scmrepo-1.5.0}/tests/user.key.pub +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: scmrepo
3
- Version: 1.4.1
3
+ Version: 1.5.0
4
4
  Summary: SCM wrapper and fsspec filesystem for Git for use in DVC
5
5
  Home-page: https://github.com/iterative/scmrepo
6
6
  Maintainer-email: support@dvc.org
@@ -17,13 +17,15 @@ Description-Content-Type: text/x-rst
17
17
  License-File: LICENSE
18
18
  Requires-Dist: gitpython>3
19
19
  Requires-Dist: dulwich>=0.21.6
20
- Requires-Dist: pygit2>=1.13.0
20
+ Requires-Dist: pygit2>=1.13.3
21
21
  Requires-Dist: pygtrie>=2.3.2
22
22
  Requires-Dist: fsspec>=2021.7.0
23
23
  Requires-Dist: pathspec>=0.9.0
24
24
  Requires-Dist: asyncssh<3,>=2.13.1
25
25
  Requires-Dist: funcy>=1.14
26
26
  Requires-Dist: shortuuid>=0.5.0
27
+ Requires-Dist: dvc-objects<2,>=1.0.1
28
+ Requires-Dist: dvc-http>=2.29.0
27
29
  Provides-Extra: tests
28
30
  Requires-Dist: pytest==7.2.0; extra == "tests"
29
31
  Requires-Dist: pytest-sugar==0.9.5; extra == "tests"
@@ -70,6 +70,7 @@ files = ["src", "tests"]
70
70
  [[tool.mypy.overrides]]
71
71
  module = [
72
72
  "pygtrie",
73
+ "dvc_http.*",
73
74
  "funcy",
74
75
  "git",
75
76
  "gitdb.*",
@@ -26,13 +26,15 @@ packages = find:
26
26
  install_requires =
27
27
  gitpython>3
28
28
  dulwich>=0.21.6
29
- pygit2>=1.13.0
29
+ pygit2>=1.13.3
30
30
  pygtrie>=2.3.2
31
31
  fsspec>=2021.7.0
32
32
  pathspec>=0.9.0
33
33
  asyncssh>=2.13.1,<3
34
34
  funcy>=1.14
35
35
  shortuuid>=0.5.0
36
+ dvc-objects>=1.0.1,<2
37
+ dvc-http>=2.29.0
36
38
 
37
39
  [options.extras_require]
38
40
  tests =
@@ -1,7 +1,8 @@
1
1
  """Manages source control systems (e.g. Git) in DVC."""
2
+ from contextlib import AbstractContextManager
2
3
 
3
4
 
4
- class Base:
5
+ class Base(AbstractContextManager):
5
6
  """Base class for source control management driver implementations."""
6
7
 
7
8
  def __init__(self, root_dir=None):
@@ -18,6 +19,9 @@ class Base:
18
19
  class_name=type(self).__name__, directory=self.dir
19
20
  )
20
21
 
22
+ def __exit__(self, exc_type, exc_value, traceback):
23
+ self.close()
24
+
21
25
  @property
22
26
  def dir(self):
23
27
  """Path to a directory with SCM specific information."""
@@ -187,9 +187,10 @@ class GitFileSystem(AbstractFileSystem):
187
187
  self,
188
188
  path: str,
189
189
  mode: str = "rb",
190
- block_size: int = None,
190
+ block_size: Optional[int] = None,
191
191
  autocommit: bool = True,
192
- cache_options: Dict = None,
192
+ cache_options: Optional[Dict] = None,
193
+ raw: bool = False,
193
194
  **kwargs: Any,
194
195
  ) -> BinaryIO:
195
196
  if mode != "rb":
@@ -197,7 +198,7 @@ class GitFileSystem(AbstractFileSystem):
197
198
 
198
199
  key = self._get_key(path)
199
200
  try:
200
- obj = self.trie.open(key, mode=mode)
201
+ obj = self.trie.open(key, mode=mode, raw=raw)
201
202
  obj.size = bytesio_len(obj)
202
203
  return obj
203
204
  except KeyError as exc:
@@ -171,6 +171,13 @@ class Git(Base):
171
171
  def ignore_file(self):
172
172
  return self.GITIGNORE
173
173
 
174
+ @cached_property
175
+ def lfs_storage(self):
176
+ from .lfs import LFSStorage
177
+ from .lfs.storage import get_storage_path
178
+
179
+ return LFSStorage(get_storage_path(self))
180
+
174
181
  def _get_gitignore(self, path):
175
182
  ignore_file_dir = os.path.dirname(path)
176
183
 
@@ -267,6 +274,8 @@ class Git(Base):
267
274
 
268
275
  def close(self):
269
276
  self.backends.close_initialized()
277
+ if "lfs_storage" in self.__dict__:
278
+ self.lfs_storage.close()
270
279
 
271
280
  @property
272
281
  def no_commits(self):
@@ -358,6 +367,7 @@ class Git(Base):
358
367
  is_tracked = partialmethod(_backend_func, "is_tracked")
359
368
  is_dirty = partialmethod(_backend_func, "is_dirty")
360
369
  active_branch = partialmethod(_backend_func, "active_branch")
370
+ active_branch_remote = partialmethod(_backend_func, "active_branch_remote")
361
371
  list_branches = partialmethod(_backend_func, "list_branches")
362
372
  list_tags = partialmethod(_backend_func, "list_tags")
363
373
  list_all_commits = partialmethod(_backend_func, "list_all_commits")
@@ -383,8 +393,11 @@ class Git(Base):
383
393
  status = partialmethod(_backend_func, "status")
384
394
  merge = partialmethod(_backend_func, "merge")
385
395
  validate_git_remote = partialmethod(_backend_func, "validate_git_remote")
396
+ get_remote_url = partialmethod(_backend_func, "get_remote_url")
386
397
  check_ref_format = partialmethod(_backend_func, "check_ref_format")
387
398
  get_tag = partialmethod(_backend_func, "get_tag")
399
+ get_config = partialmethod(_backend_func, "get_config")
400
+ check_attr = partialmethod(_backend_func, "check_attr")
388
401
 
389
402
  get_tree_obj = partialmethod(_backend_func, "get_tree_obj")
390
403
 
@@ -10,6 +10,7 @@ from ..objects import GitObject
10
10
  if TYPE_CHECKING:
11
11
  from scmrepo.progress import GitProgressEvent
12
12
 
13
+ from ..config import Config
13
14
  from ..objects import GitCommit, GitTag
14
15
 
15
16
 
@@ -135,6 +136,10 @@ class BaseGitBackend(ABC):
135
136
  def active_branch(self) -> str:
136
137
  pass
137
138
 
139
+ @abstractmethod
140
+ def active_branch_remote(self) -> str:
141
+ """Return the fetch remote name for the current branch."""
142
+
138
143
  @abstractmethod
139
144
  def list_branches(self) -> Iterable[str]:
140
145
  pass
@@ -397,6 +402,10 @@ class BaseGitBackend(ABC):
397
402
  def validate_git_remote(self, url: str, **kwargs):
398
403
  """Verify that url is a valid git URL or remote name."""
399
404
 
405
+ @abstractmethod
406
+ def get_remote_url(self, remote: str) -> str:
407
+ """Return URL for the specified remote."""
408
+
400
409
  @abstractmethod
401
410
  def check_ref_format(self, refname: str) -> bool:
402
411
  """Check if a reference name is well formed."""
@@ -413,3 +422,34 @@ class BaseGitBackend(ABC):
413
422
  String SHA for the target object if the tag is a lightweight tag.
414
423
  GitTag object if the tag is an annotated tag.
415
424
  """
425
+
426
+ @abstractmethod
427
+ def get_config(self, path: Optional[str] = None) -> "Config":
428
+ """Return a Git config object.
429
+
430
+ Args:
431
+ path: If set, a config object for the specified config file will be
432
+ returned. By default, the standard Git system/global/repo config
433
+ stack object will be returned.
434
+ """
435
+
436
+ @abstractmethod
437
+ def check_attr(
438
+ self,
439
+ path: str,
440
+ attr: str,
441
+ source: Optional[str] = None,
442
+ ) -> Optional[Union[bool, str]]:
443
+ """Return the value of the specified attribute for a pathname.
444
+
445
+ Args:
446
+ path: Pathname to check.
447
+ attr: Attribute to check.
448
+ source: Optional tree-ish source to check.
449
+
450
+ Returns:
451
+ None when the attribute is not defined for the path (unspecified).
452
+ True when the attribute is defined as true (set).
453
+ False when the attribute is defined as false (unset).
454
+ The value of the attribute when a value has been assigned.
455
+ """
@@ -27,11 +27,13 @@ from scmrepo.exceptions import AuthError, CloneError, InvalidRemote, RevError, S
27
27
  from scmrepo.progress import GitProgressReporter
28
28
  from scmrepo.utils import relpath
29
29
 
30
+ from ...config import Config
30
31
  from ...objects import GitObject, GitTag
31
32
  from ..base import BaseGitBackend, SyncStatus
32
33
 
33
34
  if TYPE_CHECKING:
34
35
  from dulwich.client import SSHVendor
36
+ from dulwich.config import ConfigFile, StackedConfig
35
37
  from dulwich.repo import Repo
36
38
 
37
39
  from scmrepo.progress import GitProgressEvent
@@ -49,7 +51,16 @@ class DulwichObject(GitObject):
49
51
  self._mode = mode
50
52
  self._sha = sha
51
53
 
52
- def open(self, mode: str = "r", encoding: str = None):
54
+ def open( # pylint: disable=unused-argument
55
+ self,
56
+ mode: str = "r",
57
+ encoding: Optional[str] = None,
58
+ raw: bool = True,
59
+ rev: Optional[str] = None,
60
+ **kwargs,
61
+ ):
62
+ if not raw:
63
+ raise NotImplementedError
53
64
  if not encoding:
54
65
  encoding = locale.getpreferredencoding(False)
55
66
  # NOTE: we didn't load the object before as Dulwich will also try to
@@ -130,6 +141,35 @@ def _get_ssh_vendor() -> "SSHVendor":
130
141
  return AsyncSSHVendor()
131
142
 
132
143
 
144
+ class DulwichConfig(Config):
145
+ def __init__(self, config: Union["ConfigFile", "StackedConfig"]):
146
+ self._config = config
147
+
148
+ @property
149
+ def encoding(self) -> str:
150
+ from dulwich.config import ConfigFile
151
+
152
+ if isinstance(self._config, ConfigFile):
153
+ return self._config.encoding
154
+ return self._config.backends[0].encoding
155
+
156
+ def get(self, section: Tuple[str, ...], name: str) -> str:
157
+ """Return the specified setting as a string."""
158
+ return self._config.get(section, name).decode(self.encoding)
159
+
160
+ def get_bool(self, section: Tuple[str, ...], name: str) -> bool:
161
+ """Return the specified setting as a boolean."""
162
+ value = self._config.get_boolean(section, name)
163
+ if value is None:
164
+ raise ValueError("setting is not a valid boolean")
165
+ return value
166
+
167
+ def get_multivar(self, section: Tuple[str, ...], name: str) -> Iterator[str]:
168
+ """Iterate over string values in the specified multivar setting."""
169
+ for value in self._config.get_multivar(section, name):
170
+ yield value.decode(self.encoding)
171
+
172
+
133
173
  class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
134
174
  """Dulwich Git backend."""
135
175
 
@@ -430,6 +470,9 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
430
470
  def active_branch(self) -> str:
431
471
  raise NotImplementedError
432
472
 
473
+ def active_branch_remote(self) -> str:
474
+ raise NotImplementedError
475
+
433
476
  def list_branches(self) -> Iterable[str]:
434
477
  base = "refs/heads/"
435
478
  return sorted(ref[len(base) :] for ref in self.iter_refs(base))
@@ -884,6 +927,14 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
884
927
  ):
885
928
  raise InvalidRemote(url)
886
929
 
930
+ def get_remote_url(self, remote: str) -> str:
931
+ from dulwich.porcelain import get_remote_repo
932
+
933
+ remote_name, location = get_remote_repo(self.repo, remote)
934
+ if not remote_name:
935
+ raise InvalidRemote(remote)
936
+ return location
937
+
887
938
  def check_ref_format(self, refname: str) -> bool:
888
939
  from dulwich.refs import check_ref_format
889
940
 
@@ -913,6 +964,21 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
913
964
  )
914
965
  return os.fsdecode(ref)
915
966
 
967
+ def get_config(self, path: Optional[str] = None) -> "Config":
968
+ from dulwich.config import ConfigFile
969
+
970
+ if path:
971
+ return DulwichConfig(ConfigFile.from_path(path))
972
+ return DulwichConfig(self.repo.get_config_stack())
973
+
974
+ def check_attr(
975
+ self,
976
+ path: str,
977
+ attr: str,
978
+ source: Optional[str] = None,
979
+ ) -> Optional[Union[bool, str]]:
980
+ raise NotImplementedError
981
+
916
982
 
917
983
  _IDENTITY_RE = re.compile(r"(?P<name>.+)\s+<(?P<email>.+)>")
918
984
 
@@ -2,6 +2,7 @@ import io
2
2
  import locale
3
3
  import logging
4
4
  import os
5
+ import re
5
6
  import sys
6
7
  from functools import partial, wraps
7
8
  from typing import (
@@ -21,6 +22,7 @@ from funcy import ignore
21
22
 
22
23
  from scmrepo.exceptions import (
23
24
  CloneError,
25
+ InvalidRemote,
24
26
  MergeConflictError,
25
27
  RevError,
26
28
  SCMError,
@@ -34,6 +36,8 @@ from .base import BaseGitBackend, SyncStatus
34
36
  if TYPE_CHECKING:
35
37
  from scmrepo.progress import GitProgressEvent
36
38
 
39
+ from ..config import Config
40
+
37
41
 
38
42
  logger = logging.getLogger(__name__)
39
43
 
@@ -80,7 +84,15 @@ class GitPythonObject(GitObject):
80
84
  def __init__(self, obj):
81
85
  self.obj = obj
82
86
 
83
- def open(self, mode: str = "r", encoding: str = None):
87
+ def open(
88
+ self,
89
+ mode: str = "r",
90
+ encoding: str = None,
91
+ raw: bool = True,
92
+ **kwargs,
93
+ ):
94
+ if not raw:
95
+ raise NotImplementedError
84
96
  if not encoding:
85
97
  encoding = locale.getpreferredencoding(False)
86
98
  # GitPython's obj.data_stream is a fragile thing, it is better to
@@ -341,6 +353,12 @@ class GitPythonBackend(BaseGitBackend): # pylint:disable=abstract-method
341
353
  except TypeError as exc:
342
354
  raise SCMError("No active branch") from exc
343
355
 
356
+ def active_branch_remote(self) -> str:
357
+ try:
358
+ return self.repo.active_branch.tracking_branch()
359
+ except (TypeError, ValueError) as exc:
360
+ raise SCMError("No active branch tracking remote") from exc
361
+
344
362
  def list_branches(self):
345
363
  return [h.name for h in self.repo.heads]
346
364
 
@@ -714,6 +732,14 @@ class GitPythonBackend(BaseGitBackend): # pylint:disable=abstract-method
714
732
  def validate_git_remote(self, url: str, **kwargs):
715
733
  raise NotImplementedError
716
734
 
735
+ def get_remote_url(self, remote: str) -> str:
736
+ from git.exc import GitCommandError
737
+
738
+ try:
739
+ return self.repo.remotes[remote].url
740
+ except (KeyError, GitCommandError) as exc:
741
+ raise InvalidRemote(remote) from exc
742
+
717
743
  def check_ref_format(self, refname: str):
718
744
  raise NotImplementedError
719
745
 
@@ -736,3 +762,32 @@ class GitPythonBackend(BaseGitBackend): # pylint:disable=abstract-method
736
762
  except IndexError:
737
763
  pass
738
764
  return None
765
+
766
+ def get_config(self, path: Optional[str] = None) -> "Config":
767
+ raise NotImplementedError
768
+
769
+ def check_attr(
770
+ self,
771
+ path: str,
772
+ attr: str,
773
+ source: Optional[str] = None,
774
+ ) -> Optional[Union[bool, str]]:
775
+ from git.exc import GitCommandError
776
+
777
+ try:
778
+ result = self.git.check_attr(attr, "--", path, source=source)
779
+ except GitCommandError as exc:
780
+ raise SCMError("Failed to check attribute") from exc
781
+ escaped_path = re.escape(path)
782
+ escaped_attr = re.escape(attr)
783
+ m = re.match(f"{escaped_path}: {escaped_attr}: (?P<info>.*)", result)
784
+ if not m or not m.group("info"):
785
+ raise SCMError("Failed to check attribute")
786
+ info = m.group("info")
787
+ if info == "unspecified":
788
+ return None
789
+ if info == "set":
790
+ return True
791
+ if info == "unset":
792
+ return False
793
+ return info
@@ -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 Oid, 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
@@ -968,6 +1057,12 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
968
1057
  def validate_git_remote(self, url: str, **kwargs):
969
1058
  raise NotImplementedError
970
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
+
971
1066
  def check_ref_format(self, refname: str):
972
1067
  raise NotImplementedError
973
1068
 
@@ -996,3 +1091,38 @@ class Pygit2Backend(BaseGitBackend): # pylint:disable=abstract-method
996
1091
  except KeyError:
997
1092
  pass
998
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