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/_gitlab.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno as _errno
|
|
4
|
+
import itertools as _itertools
|
|
5
|
+
import urllib.parse as _urlparse
|
|
6
|
+
|
|
7
|
+
from pathapi import FileStat
|
|
8
|
+
from pathapi import providers as _providers
|
|
9
|
+
from pathapi.http import drop_body, raise_for_status, server_size
|
|
10
|
+
|
|
11
|
+
from ._backend import translate_repo_errors
|
|
12
|
+
from ._hosted import HostedRepoPath
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GitLabPath(HostedRepoPath):
|
|
16
|
+
"""A path in a GitLab project's repository, read-only, through the REST
|
|
17
|
+
API v4 (the project is the URL-encoded `owner/repo`).
|
|
18
|
+
|
|
19
|
+
`GitLabPath("src/mod.py", owner="acme", repo="widgets", ref="v1.2")`; see
|
|
20
|
+
`HostedRepoPath` for the keywords. A project in a subgroup has the group
|
|
21
|
+
path as its owner: `owner="acme/team", repo="widgets"`. The default
|
|
22
|
+
instance is `gitlab.com`; a self-hosted one is just another instance (and
|
|
23
|
+
port), always at `https://{instance}/api/v4`. The tree-listing endpoint
|
|
24
|
+
doesn't carry file size, so `_scandir()` only pre-seeds a stat hint for
|
|
25
|
+
`tree` (directory) entries (`size=0` is truthful for a directory, not a
|
|
26
|
+
placeholder) -- `blob` (file) entries get a real `stat()` lazily instead of
|
|
27
|
+
guessing a size that could poison a caller trusting the hint. No mtime.
|
|
28
|
+
Needs no third-party library."""
|
|
29
|
+
|
|
30
|
+
TYPE = "gitlab"
|
|
31
|
+
INSTANCES = ("gitlab.com", "www.gitlab.com")
|
|
32
|
+
DEFAULT_INSTANCE = "gitlab.com"
|
|
33
|
+
OWNER_NESTS = True
|
|
34
|
+
__slots__ = ()
|
|
35
|
+
|
|
36
|
+
def _default_api_base(self) -> str:
|
|
37
|
+
return f"https://{self._api_authority()}/api/v4"
|
|
38
|
+
|
|
39
|
+
def _uri_segments(self, names):
|
|
40
|
+
# GitLab's own `/-/` ends the project path in its web URLs. It is
|
|
41
|
+
# needed when the project is in a group, and when a name is `-`, which
|
|
42
|
+
# would otherwise read as the separator.
|
|
43
|
+
owner = self.repo_owner.split("/")
|
|
44
|
+
if len(owner) > 1 or "-" in names:
|
|
45
|
+
return [*owner, self.repo, "-", *names]
|
|
46
|
+
return [*owner, self.repo, *names]
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _from_uri_segments(cls, parts):
|
|
50
|
+
# The first `-` after an owner and a repository ends the project path;
|
|
51
|
+
# without one the first two segments are the owner and the repository.
|
|
52
|
+
try:
|
|
53
|
+
index = list(parts).index("-", 2)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return super()._from_uri_segments(parts)
|
|
56
|
+
return "/".join(parts[: index - 1]), parts[index - 1], list(parts[index + 1 :])
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def _project_id(self) -> str:
|
|
60
|
+
return _urlparse.quote(f"{self.repo_owner}/{self.repo}", safe="")
|
|
61
|
+
|
|
62
|
+
def _params(self, **extra) -> dict:
|
|
63
|
+
ref = self.ref
|
|
64
|
+
if ref:
|
|
65
|
+
extra["ref"] = ref
|
|
66
|
+
return extra
|
|
67
|
+
|
|
68
|
+
def _file_url(self, path: str, suffix: str = "") -> str:
|
|
69
|
+
encoded = self._api_quote(path)
|
|
70
|
+
return f"{self._api_base}/projects/{self._project_id}/repository/files/{encoded}{suffix}"
|
|
71
|
+
|
|
72
|
+
def _tree_url(self) -> str:
|
|
73
|
+
return f"{self._api_base}/projects/{self._project_id}/repository/tree"
|
|
74
|
+
|
|
75
|
+
def _request(self, method, url, **kwargs):
|
|
76
|
+
"""A streamed request that succeeded: the caller decodes or reads the
|
|
77
|
+
body, which closes the response."""
|
|
78
|
+
with translate_repo_errors(self):
|
|
79
|
+
resp = self._backend.request(method, url, stream=True, **kwargs)
|
|
80
|
+
try:
|
|
81
|
+
raise_for_status(resp)
|
|
82
|
+
except BaseException:
|
|
83
|
+
drop_body(resp)
|
|
84
|
+
raise
|
|
85
|
+
return resp
|
|
86
|
+
|
|
87
|
+
def _resolved_ref(self) -> str:
|
|
88
|
+
# Unlike the tree endpoint (ref optional), GitLab's repository/files endpoints
|
|
89
|
+
# (metadata AND raw) answer 400 "ref is missing" without a `ref`: resolve and
|
|
90
|
+
# cache the project's default branch once per backend.
|
|
91
|
+
ref = self.ref
|
|
92
|
+
if ref:
|
|
93
|
+
return ref
|
|
94
|
+
# A custom `BaseRepoBackend` may have no cache: no memoization then.
|
|
95
|
+
cache = self._backend_cache()
|
|
96
|
+
key = ("gitlab_default_branch", self._api_base, self._project_id)
|
|
97
|
+
if cache is not None and key in cache:
|
|
98
|
+
return cache[key]
|
|
99
|
+
resp = self._request("GET", f"{self._api_base}/projects/{self._project_id}")
|
|
100
|
+
branch = self._decode(resp, dict).get("default_branch")
|
|
101
|
+
if not (isinstance(branch, str) and branch):
|
|
102
|
+
raise self._bad_reply("a project without a default_branch")
|
|
103
|
+
if cache is not None:
|
|
104
|
+
cache[key] = branch
|
|
105
|
+
return branch
|
|
106
|
+
|
|
107
|
+
def _file_size(self, path: str) -> "int | None":
|
|
108
|
+
"""The size of the file at `path`, or `None` when there is no such
|
|
109
|
+
file. `HEAD` on the files endpoint answers with the size in
|
|
110
|
+
`X-Gitlab-Size` and no body; a reply without it (or a server that
|
|
111
|
+
refuses `HEAD`) is followed by a `GET` of the file's metadata."""
|
|
112
|
+
url = self._file_url(path)
|
|
113
|
+
params = {"ref": self._resolved_ref()}
|
|
114
|
+
try:
|
|
115
|
+
with translate_repo_errors(self):
|
|
116
|
+
resp = self._backend.request("HEAD", url, params=params)
|
|
117
|
+
if resp.status not in (405, 501):
|
|
118
|
+
raise_for_status(resp)
|
|
119
|
+
size = server_size(resp.headers.get("X-Gitlab-Size"))
|
|
120
|
+
if size is not None and resp.status < 300:
|
|
121
|
+
return size
|
|
122
|
+
meta = self._decode(self._request("GET", url, params=params), dict)
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
return None
|
|
125
|
+
if "size" not in meta:
|
|
126
|
+
raise self._bad_reply("file metadata without a size")
|
|
127
|
+
return self._size_of(meta)
|
|
128
|
+
|
|
129
|
+
def _tree_entries(self, path: str):
|
|
130
|
+
# The tree endpoint is paginated (at most 100 per page): follow
|
|
131
|
+
# `X-Next-Page` until the last page.
|
|
132
|
+
page = 1
|
|
133
|
+
while True:
|
|
134
|
+
resp = self._request(
|
|
135
|
+
"GET",
|
|
136
|
+
self._tree_url(),
|
|
137
|
+
params=self._params(path=path, per_page=100, page=page),
|
|
138
|
+
)
|
|
139
|
+
for item in self._decode(resp, list):
|
|
140
|
+
if not (
|
|
141
|
+
isinstance(item, dict)
|
|
142
|
+
and isinstance(item.get("name"), str)
|
|
143
|
+
and isinstance(item.get("type"), str)
|
|
144
|
+
):
|
|
145
|
+
raise self._bad_reply("a tree entry without a name and a type")
|
|
146
|
+
yield item
|
|
147
|
+
next_page = resp.headers.get("X-Next-Page", "")
|
|
148
|
+
if not next_page.isdigit() or int(next_page) <= page:
|
|
149
|
+
return
|
|
150
|
+
page = int(next_page)
|
|
151
|
+
|
|
152
|
+
def stat(self, *, follow_symlinks=True):
|
|
153
|
+
hint = self._pop_stat_hint()
|
|
154
|
+
if hint is not None:
|
|
155
|
+
return hint
|
|
156
|
+
path = self.repo_path
|
|
157
|
+
if not path:
|
|
158
|
+
# Ask the server: a missing project, owner-only URI or unknown
|
|
159
|
+
# ref must not read as an existing directory.
|
|
160
|
+
self._decode(
|
|
161
|
+
self._request("GET", self._tree_url(), params=self._params(per_page=1)),
|
|
162
|
+
list,
|
|
163
|
+
)
|
|
164
|
+
return FileStat(is_dir=True)
|
|
165
|
+
size = self._file_size(path)
|
|
166
|
+
if size is not None:
|
|
167
|
+
return FileStat(st_size=size, is_dir=False)
|
|
168
|
+
# Not a file at this exact path. Git has no empty directories, so a
|
|
169
|
+
# path whose tree listing has any entry is a directory; an empty
|
|
170
|
+
# listing (or a 404) means it does not exist.
|
|
171
|
+
try:
|
|
172
|
+
resp = self._request(
|
|
173
|
+
"GET", self._tree_url(), params=self._params(path=path, per_page=1)
|
|
174
|
+
)
|
|
175
|
+
except FileNotFoundError:
|
|
176
|
+
raise FileNotFoundError(_errno.ENOENT, "No such file", str(self)) from None
|
|
177
|
+
if self._decode(resp, list):
|
|
178
|
+
return FileStat(is_dir=True)
|
|
179
|
+
raise FileNotFoundError(_errno.ENOENT, "No such file", str(self))
|
|
180
|
+
|
|
181
|
+
def _scandir(self):
|
|
182
|
+
entries = self._tree_entries(self.repo_path)
|
|
183
|
+
try:
|
|
184
|
+
first = next(entries, None)
|
|
185
|
+
except FileNotFoundError:
|
|
186
|
+
# The tree endpoint 404s for a blob path as for a missing one.
|
|
187
|
+
if self.repo_path and self._file_size(self.repo_path) is not None:
|
|
188
|
+
raise NotADirectoryError(
|
|
189
|
+
_errno.ENOTDIR, "Not a directory", str(self)
|
|
190
|
+
) from None
|
|
191
|
+
raise
|
|
192
|
+
if first is None:
|
|
193
|
+
# Git has no empty directories, so an empty listing of a
|
|
194
|
+
# non-root path is a file or a missing path (a server may answer
|
|
195
|
+
# either with an empty 200): pathlib's iterdir() raises for both.
|
|
196
|
+
if self.repo_path:
|
|
197
|
+
self._hint = None
|
|
198
|
+
if not self.stat().is_dir():
|
|
199
|
+
raise NotADirectoryError(
|
|
200
|
+
_errno.ENOTDIR, "Not a directory", str(self)
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
for entry in _itertools.chain((first,), entries):
|
|
204
|
+
# The API's names are not trusted to be one path component.
|
|
205
|
+
if not _providers.is_safe_child_name(entry["name"]):
|
|
206
|
+
continue
|
|
207
|
+
# The tree listing carries no size: a blob is known to be a file
|
|
208
|
+
# (and a tree a directory), which is all `walk()` and `glob()` ask.
|
|
209
|
+
# Any other type (a submodule) is left to a `stat()`.
|
|
210
|
+
if entry["type"] == "tree":
|
|
211
|
+
hint = FileStat(is_dir=True)
|
|
212
|
+
elif entry["type"] == "blob":
|
|
213
|
+
hint = FileStat(is_dir=False, size_known=False)
|
|
214
|
+
else:
|
|
215
|
+
hint = None
|
|
216
|
+
yield entry["name"], hint
|
|
217
|
+
|
|
218
|
+
def _open(self, mode="r", buffering=-1):
|
|
219
|
+
self._check_read_mode(mode)
|
|
220
|
+
path = self.repo_path
|
|
221
|
+
if not path:
|
|
222
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
223
|
+
try:
|
|
224
|
+
resp = self._request(
|
|
225
|
+
"GET",
|
|
226
|
+
self._file_url(path, "/raw"),
|
|
227
|
+
params={"ref": self._resolved_ref()},
|
|
228
|
+
)
|
|
229
|
+
except FileNotFoundError:
|
|
230
|
+
# The files endpoint 404s for a tree path as for a missing one.
|
|
231
|
+
try:
|
|
232
|
+
is_dir = self.stat().is_dir()
|
|
233
|
+
except FileNotFoundError:
|
|
234
|
+
is_dir = False
|
|
235
|
+
if is_dir:
|
|
236
|
+
raise IsADirectoryError(
|
|
237
|
+
_errno.EISDIR, "Is a directory", str(self)
|
|
238
|
+
) from None
|
|
239
|
+
raise
|
|
240
|
+
try:
|
|
241
|
+
return self._reader(resp.read())
|
|
242
|
+
finally:
|
|
243
|
+
resp.close()
|
repopath/_hosted.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""`HostedRepoPath`, what `GitHubPath` and `GitLabPath` share: a repository
|
|
2
|
+
named by an owner and a name on an instance, at a ref, read through the REST
|
|
3
|
+
API of the service."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import errno as _errno
|
|
8
|
+
import typing as _ty
|
|
9
|
+
import urllib.parse as _urlparse
|
|
10
|
+
|
|
11
|
+
from pathapi import providers as _providers
|
|
12
|
+
from pathapi.http import json_body, peer_text
|
|
13
|
+
|
|
14
|
+
from ._backend import BaseRepoBackend, RepoBackend
|
|
15
|
+
from ._kinds import RepoPath
|
|
16
|
+
from .exceptions import BadReplyError
|
|
17
|
+
|
|
18
|
+
if _ty.TYPE_CHECKING:
|
|
19
|
+
from pathapi import FileStat
|
|
20
|
+
|
|
21
|
+
#: What a path segment keeps unescaped when the path is spelled as a URI.
|
|
22
|
+
_SAFE_SEGMENT = "!$&'()*+,;=:@"
|
|
23
|
+
|
|
24
|
+
_FORBIDDEN_IN_INSTANCE = set("/\\?#@ \t\r\n") | {chr(i) for i in range(32)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def checked_instance(text: object) -> str:
|
|
28
|
+
"""`text` if it is a host name, an IP address (an IPv6 one in brackets) or
|
|
29
|
+
either followed by `:port`; else `TypeError` for a non-string and
|
|
30
|
+
`ValueError`. Nothing in it can reach another part of a URL."""
|
|
31
|
+
if not isinstance(text, str):
|
|
32
|
+
raise TypeError(f"instance= must be a str, not {type(text).__name__!r}")
|
|
33
|
+
if not text or _FORBIDDEN_IN_INSTANCE & set(text):
|
|
34
|
+
raise ValueError(f"instance= must be a host and an optional port, not {text!r}")
|
|
35
|
+
try:
|
|
36
|
+
parts = _urlparse.urlsplit("//" + text)
|
|
37
|
+
host, _port = parts.hostname, parts.port
|
|
38
|
+
except ValueError:
|
|
39
|
+
host = None
|
|
40
|
+
if not host:
|
|
41
|
+
raise ValueError(f"instance= must be a host and an optional port, not {text!r}")
|
|
42
|
+
return text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _checked_name(path, what: str, value: object, inherited, nested: bool) -> str:
|
|
46
|
+
if value is None:
|
|
47
|
+
if inherited is None:
|
|
48
|
+
raise TypeError(
|
|
49
|
+
f"{type(path).__name__} needs {what}= (or a segment that is a "
|
|
50
|
+
f"{type(path).__name__})"
|
|
51
|
+
)
|
|
52
|
+
return inherited
|
|
53
|
+
if not isinstance(value, str):
|
|
54
|
+
raise TypeError(f"{what}= must be a str, not {type(value).__name__!r}")
|
|
55
|
+
parts = value.split("/") if nested else [value]
|
|
56
|
+
if any(part in ("", ".", "..") for part in parts) or (not nested and "/" in value):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"{what}= must be a name"
|
|
59
|
+
+ (" or a '/' separated group path" if nested else " with no '/'")
|
|
60
|
+
+ f", with no empty or dot segment, not {value!r}"
|
|
61
|
+
)
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HostedRepoPath(RepoPath):
|
|
66
|
+
"""A repository on a hosting service that has an owner (a user, an
|
|
67
|
+
organization or, on GitLab, a group path) and a name, at a ref.
|
|
68
|
+
|
|
69
|
+
`Kind(*segments, owner=, repo=, ref=None, instance=None, token=None,
|
|
70
|
+
backend=None)`: the segments are the path inside the repository.
|
|
71
|
+
`instance` is the host of the service, with a port if it is not the
|
|
72
|
+
default one (`github.com`, `ghe.example:8443`, `[fd00::5]`); a service
|
|
73
|
+
other than the public one is GitHub Enterprise or a self-hosted GitLab,
|
|
74
|
+
and its API root is derived from the instance. `ref` is a branch, a tag
|
|
75
|
+
or a commit; without one the service's default branch is read.
|
|
76
|
+
`token` builds the path's own `RepoBackend` that sends it as a bearer
|
|
77
|
+
token; `backend=` shares one backend between paths built separately, or
|
|
78
|
+
plugs in a `BaseRepoBackend` of your own, and giving it with `token=` is a
|
|
79
|
+
`TypeError`. A path joined from another keeps all of these, and a keyword
|
|
80
|
+
moves it (`ref=""` back to the default branch). Without a backend or a
|
|
81
|
+
token, the path builds an anonymous `RepoBackend`.
|
|
82
|
+
|
|
83
|
+
The tree is read-only: `open()` in a writing mode is `NotImplementedError`.
|
|
84
|
+
A token is shown by no `str()`, `repr()` or error, and is not pickled."""
|
|
85
|
+
|
|
86
|
+
__slots__ = ()
|
|
87
|
+
|
|
88
|
+
#: The instance a path is on when none is given.
|
|
89
|
+
DEFAULT_INSTANCE: str
|
|
90
|
+
#: Whether the owner may be a `/` separated group path.
|
|
91
|
+
OWNER_NESTS = False
|
|
92
|
+
|
|
93
|
+
if _ty.TYPE_CHECKING:
|
|
94
|
+
_backend: BaseRepoBackend
|
|
95
|
+
_place: "tuple[str, str, str, str | None]"
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
*segments: str | RepoPath,
|
|
100
|
+
owner: str | None = None,
|
|
101
|
+
repo: str | None = None,
|
|
102
|
+
ref: str | None = None,
|
|
103
|
+
instance: str | None = None,
|
|
104
|
+
token: str | None = None,
|
|
105
|
+
backend: BaseRepoBackend | None = None,
|
|
106
|
+
):
|
|
107
|
+
text, donor = self._join(segments)
|
|
108
|
+
place = self._place_of(owner, repo, ref, instance, donor)
|
|
109
|
+
if backend is not None and token is not None:
|
|
110
|
+
raise TypeError("pass either backend= or token=, not both")
|
|
111
|
+
if backend is None:
|
|
112
|
+
if token is not None:
|
|
113
|
+
backend = RepoBackend(token=token)
|
|
114
|
+
elif donor is not None:
|
|
115
|
+
backend = donor._backend
|
|
116
|
+
else:
|
|
117
|
+
backend = RepoBackend()
|
|
118
|
+
elif not callable(getattr(backend, "request", None)):
|
|
119
|
+
raise TypeError("a backend is an object with a request() method")
|
|
120
|
+
self._initialize(text, place, backend)
|
|
121
|
+
|
|
122
|
+
def _place_of(self, owner, repo, ref, instance, donor) -> tuple:
|
|
123
|
+
"""`(instance, owner, repo, ref)` from the keywords, else from the
|
|
124
|
+
path this one is joined from, else the defaults."""
|
|
125
|
+
inherited = donor._place if donor is not None else (None, None, None, None)
|
|
126
|
+
if instance is None:
|
|
127
|
+
instance = inherited[0] or self.DEFAULT_INSTANCE
|
|
128
|
+
else:
|
|
129
|
+
checked_instance(instance)
|
|
130
|
+
owner = _checked_name(self, "owner", owner, inherited[1], self.OWNER_NESTS)
|
|
131
|
+
repo = _checked_name(self, "repo", repo, inherited[2], False)
|
|
132
|
+
if ref is None:
|
|
133
|
+
ref = inherited[3]
|
|
134
|
+
elif isinstance(ref, str):
|
|
135
|
+
ref = ref or None
|
|
136
|
+
else:
|
|
137
|
+
raise TypeError(f"ref= must be a str, not {type(ref).__name__!r}")
|
|
138
|
+
return instance, owner, repo, ref
|
|
139
|
+
|
|
140
|
+
def _keywords(self) -> dict:
|
|
141
|
+
instance, owner, repo, ref = self._place
|
|
142
|
+
return {"owner": owner, "repo": repo, "ref": ref, "instance": instance}
|
|
143
|
+
|
|
144
|
+
def _place_key(self) -> tuple:
|
|
145
|
+
# A host name is case-insensitive (RFC 3986 3.2.2); the other parts are not.
|
|
146
|
+
instance, owner, repo, ref = self._place
|
|
147
|
+
return instance.lower(), owner, repo, ref or ""
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def instance(self) -> str:
|
|
151
|
+
"""The host (and port) of the service, as given."""
|
|
152
|
+
return self._place[0]
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def repo_owner(self) -> str:
|
|
156
|
+
"""The user, organization or group path that owns the repository."""
|
|
157
|
+
return self._place[1]
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def repo(self) -> str:
|
|
161
|
+
"""The name of the repository."""
|
|
162
|
+
return self._place[2]
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def ref(self) -> "str | None":
|
|
166
|
+
"""The branch, tag or commit this path reads, or None for the default
|
|
167
|
+
branch of the repository."""
|
|
168
|
+
return self._place[3]
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def repo_path(self) -> str:
|
|
172
|
+
"""The path inside the repository, with no leading or trailing `/`."""
|
|
173
|
+
return "/".join(name for name in self._segments if name)
|
|
174
|
+
|
|
175
|
+
def __repr__(self) -> str:
|
|
176
|
+
shown = [
|
|
177
|
+
repr(self.as_posix()),
|
|
178
|
+
f"owner={self.repo_owner!r}",
|
|
179
|
+
f"repo={self.repo!r}",
|
|
180
|
+
]
|
|
181
|
+
if self.ref:
|
|
182
|
+
shown.append(f"ref={self.ref!r}")
|
|
183
|
+
if self.instance.lower() != self.DEFAULT_INSTANCE:
|
|
184
|
+
shown.append(f"instance={self.instance!r}")
|
|
185
|
+
backend = self._backend
|
|
186
|
+
if not (isinstance(backend, RepoBackend) and backend.api_base is None):
|
|
187
|
+
# A backend shows its API root and never a credential.
|
|
188
|
+
shown.append(f"backend={backend!r}")
|
|
189
|
+
return f"{type(self).__name__}({', '.join(shown)})"
|
|
190
|
+
|
|
191
|
+
def __str__(self) -> str:
|
|
192
|
+
return self.as_uri()
|
|
193
|
+
|
|
194
|
+
def _uri_segments(self, names: "_ty.Sequence[str]") -> "list[str]":
|
|
195
|
+
"""The path segments of `as_uri()`: the owner, the repository and the
|
|
196
|
+
names, with whatever separator the service's URLs use."""
|
|
197
|
+
return [self.repo_owner, self.repo, *names]
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def _from_uri_segments(
|
|
201
|
+
cls, parts: "_ty.Sequence[str]"
|
|
202
|
+
) -> "tuple[str, str, list[str]] | None":
|
|
203
|
+
"""`(owner, repo, names)` from the non-empty segments of the path of a
|
|
204
|
+
URI of this kind, or None when they do not hold an owner and a
|
|
205
|
+
repository. The inverse of `_uri_segments()`."""
|
|
206
|
+
if len(parts) < 2:
|
|
207
|
+
return None
|
|
208
|
+
return parts[0], parts[1], list(parts[2:])
|
|
209
|
+
|
|
210
|
+
def as_uri(self) -> str:
|
|
211
|
+
"""The `<kind>://instance/owner/repo/path` URI of this path, with
|
|
212
|
+
`?ref=` when it has a ref. It names no token."""
|
|
213
|
+
names = [name for name in self._segments if name]
|
|
214
|
+
path = "/".join(
|
|
215
|
+
_urlparse.quote(segment, safe=_SAFE_SEGMENT, errors="surrogateescape")
|
|
216
|
+
for segment in self._uri_segments(names)
|
|
217
|
+
)
|
|
218
|
+
text = f"{self._family().TYPE}://{self.instance}/{path}"
|
|
219
|
+
if self.ref:
|
|
220
|
+
text += "?ref=" + _urlparse.quote(
|
|
221
|
+
self.ref, safe="", errors="surrogateescape"
|
|
222
|
+
)
|
|
223
|
+
return text
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def _api_base(self) -> str:
|
|
227
|
+
return getattr(self._backend, "api_base", None) or self._default_api_base()
|
|
228
|
+
|
|
229
|
+
@_providers.notimplemented
|
|
230
|
+
def _default_api_base(self) -> str:
|
|
231
|
+
"""The API root of this instance when the backend names none."""
|
|
232
|
+
|
|
233
|
+
def _api_authority(self) -> str:
|
|
234
|
+
"""`host[:port]` of the instance in the case a URL spells it."""
|
|
235
|
+
return self.instance.lower()
|
|
236
|
+
|
|
237
|
+
def _same_filesystem(self, other: "HostedRepoPath") -> bool:
|
|
238
|
+
# The tree is what the API root, the repository and the ref name; a
|
|
239
|
+
# token only decides what may be read of it.
|
|
240
|
+
return (self._api_base, *self._place[1:]) == (
|
|
241
|
+
other._api_base,
|
|
242
|
+
*other._place[1:],
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def _bad_reply(self, what: str) -> BadReplyError:
|
|
246
|
+
return BadReplyError(_errno.EIO, f"Unexpected API reply: {what}", str(self))
|
|
247
|
+
|
|
248
|
+
def _decode(self, resp, *expected: type):
|
|
249
|
+
"""The JSON body of `resp`, which must be one of `expected` (`dict`,
|
|
250
|
+
`list`): anything else -- a captive portal's page, a gateway's JSON,
|
|
251
|
+
`null`, a truncated body -- is `BadReplyError` naming this path, so
|
|
252
|
+
`exists()` answers `False` and `stat()` raises."""
|
|
253
|
+
try:
|
|
254
|
+
data = json_body(resp, self)
|
|
255
|
+
except ValueError:
|
|
256
|
+
data = None
|
|
257
|
+
if not isinstance(data, expected):
|
|
258
|
+
kinds = " or ".join({dict: "object", list: "array"}[k] for k in expected)
|
|
259
|
+
raise self._bad_reply(f"not a JSON {kinds}")
|
|
260
|
+
return data
|
|
261
|
+
|
|
262
|
+
def _size_of(self, entry: dict) -> int:
|
|
263
|
+
"""The `size` of an API object: a non-negative integer, `0` when it
|
|
264
|
+
has none."""
|
|
265
|
+
size = entry.get("size")
|
|
266
|
+
if size is None:
|
|
267
|
+
return 0
|
|
268
|
+
if isinstance(size, int) and not isinstance(size, bool) and size >= 0:
|
|
269
|
+
return size
|
|
270
|
+
raise self._bad_reply(f"size {peer_text(repr(size), 60)}")
|
|
271
|
+
|
|
272
|
+
def _api_quote(self, text: str, safe: str = "") -> str:
|
|
273
|
+
"""`text` as part of an API URL path: percent-encoded (`safe` stays
|
|
274
|
+
bare), and never a `.` or `..` segment, which the client would
|
|
275
|
+
resolve out of the repository's API root."""
|
|
276
|
+
if any(part in (".", "..") for part in text.split("/")):
|
|
277
|
+
raise ValueError(f"A dot segment cannot be part of an API URL: {self}")
|
|
278
|
+
return _urlparse.quote(text, safe=safe, errors="surrogateescape")
|
|
279
|
+
|
|
280
|
+
def _backend_cache(self) -> "dict | None":
|
|
281
|
+
"""The backend's memoization dict, or None for a custom
|
|
282
|
+
`BaseRepoBackend` that has none (memoization is then skipped)."""
|
|
283
|
+
cache = getattr(self._backend, "cache", None)
|
|
284
|
+
return cache if isinstance(cache, dict) else None
|