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

1"""Expected lpip failures with a problem statement and a likely fix (ASR-0005).""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6 

7 

8class LpipError(Exception): 

9 """Base for user-facing failures. Default CLI UX should print this, not a traceback.""" 

10 

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()) 

22 

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) 

30 

31 

32class InstallUsageError(LpipError): 

33 """install NAME and -r were combined, or --catalog-only without -r.""" 

34 

35 

36class InstallRevisionEqualsConflictError(LpipError): 

37 """``--revision`` was combined with ``==`` / ``===`` (ADR-0023).""" 

38 

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 ) 

45 

46 

47class RequirementsFileError(LpipError): 

48 """Requirements file is missing or has an unsupported line (ADR-0017).""" 

49 

50 

51class RequirementsParseError(LpipError): 

52 """A requirements line is not a PEP 508 requirement.""" 

53 

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 ) 

63 

64 

65class UnsupportedCatalogSpecifierError(LpipError): 

66 """Catalog install used a specifier other than == (ADR-0018).""" 

67 

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 ) 

74 

75 

76class CatalogExtrasNotSupportedError(LpipError): 

77 """Catalog install used extras (not in the v1 schema).""" 

78 

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 ) 

84 

85 

86class VersionTagNotFoundError(LpipError): 

87 """No Git tag maps to the requested PEP 440 version (ADR-0018).""" 

88 

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 ) 

97 

98 

99class UnknownPackageError(LpipError): 

100 """Package name is not in the catalog (AC-0002, AC-0008).""" 

101 

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 ) 

110 

111 

112class TomlParseError(LpipError): 

113 """TOML syntax is invalid (AC-0003).""" 

114 

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 ) 

124 

125 

126class UnknownTomlKeyError(LpipError): 

127 """Unknown key or unexpected table (AC-0010, ADR-0011).""" 

128 

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 ) 

137 

138 

139class CatalogSchemaError(LpipError): 

140 """Catalog structure is invalid (AC-0003).""" 

141 

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 ) 

148 

149 

150class ConfigSchemaError(LpipError): 

151 """Persistent config structure is invalid.""" 

152 

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 ) 

159 

160 

161class MissingRevisionError(LpipError): 

162 """Package revision omitted while pinning is required (AC-0011).""" 

163 

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 ) 

172 

173 

174class DisallowedSchemeError(LpipError): 

175 """Package URL scheme is not in policy.allowed_schemes.""" 

176 

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 ) 

192 

193 

194class SubdirectoryEscapeError(LpipError): 

195 """subdirectory would leave the repository root.""" 

196 

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 ) 

204 

205 

206class GitNotFoundError(LpipError): 

207 """git executable is missing when required (AC-0004).""" 

208 

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 ) 

215 

216 

217class PipxNotFoundError(LpipError): 

218 """pipx executable is missing when the pipx backend is selected (ADR-0024).""" 

219 

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 ) 

226 

227 

228class GitCommandError(LpipError): 

229 """A git subprocess failed (clone, fetch, or checkout).""" 

230 

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 ) 

240 

241 

242class CatalogAlreadyExistsError(LpipError): 

243 """catalog init refused to overwrite (AC-0019).""" 

244 

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 ) 

251 

252 

253class ConfigAlreadyExistsError(LpipError): 

254 """config init refused to overwrite (AC-0009).""" 

255 

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 ) 

262 

263 

264class UnsupportedAuthoringUrlError(LpipError): 

265 """catalog add URL is not ssh/https or git+ equivalent (ADR-0015).""" 

266 

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 ) 

274 

275 

276class PackageAlreadyExistsError(LpipError): 

277 """catalog add hit an existing package name without --force.""" 

278 

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 ) 

286 

287 

288class MissingCatalogAddFieldsError(LpipError): 

289 """--non-interactive catalog add is missing name or URL.""" 

290 

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 ) 

296 

297 

298class InvalidPackageNameError(LpipError): 

299 """Catalog package key is not a usable TOML / pip name.""" 

300 

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 ) 

307 

308 

309class InvalidSourceNameError(LpipError): 

310 """[[sources]] name is empty or illegal.""" 

311 

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 ) 

317 

318 

319class SourceAlreadyExistsError(LpipError): 

320 """source add hit an existing name.""" 

321 

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 ) 

328 

329 

330class SourceNotFoundError(LpipError): 

331 """source remove / show on an unknown name.""" 

332 

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 ) 

338 

339 

340class CatalogFileNotFoundError(LpipError): 

341 """Configured catalog path does not exist.""" 

342 

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 )