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