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 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
+ [![Version](https://img.shields.io/pypi/v/repopath.svg)](https://pypi.org/project/repopath/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/repopath.svg)](https://pypi.org/project/repopath/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/repopath/blob/main/LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/repopath/)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/repopath/test.yml)](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
+ ]