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/_backend.py ADDED
@@ -0,0 +1,219 @@
1
+ """The backends of a repository path: the contract, and the one that sends the
2
+ requests through `pathapi.http`."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextlib as _contextlib
7
+ import errno as _errno
8
+ import ipaddress as _ipaddress
9
+ import urllib.parse as _urlparse
10
+ import warnings as _warnings
11
+
12
+ from pathapi import providers as _providers
13
+ from pathapi.http import (
14
+ DEFAULT_TIMEOUT,
15
+ HTTPClient,
16
+ HTTPResponse,
17
+ HTTPStatusError,
18
+ get_transport,
19
+ peer_text,
20
+ )
21
+
22
+ from ._version import __version__
23
+ from .exceptions import InsecureTransportWarning, RateLimitError
24
+
25
+ _UNSET = object()
26
+ """The `timeout` of a call that did not give one: the backend's own."""
27
+
28
+
29
+ def _is_loopback(host: "str | None") -> bool:
30
+ if not host:
31
+ return False
32
+ host = host.lower()
33
+ if host == "localhost" or host.endswith(".localhost"):
34
+ return True
35
+ try:
36
+ return _ipaddress.ip_address(host).is_loopback
37
+ except ValueError:
38
+ return False
39
+
40
+
41
+ def _warn_if_plaintext(url: str, authorized: bool) -> None:
42
+ if not url.lower().startswith("http://"):
43
+ return
44
+ if not authorized:
45
+ return
46
+ host = _urlparse.urlsplit(url).hostname
47
+ if _is_loopback(host):
48
+ return
49
+ _warnings.warn(
50
+ f"Sending an Authorization header over plain http to {host}; "
51
+ "use an https API root",
52
+ InsecureTransportWarning,
53
+ stacklevel=3,
54
+ )
55
+
56
+
57
+ def _rate_limit_error(response, path) -> "RateLimitError | None":
58
+ """The `RateLimitError` for a rate-limited reply, else None. Both hosts
59
+ signal a primary limit with 403 or 429 and a `(X-)RateLimit-Remaining: 0`
60
+ header, and a secondary limit with 403 or 429 and a `Retry-After` header;
61
+ a bare 429 is a limit too. Checked before the generic mapping, which would
62
+ read a secondary-limit 403 as PermissionError and a 429 as EIO."""
63
+ if response is None or response.status not in (403, 429):
64
+ return None
65
+ headers = response.headers
66
+ remaining = headers.get("X-RateLimit-Remaining", headers.get("RateLimit-Remaining"))
67
+ retry_after = headers.get("Retry-After")
68
+ if response.status == 403 and remaining != "0" and retry_after is None:
69
+ return None
70
+ if retry_after is not None:
71
+ when = f"retry after {peer_text(retry_after, 40)}s"
72
+ else:
73
+ reset = headers.get("X-RateLimit-Reset", headers.get("RateLimit-Reset", "?"))
74
+ when = f"resets at {peer_text(reset, 40)}"
75
+ return RateLimitError(
76
+ _errno.EAGAIN,
77
+ f"API rate limit exceeded (HTTP {response.status}) ({when})",
78
+ str(path),
79
+ )
80
+
81
+
82
+ @_contextlib.contextmanager
83
+ def translate_repo_errors(path):
84
+ """Maps what a request raised to what `pathlib` raises for `path`: a
85
+ rate-limited reply is `RateLimitError`, 404 `FileNotFoundError`, 401 and
86
+ 403 `PermissionError`, any other status `OSError(EIO)`; a timeout or a
87
+ refused connection keeps its type and names `path`."""
88
+ # `from None`: a chained cause is printed by every traceback. The status
89
+ # code or exception type stays in the message.
90
+ try:
91
+ yield
92
+ except HTTPStatusError as e:
93
+ response = e.response
94
+ status = response.status
95
+ reason = peer_text(response.reason or "")
96
+ limited = _rate_limit_error(response, path)
97
+ if limited is not None:
98
+ raise limited from None
99
+ if status == 404:
100
+ raise FileNotFoundError(_errno.ENOENT, "No such file", str(path)) from None
101
+ elif status in (401, 403):
102
+ raise PermissionError(
103
+ _errno.EACCES, "Permission denied", str(path)
104
+ ) from None
105
+ else:
106
+ message = f"HTTP Error {status} {reason}".rstrip()
107
+ raise OSError(_errno.EIO, message, str(path)) from None
108
+ except TimeoutError as e:
109
+ raise TimeoutError(f"Timeout for {path} ({type(e).__name__})") from None
110
+ except ConnectionError as e:
111
+ raise ConnectionError(
112
+ f"Connection error for {path} ({type(e).__name__})"
113
+ ) from None
114
+
115
+
116
+ class BaseRepoBackend(object):
117
+ """Protocol for issuing an authenticated HTTP request against the REST API
118
+ of a hosting service. Subclass this to plug in custom auth or session
119
+ handling; `RepoBackend` is the one that sends through `pathapi.http`.
120
+
121
+ A backend may carry an `api_base` (the API root, replacing the one derived
122
+ from the instance of a path) and a `cache` (a dict, to memoize the default
123
+ branch of a repository); a path uses either only when the backend has
124
+ one."""
125
+
126
+ # Weakly referenceable so a path can record which backends it derived.
127
+ __slots__ = ("__weakref__",)
128
+
129
+ @_providers.notimplemented
130
+ def request(self, method, url, headers=None, params=None, **kwargs):
131
+ """Send one request to the API and return its `HTTPResponse`."""
132
+
133
+
134
+ class RepoBackend(HTTPClient, BaseRepoBackend):
135
+ """The transport and the optional bearer `token` sent on every request.
136
+ Unauthenticated works fine for public repositories, subject to the host's
137
+ anonymous rate limit (60 requests an hour for github.com). A context
138
+ manager: leaving the block closes a transport the backend built.
139
+
140
+ `transport` is any object with a `send()` method as
141
+ `pathapi.http.HTTPTransport.send()`; without one the built-in
142
+ `HTTPTransport` is built on first use. `session=` (a `requests.Session`)
143
+ and `requests_args` (`verify=`, `proxies=`, ...) select the `requests`
144
+ transport of `pathapi-requests`; without it they raise `ImportError`.
145
+ `headers` go with every request, under the request's own. `api_base`, when
146
+ set, overrides the API root a path derives from its instance entirely
147
+ (e.g. `https://api.github.com` or `https://{host}/api/v3`) -- the seam a
148
+ test fake, or a reverse-proxied self-hosted setup, plugs into, since the
149
+ derived root is always `https`; a request that carries an `Authorization`
150
+ header to a plain `http://` URL on a host other than loopback warns
151
+ (`InsecureTransportWarning`). Every request gets
152
+ `timeout=DEFAULT_TIMEOUT` (`(10, 60)` seconds) unless `timeout` here or in
153
+ the call says otherwise; `None` waits without limit. A `GET` follows
154
+ redirects, dropping the token when the host changes."""
155
+
156
+ __slots__ = ("token", "api_base", "cache")
157
+
158
+ def __init__(
159
+ self,
160
+ token: str = None,
161
+ session=None,
162
+ api_base: str = None,
163
+ *,
164
+ transport=None,
165
+ headers=None,
166
+ timeout=DEFAULT_TIMEOUT,
167
+ **requests_args,
168
+ ):
169
+ if session is not None or requests_args:
170
+ if transport is not None:
171
+ raise TypeError("give a transport, or a session and its arguments")
172
+ transport = get_transport("requests")(session, **requests_args)
173
+ super().__init__(
174
+ transport,
175
+ headers=headers,
176
+ timeout=timeout,
177
+ user_agent=f"repopath/{__version__}",
178
+ )
179
+ self.token = token or None
180
+ self.api_base = api_base.rstrip("/") if api_base else None
181
+ # Memoization shared by every path that shares this backend (the
182
+ # resolved default branch of a repository, see `_hosted.py`).
183
+ self.cache = {}
184
+
185
+ def __repr__(self) -> str:
186
+ # The token and the headers are left out: they are credentials.
187
+ return f"{type(self).__name__}(api_base={self.api_base!r})"
188
+
189
+ def request(
190
+ self,
191
+ method,
192
+ url,
193
+ headers=None,
194
+ params=None,
195
+ *,
196
+ data=None,
197
+ timeout=_UNSET,
198
+ stream=False,
199
+ ) -> HTTPResponse:
200
+ sent = dict(headers or {})
201
+ authorized = bool(self.token) or any(
202
+ name.lower() == "authorization" for name in (*self.headers, *sent)
203
+ )
204
+ if params:
205
+ query = _urlparse.urlencode(
206
+ [(k, v) for k, v in dict(params).items() if v is not None], doseq=True
207
+ )
208
+ if query:
209
+ url = url + ("&" if "?" in url else "?") + query
210
+ _warn_if_plaintext(url, authorized)
211
+ return super().request(
212
+ method,
213
+ url,
214
+ data=data,
215
+ headers=sent,
216
+ stream=stream,
217
+ timeout=self.timeout if timeout is _UNSET else timeout,
218
+ auth=f"Bearer {self.token}" if self.token else None,
219
+ )
repopath/_github.py ADDED
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import base64 as _base64
4
+ import errno as _errno
5
+
6
+ from pathapi import FileStat
7
+ from pathapi import providers as _providers
8
+ from pathapi.http import drop_body, raise_for_status, server_size
9
+
10
+ from ._backend import translate_repo_errors
11
+ from ._hosted import HostedRepoPath
12
+
13
+
14
+ class GitHubPath(HostedRepoPath):
15
+ """A path in a GitHub repository, read-only, through the REST contents API
16
+ (`GET /repos/{owner}/{repo}/contents/{path}`).
17
+
18
+ `GitHubPath("src/mod.py", owner="acme", repo="widgets", ref="v1.2")`; see
19
+ `HostedRepoPath` for the keywords. The default instance is `github.com`
20
+ (API at `api.github.com`); any other is GitHub Enterprise (API at
21
+ `https://{instance}/api/v3`). File bodies are fetched with the `raw` media
22
+ type (skips base64 and its ~1MB inline-content cap) instead of the default
23
+ JSON+base64 envelope. A `symlink` or `submodule` tree entry is treated as a
24
+ plain file. No mtime (would need a separate commits-history call per
25
+ path). Needs no third-party library."""
26
+
27
+ TYPE = "github"
28
+ INSTANCES = ("github.com", "www.github.com")
29
+ DEFAULT_INSTANCE = "github.com"
30
+ __slots__ = ()
31
+
32
+ _RAW_ACCEPT = "application/vnd.github.raw+json"
33
+ # The contents API returns at most this many entries per directory, with
34
+ # no pagination and no truncation flag.
35
+ _CONTENTS_LIMIT = 1000
36
+
37
+ def _default_api_base(self) -> str:
38
+ if self.instance.lower() in ("github.com", "www.github.com"):
39
+ return "https://api.github.com"
40
+ return f"https://{self._api_authority()}/api/v3"
41
+
42
+ @property
43
+ def _repo_url(self) -> str:
44
+ owner, repo = self._api_quote(self.repo_owner), self._api_quote(self.repo)
45
+ return f"{self._api_base}/repos/{owner}/{repo}"
46
+
47
+ def _contents_url(self, path: "str | None" = None) -> str:
48
+ url = f"{self._repo_url}/contents"
49
+ path = self.repo_path if path is None else path
50
+ if path:
51
+ url += f"/{self._api_quote(path, '/')}"
52
+ return url
53
+
54
+ def _params(self) -> dict:
55
+ ref = self.ref
56
+ return {"ref": ref} if ref else {}
57
+
58
+ def _request(self, headers=None, url=None, params=None):
59
+ """A streamed `GET` that succeeded: the caller decodes or reads the
60
+ body, which closes the response."""
61
+ with translate_repo_errors(self):
62
+ resp = self._backend.request(
63
+ "GET",
64
+ url or self._contents_url(),
65
+ params=self._params() if params is None else params,
66
+ headers=headers,
67
+ stream=True,
68
+ )
69
+ # A rate-limited reply (403/429) becomes `RateLimitError` in
70
+ # `translate_repo_errors`.
71
+ try:
72
+ raise_for_status(resp)
73
+ except BaseException:
74
+ drop_body(resp)
75
+ raise
76
+ return resp
77
+
78
+ def _object(self, data: dict) -> dict:
79
+ """`data` if it describes one entry of a repository (it has a
80
+ `type`), else `BadReplyError`."""
81
+ if not isinstance(data.get("type"), str):
82
+ raise self._bad_reply("an object without a type")
83
+ return data
84
+
85
+ def _entry(self, item) -> dict:
86
+ if not (
87
+ isinstance(item, dict)
88
+ and isinstance(item.get("name"), str)
89
+ and isinstance(item.get("type"), str)
90
+ ):
91
+ raise self._bad_reply("a directory entry without a name and a type")
92
+ self._size_of(item)
93
+ return item
94
+
95
+ def _head_size(self) -> "int | None":
96
+ """The size of the file here from a request without a body, or `None`
97
+ when the reply does not settle it. Asked for the raw media type, the
98
+ API answers a file with that type and the file's length and a
99
+ directory with a JSON type; a reply without `X-GitHub-Media-Type` is
100
+ not the API's own (a proxy, a server without `HEAD`)."""
101
+ with translate_repo_errors(self):
102
+ resp = self._backend.request(
103
+ "HEAD",
104
+ self._contents_url(),
105
+ params=self._params(),
106
+ # A compressed reply would state the compressed length.
107
+ headers={"Accept": self._RAW_ACCEPT, "Accept-Encoding": "identity"},
108
+ )
109
+ if "X-GitHub-Media-Type" not in resp.headers:
110
+ return None
111
+ raise_for_status(resp)
112
+ headers = resp.headers
113
+ media_type = headers.get("Content-Type", "").partition(";")[0].strip().lower()
114
+ if (
115
+ resp.status != 200
116
+ or not media_type.startswith("application/vnd.github")
117
+ or "raw" not in media_type
118
+ or headers.get("Content-Encoding", "identity").strip().lower() != "identity"
119
+ ):
120
+ return None
121
+ return server_size(headers.get("Content-Length"))
122
+
123
+ def stat(self, *, follow_symlinks=True):
124
+ hint = self._pop_stat_hint()
125
+ if hint is not None:
126
+ return hint
127
+ # The root of a repository is never a file.
128
+ size = self._head_size() if self.repo_path else None
129
+ if size is not None:
130
+ return FileStat(st_size=size, is_dir=False)
131
+ data = self._decode(self._request(), list, dict)
132
+ if isinstance(data, list):
133
+ return FileStat(is_dir=True)
134
+ return FileStat(st_size=self._size_of(self._object(data)), is_dir=False)
135
+
136
+ def _entries(self, path: str) -> "list[dict]":
137
+ """Contents-API-shaped entries of the directory `path`. A listing
138
+ that hits the contents API's 1,000-entry cap is re-read through the
139
+ Git Trees API, which has no such cap."""
140
+ data = self._decode(self._request(url=self._contents_url(path)), list, dict)
141
+ if isinstance(data, dict):
142
+ self._object(data)
143
+ raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
144
+ entries = [self._entry(item) for item in data]
145
+ if len(entries) >= self._CONTENTS_LIMIT:
146
+ entries = self._tree_entries(self._tree_sha(path))
147
+ return entries
148
+
149
+ def _tree_sha(self, path: str) -> str:
150
+ if not path:
151
+ return self.ref or self._default_branch()
152
+ parent, _, name = path.rpartition("/")
153
+ for entry in self._entries(parent):
154
+ if entry["name"] == name and entry["type"] == "dir":
155
+ sha = entry.get("sha")
156
+ if not isinstance(sha, str):
157
+ raise self._bad_reply("a directory entry without a sha")
158
+ return sha
159
+ raise FileNotFoundError(_errno.ENOENT, "No such file", str(self))
160
+
161
+ def _default_branch(self) -> str:
162
+ cache = self._backend_cache()
163
+ key = ("github_default_branch", self._repo_url)
164
+ if cache is not None and key in cache:
165
+ return cache[key]
166
+ data = self._decode(self._request(url=self._repo_url, params={}), dict)
167
+ branch = data.get("default_branch")
168
+ if not (isinstance(branch, str) and branch):
169
+ raise self._bad_reply("a repository without a default_branch")
170
+ if cache is not None:
171
+ cache[key] = branch
172
+ return branch
173
+
174
+ def _tree_entries(self, sha: str) -> "list[dict]":
175
+ url = f"{self._repo_url}/git/trees/{self._api_quote(sha, '/')}"
176
+ data = self._decode(self._request(url=url, params={}), dict)
177
+ if data.get("truncated"):
178
+ # Only for trees far past any directory listing (100,000
179
+ # entries); never return a partial listing as a complete one.
180
+ raise OSError(_errno.EIO, "Git tree listing truncated", str(self))
181
+ tree = data.get("tree")
182
+ if not isinstance(tree, list):
183
+ raise self._bad_reply("a tree without a tree list")
184
+ kinds = {"tree": "dir", "blob": "file", "commit": "submodule"}
185
+ entries = []
186
+ for item in tree:
187
+ if not (
188
+ isinstance(item, dict)
189
+ and isinstance(item.get("path"), str)
190
+ and isinstance(item.get("type"), str)
191
+ ):
192
+ raise self._bad_reply("a tree entry without a path and a type")
193
+ entries.append(
194
+ self._entry(
195
+ {
196
+ "name": item["path"],
197
+ "type": kinds.get(item["type"], item["type"]),
198
+ "size": item.get("size"),
199
+ "sha": item.get("sha"),
200
+ }
201
+ )
202
+ )
203
+ return entries
204
+
205
+ def _scandir(self):
206
+ for entry in self._entries(self.repo_path):
207
+ # The API's names are not trusted to be one path component.
208
+ if not _providers.is_safe_child_name(entry["name"]):
209
+ continue
210
+ is_dir = entry["type"] == "dir"
211
+ yield entry["name"], FileStat(
212
+ st_size=0 if is_dir else self._size_of(entry), is_dir=is_dir
213
+ )
214
+
215
+ def _open(self, mode="r", buffering=-1):
216
+ self._check_read_mode(mode)
217
+ resp = self._request(headers={"Accept": self._RAW_ACCEPT})
218
+ try:
219
+ return self._read_contents(resp)
220
+ finally:
221
+ resp.close()
222
+
223
+ def _read_contents(self, resp):
224
+ content_type = resp.headers.get("Content-Type", "")
225
+ if content_type.startswith("application/json"):
226
+ # "raw" is ignored by the API for a directory listing (and for
227
+ # a symlink/submodule entry, which carries its own JSON shape).
228
+ data = self._decode(resp, list, dict)
229
+ if isinstance(data, list):
230
+ raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
231
+ content = data.get("content")
232
+ if data.get("encoding") == "base64" and isinstance(content, str):
233
+ try:
234
+ return self._reader(_base64.b64decode(content))
235
+ except ValueError:
236
+ raise self._bad_reply("content that is not base64") from None
237
+ raise self._bad_reply("an unsupported content response")
238
+ media_type = content_type.partition(";")[0].strip().lower()
239
+ if media_type == "text/html" and "X-GitHub-Media-Type" not in resp.headers:
240
+ # Every reply of the API names its media type in that header, so
241
+ # an HTML page without it is a proxy's or a portal's, not a file.
242
+ raise self._bad_reply("an HTML page that is not the API's answer")
243
+ return self._reader(resp.read())