lpip.errors

src/lpip/errors.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
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
180
181
182
183
184
185
186
187
188
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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
"""Expected lpip failures with a problem statement and a likely fix (ASR-0005)."""

from __future__ import annotations

from pathlib import Path


class LpipError(Exception):
    """Base for user-facing failures. Default CLI UX should print this, not a traceback."""

    def __init__(
        self,
        message: str,
        *,
        hint: str | None = None,
        path: Path | None = None,
    ) -> None:
        self.message = message
        self.hint = hint
        self.path = path
        super().__init__(self.format())

    def format(self) -> str:
        parts = [self.message]
        if self.path is not None:
            parts.append(f"File: {self.path}")
        if self.hint:
            parts.append(self.hint)
        return "\n".join(parts)


class InstallUsageError(LpipError):
    """install NAME and -r were combined, or --catalog-only without -r."""


class InstallRevisionEqualsConflictError(LpipError):
    """``--revision`` was combined with ``==`` / ``===`` (ADR-0023)."""

    def __init__(self) -> None:
        super().__init__(
            "Do not combine --revision with == or ===.",
            hint="Use lpip install libfoo==1.2.3 for a Git tag, or "
            "lpip install libfoo --revision <ref> for a branch, tag, or SHA.",
        )


class RequirementsFileError(LpipError):
    """Requirements file is missing or has an unsupported line (ADR-0017)."""


class RequirementsParseError(LpipError):
    """A requirements line is not a PEP 508 requirement."""

    def __init__(self, line: str, *, detail: str | None = None) -> None:
        msg = f"Could not parse requirement {line!r}."
        if detail:
            msg = f"{msg} {detail}"
        super().__init__(
            msg,
            hint="Use a pip requirement line (name, optional extras/specifiers), "
            "or a # comment.",
        )


class UnsupportedCatalogSpecifierError(LpipError):
    """Catalog install used a specifier other than == (ADR-0018)."""

    def __init__(self, line: str) -> None:
        super().__init__(
            f"Catalog packages only accept == (or no specifier), not {line!r}.",
            hint="Use lpip install libfoo or lpip install libfoo==1.2.3. "
            "Ranges like >= are not resolved from Git tags.",
        )


class CatalogExtrasNotSupportedError(LpipError):
    """Catalog install used extras (not in the v1 schema)."""

    def __init__(self, name: str) -> None:
        super().__init__(
            f"Catalog package {name!r} does not support extras.",
            hint="Install without [extras], or add extras in a later lpip version.",
        )


class VersionTagNotFoundError(LpipError):
    """No Git tag maps to the requested PEP 440 version (ADR-0018)."""

    def __init__(self, name: str, version: str) -> None:
        super().__init__(
            f"No Git tag for {name}=={version}.",
            hint=(
                f"Run lpip versions {name} (add --pre for pre-releases), "
                f"or tag the repository with {version} or v{version}."
            ),
        )


class UnknownPackageError(LpipError):
    """Package name is not in the catalog (AC-0002, AC-0008)."""

    def __init__(self, name: str, *, catalog_path: Path | None = None) -> None:
        self.package_name = name
        super().__init__(
            f"Package {name!r} is not defined in the catalog.",
            hint="Check the spelling, run lpip catalog add, or add a "
            "[packages.<name>] table in lpip-sources.toml.",
            path=catalog_path,
        )


class TomlParseError(LpipError):
    """TOML syntax is invalid (AC-0003)."""

    def __init__(self, path: Path, *, detail: str | None = None) -> None:
        msg = f"Could not parse TOML in {path}."
        if detail:
            msg = f"{msg} {detail}"
        super().__init__(
            msg,
            hint="Fix the TOML syntax, then re-run lpip validate.",
            path=path,
        )


class UnknownTomlKeyError(LpipError):
    """Unknown key or unexpected table (AC-0010, ADR-0011)."""

    def __init__(self, key: str, *, path: Path, kind: str = "catalog") -> None:
        self.key = key
        self.kind = kind
        super().__init__(
            f"Unexpected {kind} key or table {key!r} is not in the v1 schema.",
            hint="Remove the unknown field, or wait for an lpip version that documents it.",
            path=path,
        )


class CatalogSchemaError(LpipError):
    """Catalog structure is invalid (AC-0003)."""

    def __init__(self, message: str, *, path: Path | None = None, hint: str | None = None) -> None:
        super().__init__(
            message,
            hint=hint or "See docs/architecture/cli-and-configuration.md for the catalog schema.",
            path=path,
        )


class ConfigSchemaError(LpipError):
    """Persistent config structure is invalid."""

    def __init__(self, message: str, *, path: Path | None = None, hint: str | None = None) -> None:
        super().__init__(
            message,
            hint=hint or "See docs/architecture/cli-and-configuration.md for the config schema.",
            path=path,
        )


class MissingRevisionError(LpipError):
    """Package revision omitted while pinning is required (AC-0011)."""

    def __init__(self, name: str, *, catalog_path: Path | None = None) -> None:
        self.package_name = name
        super().__init__(
            f"Package {name!r} has no revision, but policy.require_pinned_revision is true.",
            hint="Set revision on the package (branch, tag, or SHA), or set "
            "require_pinned_revision to false.",
            path=catalog_path,
        )


class DisallowedSchemeError(LpipError):
    """Package URL scheme is not in policy.allowed_schemes."""

    def __init__(
        self,
        url: str,
        *,
        allowed: tuple[str, ...],
        catalog_path: Path | None = None,
    ) -> None:
        self.url = url
        self.allowed = allowed
        schemes = ", ".join(allowed) if allowed else "(none)"
        super().__init__(
            f"URL {url!r} uses a scheme that is not allowed.",
            hint=f"Use one of: {schemes}. Or add the scheme to policy.allowed_schemes.",
            path=catalog_path,
        )


class SubdirectoryEscapeError(LpipError):
    """subdirectory would leave the repository root."""

    def __init__(self, subdirectory: str, *, catalog_path: Path | None = None) -> None:
        self.subdirectory = subdirectory
        super().__init__(
            f"subdirectory {subdirectory!r} is not a path inside the repository root.",
            hint="Use a relative path without '..' segments that escape the repo.",
            path=catalog_path,
        )


class GitNotFoundError(LpipError):
    """git executable is missing when required (AC-0004)."""

    def __init__(self) -> None:
        super().__init__(
            "git was not found on PATH.",
            hint="Install Git and ensure the git executable is on PATH. "
            "On Windows, Git for Windows typically provides Git\\cmd\\git.exe.",
        )


class PipxNotFoundError(LpipError):
    """pipx executable is missing when the pipx backend is selected (ADR-0024)."""

    def __init__(self) -> None:
        super().__init__(
            "pipx was not found on PATH.",
            hint="Install pipx and ensure the pipx executable is on PATH. "
            "The pipx backend is optional; lpip install without --pipx still uses pip.",
        )


class GitCommandError(LpipError):
    """A git subprocess failed (clone, fetch, or checkout)."""

    def __init__(self, args: list[str], *, detail: str | None = None) -> None:
        shown = " ".join(args)
        msg = f"git {shown} failed."
        if detail:
            msg = f"{msg} {detail}"
        super().__init__(
            msg,
            hint="Check the repository URL, credentials, and that the revision exists.",
        )


class CatalogAlreadyExistsError(LpipError):
    """catalog init refused to overwrite (AC-0019)."""

    def __init__(self, path: Path) -> None:
        super().__init__(
            f"Catalog file already exists: {path}",
            hint="Edit that file, or remove it before running lpip catalog init.",
            path=path,
        )


class ConfigAlreadyExistsError(LpipError):
    """config init refused to overwrite (AC-0009)."""

    def __init__(self, path: Path) -> None:
        super().__init__(
            f"Config file already exists: {path}",
            hint="Edit that file, or remove it before running lpip config init. v1 has no --force.",
            path=path,
        )


class UnsupportedAuthoringUrlError(LpipError):
    """catalog add URL is not ssh/https or git+ equivalent (ADR-0015)."""

    def __init__(self, url: str) -> None:
        self.url = url
        super().__init__(
            f"URL {url!r} is not a supported Git location.",
            hint="Use ssh://, https://, git@host:path, git+ssh://, or git+https:// "
            "— the same kinds of URL git clone accepts.",
        )


class PackageAlreadyExistsError(LpipError):
    """catalog add hit an existing package name without --force."""

    def __init__(self, name: str, *, catalog_path: Path | None = None) -> None:
        self.package_name = name
        super().__init__(
            f"Package {name!r} is already in the catalog.",
            hint="Use a different name, or pass --force to replace the entry.",
            path=catalog_path,
        )


class MissingCatalogAddFieldsError(LpipError):
    """--non-interactive catalog add is missing name or URL."""

    def __init__(self) -> None:
        super().__init__(
            "Non-interactive catalog add requires a package name and --url.",
            hint="Pass NAME --url ssh://… (or https://) or omit --non-interactive to be prompted.",
        )


class InvalidPackageNameError(LpipError):
    """Catalog package key is not a usable TOML / pip name."""

    def __init__(self, name: str) -> None:
        super().__init__(
            f"Package name {name!r} is not a valid catalog key.",
            hint="Use a pip distribution name: letters, digits, ., _, +, or -; "
            "start with a letter or digit.",
        )


class InvalidSourceNameError(LpipError):
    """[[sources]] name is empty or illegal."""

    def __init__(self, name: str) -> None:
        super().__init__(
            f"Source name {name!r} is not valid.",
            hint="Use a letter, then letters, digits, _ or - (for example company).",
        )


class SourceAlreadyExistsError(LpipError):
    """source add hit an existing name."""

    def __init__(self, name: str, *, path: Path | None = None) -> None:
        super().__init__(
            f"Source {name!r} is already configured.",
            hint="Use a different name, or lpip source remove first.",
            path=path,
        )


class SourceNotFoundError(LpipError):
    """source remove / show on an unknown name."""

    def __init__(self, name: str) -> None:
        super().__init__(
            f"Source {name!r} is not configured.",
            hint="Run lpip source list.",
        )


class CatalogFileNotFoundError(LpipError):
    """Configured catalog path does not exist."""

    def __init__(self, path: Path) -> None:
        super().__init__(
            f"Catalog file was not found: {path}",
            hint="Check source.path in config, or refresh a Git-backed catalog "
            "so the in-repo file is present.",
            path=path,
        )