Skip to content

Paths and backends

The paths need no URI and no library beyond pathapi.

GitHubPath and GitLabPath share a base class: the properties it defines (owner, repo, ref, instance, repo_path) are listed under each of them.

repopath

pathlib-style, read-only paths for the repository trees of GitHub and GitLab.

RepoPath is a path in the repository of a hosting service and GitHubPath and GitLabPath are its two kinds; they read the REST API of the service and speak no git protocol. They need no URI and no third-party library, so import repopath needs no extra and never imports uripath. The repo:, github: and gitlab: schemes of uripath are repopath.uri.

RepoPath(*segments, type=None, instance=None, **options)

Bases: TreePath

A path in the repository tree of a hosting service, read-only, over the service's REST API.

RepoPath(*segments, instance=..., type=None, **options) returns a path of one kind: a GitHubPath or a GitLabPath. type names the kind ("github", "gitlab"); without it the kind is detected from instance (github.com and gitlab.com), and any other instance is a ValueError that asks for type=. The segments are the path inside the repository; the options are those of the kind (owner=, repo=, ref=, token=, backend=). Every kind is a subclass, so isinstance(path, RepoPath) holds for all of them; RepoPath.kinds() lists the registered names.

A kind is a subclass whose class body sets TYPE (its name) and INSTANCES (the instances it is detected for): defining the class registers it. A name or an instance another class holds is a ValueError unless the class statement passes replace=True.

A path holds the names below the root of the tree, the tree it is in (the place) and the backend that reaches the service. Nothing connects when a path is built. A path joined from another keeps its place and its backend.

Source code in src/repopath/_kinds.py
137
138
139
140
141
142
143
def __init__(
    self,
    *segments: str | RepoPath,
    type: str | None = None,
    instance: str | None = None,
    **options: _ty.Any,
) -> None: ...

INSTANCES = () class-attribute instance-attribute

TYPE = None class-attribute instance-attribute

backend property

The backend that sends the requests of this path.

kind property

The name of the kind of this path ("github", "gitlab").

is_local()

Whether the tree is on this machine: never. A repository is on a hosting service, so nothing is resolved.

Source code in src/repopath/_kinds.py
259
260
261
262
def is_local(self) -> bool:
    """Whether the tree is on this machine: never. A repository is on a
    hosting service, so nothing is resolved."""
    return False

iterdir()

Source code in src/repopath/_kinds.py
292
293
294
def iterdir(self):
    for name, listed in self._scandir():
        yield self._make_child_relpath(name, stat_hint=listed)

kinds() staticmethod

The names of the registered kinds, sorted.

Source code in src/repopath/_kinds.py
151
152
153
154
155
@staticmethod
def kinds() -> "tuple[str, ...]":
    """The names of the registered kinds, sorted."""
    with _LOCK:
        return tuple(sorted(_KINDS))

GitHubPath(*segments, owner=None, repo=None, ref=None, instance=None, token=None, backend=None)

Bases: HostedRepoPath

A path in a GitHub repository, read-only, through the REST contents API (GET /repos/{owner}/{repo}/contents/{path}).

GitHubPath("src/mod.py", owner="acme", repo="widgets", ref="v1.2"); see HostedRepoPath for the keywords. The default instance is github.com (API at api.github.com); any other is GitHub Enterprise (API at https://{instance}/api/v3). File bodies are fetched with the raw media type (skips base64 and its ~1MB inline-content cap) instead of the default JSON+base64 envelope. A symlink or submodule tree entry is treated as a plain file. No mtime (would need a separate commits-history call per path). Needs no third-party library.

Source code in src/repopath/_hosted.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def __init__(
    self,
    *segments: str | RepoPath,
    owner: str | None = None,
    repo: str | None = None,
    ref: str | None = None,
    instance: str | None = None,
    token: str | None = None,
    backend: BaseRepoBackend | None = None,
):
    text, donor = self._join(segments)
    place = self._place_of(owner, repo, ref, instance, donor)
    if backend is not None and token is not None:
        raise TypeError("pass either backend= or token=, not both")
    if backend is None:
        if token is not None:
            backend = RepoBackend(token=token)
        elif donor is not None:
            backend = donor._backend
        else:
            backend = RepoBackend()
    elif not callable(getattr(backend, "request", None)):
        raise TypeError("a backend is an object with a request() method")
    self._initialize(text, place, backend)

DEFAULT_INSTANCE = 'github.com' class-attribute instance-attribute

INSTANCES = ('github.com', 'www.github.com') class-attribute instance-attribute

TYPE = 'github' class-attribute instance-attribute

instance property

The host (and port) of the service, as given.

ref property

The branch, tag or commit this path reads, or None for the default branch of the repository.

repo property

The name of the repository.

repo_owner property

The user, organization or group path that owns the repository.

repo_path property

The path inside the repository, with no leading or trailing /.

as_uri()

The <kind>://instance/owner/repo/path URI of this path, with ?ref= when it has a ref. It names no token.

Source code in src/repopath/_hosted.py
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def as_uri(self) -> str:
    """The `<kind>://instance/owner/repo/path` URI of this path, with
    `?ref=` when it has a ref. It names no token."""
    names = [name for name in self._segments if name]
    path = "/".join(
        _urlparse.quote(segment, safe=_SAFE_SEGMENT, errors="surrogateescape")
        for segment in self._uri_segments(names)
    )
    text = f"{self._family().TYPE}://{self.instance}/{path}"
    if self.ref:
        text += "?ref=" + _urlparse.quote(
            self.ref, safe="", errors="surrogateescape"
        )
    return text

stat(*, follow_symlinks=True)

Source code in src/repopath/_github.py
123
124
125
126
127
128
129
130
131
132
133
134
def stat(self, *, follow_symlinks=True):
    hint = self._pop_stat_hint()
    if hint is not None:
        return hint
    # The root of a repository is never a file.
    size = self._head_size() if self.repo_path else None
    if size is not None:
        return FileStat(st_size=size, is_dir=False)
    data = self._decode(self._request(), list, dict)
    if isinstance(data, list):
        return FileStat(is_dir=True)
    return FileStat(st_size=self._size_of(self._object(data)), is_dir=False)

GitLabPath(*segments, owner=None, repo=None, ref=None, instance=None, token=None, backend=None)

Bases: HostedRepoPath

A path in a GitLab project's repository, read-only, through the REST API v4 (the project is the URL-encoded owner/repo).

GitLabPath("src/mod.py", owner="acme", repo="widgets", ref="v1.2"); see HostedRepoPath for the keywords. A project in a subgroup has the group path as its owner: owner="acme/team", repo="widgets". The default instance is gitlab.com; a self-hosted one is just another instance (and port), always at https://{instance}/api/v4. The tree-listing endpoint doesn't carry file size, so _scandir() only pre-seeds a stat hint for tree (directory) entries (size=0 is truthful for a directory, not a placeholder) -- blob (file) entries get a real stat() lazily instead of guessing a size that could poison a caller trusting the hint. No mtime. Needs no third-party library.

Source code in src/repopath/_hosted.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def __init__(
    self,
    *segments: str | RepoPath,
    owner: str | None = None,
    repo: str | None = None,
    ref: str | None = None,
    instance: str | None = None,
    token: str | None = None,
    backend: BaseRepoBackend | None = None,
):
    text, donor = self._join(segments)
    place = self._place_of(owner, repo, ref, instance, donor)
    if backend is not None and token is not None:
        raise TypeError("pass either backend= or token=, not both")
    if backend is None:
        if token is not None:
            backend = RepoBackend(token=token)
        elif donor is not None:
            backend = donor._backend
        else:
            backend = RepoBackend()
    elif not callable(getattr(backend, "request", None)):
        raise TypeError("a backend is an object with a request() method")
    self._initialize(text, place, backend)

DEFAULT_INSTANCE = 'gitlab.com' class-attribute instance-attribute

INSTANCES = ('gitlab.com', 'www.gitlab.com') class-attribute instance-attribute

OWNER_NESTS = True class-attribute instance-attribute

TYPE = 'gitlab' class-attribute instance-attribute

instance property

The host (and port) of the service, as given.

ref property

The branch, tag or commit this path reads, or None for the default branch of the repository.

repo property

The name of the repository.

repo_owner property

The user, organization or group path that owns the repository.

repo_path property

The path inside the repository, with no leading or trailing /.

as_uri()

The <kind>://instance/owner/repo/path URI of this path, with ?ref= when it has a ref. It names no token.

Source code in src/repopath/_hosted.py
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def as_uri(self) -> str:
    """The `<kind>://instance/owner/repo/path` URI of this path, with
    `?ref=` when it has a ref. It names no token."""
    names = [name for name in self._segments if name]
    path = "/".join(
        _urlparse.quote(segment, safe=_SAFE_SEGMENT, errors="surrogateescape")
        for segment in self._uri_segments(names)
    )
    text = f"{self._family().TYPE}://{self.instance}/{path}"
    if self.ref:
        text += "?ref=" + _urlparse.quote(
            self.ref, safe="", errors="surrogateescape"
        )
    return text

stat(*, follow_symlinks=True)

Source code in src/repopath/_gitlab.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def stat(self, *, follow_symlinks=True):
    hint = self._pop_stat_hint()
    if hint is not None:
        return hint
    path = self.repo_path
    if not path:
        # Ask the server: a missing project, owner-only URI or unknown
        # ref must not read as an existing directory.
        self._decode(
            self._request("GET", self._tree_url(), params=self._params(per_page=1)),
            list,
        )
        return FileStat(is_dir=True)
    size = self._file_size(path)
    if size is not None:
        return FileStat(st_size=size, is_dir=False)
    # Not a file at this exact path. Git has no empty directories, so a
    # path whose tree listing has any entry is a directory; an empty
    # listing (or a 404) means it does not exist.
    try:
        resp = self._request(
            "GET", self._tree_url(), params=self._params(path=path, per_page=1)
        )
    except FileNotFoundError:
        raise FileNotFoundError(_errno.ENOENT, "No such file", str(self)) from None
    if self._decode(resp, list):
        return FileStat(is_dir=True)
    raise FileNotFoundError(_errno.ENOENT, "No such file", str(self))

RepoBackend(token=None, session=None, api_base=None, *, transport=None, headers=None, timeout=DEFAULT_TIMEOUT, **requests_args)

Bases: HTTPClient, BaseRepoBackend

The transport and the optional bearer token sent on every request. Unauthenticated works fine for public repositories, subject to the host's anonymous rate limit (60 requests an hour for github.com). A context manager: leaving the block closes a transport the backend built.

transport is any object with a send() method as pathapi.http.HTTPTransport.send(); without one the built-in HTTPTransport is built on first use. session= (a requests.Session) and requests_args (verify=, proxies=, ...) select the requests transport of pathapi-requests; without it they raise ImportError. headers go with every request, under the request's own. api_base, when set, overrides the API root a path derives from its instance entirely (e.g. https://api.github.com or https://{host}/api/v3) -- the seam a test fake, or a reverse-proxied self-hosted setup, plugs into, since the derived root is always https; a request that carries an Authorization header to a plain http:// URL on a host other than loopback warns (InsecureTransportWarning). Every request gets timeout=DEFAULT_TIMEOUT ((10, 60) seconds) unless timeout here or in the call says otherwise; None waits without limit. A GET follows redirects, dropping the token when the host changes.

Source code in src/repopath/_backend.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def __init__(
    self,
    token: str = None,
    session=None,
    api_base: str = None,
    *,
    transport=None,
    headers=None,
    timeout=DEFAULT_TIMEOUT,
    **requests_args,
):
    if session is not None or requests_args:
        if transport is not None:
            raise TypeError("give a transport, or a session and its arguments")
        transport = get_transport("requests")(session, **requests_args)
    super().__init__(
        transport,
        headers=headers,
        timeout=timeout,
        user_agent=f"repopath/{__version__}",
    )
    self.token = token or None
    self.api_base = api_base.rstrip("/") if api_base else None
    # Memoization shared by every path that shares this backend (the
    # resolved default branch of a repository, see `_hosted.py`).
    self.cache = {}

api_base = api_base.rstrip('/') if api_base else None instance-attribute

cache = {} instance-attribute

token = token or None instance-attribute

request(method, url, headers=None, params=None, *, data=None, timeout=_UNSET, stream=False)

Source code in src/repopath/_backend.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def request(
    self,
    method,
    url,
    headers=None,
    params=None,
    *,
    data=None,
    timeout=_UNSET,
    stream=False,
) -> HTTPResponse:
    sent = dict(headers or {})
    authorized = bool(self.token) or any(
        name.lower() == "authorization" for name in (*self.headers, *sent)
    )
    if params:
        query = _urlparse.urlencode(
            [(k, v) for k, v in dict(params).items() if v is not None], doseq=True
        )
        if query:
            url = url + ("&" if "?" in url else "?") + query
    _warn_if_plaintext(url, authorized)
    return super().request(
        method,
        url,
        data=data,
        headers=sent,
        stream=stream,
        timeout=self.timeout if timeout is _UNSET else timeout,
        auth=f"Bearer {self.token}" if self.token else None,
    )

BaseRepoBackend

Bases: object

Protocol for issuing an authenticated HTTP request against the REST API of a hosting service. Subclass this to plug in custom auth or session handling; RepoBackend is the one that sends through pathapi.http.

A backend may carry an api_base (the API root, replacing the one derived from the instance of a path) and a cache (a dict, to memoize the default branch of a repository); a path uses either only when the backend has one.

request(method, url, headers=None, params=None, **kwargs)

Send one request to the API and return its HTTPResponse.

Source code in src/repopath/_backend.py
129
130
131
@_providers.notimplemented
def request(self, method, url, headers=None, params=None, **kwargs):
    """Send one request to the API and return its `HTTPResponse`."""