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/_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