Coverage for src/lpip/errors.py: 91%
115 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-17 20:20 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-17 20:20 +0000
1"""Expected lpip failures with a problem statement and a likely fix (ASR-0005)."""
3from __future__ import annotations
5from pathlib import Path
8class LpipError(Exception):
9 """Base for user-facing failures. Default CLI UX should print this, not a traceback."""
11 def __init__(
12 self,
13 message: str,
14 *,
15 hint: str | None = None,
16 path: Path | None = None,
17 ) -> None:
18 self.message = message
19 self.hint = hint
20 self.path = path
21 super().__init__(self.format())
23 def format(self) -> str:
24 parts = [self.message]
25 if self.path is not None:
26 parts.append(f"File: {self.path}")
27 if self.hint: 27 ↛ 29line 27 didn't jump to line 29 because the condition on line 27 was always true
28 parts.append(self.hint)
29 return "\n".join(parts)
32class InstallUsageError(LpipError):
33 """install NAME and -r were combined, or --catalog-only without -r."""
36class InstallRevisionEqualsConflictError(LpipError):
37 """``--revision`` was combined with ``==`` / ``===`` (ADR-0023)."""
39 def __init__(self) -> None:
40 super().__init__(
41 "Do not combine --revision with == or ===.",
42 hint="Use lpip install libfoo==1.2.3 for a Git tag, or "
43 "lpip install libfoo --revision <ref> for a branch, tag, or SHA.",
44 )
47class RequirementsFileError(LpipError):
48 """Requirements file is missing or has an unsupported line (ADR-0017)."""
51class RequirementsParseError(LpipError):
52 """A requirements line is not a PEP 508 requirement."""
54 def __init__(self, line: str, *, detail: str | None = None) -> None:
55 msg = f"Could not parse requirement {line!r}."
56 if detail: 56 ↛ 58line 56 didn't jump to line 58 because the condition on line 56 was always true
57 msg = f"{msg} {detail}"
58 super().__init__(
59 msg,
60 hint="Use a pip requirement line (name, optional extras/specifiers), "
61 "or a # comment.",
62 )
65class UnsupportedCatalogSpecifierError(LpipError):
66 """Catalog install used a specifier other than == (ADR-0018)."""
68 def __init__(self, line: str) -> None:
69 super().__init__(
70 f"Catalog packages only accept == (or no specifier), not {line!r}.",
71 hint="Use lpip install libfoo or lpip install libfoo==1.2.3. "
72 "Ranges like >= are not resolved from Git tags.",
73 )
76class CatalogExtrasNotSupportedError(LpipError):
77 """Catalog install used extras (not in the v1 schema)."""
79 def __init__(self, name: str) -> None:
80 super().__init__(
81 f"Catalog package {name!r} does not support extras.",
82 hint="Install without [extras], or add extras in a later lpip version.",
83 )
86class VersionTagNotFoundError(LpipError):
87 """No Git tag maps to the requested PEP 440 version (ADR-0018)."""
89 def __init__(self, name: str, version: str) -> None:
90 super().__init__(
91 f"No Git tag for {name}=={version}.",
92 hint=(
93 f"Run lpip versions {name} (add --pre for pre-releases), "
94 f"or tag the repository with {version} or v{version}."
95 ),
96 )
99class UnknownPackageError(LpipError):
100 """Package name is not in the catalog (AC-0002, AC-0008)."""
102 def __init__(self, name: str, *, catalog_path: Path | None = None) -> None:
103 self.package_name = name
104 super().__init__(
105 f"Package {name!r} is not defined in the catalog.",
106 hint="Check the spelling, run lpip catalog add, or add a "
107 "[packages.<name>] table in lpip-sources.toml.",
108 path=catalog_path,
109 )
112class TomlParseError(LpipError):
113 """TOML syntax is invalid (AC-0003)."""
115 def __init__(self, path: Path, *, detail: str | None = None) -> None:
116 msg = f"Could not parse TOML in {path}."
117 if detail: 117 ↛ 119line 117 didn't jump to line 119 because the condition on line 117 was always true
118 msg = f"{msg} {detail}"
119 super().__init__(
120 msg,
121 hint="Fix the TOML syntax, then re-run lpip validate.",
122 path=path,
123 )
126class UnknownTomlKeyError(LpipError):
127 """Unknown key or unexpected table (AC-0010, ADR-0011)."""
129 def __init__(self, key: str, *, path: Path, kind: str = "catalog") -> None:
130 self.key = key
131 self.kind = kind
132 super().__init__(
133 f"Unexpected {kind} key or table {key!r} is not in the v1 schema.",
134 hint="Remove the unknown field, or wait for an lpip version that documents it.",
135 path=path,
136 )
139class CatalogSchemaError(LpipError):
140 """Catalog structure is invalid (AC-0003)."""
142 def __init__(self, message: str, *, path: Path | None = None, hint: str | None = None) -> None:
143 super().__init__(
144 message,
145 hint=hint or "See docs/architecture/cli-and-configuration.md for the catalog schema.",
146 path=path,
147 )
150class ConfigSchemaError(LpipError):
151 """Persistent config structure is invalid."""
153 def __init__(self, message: str, *, path: Path | None = None, hint: str | None = None) -> None:
154 super().__init__(
155 message,
156 hint=hint or "See docs/architecture/cli-and-configuration.md for the config schema.",
157 path=path,
158 )
161class MissingRevisionError(LpipError):
162 """Package revision omitted while pinning is required (AC-0011)."""
164 def __init__(self, name: str, *, catalog_path: Path | None = None) -> None:
165 self.package_name = name
166 super().__init__(
167 f"Package {name!r} has no revision, but policy.require_pinned_revision is true.",
168 hint="Set revision on the package (branch, tag, or SHA), or set "
169 "require_pinned_revision to false.",
170 path=catalog_path,
171 )
174class DisallowedSchemeError(LpipError):
175 """Package URL scheme is not in policy.allowed_schemes."""
177 def __init__(
178 self,
179 url: str,
180 *,
181 allowed: tuple[str, ...],
182 catalog_path: Path | None = None,
183 ) -> None:
184 self.url = url
185 self.allowed = allowed
186 schemes = ", ".join(allowed) if allowed else "(none)"
187 super().__init__(
188 f"URL {url!r} uses a scheme that is not allowed.",
189 hint=f"Use one of: {schemes}. Or add the scheme to policy.allowed_schemes.",
190 path=catalog_path,
191 )
194class SubdirectoryEscapeError(LpipError):
195 """subdirectory would leave the repository root."""
197 def __init__(self, subdirectory: str, *, catalog_path: Path | None = None) -> None:
198 self.subdirectory = subdirectory
199 super().__init__(
200 f"subdirectory {subdirectory!r} is not a path inside the repository root.",
201 hint="Use a relative path without '..' segments that escape the repo.",
202 path=catalog_path,
203 )
206class GitNotFoundError(LpipError):
207 """git executable is missing when required (AC-0004)."""
209 def __init__(self) -> None:
210 super().__init__(
211 "git was not found on PATH.",
212 hint="Install Git and ensure the git executable is on PATH. "
213 "On Windows, Git for Windows typically provides Git\\cmd\\git.exe.",
214 )
217class PipxNotFoundError(LpipError):
218 """pipx executable is missing when the pipx backend is selected (ADR-0024)."""
220 def __init__(self) -> None:
221 super().__init__(
222 "pipx was not found on PATH.",
223 hint="Install pipx and ensure the pipx executable is on PATH. "
224 "The pipx backend is optional; lpip install without --pipx still uses pip.",
225 )
228class GitCommandError(LpipError):
229 """A git subprocess failed (clone, fetch, or checkout)."""
231 def __init__(self, args: list[str], *, detail: str | None = None) -> None:
232 shown = " ".join(args)
233 msg = f"git {shown} failed."
234 if detail: 234 ↛ 236line 234 didn't jump to line 236 because the condition on line 234 was always true
235 msg = f"{msg} {detail}"
236 super().__init__(
237 msg,
238 hint="Check the repository URL, credentials, and that the revision exists.",
239 )
242class CatalogAlreadyExistsError(LpipError):
243 """catalog init refused to overwrite (AC-0019)."""
245 def __init__(self, path: Path) -> None:
246 super().__init__(
247 f"Catalog file already exists: {path}",
248 hint="Edit that file, or remove it before running lpip catalog init.",
249 path=path,
250 )
253class ConfigAlreadyExistsError(LpipError):
254 """config init refused to overwrite (AC-0009)."""
256 def __init__(self, path: Path) -> None:
257 super().__init__(
258 f"Config file already exists: {path}",
259 hint="Edit that file, or remove it before running lpip config init. v1 has no --force.",
260 path=path,
261 )
264class UnsupportedAuthoringUrlError(LpipError):
265 """catalog add URL is not ssh/https or git+ equivalent (ADR-0015)."""
267 def __init__(self, url: str) -> None:
268 self.url = url
269 super().__init__(
270 f"URL {url!r} is not a supported Git location.",
271 hint="Use ssh://, https://, git@host:path, git+ssh://, or git+https:// "
272 "— the same kinds of URL git clone accepts.",
273 )
276class PackageAlreadyExistsError(LpipError):
277 """catalog add hit an existing package name without --force."""
279 def __init__(self, name: str, *, catalog_path: Path | None = None) -> None:
280 self.package_name = name
281 super().__init__(
282 f"Package {name!r} is already in the catalog.",
283 hint="Use a different name, or pass --force to replace the entry.",
284 path=catalog_path,
285 )
288class MissingCatalogAddFieldsError(LpipError):
289 """--non-interactive catalog add is missing name or URL."""
291 def __init__(self) -> None:
292 super().__init__(
293 "Non-interactive catalog add requires a package name and --url.",
294 hint="Pass NAME --url ssh://… (or https://) or omit --non-interactive to be prompted.",
295 )
298class InvalidPackageNameError(LpipError):
299 """Catalog package key is not a usable TOML / pip name."""
301 def __init__(self, name: str) -> None:
302 super().__init__(
303 f"Package name {name!r} is not a valid catalog key.",
304 hint="Use a pip distribution name: letters, digits, ., _, +, or -; "
305 "start with a letter or digit.",
306 )
309class InvalidSourceNameError(LpipError):
310 """[[sources]] name is empty or illegal."""
312 def __init__(self, name: str) -> None:
313 super().__init__(
314 f"Source name {name!r} is not valid.",
315 hint="Use a letter, then letters, digits, _ or - (for example company).",
316 )
319class SourceAlreadyExistsError(LpipError):
320 """source add hit an existing name."""
322 def __init__(self, name: str, *, path: Path | None = None) -> None:
323 super().__init__(
324 f"Source {name!r} is already configured.",
325 hint="Use a different name, or lpip source remove first.",
326 path=path,
327 )
330class SourceNotFoundError(LpipError):
331 """source remove / show on an unknown name."""
333 def __init__(self, name: str) -> None:
334 super().__init__(
335 f"Source {name!r} is not configured.",
336 hint="Run lpip source list.",
337 )
340class CatalogFileNotFoundError(LpipError):
341 """Configured catalog path does not exist."""
343 def __init__(self, path: Path) -> None:
344 super().__init__(
345 f"Catalog file was not found: {path}",
346 hint="Check source.path in config, or refresh a Git-backed catalog "
347 "so the in-repo file is present.",
348 path=path,
349 )