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/_kinds.py ADDED
@@ -0,0 +1,314 @@
1
+ """`RepoPath`, the family of repository trees on a hosting service, and the
2
+ registry of the kinds that make it up."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import inspect as _inspect
7
+ import io as _io
8
+ import re as _re
9
+ import stat as _stat
10
+ import threading as _threading
11
+ import typing as _ty
12
+
13
+ from pathapi import FileStat
14
+ from pathapi.providers import TreePath
15
+
16
+ if _ty.TYPE_CHECKING:
17
+ from ._backend import BaseRepoBackend
18
+
19
+ _KINDS: "dict[str, type[RepoPath]]" = {}
20
+ """Kind name -> the class that registered it."""
21
+
22
+ _DETECTED: "dict[str, type[RepoPath]]" = {}
23
+ """Instance (folded to lower case) -> the kind a call that names no `type`
24
+ gets for it."""
25
+
26
+ _LOCK = _threading.Lock()
27
+
28
+ _NAME = _re.compile(r"[a-z][a-z0-9_-]*")
29
+
30
+
31
+ class _Kinds(type(TreePath)):
32
+ """The metaclass that lets calling `RepoPath` itself return a path of one
33
+ of its kinds, chosen by `type=` or by the `instance=` it names."""
34
+
35
+ def __call__(cls, *args, **kwargs):
36
+ if cls is RepoPath:
37
+ kind = RepoPath._kind_for(
38
+ args, kwargs.pop("type", None), kwargs.get("instance")
39
+ )
40
+ return kind(*args, **kwargs)
41
+ return super().__call__(*args, **kwargs)
42
+
43
+ @property
44
+ def __signature__(cls) -> _inspect.Signature:
45
+ """What `inspect.signature()` reads: the constructor a call of the
46
+ class runs, not the `__call__` above."""
47
+ if cls is RepoPath:
48
+ parameter = _inspect.Parameter
49
+ return _inspect.Signature(
50
+ [
51
+ parameter("segments", parameter.VAR_POSITIONAL),
52
+ parameter("type", parameter.KEYWORD_ONLY, default=None),
53
+ parameter("instance", parameter.KEYWORD_ONLY, default=None),
54
+ parameter("options", parameter.VAR_KEYWORD),
55
+ ]
56
+ )
57
+ signature = _inspect.signature(cls.__init__)
58
+ return signature.replace(parameters=list(signature.parameters.values())[1:])
59
+
60
+
61
+ def _same_definition(a: type, b: type) -> bool:
62
+ """Whether two classes come from one `class` statement run twice (a
63
+ reload, a function body called again): same module, same qualified name."""
64
+ return a.__module__ == b.__module__ and a.__qualname__ == b.__qualname__
65
+
66
+
67
+ def _register(cls: type, name: object, instances: object, replace: bool) -> None:
68
+ if not isinstance(name, str) or not _NAME.fullmatch(name):
69
+ raise ValueError(
70
+ f"TYPE of {cls.__qualname__} must be a lower-case name "
71
+ f"(letters, digits, '_' and '-'), not {name!r}"
72
+ )
73
+ if isinstance(instances, str) or not all(isinstance(i, str) for i in instances):
74
+ raise TypeError(f"INSTANCES of {cls.__qualname__} must be a tuple of str")
75
+ folded = tuple(dict.fromkeys(i.lower() for i in instances))
76
+ with _LOCK:
77
+ held = _KINDS.get(name)
78
+ if held is not None and not (replace or _same_definition(held, cls)):
79
+ raise ValueError(
80
+ f"repository kind {name!r} is already registered by "
81
+ f"{held.__module__}.{held.__qualname__}; pass replace=True in "
82
+ f"the class statement of {cls.__qualname__} to replace it"
83
+ )
84
+ for instance in folded:
85
+ owner = _DETECTED.get(instance)
86
+ if (
87
+ owner is not None
88
+ and owner is not held
89
+ and not (replace or _same_definition(owner, cls))
90
+ ):
91
+ raise ValueError(
92
+ f"instance {instance!r} is already detected as the "
93
+ f"{owner.TYPE!r} kind; pass replace=True in the class "
94
+ f"statement of {cls.__qualname__} to replace it"
95
+ )
96
+ if held is not None:
97
+ for instance in [k for k, v in _DETECTED.items() if v is held]:
98
+ del _DETECTED[instance]
99
+ _KINDS[name] = cls
100
+ for instance in folded:
101
+ _DETECTED[instance] = cls
102
+
103
+
104
+ class RepoPath(TreePath, metaclass=_Kinds):
105
+ """A path in the repository tree of a hosting service, read-only, over the
106
+ service's REST API.
107
+
108
+ `RepoPath(*segments, instance=..., type=None, **options)` returns a path
109
+ of one *kind*: a `GitHubPath` or a `GitLabPath`. `type` names the kind
110
+ (`"github"`, `"gitlab"`); without it the kind is detected from `instance`
111
+ (`github.com` and `gitlab.com`), and any other instance is a `ValueError`
112
+ that asks for `type=`. The segments are the path inside the repository;
113
+ the options are those of the kind (`owner=`, `repo=`, `ref=`, `token=`,
114
+ `backend=`). Every kind is a subclass, so `isinstance(path, RepoPath)`
115
+ holds for all of them; `RepoPath.kinds()` lists the registered names.
116
+
117
+ A kind is a subclass whose class body sets `TYPE` (its name) and
118
+ `INSTANCES` (the instances it is detected for): defining the class
119
+ registers it. A name or an instance another class holds is a `ValueError`
120
+ unless the class statement passes `replace=True`.
121
+
122
+ A path holds the names below the root of the tree, the tree it is in (the
123
+ place) and the backend that reaches the service. Nothing connects when a
124
+ path is built. A path joined from another keeps its place and its
125
+ backend."""
126
+
127
+ __slots__ = ("_backend", "_place", "_hint")
128
+
129
+ #: The name of the kind, set in the class body of a kind.
130
+ TYPE: "str | None" = None
131
+ #: The instances (hosts) a call that names no `type` gets this kind for.
132
+ INSTANCES: "tuple[str, ...]" = ()
133
+
134
+ if _ty.TYPE_CHECKING:
135
+ _segments: "tuple[str, ...]"
136
+
137
+ def __init__(
138
+ self,
139
+ *segments: str | RepoPath,
140
+ type: str | None = None,
141
+ instance: str | None = None,
142
+ **options: _ty.Any,
143
+ ) -> None: ...
144
+
145
+ def __init_subclass__(cls, replace: bool = False, **kwargs):
146
+ super().__init_subclass__(**kwargs)
147
+ name = cls.__dict__.get("TYPE")
148
+ if name is not None:
149
+ _register(cls, name, cls.__dict__.get("INSTANCES", ()), replace)
150
+
151
+ @staticmethod
152
+ def kinds() -> "tuple[str, ...]":
153
+ """The names of the registered kinds, sorted."""
154
+ with _LOCK:
155
+ return tuple(sorted(_KINDS))
156
+
157
+ @staticmethod
158
+ def _kind_for(segments, name: object, instance: object) -> "type[RepoPath]":
159
+ """The kind a call to `RepoPath(...)` makes: the one `name` registers,
160
+ else the one `instance` is detected for, else the one of the first
161
+ segment that is a repository path."""
162
+ with _LOCK:
163
+ kinds = dict(_KINDS)
164
+ detected = dict(_DETECTED)
165
+ known = ", ".join(sorted(kinds)) or "none"
166
+ if name is not None:
167
+ if not isinstance(name, str):
168
+ raise TypeError(f"type= must be a str, not {type(name).__name__!r}")
169
+ try:
170
+ return kinds[name]
171
+ except KeyError:
172
+ raise ValueError(
173
+ f"no repository kind {name!r}; the registered kinds are: {known}"
174
+ ) from None
175
+ if instance is not None:
176
+ if not isinstance(instance, str):
177
+ raise TypeError(
178
+ f"instance= must be a str, not {type(instance).__name__!r}"
179
+ )
180
+ try:
181
+ return detected[instance.lower()]
182
+ except KeyError:
183
+ raise ValueError(
184
+ f"cannot tell the kind of instance {instance!r}; pass type= "
185
+ f"with one of: {known}"
186
+ ) from None
187
+ for segment in segments:
188
+ if isinstance(segment, RepoPath):
189
+ return segment._family()
190
+ raise TypeError(f"RepoPath needs type= or instance= (kinds: {known})")
191
+
192
+ @classmethod
193
+ def _family(cls) -> type:
194
+ """The kind `cls` is, or derives from: its instances can donate a
195
+ place and a backend to a join, and a path of another kind cannot."""
196
+ return next((c for c in cls.__mro__ if c.__dict__.get("TYPE")), RepoPath)
197
+
198
+ def _initialize(self, text: str, place: tuple, backend) -> None:
199
+ self._segments = self._parse(text)
200
+ self._place = place
201
+ self._backend = backend
202
+ self._hint = None
203
+
204
+ def _derived(self, segments: "tuple[str, ...]"):
205
+ """A path of this class, place and backend with the names `segments`."""
206
+ path = object.__new__(type(self))
207
+ path._segments = segments
208
+ path._place = self._place
209
+ path._backend = self._backend
210
+ path._hint = None
211
+ return path
212
+
213
+ def _rebuild(self, text: str):
214
+ return self._derived(self._parse(text))
215
+
216
+ def _keywords(self) -> dict:
217
+ """The constructor keywords that name the place of this path."""
218
+ raise NotImplementedError
219
+
220
+ def _place_key(self) -> tuple:
221
+ """What of the place `==`, `hash()` and ordering compare."""
222
+ return self._place
223
+
224
+ def _identity(self) -> tuple:
225
+ # Relative and rooted spellings name one node: the tree has no
226
+ # working directory.
227
+ return (self._place_key(), tuple(name for name in self._segments if name))
228
+
229
+ def __eq__(self, other: object):
230
+ if type(self) is not type(other):
231
+ return NotImplemented
232
+ return self._identity() == other._identity()
233
+
234
+ def __hash__(self) -> int:
235
+ return hash((type(self), self._identity()))
236
+
237
+ def _order_key(self):
238
+ return self._identity()
239
+
240
+ def __reduce__(self):
241
+ # The place and the names; a backend travels only when it says it can
242
+ # (its token is a credential), else the receiving process builds a
243
+ # backend of its own.
244
+ keywords = self._keywords()
245
+ if getattr(self._backend, "picklable", False) is True:
246
+ keywords["backend"] = self._backend
247
+ return _rebuild_path, (type(self), self._segments, keywords)
248
+
249
+ @property
250
+ def kind(self) -> str:
251
+ """The name of the kind of this path (`"github"`, `"gitlab"`)."""
252
+ return self._family().TYPE
253
+
254
+ @property
255
+ def backend(self) -> "BaseRepoBackend":
256
+ """The backend that sends the requests of this path."""
257
+ return self._backend
258
+
259
+ def is_local(self) -> bool:
260
+ """Whether the tree is on this machine: never. A repository is on a
261
+ hosting service, so nothing is resolved."""
262
+ return False
263
+
264
+ def _pop_stat_hint(self) -> "FileStat | None":
265
+ """The stat that the listing of the parent attached to this child,
266
+ once; the next call asks the service. A hint that knows the type and
267
+ not the size is consumed here and not returned: `stat()` has to give
268
+ a size."""
269
+ hint, self._hint = self._hint, None
270
+ if hint is not None and not hint.size_known:
271
+ return None
272
+ return hint
273
+
274
+ def _st_mode(self, *, follow_symlinks=True):
275
+ # The type questions are answered by a type-only hint, without a request.
276
+ hint = self._hint
277
+ if hint is not None and not hint.size_known:
278
+ self._hint = None
279
+ mode = hint.st_mode
280
+ if _stat.S_IFMT(mode):
281
+ return mode
282
+ return super()._st_mode(follow_symlinks=follow_symlinks)
283
+
284
+ def _make_child_relpath(self, name: str, stat_hint=None, **kwargs):
285
+ """The child called exactly `name`, with the stat its listing gave."""
286
+ segments = self._segments
287
+ names = ("", name) if segments == ("", "") else (*segments, name)
288
+ child = self._derived(names)
289
+ child._hint = stat_hint
290
+ return child
291
+
292
+ def iterdir(self):
293
+ for name, listed in self._scandir():
294
+ yield self._make_child_relpath(name, stat_hint=listed)
295
+
296
+ @staticmethod
297
+ def _check_read_mode(mode: str) -> None:
298
+ # `open()` hands `_open()` a canonical mode ("r", "r+", "w", ...);
299
+ # anything but plain "r" would write, and writes are unsupported.
300
+ if mode.replace("b", "").replace("t", "") != "r":
301
+ raise NotImplementedError(f"open(mode={mode!r}): a repository is read-only")
302
+
303
+ @staticmethod
304
+ def _reader(data: bytes) -> _ty.BinaryIO:
305
+ # Read-only like a local file opened "rb": a write raises
306
+ # io.UnsupportedOperation instead of landing in a detached buffer.
307
+ return _io.BufferedReader(_io.BytesIO(data))
308
+
309
+
310
+ def _rebuild_path(cls, segments, keywords):
311
+ """Unpickles a path: `cls(**keywords)` with the names it had."""
312
+ path = cls(**keywords)
313
+ path._segments = segments
314
+ return path
repopath/_version.py ADDED
@@ -0,0 +1,11 @@
1
+ """The version of the installed distribution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import metadata as _metadata
6
+
7
+ try:
8
+ __version__ = _metadata.version("repopath")
9
+ except _metadata.PackageNotFoundError:
10
+ # Run from a source tree that was never installed.
11
+ __version__ = "0+unknown"
repopath/exceptions.py ADDED
@@ -0,0 +1,43 @@
1
+ """The exceptions and the warning `repopath` defines.
2
+
3
+ A failure of a hosting service or of the connection to it is the `OSError` type
4
+ that `pathlib` raises for the same condition (`FileNotFoundError`,
5
+ `PermissionError`, `TimeoutError`, `ConnectionError`, ...); this module holds
6
+ the two that name a cause no builtin does. An exception of the HTTP transport is
7
+ never raised across the API. A bad argument is a plain `ValueError` or
8
+ `TypeError`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathapi import PathError
14
+
15
+ __all__ = [
16
+ "BadReplyError",
17
+ "InsecureTransportWarning",
18
+ "RateLimitError",
19
+ "RepoPathError",
20
+ ]
21
+
22
+
23
+ class RepoPathError(PathError):
24
+ """Base class of the exceptions `repopath` defines for a failure at run
25
+ time that no builtin describes."""
26
+
27
+
28
+ class RateLimitError(RepoPathError, BlockingIOError):
29
+ """The service refused a request because the caller used up its rate limit
30
+ (errno `EAGAIN`): HTTP 403 or 429 with a `RateLimit-Remaining: 0` or a
31
+ `Retry-After` header. The message says when to try again."""
32
+
33
+
34
+ class BadReplyError(RepoPathError, OSError):
35
+ """The service answered with something that is not the shape its API
36
+ documents (errno `EIO`): a page of a proxy or a captive portal, a gateway's
37
+ JSON, `null`, a truncated body, a size that is not a number."""
38
+
39
+
40
+ class InsecureTransportWarning(UserWarning):
41
+ """A `RepoBackend` is about to send an `Authorization` header (its token,
42
+ or one given in `headers=`) over plain `http://` to a host that is not
43
+ this machine, where anyone on the route can read it."""
repopath/py.typed ADDED
File without changes
repopath/uri.py ADDED
@@ -0,0 +1,253 @@
1
+ """The `repo:`, `github:` and `gitlab:` schemes of `uripath`: `RepoURIPath`,
2
+ `GitHubURIPath` and `GitLabURIPath`, URI paths that delegate to a `GitHubPath`
3
+ or a `GitLabPath`.
4
+
5
+ Importing this module needs `uripath`: `pip install "repopath[uri]"`. `uripath`
6
+ loads it by itself, through the `uripath.schemes` entry points, the first time
7
+ a path of one of the schemes is used.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ try:
13
+ import uripath as _uripath # noqa: F401
14
+ except ImportError as _error:
15
+ raise ImportError(
16
+ 'repopath.uri needs the uripath distribution: pip install "repopath[uri]"',
17
+ name="uripath",
18
+ ) from _error
19
+
20
+ import contextlib as _contextlib
21
+ import errno as _errno
22
+ import typing as _ty
23
+
24
+ from uripath import Source
25
+ from uripath.exceptions import URISyntaxError
26
+ from uripath.schemes import DelegatingURIPath
27
+ from uripath.source import compose_host, fold_host
28
+
29
+ from ._backend import RepoBackend
30
+ from ._github import GitHubPath
31
+ from ._gitlab import GitLabPath
32
+ from ._hosted import HostedRepoPath
33
+ from ._kinds import RepoPath
34
+
35
+ __all__ = ["GitHubURIPath", "GitLabURIPath", "RepoURIPath"]
36
+
37
+
38
+ class RepoURIPath(DelegatingURIPath):
39
+ """`repo:` scheme (`repo://instance/owner/repo/path?ref=REF`): read-only
40
+ access to a repository on a hosting service, done by the path of its kind
41
+ (a `GitHubPath` or a `GitLabPath`) on the backend of this path. The kind is
42
+ detected from the host: `github.com` and `gitlab.com` (and `www.`); any
43
+ other host is a `ValueError` that names `github://host/...` and
44
+ `gitlab://host/...`, which pin the kind on any host. `ref` (branch, tag or
45
+ commit) is optional and carried in the `?ref=` query; without one the
46
+ service's default branch is read.
47
+
48
+ Auth: a bearer token given as the user slot of the URI,
49
+ `repo://TOKEN@host/...`, or as its password slot,
50
+ `repo://x-access-token:TOKEN@host/...` (the `oauth2:TOKEN` of GitLab
51
+ too); the `backend` keyword wins if both are given. Because the user slot
52
+ can be the secret, `str()`, `repr()`, `as_posix()` and `as_uri(sanitize=True)`,
53
+ and so every message of an exception, drop the WHOLE userinfo for these
54
+ schemes; `as_uri()` still round-trips it. The tree is read-only."""
55
+
56
+ __SCHEMES = ("repo",)
57
+ __slots__ = ()
58
+ _DELEGATED = ("_scandir", "_listdir", "_open")
59
+
60
+ #: The kind this class pins, or None to detect it from the host.
61
+ _KIND: "type[HostedRepoPath] | None" = None
62
+
63
+ if _ty.TYPE_CHECKING:
64
+ backend: RepoBackend
65
+
66
+ def __init__(self, *args, **kwargs):
67
+ super().__init__(*args, **kwargs)
68
+ # Settles the parts, so a host that no kind is detected for is refused
69
+ # when the path is built and not at its first request.
70
+ self.source
71
+
72
+ def _init(self, source: Source, path, query, fragment, /, **kwargs):
73
+ self._kind_of(source, path, query, fragment)
74
+ super()._init(source, path, query, fragment, **kwargs)
75
+
76
+ @classmethod
77
+ def _kind_of(
78
+ cls, source: Source, path: str = "", query="", fragment=""
79
+ ) -> "type[HostedRepoPath]":
80
+ """The kind of path that serves a URI at `source`: the one this class
81
+ pins, else the one its host is detected for."""
82
+ if cls._KIND is not None:
83
+ return cls._KIND
84
+ host = str(source.host or "").lower()
85
+ try:
86
+ kind = RepoPath._kind_for((), None, host or None)
87
+ except (TypeError, ValueError):
88
+ kind = None
89
+ if kind is None or not issubclass(kind, HostedRepoPath):
90
+ # Redacted like the paths: the userinfo can be a bare token.
91
+ shown = cls._format_parsed_parts(
92
+ source, path, query, fragment, sanitize=True
93
+ )
94
+ raise URISyntaxError(
95
+ f"repo: can only auto-detect github.com and gitlab.com; "
96
+ f"use github: or gitlab: for {shown!r}"
97
+ )
98
+ return kind
99
+
100
+ def as_posix(self) -> str:
101
+ # `user@host:path` would show the token when it is the user slot.
102
+ host = self.source.host
103
+ return f"{host}:{self.path}" if host else self.path
104
+
105
+ def _initbackend(self):
106
+ user, password = self.source.parsed_userinfo()
107
+ return RepoBackend(token=password or user or None)
108
+
109
+ def is_local(self) -> bool:
110
+ """Whether the tree is on this machine: never. The host of the URI is
111
+ a hosting service and is not resolved."""
112
+ return False
113
+
114
+ @property
115
+ def kind(self) -> str:
116
+ """The name of the kind of repository this path reads (`"github"`,
117
+ `"gitlab"`)."""
118
+ return self._kind_of(self.source).TYPE
119
+
120
+ @property
121
+ def instance(self) -> str:
122
+ """The host (and port) of the service, in the case a URL spells it."""
123
+ text = self._instance()
124
+ return text or self._kind_of(self.source).DEFAULT_INSTANCE
125
+
126
+ def _instance(self) -> "str | None":
127
+ """`host[:port]` of the URI with an IPv6 literal bracketed, or None
128
+ when the URI names no host (the kind's default instance then)."""
129
+ source = self.source
130
+ if not source.host:
131
+ return None
132
+ text = compose_host(fold_host(source.host))
133
+ return f"{text}:{source.port}" if source.port else text
134
+
135
+ def _split(self) -> "tuple[str, str, list[str]]":
136
+ """`(owner, repo, names)` of the path of this URI, the kind's way:
137
+ `("", "", [])` and the like when it has fewer parts than a
138
+ repository."""
139
+ parts = [part for part in self.segments if part]
140
+ split = self._kind_of(self.source)._from_uri_segments(parts)
141
+ if split is not None:
142
+ return split
143
+ return (parts[0] if parts else ""), "", []
144
+
145
+ @property
146
+ def repo_owner(self) -> str:
147
+ """The owner of the repository: the first segment (the group path of a
148
+ GitLab project in a subgroup, ended by `/-/`)."""
149
+ return self._split()[0]
150
+
151
+ @property
152
+ def repo(self) -> str:
153
+ """The name of the repository."""
154
+ return self._split()[1]
155
+
156
+ @property
157
+ def repo_path(self) -> str:
158
+ """The path inside the repository, with no leading or trailing `/`."""
159
+ return "/".join(self._split()[2])
160
+
161
+ @property
162
+ def ref(self) -> "str | None":
163
+ """The branch, tag or commit of the `?ref=` query, or None."""
164
+ return self.query.to_dict(single=True).get("ref") or None
165
+
166
+ def _make_core(self) -> HostedRepoPath:
167
+ kind = self._kind_of(self.source)
168
+ owner, repo, names = self._split()
169
+ if not (owner and repo):
170
+ raise FileNotFoundError(
171
+ _errno.ENOENT,
172
+ f"a {self.source.scheme}: path needs an owner and a repository",
173
+ str(self),
174
+ )
175
+ return kind(
176
+ *names,
177
+ owner=owner,
178
+ repo=repo,
179
+ ref=self.ref,
180
+ instance=self._instance(),
181
+ backend=self.backend,
182
+ )
183
+
184
+ @_contextlib.contextmanager
185
+ def _delegated(self, *extra: "tuple[str, str]"):
186
+ # The core path names itself by its own scheme (`github:` for a path
187
+ # of `repo:`), and in the middle of a message as well as alone: the
188
+ # name of this path replaces it wherever it is.
189
+ try:
190
+ with super()._delegated(*extra):
191
+ yield
192
+ except OSError as error:
193
+ core_path = self._core
194
+ if core_path is not None and str(core_path) != str(self):
195
+ core, mine = str(core_path), str(self)
196
+ error.args = tuple(
197
+ arg.replace(core, mine) if isinstance(arg, str) else arg
198
+ for arg in error.args
199
+ )
200
+ # `str(OSError)` reads these, not `args`.
201
+ for attribute in ("strerror", "filename", "filename2"):
202
+ value = getattr(error, attribute)
203
+ if isinstance(value, str) and core in value:
204
+ setattr(error, attribute, value.replace(core, mine))
205
+ raise
206
+
207
+
208
+ class GitHubURIPath(RepoURIPath):
209
+ """`github:` scheme (`github://[TOKEN@]host/owner/repo/path?ref=REF`):
210
+ read-only access to a GitHub repository tree via the REST contents API,
211
+ done by a `GitHubPath`. `host` defaults to `github.com` (API at
212
+ `api.github.com`); any other host is GitHub Enterprise (API at
213
+ `https://{host}/api/v3`). The kind is fixed whatever the host. See
214
+ `RepoURIPath` for the token and the ref."""
215
+
216
+ __SCHEMES = ("github",)
217
+ __slots__ = ()
218
+ _KIND = GitHubPath
219
+
220
+
221
+ class GitLabURIPath(RepoURIPath):
222
+ """`gitlab:` scheme (`gitlab://[TOKEN@]host[:port]/owner/repo/path?ref=REF`):
223
+ read-only access to a GitLab project's repository tree via the REST API
224
+ v4, done by a `GitLabPath`. A project in a subgroup is addressed with
225
+ GitLab's own `/-/` separator, as in its web URLs:
226
+ `gitlab://host/group/subgroup/project/-/path/in/repo`; without the
227
+ separator the first two segments are owner and repository. `host` defaults
228
+ to `gitlab.com`; a self-hosted instance is just a different host (and
229
+ port), always at `https://{host}/api/v4`. See `RepoURIPath` for the token
230
+ and the ref."""
231
+
232
+ __SCHEMES = ("gitlab",)
233
+ __slots__ = ()
234
+ _KIND = GitLabPath
235
+
236
+ def _separator_index(self) -> "int | None":
237
+ # GitLab's `/-/` ends the project path; it needs at least
238
+ # `owner/repo` in front of it.
239
+ try:
240
+ return self.segments.index("-", 3)
241
+ except ValueError:
242
+ return None
243
+
244
+ def _make_child_relpath(self, name: str, **kwargs):
245
+ parent = self
246
+ if name == "-" and self._separator_index() is None and len(self.segments) > 2:
247
+ # A directory literally named "-" would read as the separator:
248
+ # spell the parent with `/-/` so the child stays that directory.
249
+ segments = self.segments[:3] + ("-",) + self.segments[3:]
250
+ parent = self._from_parsed_parts(
251
+ self.source, "/".join(segments), self.query, self.fragment
252
+ )
253
+ return super(GitLabURIPath, parent)._make_child_relpath(name, **kwargs)