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/AGENTS.md
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# `repopath` — public API header
|
|
2
|
+
|
|
3
|
+
Header-file-style reference for the `repopath` package: every public export with
|
|
4
|
+
its signature, arguments, contract and gotchas, so the package can be used
|
|
5
|
+
without reading its source. It ships inside the package and is
|
|
6
|
+
self-contained. Development documentation lives with the source at
|
|
7
|
+
<https://github.com/jose-pr/repopath>.
|
|
8
|
+
|
|
9
|
+
Names are imported from `repopath` (the paths, the backends, the exceptions, the
|
|
10
|
+
warning and `__version__`), from `repopath.exceptions` (the exceptions and the
|
|
11
|
+
warning) and from `repopath.uri` (the `repo:`, `github:` and `gitlab:` schemes
|
|
12
|
+
of `uripath`). Every module whose name starts with `_` is private. `import
|
|
13
|
+
repopath` imports no third-party library and never `uripath`. The classes are
|
|
14
|
+
read-only clients of the REST APIs of GitHub and GitLab and speak no git
|
|
15
|
+
protocol: there is no clone, no `git:` URL and no commit.
|
|
16
|
+
|
|
17
|
+
Install with `pip install repopath`, which needs only `pathapi`; the
|
|
18
|
+
requests go through `pathapi.http`, so no HTTP library is installed. One
|
|
19
|
+
extra adds the URI layer; `repopath.uri` without it raises an `ImportError`
|
|
20
|
+
that names the extra:
|
|
21
|
+
|
|
22
|
+
| Extra | Adds | For |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `uri` | `uripath` | `repopath.uri`: the `repo:`, `github:` and `gitlab:` schemes |
|
|
25
|
+
| `all` | all of the above | |
|
|
26
|
+
|
|
27
|
+
`repopath.__version__` is the installed version, read from the distribution's
|
|
28
|
+
metadata.
|
|
29
|
+
|
|
30
|
+
## Paths (`repopath`)
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
class RepoPath(Path):
|
|
34
|
+
TYPE: str | None
|
|
35
|
+
INSTANCES: tuple[str, ...]
|
|
36
|
+
kind: str
|
|
37
|
+
backend: BaseRepoBackend
|
|
38
|
+
@staticmethod
|
|
39
|
+
def kinds() -> tuple[str, ...]: ...
|
|
40
|
+
def is_local(self) -> bool: ...
|
|
41
|
+
|
|
42
|
+
class GitHubPath(RepoPath):
|
|
43
|
+
def __init__(
|
|
44
|
+
self, *segments, owner=None, repo=None, ref=None, instance=None,
|
|
45
|
+
token=None, backend=None,
|
|
46
|
+
): ...
|
|
47
|
+
instance: str
|
|
48
|
+
repo_owner: str
|
|
49
|
+
repo: str
|
|
50
|
+
ref: str | None
|
|
51
|
+
repo_path: str
|
|
52
|
+
def as_uri(self) -> str: ...
|
|
53
|
+
|
|
54
|
+
class GitLabPath(RepoPath):
|
|
55
|
+
def __init__(
|
|
56
|
+
self, *segments, owner=None, repo=None, ref=None, instance=None,
|
|
57
|
+
token=None, backend=None,
|
|
58
|
+
): ...
|
|
59
|
+
instance: str
|
|
60
|
+
repo_owner: str
|
|
61
|
+
repo: str
|
|
62
|
+
ref: str | None
|
|
63
|
+
repo_path: str
|
|
64
|
+
def as_uri(self) -> str: ...
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`RepoPath` is the family of a repository tree on a hosting service, and
|
|
68
|
+
`GitHubPath` and `GitLabPath` are its two kinds. Each is a
|
|
69
|
+
`pathapi.Path` (so `pathlib.Path`'s read methods, `copy()` out of the tree,
|
|
70
|
+
`walk()`, `glob()` and `PathSyncer` as a source work on it) and needs no URI.
|
|
71
|
+
`segments` are the path inside the repository, joined and normalized like a
|
|
72
|
+
POSIX path: `x` and `/x` name one file, empty and `.` segments go, `..` is kept
|
|
73
|
+
as a name.
|
|
74
|
+
|
|
75
|
+
- **Calling `RepoPath`.** `RepoPath(*segments, instance=..., type=None,
|
|
76
|
+
**options)` returns a path of one kind, with `options` the keywords of that
|
|
77
|
+
kind. `type` names the kind (`"github"`, `"gitlab"`); without it the kind is
|
|
78
|
+
detected from `instance` (`github.com` and `gitlab.com`, with or without
|
|
79
|
+
`www.`, in any case). An instance that no kind is detected for is a
|
|
80
|
+
`ValueError` that asks for `type=` and lists the kinds; an unregistered `type`
|
|
81
|
+
is a `ValueError` that lists the registered ones; neither is a `TypeError`;
|
|
82
|
+
`type=` or `instance=` that is not a `str` is a `TypeError`. A segment that is
|
|
83
|
+
already a repository path gives its kind. `type=` without `instance=` gets the
|
|
84
|
+
default instance of the kind. Every kind is a subclass, so `isinstance(path,
|
|
85
|
+
RepoPath)` holds. `RepoPath.kinds()` lists the registered kind names, sorted
|
|
86
|
+
(`("github", "gitlab")`); `path.kind` is the name of the kind of a path.
|
|
87
|
+
- **Place.** `owner=` and `repo=` are required, unless a segment is a path of
|
|
88
|
+
the same kind and supplies them (`path / "x"`, `path.parent` and
|
|
89
|
+
`GitHubPath(path, "x")` keep the place); a keyword then moves the path. A
|
|
90
|
+
value that is not a `str` is a `TypeError`; an empty one, a `.` or `..`, or an
|
|
91
|
+
owner or repository holding a `/` is a `ValueError` (a `GitLabPath` owner is
|
|
92
|
+
the one exception: a `/` separated group path, `"acme/team"`, names the
|
|
93
|
+
project of a subgroup). `instance=` is the host of the service, with a port
|
|
94
|
+
when it is not the default one: `github.com` (the default of `GitHubPath`),
|
|
95
|
+
`gitlab.com` (the default of `GitLabPath`), `ghe.example:8443`,
|
|
96
|
+
`[fd00::5]`. Another instance than the public one is GitHub Enterprise or a
|
|
97
|
+
self-hosted GitLab, with the API at `https://<instance>/api/v3` or
|
|
98
|
+
`https://<instance>/api/v4`; anything that is not a host and an optional port
|
|
99
|
+
(a `/`, `@`, `?`, `#`, space, a bad port) is a `ValueError`. `ref=` is a
|
|
100
|
+
branch, a tag or a commit; without one the default branch of the repository is
|
|
101
|
+
read (`GitLabPath` looks it up once per backend); `ref=""` on a joined path
|
|
102
|
+
goes back to the default branch.
|
|
103
|
+
- **Backend.** `token=` builds the path's own `RepoBackend` that sends it as a
|
|
104
|
+
bearer token; every path derived from it keeps that backend. `backend=` shares
|
|
105
|
+
one backend between paths built separately, or plugs in a `BaseRepoBackend` of
|
|
106
|
+
your own; it must have a `request()` method (`TypeError`), and giving it with
|
|
107
|
+
`token=` is a `TypeError`. Without either, the path builds an anonymous
|
|
108
|
+
`RepoBackend`. Nothing connects when a path is built.
|
|
109
|
+
- **Identity.** Two paths are equal when they are of one class and name one
|
|
110
|
+
instance (in any case), owner, repository and ref and the same names; the
|
|
111
|
+
backend is not part of it. They are one tree (`_same_filesystem()`) when their
|
|
112
|
+
API root, owner, repository and ref are the same, whatever token each uses.
|
|
113
|
+
Paths of different kinds are never equal and cannot be joined
|
|
114
|
+
(`NotImplementedError` from the constructor, `TypeError` from `/`).
|
|
115
|
+
- **Text.** `str(path)` and `path.as_uri()` are `github://instance/owner/repo/path`
|
|
116
|
+
(`gitlab://...`), with `?ref=REF` when there is a ref; a GitLab project in a
|
|
117
|
+
group, and a name `-`, get GitLab's `/-/` separator in front of the path
|
|
118
|
+
(`gitlab://gitlab.com/acme/team/widgets/-/src`). `as_posix()` is the names
|
|
119
|
+
only. `repr()` is `GitHubPath('src/x.py', owner='acme', repo='widgets',
|
|
120
|
+
ref='v1')`, with `instance=` when it is not the default and `backend=` when it
|
|
121
|
+
is not a plain `RepoBackend`. No display form and no error message shows a
|
|
122
|
+
token.
|
|
123
|
+
- **Pickling and copying.** A path pickles its place and its names and never its
|
|
124
|
+
token: the receiving process builds an anonymous backend. A backend whose class
|
|
125
|
+
sets `picklable = True` travels with the path. `copy.copy()` and
|
|
126
|
+
`copy.deepcopy()` keep the backend.
|
|
127
|
+
- **Reading.** `stat()`, `exists()`, `is_dir()`, `is_file()`, `iterdir()`,
|
|
128
|
+
`open("r")`/`open("rb")`, `read_bytes()`, `read_text()`, `walk()`, `glob()`
|
|
129
|
+
and `copy()` out of the tree work; `st_mtime` is `0`. Every write method
|
|
130
|
+
(`mkdir`, `unlink`, `rmdir`, `rename`, `chmod`, `open("w")`, `open("r+")`, ...)
|
|
131
|
+
raises `NotImplementedError`. A file is read whole into memory. A directory
|
|
132
|
+
listed by `iterdir()` hands each child the stat the listing gave, once, so a
|
|
133
|
+
`walk()` or `glob()` costs one request per directory.
|
|
134
|
+
- **Limits of git.** Git has no empty directories, so one exists only with a
|
|
135
|
+
file below it. A symlink or a submodule is an ordinary file of the size the
|
|
136
|
+
API reports. A name an API lists that is not one path component (`..`, `a/b`,
|
|
137
|
+
an empty name, one with NUL) is left out of the listing.
|
|
138
|
+
|
|
139
|
+
`GitHubPath` reads the contents API (`GET /repos/{owner}/{repo}/contents/{path}`):
|
|
140
|
+
|
|
141
|
+
- API root `https://api.github.com` for `github.com`, else
|
|
142
|
+
`https://<instance>/api/v3`. A directory is one `GET`; a listing that reaches
|
|
143
|
+
the API's 1,000-entry cap is read again through the Git Trees API, which has no
|
|
144
|
+
cap. File bodies are asked for with the raw media type.
|
|
145
|
+
- `stat()`, `exists()` and `is_file()` of a file are one `HEAD` of the contents
|
|
146
|
+
URL asked for the raw media type and unencoded: the size is its
|
|
147
|
+
`Content-Length`, and a `404` there is `FileNotFoundError`. A directory answers
|
|
148
|
+
with a JSON type and is then listed with a `GET` (two requests; the root of the
|
|
149
|
+
repository is listed directly). A `HEAD` reply without `X-GitHub-Media-Type`
|
|
150
|
+
(a proxy's, a server that refuses `HEAD`), with an encoding, or without a
|
|
151
|
+
plain `Content-Length` is not used: the `GET` of the contents object answers.
|
|
152
|
+
- A `200` whose `Content-Type` is `text/html` and that carries no
|
|
153
|
+
`X-GitHub-Media-Type` header is a proxy's or a captive portal's page, not a
|
|
154
|
+
file: `open()` of it is `BadReplyError`.
|
|
155
|
+
|
|
156
|
+
`GitLabPath` reads the REST API v4:
|
|
157
|
+
|
|
158
|
+
- API root `https://<instance>/api/v4`. Listings are paginated (100 per page) and
|
|
159
|
+
read to the last page. A blob entry is a type-only hint (`size_known=False`),
|
|
160
|
+
so `walk()` and `glob()` cost one request per directory and
|
|
161
|
+
`is_dir()`/`is_file()` of a listed child ask nothing; `stat()` of a file is a
|
|
162
|
+
`HEAD` of the files endpoint reading `X-Gitlab-Size` (a reply without it, or a
|
|
163
|
+
refused `HEAD`, is followed by a `GET` of the file's metadata). The root of a
|
|
164
|
+
project and an unknown ref are settled by the server, never by the shape of
|
|
165
|
+
the path.
|
|
166
|
+
- The files endpoints need a ref, so the default branch is looked up
|
|
167
|
+
(`GET /projects/:id`) and kept in `backend.cache` for every path that shares
|
|
168
|
+
the backend.
|
|
169
|
+
|
|
170
|
+
### Failures
|
|
171
|
+
|
|
172
|
+
Every failure is the `OSError` `pathlib` raises for the same condition:
|
|
173
|
+
|
|
174
|
+
| Reply | Raised |
|
|
175
|
+
| --- | --- |
|
|
176
|
+
| 404 | `FileNotFoundError` |
|
|
177
|
+
| 401, 403 | `PermissionError` |
|
|
178
|
+
| 403 or 429 with `RateLimit-Remaining: 0` or `Retry-After`, any 429 | `RateLimitError`, a `BlockingIOError` with errno `EAGAIN`; the message says when to retry |
|
|
179
|
+
| another status | `OSError(EIO)` naming the status |
|
|
180
|
+
| a reply that is not the documented shape (a page of a proxy, `null`, an array where an object belongs, a truncated body, a size that is not a number) | `BadReplyError`, an `OSError(EIO)` naming the path; `exists()` is `False` |
|
|
181
|
+
| a JSON reply over `pathapi.http.MAX_REPLY_BYTES` | `OSError(EFBIG)` |
|
|
182
|
+
| a path that is a file where a directory is needed, or the reverse | `NotADirectoryError`, `IsADirectoryError` |
|
|
183
|
+
| no answer in time, a refused connection | `TimeoutError`, `ConnectionError` naming the path |
|
|
184
|
+
|
|
185
|
+
An exception of the transport is never raised across the API and is never
|
|
186
|
+
chained: the text of one can carry a URL or a header.
|
|
187
|
+
|
|
188
|
+
A `.` or `..` name, in the path or in the ref, cannot leave the API root of the
|
|
189
|
+
repository: the request is not made and the call raises `ValueError`
|
|
190
|
+
(`exists()` answers `False`). Owner, repository, path and ref are
|
|
191
|
+
percent-encoded into the API URL, and the ref travels as a query parameter.
|
|
192
|
+
|
|
193
|
+
## Backends (`repopath`)
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
class BaseRepoBackend:
|
|
197
|
+
def request(self, method, url, headers=None, params=None, **kwargs): ...
|
|
198
|
+
|
|
199
|
+
class RepoBackend(HTTPClient, BaseRepoBackend):
|
|
200
|
+
token: str | None
|
|
201
|
+
api_base: str | None
|
|
202
|
+
cache: dict
|
|
203
|
+
def __init__(
|
|
204
|
+
self, token=None, session=None, api_base=None, *, transport=None,
|
|
205
|
+
headers=None, timeout=(10, 60), **requests_args,
|
|
206
|
+
): ...
|
|
207
|
+
def request(
|
|
208
|
+
self, method, url, headers=None, params=None, *, data=None, timeout=...,
|
|
209
|
+
stream=False,
|
|
210
|
+
): ...
|
|
211
|
+
def close(self) -> None: ...
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`BaseRepoBackend` is the contract: `request()` sends one request to the API and
|
|
215
|
+
returns a `pathapi.http.HTTPResponse`. A backend may carry `api_base` (the
|
|
216
|
+
API root, replacing the one a path derives from its instance) and `cache` (a
|
|
217
|
+
dict that memoizes the default branch); a path uses either only when the backend
|
|
218
|
+
has one. `RepoBackend` sends through `pathapi.http`, so everything that
|
|
219
|
+
module documents applies:
|
|
220
|
+
|
|
221
|
+
- `token` is sent as `Authorization: Bearer <token>`. It is never shown by
|
|
222
|
+
`repr()` and not sent to another origin: a redirect to another scheme, host or
|
|
223
|
+
port drops it. A request that carries `Authorization` (the token, or one in
|
|
224
|
+
`headers=`) to a plain `http://` URL on a host other than loopback is still
|
|
225
|
+
sent, with an `InsecureTransportWarning`.
|
|
226
|
+
- `transport` is any object with a `send()` method as
|
|
227
|
+
`pathapi.http.HTTPTransport.send()`. Without one, the backend builds the
|
|
228
|
+
transport that `PATHAPI_HTTP_TRANSPORT` names, `builtin` (standard
|
|
229
|
+
library, certificates verified) by default. `session=` (a `requests.Session`)
|
|
230
|
+
and `requests_args` (`verify=`, `proxies=`, ...) select the `requests`
|
|
231
|
+
transport of the distribution `pathapi-requests`, and raise
|
|
232
|
+
`ImportError` naming it without it; with `transport=` they are a `TypeError`.
|
|
233
|
+
- `headers` go with every request, under the request's own; the `User-Agent` is
|
|
234
|
+
`repopath/<version>`. Every request gets `timeout=(10, 60)` seconds
|
|
235
|
+
(`connect`, `read`) unless `timeout` here or in the call says otherwise;
|
|
236
|
+
`None` waits without limit.
|
|
237
|
+
- `api_base` overrides the API root entirely (the seam a test fake, or a
|
|
238
|
+
reverse-proxied self-hosted service, plugs into, since the derived root is
|
|
239
|
+
always `https`).
|
|
240
|
+
- A `RepoBackend` is a context manager: leaving the block, or `close()`, closes
|
|
241
|
+
a transport the backend built, never one it was given. It may be shared between
|
|
242
|
+
threads.
|
|
243
|
+
|
|
244
|
+
## The `repo:`, `github:` and `gitlab:` schemes (`repopath.uri`)
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
class RepoURIPath(DelegatingURIPath):
|
|
248
|
+
kind: str
|
|
249
|
+
instance: str
|
|
250
|
+
repo_owner: str
|
|
251
|
+
repo: str
|
|
252
|
+
repo_path: str
|
|
253
|
+
ref: str | None
|
|
254
|
+
def is_local(self) -> bool: ...
|
|
255
|
+
|
|
256
|
+
class GitHubURIPath(RepoURIPath): ...
|
|
257
|
+
|
|
258
|
+
class GitLabURIPath(RepoURIPath): ...
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Importing `repopath.uri` needs `uripath` (`pip install "repopath[uri]"`).
|
|
262
|
+
`uripath` loads it by itself, through its `uripath.schemes` entry points, the
|
|
263
|
+
first time a path of one of the schemes is used; `URIPath("github://...")`
|
|
264
|
+
returns a `GitHubURIPath`. Each class is a `uripath.schemes.DelegatingURIPath`
|
|
265
|
+
that hands every read to the `GitHubPath` or `GitLabPath` of its URI, built once
|
|
266
|
+
per instance on the backend of the URI path, so everything above holds for a URI
|
|
267
|
+
path too, and an error names the URI.
|
|
268
|
+
|
|
269
|
+
- `github://[TOKEN@]host/owner/repo/path?ref=REF` is a `GitHubURIPath`;
|
|
270
|
+
`gitlab://[TOKEN@]host[:port]/owner/repo/path?ref=REF` a `GitLabURIPath`, with
|
|
271
|
+
GitLab's own `/-/` ending the project path: `gitlab://host/group/subgroup/
|
|
272
|
+
project/-/path`. Without the separator the first two segments are owner and
|
|
273
|
+
repository; a `-` name needs it in front (`.../repo/-/-/x`), and the child
|
|
274
|
+
`path / "-"` writes it. Any `host` is allowed: the kind is the scheme's.
|
|
275
|
+
- `repo://host/owner/repo/path?ref=REF` is a `RepoURIPath`, whose kind is
|
|
276
|
+
detected from the host (`github.com`, `gitlab.com`, with or without `www.`,
|
|
277
|
+
in any case). Any other host raises `URISyntaxError` (a `ValueError`) when the
|
|
278
|
+
path is built, naming `github://host/...` and `gitlab://host/...`, which pin the
|
|
279
|
+
kind on any host. `git:`, `git+github:` and `git+gitlab:` are not schemes of
|
|
280
|
+
this package.
|
|
281
|
+
- `path.repo_owner`, `repo`, `repo_path`, `ref`, `instance` and `kind` are read from
|
|
282
|
+
the URI; a URI that holds no owner and repository (`github://github.com/acme`)
|
|
283
|
+
is a path that does not exist (`FileNotFoundError`, no request).
|
|
284
|
+
- The token is the user slot of the URI (`repo://TOKEN@host/...`) or its password
|
|
285
|
+
slot (`github://x-access-token:TOKEN@host/...`, GitLab's `oauth2:TOKEN`), unless
|
|
286
|
+
`backend=` is given. Because the user slot can be the secret, `str()`,
|
|
287
|
+
`repr()`, `as_posix()` and `as_uri(sanitize=True)` of these paths, `str()` and
|
|
288
|
+
`repr()` of their `Source`, and so every message of an exception, drop the
|
|
289
|
+
whole userinfo; `as_uri()` still round-trips it, and so does a pickle.
|
|
290
|
+
`as_posix()` is `host:path`.
|
|
291
|
+
- `?ref=` is carried to every child. Paths derived from one URI share the backend
|
|
292
|
+
the first of them builds, so the default branch of a GitLab project is looked up
|
|
293
|
+
once; `backend=` supplies one (a `RepoBackend` or a `BaseRepoBackend`).
|
|
294
|
+
- `is_local()` is `False`: the host is a hosting service and is never resolved.
|
|
295
|
+
|
|
296
|
+
## Exceptions (`repopath.exceptions`)
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
class RepoPathError(PathError): ...
|
|
300
|
+
class RateLimitError(RepoPathError, BlockingIOError): ...
|
|
301
|
+
class BadReplyError(RepoPathError, OSError): ...
|
|
302
|
+
class InsecureTransportWarning(UserWarning): ...
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`RepoPathError` (under `pathapi.PathError`) is the base of what this package
|
|
306
|
+
raises on its own at run time. `RateLimitError` and `BadReplyError` are the two
|
|
307
|
+
failures no builtin names, and keep the builtin a caller already catches. A
|
|
308
|
+
caller's bad argument is a plain `ValueError` or `TypeError`; an unsupported
|
|
309
|
+
operation is `NotImplementedError`. `InsecureTransportWarning` is a
|
|
310
|
+
`UserWarning`: a `RepoBackend` is about to send an `Authorization` header over
|
|
311
|
+
plain `http://` to a host that is not this machine.
|
|
312
|
+
|
|
313
|
+
## Gotchas
|
|
314
|
+
|
|
315
|
+
- Anonymous requests are rate limited by the host (60 an hour for `github.com`);
|
|
316
|
+
a listing of a large tree or a recursive copy can use that up. Pass `token=`.
|
|
317
|
+
- A recursive `copy()` or `PathSyncer` run out of a tree makes one request per
|
|
318
|
+
directory and one per file; a file is read whole into memory.
|
|
319
|
+
- A new kind of repository is a `RepoPath` subclass whose class body sets `TYPE`
|
|
320
|
+
and `INSTANCES`; defining it registers the name and the instances, and a name or
|
|
321
|
+
an instance another class holds is a `ValueError` unless the class statement
|
|
322
|
+
passes `replace=True`. A kind supplies its constructor (calling
|
|
323
|
+
`self._initialize(text, place, backend)`), `_keywords()` (the constructor
|
|
324
|
+
keywords that name the place), `as_uri()` and the read hooks (`stat()`,
|
|
325
|
+
`_scandir()`, `_open()`); `RepoPath(type="name", **keywords)` then returns it.
|
|
326
|
+
- `GitHubPath(...)` and `RepoPath(..., instance="github.com")` are the same
|
|
327
|
+
class; the second spelling is for a name the caller reads from data.
|
|
328
|
+
- The settings of the transport (`PATHAPI_HTTP_TRANSPORT`, proxies, the
|
|
329
|
+
limit `pathapi.http.MAX_REPLY_BYTES` on a JSON reply) belong to
|
|
330
|
+
`pathapi.http`: assigning the limit changes it for every package that
|
|
331
|
+
sends through it.
|
repopath/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# repopath
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/repopath/)
|
|
4
|
+
[](https://pypi.org/project/repopath/)
|
|
5
|
+
[](https://github.com/jose-pr/repopath/blob/main/LICENSE)
|
|
6
|
+
[](https://jose-pr.github.io/repopath/)
|
|
7
|
+
[](https://github.com/jose-pr/repopath/actions/workflows/test.yml)
|
|
8
|
+
|
|
9
|
+
**`pathlib` for the repository trees of GitHub and GitLab, read-only.**
|
|
10
|
+
`GitHubPath("src/mod.py", owner="acme", repo="widgets", ref="v1.2")` has the method
|
|
11
|
+
names, signatures and exception types of `pathlib.Path`, and `GitLabPath` does the same
|
|
12
|
+
for GitLab; `RepoPath` is the class that returns either. None needs a URI, and all
|
|
13
|
+
build on [`pathapi`](https://github.com/jose-pr/pathapi) alone; with
|
|
14
|
+
[`uripath`](https://github.com/jose-pr/uripath) installed the same package also
|
|
15
|
+
provides the `repo:`, `github:` and `gitlab:` schemes. It is documented at
|
|
16
|
+
<https://jose-pr.github.io/repopath/>.
|
|
17
|
+
|
|
18
|
+
The classes are clients of the hosting services' REST APIs. They speak no git
|
|
19
|
+
protocol: there is no clone, no commit and no `git:` URL, and the tree is read-only.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
| Kind | Class | `type=` | Detected for | API |
|
|
24
|
+
| --- | --- | --- | --- | --- |
|
|
25
|
+
| GitHub and GitHub Enterprise | `GitHubPath` | `"github"` | `github.com` | contents API at `api.github.com`, or `https://<instance>/api/v3` |
|
|
26
|
+
| GitLab and self-hosted GitLab | `GitLabPath` | `"gitlab"` | `gitlab.com` | REST API v4 at `https://<instance>/api/v4` |
|
|
27
|
+
|
|
28
|
+
- **A `pathlib` path in a repository**: `open`, `read_text`, `iterdir`, `glob`, `stat`
|
|
29
|
+
and the rest, plus `walk`, `copy` out of the tree and `PathSyncer` as a source, from
|
|
30
|
+
`pathapi`. Nothing connects until the first request.
|
|
31
|
+
- **One class for the kind**: `RepoPath("f", owner="acme", repo="widgets",
|
|
32
|
+
instance="github.com")` is a `GitHubPath`; another instance needs `type=`, and
|
|
33
|
+
`RepoPath.kinds()` lists the kinds. A kind is a subclass that registers itself.
|
|
34
|
+
- **Cheap listings**: a directory is one request and each child carries the size and
|
|
35
|
+
type the listing gave, so `walk()` and `glob()` cost one request per directory;
|
|
36
|
+
`stat()` of a file is a `HEAD`. Listings past GitHub's 1,000-entry cap and GitLab's
|
|
37
|
+
pages are read to the end.
|
|
38
|
+
- **Authentication that stays out of sight**: `token=` is sent as a bearer token to the
|
|
39
|
+
API's own origin only, and shown by no `str()`, `repr()`, pickle or error message. A
|
|
40
|
+
token over plain `http://` to another host warns.
|
|
41
|
+
- **No library to install**: requests go through `pathapi.http`, on the standard
|
|
42
|
+
library, with a replaceable transport. Errors are the `OSError` types `pathlib` raises,
|
|
43
|
+
`RateLimitError` (a `BlockingIOError`) for a rate limit and `BadReplyError` for a reply
|
|
44
|
+
that is not the shape the API documents.
|
|
45
|
+
- **The `repo:`, `github:` and `gitlab:` schemes** for `uripath`:
|
|
46
|
+
`URIPath("github://github.com/acme/widgets/src/mod.py?ref=v1.2")` is a
|
|
47
|
+
`GitHubURIPath` that delegates to a `GitHubPath`.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install repopath
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Requires `pathapi`, which pip installs with it, and Python 3.9 or newer.
|
|
56
|
+
|
|
57
|
+
| Install | Adds |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `pip install repopath` | the paths and the backends; no other library |
|
|
60
|
+
| `pip install "repopath[uri]"` | `uripath` and with it the `repo:`, `github:` and `gitlab:` schemes |
|
|
61
|
+
| `pip install "repopath[all]"` | all of the above |
|
|
62
|
+
|
|
63
|
+
## Quick start
|
|
64
|
+
|
|
65
|
+
**A path in a repository**: the owner and repository are keywords, the segments are the
|
|
66
|
+
path inside it, and `ref` is a branch, a tag or a commit:
|
|
67
|
+
|
|
68
|
+
<!-- example: skip: reads from GitHub -->
|
|
69
|
+
```python
|
|
70
|
+
from repopath import GitHubPath
|
|
71
|
+
|
|
72
|
+
readme = GitHubPath("README.md", owner="psf", repo="requests")
|
|
73
|
+
print(readme.read_text()[:200])
|
|
74
|
+
for child in readme.parent.iterdir():
|
|
75
|
+
print(child.name, child.is_dir())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Another kind, or another instance**: `instance` names the server, `type` the kind when
|
|
79
|
+
the instance does not tell, and `token` is the credential:
|
|
80
|
+
|
|
81
|
+
<!-- example: skip: reads from GitLab -->
|
|
82
|
+
```python
|
|
83
|
+
from repopath import GitLabPath, RepoPath
|
|
84
|
+
|
|
85
|
+
doc = RepoPath("doc", owner="gitlab-org", repo="gitlab", instance="gitlab.com")
|
|
86
|
+
private = RepoPath(
|
|
87
|
+
"src", owner="team/tools", repo="app", instance="git.example.org",
|
|
88
|
+
type="gitlab", token="glpat-...",
|
|
89
|
+
)
|
|
90
|
+
assert isinstance(doc, GitLabPath)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Copy a tree** out of a repository with `pathapi`:
|
|
94
|
+
|
|
95
|
+
<!-- example: skip: reads from GitHub -->
|
|
96
|
+
```python
|
|
97
|
+
from pathapi import LocalPath
|
|
98
|
+
from repopath import GitHubPath
|
|
99
|
+
|
|
100
|
+
GitHubPath("docs", owner="psf", repo="requests", ref="main").copy(
|
|
101
|
+
LocalPath("docs-copy"), recursive=True
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**The schemes**, with `uripath` installed:
|
|
106
|
+
|
|
107
|
+
<!-- example: skip: reads from GitHub -->
|
|
108
|
+
```python
|
|
109
|
+
from uripath import URIPath
|
|
110
|
+
|
|
111
|
+
print(URIPath("repo://github.com/psf/requests/README.md?ref=main").read_text()[:200])
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## API overview
|
|
115
|
+
|
|
116
|
+
| Module | Purpose |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| `repopath` | `RepoPath`, `GitHubPath`, `GitLabPath`, the backends `RepoBackend` and `BaseRepoBackend`, the exceptions `RepoPathError`, `RateLimitError` and `BadReplyError`, the warning `InsecureTransportWarning` and `__version__` |
|
|
119
|
+
| `repopath.exceptions` | The exceptions and the warning |
|
|
120
|
+
| `repopath.uri` | `RepoURIPath`, `GitHubURIPath` and `GitLabURIPath`, the schemes of `uripath` (needs the `uri` extra) |
|
|
121
|
+
|
|
122
|
+
The full reference is on the [documentation site](https://jose-pr.github.io/repopath/);
|
|
123
|
+
an agent-oriented API summary ships inside the package as `repopath/AGENTS.md`.
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pip install -e ".[dev,all]"
|
|
129
|
+
python -m pytest -q -rs
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The tests send every request to a fake of the API on the loopback interface; none
|
|
133
|
+
reaches another host. A test that needs `uripath` is skipped without it, so check the
|
|
134
|
+
skip list (`-rs`). Contributor notes (virtual environments, formatting, CI, releases) are
|
|
135
|
+
in the repository's [`AGENTS.md`](https://github.com/jose-pr/repopath/blob/main/AGENTS.md).
|
|
136
|
+
To preview the documentation: `pip install -e ".[all,docs]"` then `mkdocs serve`.
|
|
137
|
+
|
|
138
|
+
Changes are recorded in the
|
|
139
|
+
[changelog](https://github.com/jose-pr/repopath/blob/main/CHANGELOG.md).
|
|
140
|
+
|
|
141
|
+
### Releasing
|
|
142
|
+
|
|
143
|
+
This project follows [Semantic Versioning](https://semver.org/) and keeps a
|
|
144
|
+
[`CHANGELOG.md`](https://github.com/jose-pr/repopath/blob/main/CHANGELOG.md). Pushing a tag matching `v*` triggers the release
|
|
145
|
+
workflow: test gate → build (checking the tag names the version built) → a strict
|
|
146
|
+
docs build as a gate → GitHub release → publish. The release workflow never deploys
|
|
147
|
+
the docs site itself: for a final release its last job dispatches the docs workflow
|
|
148
|
+
at the tag, which owns every Pages deploy.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT — see [LICENSE](https://github.com/jose-pr/repopath/blob/main/LICENSE).
|
repopath/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""pathlib-style, read-only paths for the repository trees of GitHub and GitLab.
|
|
2
|
+
|
|
3
|
+
`RepoPath` is a path in the repository of a hosting service and `GitHubPath`
|
|
4
|
+
and `GitLabPath` are its two kinds; they read the REST API of the service and
|
|
5
|
+
speak no git protocol. They need no URI and no third-party library, so
|
|
6
|
+
`import repopath` needs no extra and never imports `uripath`. The `repo:`,
|
|
7
|
+
`github:` and `gitlab:` schemes of `uripath` are `repopath.uri`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ._backend import BaseRepoBackend, RepoBackend
|
|
13
|
+
from ._github import GitHubPath
|
|
14
|
+
from ._gitlab import GitLabPath
|
|
15
|
+
from ._kinds import RepoPath
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
BadReplyError,
|
|
19
|
+
InsecureTransportWarning,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
RepoPathError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"BadReplyError",
|
|
26
|
+
"BaseRepoBackend",
|
|
27
|
+
"GitHubPath",
|
|
28
|
+
"GitLabPath",
|
|
29
|
+
"InsecureTransportWarning",
|
|
30
|
+
"RateLimitError",
|
|
31
|
+
"RepoBackend",
|
|
32
|
+
"RepoPath",
|
|
33
|
+
"RepoPathError",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|