repopath 0.1.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.
- repopath/AGENTS.md +331 -0
- repopath/README.md +152 -0
- repopath/__init__.py +35 -0
- repopath/_backend.py +219 -0
- repopath/_github.py +243 -0
- repopath/_gitlab.py +243 -0
- repopath/_hosted.py +284 -0
- repopath/_kinds.py +314 -0
- repopath/_version.py +11 -0
- repopath/exceptions.py +43 -0
- repopath/py.typed +0 -0
- repopath/uri.py +253 -0
- repopath-0.1.0.dist-info/METADATA +191 -0
- repopath-0.1.0.dist-info/RECORD +17 -0
- repopath-0.1.0.dist-info/WHEEL +4 -0
- repopath-0.1.0.dist-info/entry_points.txt +4 -0
- repopath-0.1.0.dist-info/licenses/LICENSE +21 -0
repopath/_kinds.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""`RepoPath`, the family of repository trees on a hosting service, and the
|
|
2
|
+
registry of the kinds that make it up."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import inspect as _inspect
|
|
7
|
+
import io as _io
|
|
8
|
+
import re as _re
|
|
9
|
+
import stat as _stat
|
|
10
|
+
import threading as _threading
|
|
11
|
+
import typing as _ty
|
|
12
|
+
|
|
13
|
+
from pathapi import FileStat
|
|
14
|
+
from pathapi.providers import TreePath
|
|
15
|
+
|
|
16
|
+
if _ty.TYPE_CHECKING:
|
|
17
|
+
from ._backend import BaseRepoBackend
|
|
18
|
+
|
|
19
|
+
_KINDS: "dict[str, type[RepoPath]]" = {}
|
|
20
|
+
"""Kind name -> the class that registered it."""
|
|
21
|
+
|
|
22
|
+
_DETECTED: "dict[str, type[RepoPath]]" = {}
|
|
23
|
+
"""Instance (folded to lower case) -> the kind a call that names no `type`
|
|
24
|
+
gets for it."""
|
|
25
|
+
|
|
26
|
+
_LOCK = _threading.Lock()
|
|
27
|
+
|
|
28
|
+
_NAME = _re.compile(r"[a-z][a-z0-9_-]*")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Kinds(type(TreePath)):
|
|
32
|
+
"""The metaclass that lets calling `RepoPath` itself return a path of one
|
|
33
|
+
of its kinds, chosen by `type=` or by the `instance=` it names."""
|
|
34
|
+
|
|
35
|
+
def __call__(cls, *args, **kwargs):
|
|
36
|
+
if cls is RepoPath:
|
|
37
|
+
kind = RepoPath._kind_for(
|
|
38
|
+
args, kwargs.pop("type", None), kwargs.get("instance")
|
|
39
|
+
)
|
|
40
|
+
return kind(*args, **kwargs)
|
|
41
|
+
return super().__call__(*args, **kwargs)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def __signature__(cls) -> _inspect.Signature:
|
|
45
|
+
"""What `inspect.signature()` reads: the constructor a call of the
|
|
46
|
+
class runs, not the `__call__` above."""
|
|
47
|
+
if cls is RepoPath:
|
|
48
|
+
parameter = _inspect.Parameter
|
|
49
|
+
return _inspect.Signature(
|
|
50
|
+
[
|
|
51
|
+
parameter("segments", parameter.VAR_POSITIONAL),
|
|
52
|
+
parameter("type", parameter.KEYWORD_ONLY, default=None),
|
|
53
|
+
parameter("instance", parameter.KEYWORD_ONLY, default=None),
|
|
54
|
+
parameter("options", parameter.VAR_KEYWORD),
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
signature = _inspect.signature(cls.__init__)
|
|
58
|
+
return signature.replace(parameters=list(signature.parameters.values())[1:])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _same_definition(a: type, b: type) -> bool:
|
|
62
|
+
"""Whether two classes come from one `class` statement run twice (a
|
|
63
|
+
reload, a function body called again): same module, same qualified name."""
|
|
64
|
+
return a.__module__ == b.__module__ and a.__qualname__ == b.__qualname__
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _register(cls: type, name: object, instances: object, replace: bool) -> None:
|
|
68
|
+
if not isinstance(name, str) or not _NAME.fullmatch(name):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"TYPE of {cls.__qualname__} must be a lower-case name "
|
|
71
|
+
f"(letters, digits, '_' and '-'), not {name!r}"
|
|
72
|
+
)
|
|
73
|
+
if isinstance(instances, str) or not all(isinstance(i, str) for i in instances):
|
|
74
|
+
raise TypeError(f"INSTANCES of {cls.__qualname__} must be a tuple of str")
|
|
75
|
+
folded = tuple(dict.fromkeys(i.lower() for i in instances))
|
|
76
|
+
with _LOCK:
|
|
77
|
+
held = _KINDS.get(name)
|
|
78
|
+
if held is not None and not (replace or _same_definition(held, cls)):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"repository kind {name!r} is already registered by "
|
|
81
|
+
f"{held.__module__}.{held.__qualname__}; pass replace=True in "
|
|
82
|
+
f"the class statement of {cls.__qualname__} to replace it"
|
|
83
|
+
)
|
|
84
|
+
for instance in folded:
|
|
85
|
+
owner = _DETECTED.get(instance)
|
|
86
|
+
if (
|
|
87
|
+
owner is not None
|
|
88
|
+
and owner is not held
|
|
89
|
+
and not (replace or _same_definition(owner, cls))
|
|
90
|
+
):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"instance {instance!r} is already detected as the "
|
|
93
|
+
f"{owner.TYPE!r} kind; pass replace=True in the class "
|
|
94
|
+
f"statement of {cls.__qualname__} to replace it"
|
|
95
|
+
)
|
|
96
|
+
if held is not None:
|
|
97
|
+
for instance in [k for k, v in _DETECTED.items() if v is held]:
|
|
98
|
+
del _DETECTED[instance]
|
|
99
|
+
_KINDS[name] = cls
|
|
100
|
+
for instance in folded:
|
|
101
|
+
_DETECTED[instance] = cls
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RepoPath(TreePath, metaclass=_Kinds):
|
|
105
|
+
"""A path in the repository tree of a hosting service, read-only, over the
|
|
106
|
+
service's REST API.
|
|
107
|
+
|
|
108
|
+
`RepoPath(*segments, instance=..., type=None, **options)` returns a path
|
|
109
|
+
of one *kind*: a `GitHubPath` or a `GitLabPath`. `type` names the kind
|
|
110
|
+
(`"github"`, `"gitlab"`); without it the kind is detected from `instance`
|
|
111
|
+
(`github.com` and `gitlab.com`), and any other instance is a `ValueError`
|
|
112
|
+
that asks for `type=`. The segments are the path inside the repository;
|
|
113
|
+
the options are those of the kind (`owner=`, `repo=`, `ref=`, `token=`,
|
|
114
|
+
`backend=`). Every kind is a subclass, so `isinstance(path, RepoPath)`
|
|
115
|
+
holds for all of them; `RepoPath.kinds()` lists the registered names.
|
|
116
|
+
|
|
117
|
+
A kind is a subclass whose class body sets `TYPE` (its name) and
|
|
118
|
+
`INSTANCES` (the instances it is detected for): defining the class
|
|
119
|
+
registers it. A name or an instance another class holds is a `ValueError`
|
|
120
|
+
unless the class statement passes `replace=True`.
|
|
121
|
+
|
|
122
|
+
A path holds the names below the root of the tree, the tree it is in (the
|
|
123
|
+
place) and the backend that reaches the service. Nothing connects when a
|
|
124
|
+
path is built. A path joined from another keeps its place and its
|
|
125
|
+
backend."""
|
|
126
|
+
|
|
127
|
+
__slots__ = ("_backend", "_place", "_hint")
|
|
128
|
+
|
|
129
|
+
#: The name of the kind, set in the class body of a kind.
|
|
130
|
+
TYPE: "str | None" = None
|
|
131
|
+
#: The instances (hosts) a call that names no `type` gets this kind for.
|
|
132
|
+
INSTANCES: "tuple[str, ...]" = ()
|
|
133
|
+
|
|
134
|
+
if _ty.TYPE_CHECKING:
|
|
135
|
+
_segments: "tuple[str, ...]"
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
*segments: str | RepoPath,
|
|
140
|
+
type: str | None = None,
|
|
141
|
+
instance: str | None = None,
|
|
142
|
+
**options: _ty.Any,
|
|
143
|
+
) -> None: ...
|
|
144
|
+
|
|
145
|
+
def __init_subclass__(cls, replace: bool = False, **kwargs):
|
|
146
|
+
super().__init_subclass__(**kwargs)
|
|
147
|
+
name = cls.__dict__.get("TYPE")
|
|
148
|
+
if name is not None:
|
|
149
|
+
_register(cls, name, cls.__dict__.get("INSTANCES", ()), replace)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def kinds() -> "tuple[str, ...]":
|
|
153
|
+
"""The names of the registered kinds, sorted."""
|
|
154
|
+
with _LOCK:
|
|
155
|
+
return tuple(sorted(_KINDS))
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _kind_for(segments, name: object, instance: object) -> "type[RepoPath]":
|
|
159
|
+
"""The kind a call to `RepoPath(...)` makes: the one `name` registers,
|
|
160
|
+
else the one `instance` is detected for, else the one of the first
|
|
161
|
+
segment that is a repository path."""
|
|
162
|
+
with _LOCK:
|
|
163
|
+
kinds = dict(_KINDS)
|
|
164
|
+
detected = dict(_DETECTED)
|
|
165
|
+
known = ", ".join(sorted(kinds)) or "none"
|
|
166
|
+
if name is not None:
|
|
167
|
+
if not isinstance(name, str):
|
|
168
|
+
raise TypeError(f"type= must be a str, not {type(name).__name__!r}")
|
|
169
|
+
try:
|
|
170
|
+
return kinds[name]
|
|
171
|
+
except KeyError:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"no repository kind {name!r}; the registered kinds are: {known}"
|
|
174
|
+
) from None
|
|
175
|
+
if instance is not None:
|
|
176
|
+
if not isinstance(instance, str):
|
|
177
|
+
raise TypeError(
|
|
178
|
+
f"instance= must be a str, not {type(instance).__name__!r}"
|
|
179
|
+
)
|
|
180
|
+
try:
|
|
181
|
+
return detected[instance.lower()]
|
|
182
|
+
except KeyError:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"cannot tell the kind of instance {instance!r}; pass type= "
|
|
185
|
+
f"with one of: {known}"
|
|
186
|
+
) from None
|
|
187
|
+
for segment in segments:
|
|
188
|
+
if isinstance(segment, RepoPath):
|
|
189
|
+
return segment._family()
|
|
190
|
+
raise TypeError(f"RepoPath needs type= or instance= (kinds: {known})")
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def _family(cls) -> type:
|
|
194
|
+
"""The kind `cls` is, or derives from: its instances can donate a
|
|
195
|
+
place and a backend to a join, and a path of another kind cannot."""
|
|
196
|
+
return next((c for c in cls.__mro__ if c.__dict__.get("TYPE")), RepoPath)
|
|
197
|
+
|
|
198
|
+
def _initialize(self, text: str, place: tuple, backend) -> None:
|
|
199
|
+
self._segments = self._parse(text)
|
|
200
|
+
self._place = place
|
|
201
|
+
self._backend = backend
|
|
202
|
+
self._hint = None
|
|
203
|
+
|
|
204
|
+
def _derived(self, segments: "tuple[str, ...]"):
|
|
205
|
+
"""A path of this class, place and backend with the names `segments`."""
|
|
206
|
+
path = object.__new__(type(self))
|
|
207
|
+
path._segments = segments
|
|
208
|
+
path._place = self._place
|
|
209
|
+
path._backend = self._backend
|
|
210
|
+
path._hint = None
|
|
211
|
+
return path
|
|
212
|
+
|
|
213
|
+
def _rebuild(self, text: str):
|
|
214
|
+
return self._derived(self._parse(text))
|
|
215
|
+
|
|
216
|
+
def _keywords(self) -> dict:
|
|
217
|
+
"""The constructor keywords that name the place of this path."""
|
|
218
|
+
raise NotImplementedError
|
|
219
|
+
|
|
220
|
+
def _place_key(self) -> tuple:
|
|
221
|
+
"""What of the place `==`, `hash()` and ordering compare."""
|
|
222
|
+
return self._place
|
|
223
|
+
|
|
224
|
+
def _identity(self) -> tuple:
|
|
225
|
+
# Relative and rooted spellings name one node: the tree has no
|
|
226
|
+
# working directory.
|
|
227
|
+
return (self._place_key(), tuple(name for name in self._segments if name))
|
|
228
|
+
|
|
229
|
+
def __eq__(self, other: object):
|
|
230
|
+
if type(self) is not type(other):
|
|
231
|
+
return NotImplemented
|
|
232
|
+
return self._identity() == other._identity()
|
|
233
|
+
|
|
234
|
+
def __hash__(self) -> int:
|
|
235
|
+
return hash((type(self), self._identity()))
|
|
236
|
+
|
|
237
|
+
def _order_key(self):
|
|
238
|
+
return self._identity()
|
|
239
|
+
|
|
240
|
+
def __reduce__(self):
|
|
241
|
+
# The place and the names; a backend travels only when it says it can
|
|
242
|
+
# (its token is a credential), else the receiving process builds a
|
|
243
|
+
# backend of its own.
|
|
244
|
+
keywords = self._keywords()
|
|
245
|
+
if getattr(self._backend, "picklable", False) is True:
|
|
246
|
+
keywords["backend"] = self._backend
|
|
247
|
+
return _rebuild_path, (type(self), self._segments, keywords)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def kind(self) -> str:
|
|
251
|
+
"""The name of the kind of this path (`"github"`, `"gitlab"`)."""
|
|
252
|
+
return self._family().TYPE
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def backend(self) -> "BaseRepoBackend":
|
|
256
|
+
"""The backend that sends the requests of this path."""
|
|
257
|
+
return self._backend
|
|
258
|
+
|
|
259
|
+
def is_local(self) -> bool:
|
|
260
|
+
"""Whether the tree is on this machine: never. A repository is on a
|
|
261
|
+
hosting service, so nothing is resolved."""
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
def _pop_stat_hint(self) -> "FileStat | None":
|
|
265
|
+
"""The stat that the listing of the parent attached to this child,
|
|
266
|
+
once; the next call asks the service. A hint that knows the type and
|
|
267
|
+
not the size is consumed here and not returned: `stat()` has to give
|
|
268
|
+
a size."""
|
|
269
|
+
hint, self._hint = self._hint, None
|
|
270
|
+
if hint is not None and not hint.size_known:
|
|
271
|
+
return None
|
|
272
|
+
return hint
|
|
273
|
+
|
|
274
|
+
def _st_mode(self, *, follow_symlinks=True):
|
|
275
|
+
# The type questions are answered by a type-only hint, without a request.
|
|
276
|
+
hint = self._hint
|
|
277
|
+
if hint is not None and not hint.size_known:
|
|
278
|
+
self._hint = None
|
|
279
|
+
mode = hint.st_mode
|
|
280
|
+
if _stat.S_IFMT(mode):
|
|
281
|
+
return mode
|
|
282
|
+
return super()._st_mode(follow_symlinks=follow_symlinks)
|
|
283
|
+
|
|
284
|
+
def _make_child_relpath(self, name: str, stat_hint=None, **kwargs):
|
|
285
|
+
"""The child called exactly `name`, with the stat its listing gave."""
|
|
286
|
+
segments = self._segments
|
|
287
|
+
names = ("", name) if segments == ("", "") else (*segments, name)
|
|
288
|
+
child = self._derived(names)
|
|
289
|
+
child._hint = stat_hint
|
|
290
|
+
return child
|
|
291
|
+
|
|
292
|
+
def iterdir(self):
|
|
293
|
+
for name, listed in self._scandir():
|
|
294
|
+
yield self._make_child_relpath(name, stat_hint=listed)
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def _check_read_mode(mode: str) -> None:
|
|
298
|
+
# `open()` hands `_open()` a canonical mode ("r", "r+", "w", ...);
|
|
299
|
+
# anything but plain "r" would write, and writes are unsupported.
|
|
300
|
+
if mode.replace("b", "").replace("t", "") != "r":
|
|
301
|
+
raise NotImplementedError(f"open(mode={mode!r}): a repository is read-only")
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def _reader(data: bytes) -> _ty.BinaryIO:
|
|
305
|
+
# Read-only like a local file opened "rb": a write raises
|
|
306
|
+
# io.UnsupportedOperation instead of landing in a detached buffer.
|
|
307
|
+
return _io.BufferedReader(_io.BytesIO(data))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _rebuild_path(cls, segments, keywords):
|
|
311
|
+
"""Unpickles a path: `cls(**keywords)` with the names it had."""
|
|
312
|
+
path = cls(**keywords)
|
|
313
|
+
path._segments = segments
|
|
314
|
+
return path
|
repopath/_version.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The version of the installed distribution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata as _metadata
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = _metadata.version("repopath")
|
|
9
|
+
except _metadata.PackageNotFoundError:
|
|
10
|
+
# Run from a source tree that was never installed.
|
|
11
|
+
__version__ = "0+unknown"
|
repopath/exceptions.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The exceptions and the warning `repopath` defines.
|
|
2
|
+
|
|
3
|
+
A failure of a hosting service or of the connection to it is the `OSError` type
|
|
4
|
+
that `pathlib` raises for the same condition (`FileNotFoundError`,
|
|
5
|
+
`PermissionError`, `TimeoutError`, `ConnectionError`, ...); this module holds
|
|
6
|
+
the two that name a cause no builtin does. An exception of the HTTP transport is
|
|
7
|
+
never raised across the API. A bad argument is a plain `ValueError` or
|
|
8
|
+
`TypeError`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathapi import PathError
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BadReplyError",
|
|
17
|
+
"InsecureTransportWarning",
|
|
18
|
+
"RateLimitError",
|
|
19
|
+
"RepoPathError",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RepoPathError(PathError):
|
|
24
|
+
"""Base class of the exceptions `repopath` defines for a failure at run
|
|
25
|
+
time that no builtin describes."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RateLimitError(RepoPathError, BlockingIOError):
|
|
29
|
+
"""The service refused a request because the caller used up its rate limit
|
|
30
|
+
(errno `EAGAIN`): HTTP 403 or 429 with a `RateLimit-Remaining: 0` or a
|
|
31
|
+
`Retry-After` header. The message says when to try again."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BadReplyError(RepoPathError, OSError):
|
|
35
|
+
"""The service answered with something that is not the shape its API
|
|
36
|
+
documents (errno `EIO`): a page of a proxy or a captive portal, a gateway's
|
|
37
|
+
JSON, `null`, a truncated body, a size that is not a number."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InsecureTransportWarning(UserWarning):
|
|
41
|
+
"""A `RepoBackend` is about to send an `Authorization` header (its token,
|
|
42
|
+
or one given in `headers=`) over plain `http://` to a host that is not
|
|
43
|
+
this machine, where anyone on the route can read it."""
|
repopath/py.typed
ADDED
|
File without changes
|
repopath/uri.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""The `repo:`, `github:` and `gitlab:` schemes of `uripath`: `RepoURIPath`,
|
|
2
|
+
`GitHubURIPath` and `GitLabURIPath`, URI paths that delegate to a `GitHubPath`
|
|
3
|
+
or a `GitLabPath`.
|
|
4
|
+
|
|
5
|
+
Importing this module needs `uripath`: `pip install "repopath[uri]"`. `uripath`
|
|
6
|
+
loads it by itself, through the `uripath.schemes` entry points, the first time
|
|
7
|
+
a path of one of the schemes is used.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import uripath as _uripath # noqa: F401
|
|
14
|
+
except ImportError as _error:
|
|
15
|
+
raise ImportError(
|
|
16
|
+
'repopath.uri needs the uripath distribution: pip install "repopath[uri]"',
|
|
17
|
+
name="uripath",
|
|
18
|
+
) from _error
|
|
19
|
+
|
|
20
|
+
import contextlib as _contextlib
|
|
21
|
+
import errno as _errno
|
|
22
|
+
import typing as _ty
|
|
23
|
+
|
|
24
|
+
from uripath import Source
|
|
25
|
+
from uripath.exceptions import URISyntaxError
|
|
26
|
+
from uripath.schemes import DelegatingURIPath
|
|
27
|
+
from uripath.source import compose_host, fold_host
|
|
28
|
+
|
|
29
|
+
from ._backend import RepoBackend
|
|
30
|
+
from ._github import GitHubPath
|
|
31
|
+
from ._gitlab import GitLabPath
|
|
32
|
+
from ._hosted import HostedRepoPath
|
|
33
|
+
from ._kinds import RepoPath
|
|
34
|
+
|
|
35
|
+
__all__ = ["GitHubURIPath", "GitLabURIPath", "RepoURIPath"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RepoURIPath(DelegatingURIPath):
|
|
39
|
+
"""`repo:` scheme (`repo://instance/owner/repo/path?ref=REF`): read-only
|
|
40
|
+
access to a repository on a hosting service, done by the path of its kind
|
|
41
|
+
(a `GitHubPath` or a `GitLabPath`) on the backend of this path. The kind is
|
|
42
|
+
detected from the host: `github.com` and `gitlab.com` (and `www.`); any
|
|
43
|
+
other host is a `ValueError` that names `github://host/...` and
|
|
44
|
+
`gitlab://host/...`, which pin the kind on any host. `ref` (branch, tag or
|
|
45
|
+
commit) is optional and carried in the `?ref=` query; without one the
|
|
46
|
+
service's default branch is read.
|
|
47
|
+
|
|
48
|
+
Auth: a bearer token given as the user slot of the URI,
|
|
49
|
+
`repo://TOKEN@host/...`, or as its password slot,
|
|
50
|
+
`repo://x-access-token:TOKEN@host/...` (the `oauth2:TOKEN` of GitLab
|
|
51
|
+
too); the `backend` keyword wins if both are given. Because the user slot
|
|
52
|
+
can be the secret, `str()`, `repr()`, `as_posix()` and `as_uri(sanitize=True)`,
|
|
53
|
+
and so every message of an exception, drop the WHOLE userinfo for these
|
|
54
|
+
schemes; `as_uri()` still round-trips it. The tree is read-only."""
|
|
55
|
+
|
|
56
|
+
__SCHEMES = ("repo",)
|
|
57
|
+
__slots__ = ()
|
|
58
|
+
_DELEGATED = ("_scandir", "_listdir", "_open")
|
|
59
|
+
|
|
60
|
+
#: The kind this class pins, or None to detect it from the host.
|
|
61
|
+
_KIND: "type[HostedRepoPath] | None" = None
|
|
62
|
+
|
|
63
|
+
if _ty.TYPE_CHECKING:
|
|
64
|
+
backend: RepoBackend
|
|
65
|
+
|
|
66
|
+
def __init__(self, *args, **kwargs):
|
|
67
|
+
super().__init__(*args, **kwargs)
|
|
68
|
+
# Settles the parts, so a host that no kind is detected for is refused
|
|
69
|
+
# when the path is built and not at its first request.
|
|
70
|
+
self.source
|
|
71
|
+
|
|
72
|
+
def _init(self, source: Source, path, query, fragment, /, **kwargs):
|
|
73
|
+
self._kind_of(source, path, query, fragment)
|
|
74
|
+
super()._init(source, path, query, fragment, **kwargs)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def _kind_of(
|
|
78
|
+
cls, source: Source, path: str = "", query="", fragment=""
|
|
79
|
+
) -> "type[HostedRepoPath]":
|
|
80
|
+
"""The kind of path that serves a URI at `source`: the one this class
|
|
81
|
+
pins, else the one its host is detected for."""
|
|
82
|
+
if cls._KIND is not None:
|
|
83
|
+
return cls._KIND
|
|
84
|
+
host = str(source.host or "").lower()
|
|
85
|
+
try:
|
|
86
|
+
kind = RepoPath._kind_for((), None, host or None)
|
|
87
|
+
except (TypeError, ValueError):
|
|
88
|
+
kind = None
|
|
89
|
+
if kind is None or not issubclass(kind, HostedRepoPath):
|
|
90
|
+
# Redacted like the paths: the userinfo can be a bare token.
|
|
91
|
+
shown = cls._format_parsed_parts(
|
|
92
|
+
source, path, query, fragment, sanitize=True
|
|
93
|
+
)
|
|
94
|
+
raise URISyntaxError(
|
|
95
|
+
f"repo: can only auto-detect github.com and gitlab.com; "
|
|
96
|
+
f"use github: or gitlab: for {shown!r}"
|
|
97
|
+
)
|
|
98
|
+
return kind
|
|
99
|
+
|
|
100
|
+
def as_posix(self) -> str:
|
|
101
|
+
# `user@host:path` would show the token when it is the user slot.
|
|
102
|
+
host = self.source.host
|
|
103
|
+
return f"{host}:{self.path}" if host else self.path
|
|
104
|
+
|
|
105
|
+
def _initbackend(self):
|
|
106
|
+
user, password = self.source.parsed_userinfo()
|
|
107
|
+
return RepoBackend(token=password or user or None)
|
|
108
|
+
|
|
109
|
+
def is_local(self) -> bool:
|
|
110
|
+
"""Whether the tree is on this machine: never. The host of the URI is
|
|
111
|
+
a hosting service and is not resolved."""
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def kind(self) -> str:
|
|
116
|
+
"""The name of the kind of repository this path reads (`"github"`,
|
|
117
|
+
`"gitlab"`)."""
|
|
118
|
+
return self._kind_of(self.source).TYPE
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def instance(self) -> str:
|
|
122
|
+
"""The host (and port) of the service, in the case a URL spells it."""
|
|
123
|
+
text = self._instance()
|
|
124
|
+
return text or self._kind_of(self.source).DEFAULT_INSTANCE
|
|
125
|
+
|
|
126
|
+
def _instance(self) -> "str | None":
|
|
127
|
+
"""`host[:port]` of the URI with an IPv6 literal bracketed, or None
|
|
128
|
+
when the URI names no host (the kind's default instance then)."""
|
|
129
|
+
source = self.source
|
|
130
|
+
if not source.host:
|
|
131
|
+
return None
|
|
132
|
+
text = compose_host(fold_host(source.host))
|
|
133
|
+
return f"{text}:{source.port}" if source.port else text
|
|
134
|
+
|
|
135
|
+
def _split(self) -> "tuple[str, str, list[str]]":
|
|
136
|
+
"""`(owner, repo, names)` of the path of this URI, the kind's way:
|
|
137
|
+
`("", "", [])` and the like when it has fewer parts than a
|
|
138
|
+
repository."""
|
|
139
|
+
parts = [part for part in self.segments if part]
|
|
140
|
+
split = self._kind_of(self.source)._from_uri_segments(parts)
|
|
141
|
+
if split is not None:
|
|
142
|
+
return split
|
|
143
|
+
return (parts[0] if parts else ""), "", []
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def repo_owner(self) -> str:
|
|
147
|
+
"""The owner of the repository: the first segment (the group path of a
|
|
148
|
+
GitLab project in a subgroup, ended by `/-/`)."""
|
|
149
|
+
return self._split()[0]
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def repo(self) -> str:
|
|
153
|
+
"""The name of the repository."""
|
|
154
|
+
return self._split()[1]
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def repo_path(self) -> str:
|
|
158
|
+
"""The path inside the repository, with no leading or trailing `/`."""
|
|
159
|
+
return "/".join(self._split()[2])
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def ref(self) -> "str | None":
|
|
163
|
+
"""The branch, tag or commit of the `?ref=` query, or None."""
|
|
164
|
+
return self.query.to_dict(single=True).get("ref") or None
|
|
165
|
+
|
|
166
|
+
def _make_core(self) -> HostedRepoPath:
|
|
167
|
+
kind = self._kind_of(self.source)
|
|
168
|
+
owner, repo, names = self._split()
|
|
169
|
+
if not (owner and repo):
|
|
170
|
+
raise FileNotFoundError(
|
|
171
|
+
_errno.ENOENT,
|
|
172
|
+
f"a {self.source.scheme}: path needs an owner and a repository",
|
|
173
|
+
str(self),
|
|
174
|
+
)
|
|
175
|
+
return kind(
|
|
176
|
+
*names,
|
|
177
|
+
owner=owner,
|
|
178
|
+
repo=repo,
|
|
179
|
+
ref=self.ref,
|
|
180
|
+
instance=self._instance(),
|
|
181
|
+
backend=self.backend,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@_contextlib.contextmanager
|
|
185
|
+
def _delegated(self, *extra: "tuple[str, str]"):
|
|
186
|
+
# The core path names itself by its own scheme (`github:` for a path
|
|
187
|
+
# of `repo:`), and in the middle of a message as well as alone: the
|
|
188
|
+
# name of this path replaces it wherever it is.
|
|
189
|
+
try:
|
|
190
|
+
with super()._delegated(*extra):
|
|
191
|
+
yield
|
|
192
|
+
except OSError as error:
|
|
193
|
+
core_path = self._core
|
|
194
|
+
if core_path is not None and str(core_path) != str(self):
|
|
195
|
+
core, mine = str(core_path), str(self)
|
|
196
|
+
error.args = tuple(
|
|
197
|
+
arg.replace(core, mine) if isinstance(arg, str) else arg
|
|
198
|
+
for arg in error.args
|
|
199
|
+
)
|
|
200
|
+
# `str(OSError)` reads these, not `args`.
|
|
201
|
+
for attribute in ("strerror", "filename", "filename2"):
|
|
202
|
+
value = getattr(error, attribute)
|
|
203
|
+
if isinstance(value, str) and core in value:
|
|
204
|
+
setattr(error, attribute, value.replace(core, mine))
|
|
205
|
+
raise
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class GitHubURIPath(RepoURIPath):
|
|
209
|
+
"""`github:` scheme (`github://[TOKEN@]host/owner/repo/path?ref=REF`):
|
|
210
|
+
read-only access to a GitHub repository tree via the REST contents API,
|
|
211
|
+
done by a `GitHubPath`. `host` defaults to `github.com` (API at
|
|
212
|
+
`api.github.com`); any other host is GitHub Enterprise (API at
|
|
213
|
+
`https://{host}/api/v3`). The kind is fixed whatever the host. See
|
|
214
|
+
`RepoURIPath` for the token and the ref."""
|
|
215
|
+
|
|
216
|
+
__SCHEMES = ("github",)
|
|
217
|
+
__slots__ = ()
|
|
218
|
+
_KIND = GitHubPath
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class GitLabURIPath(RepoURIPath):
|
|
222
|
+
"""`gitlab:` scheme (`gitlab://[TOKEN@]host[:port]/owner/repo/path?ref=REF`):
|
|
223
|
+
read-only access to a GitLab project's repository tree via the REST API
|
|
224
|
+
v4, done by a `GitLabPath`. A project in a subgroup is addressed with
|
|
225
|
+
GitLab's own `/-/` separator, as in its web URLs:
|
|
226
|
+
`gitlab://host/group/subgroup/project/-/path/in/repo`; without the
|
|
227
|
+
separator the first two segments are owner and repository. `host` defaults
|
|
228
|
+
to `gitlab.com`; a self-hosted instance is just a different host (and
|
|
229
|
+
port), always at `https://{host}/api/v4`. See `RepoURIPath` for the token
|
|
230
|
+
and the ref."""
|
|
231
|
+
|
|
232
|
+
__SCHEMES = ("gitlab",)
|
|
233
|
+
__slots__ = ()
|
|
234
|
+
_KIND = GitLabPath
|
|
235
|
+
|
|
236
|
+
def _separator_index(self) -> "int | None":
|
|
237
|
+
# GitLab's `/-/` ends the project path; it needs at least
|
|
238
|
+
# `owner/repo` in front of it.
|
|
239
|
+
try:
|
|
240
|
+
return self.segments.index("-", 3)
|
|
241
|
+
except ValueError:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
def _make_child_relpath(self, name: str, **kwargs):
|
|
245
|
+
parent = self
|
|
246
|
+
if name == "-" and self._separator_index() is None and len(self.segments) > 2:
|
|
247
|
+
# A directory literally named "-" would read as the separator:
|
|
248
|
+
# spell the parent with `/-/` so the child stays that directory.
|
|
249
|
+
segments = self.segments[:3] + ("-",) + self.segments[3:]
|
|
250
|
+
parent = self._from_parsed_parts(
|
|
251
|
+
self.source, "/".join(segments), self.query, self.fragment
|
|
252
|
+
)
|
|
253
|
+
return super(GitLabURIPath, parent)._make_child_relpath(name, **kwargs)
|