Coverage for pyTooling / Dependency / Python.py: 68%

329 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-08 23:46 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ ___ _ __ ___ _ __ __| | ___ _ __ ___ _ _ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | |/ _ \ '_ \ / _ \ '_ \ / _` |/ _ \ '_ \ / __| | | | # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| |_| | __/ |_) | __/ | | | (_| | __/ | | | (__| |_| | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \___| .__/ \___|_| |_|\__,_|\___|_| |_|\___|\__, | # 

7# |_| |___/ |___/ |_| |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2025-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32Implementation of package dependencies. 

33 

34.. hint:: 

35 

36 See :ref:`high-level help <DEPENDENCIES>` for explanations and usage examples. 

37""" 

38from asyncio import run as asyncio_run, gather as asyncio_gather 

39from datetime import datetime 

40from enum import IntEnum 

41from functools import wraps, update_wrapper 

42from threading import RLock 

43from typing import Optional as Nullable, List, Dict, Union, Iterable, Mapping, Callable, Iterator 

44 

45try: 

46 from aiohttp import ClientSession 

47except ImportError as ex: # pragma: no cover 

48 raise Exception(f"Optional dependency 'aiohttp' not installed. Either install pyTooling with extra dependencies 'pyTooling[pypi]' or install 'aiohttp' directly.") from ex 

49 

50try: 

51 from packaging.requirements import Requirement 

52except ImportError as ex: # pragma: no cover 

53 raise Exception(f"Optional dependency 'packaging' not installed. Either install pyTooling with extra dependencies 'pyTooling[pypi]' or install 'packaging' directly.") from ex 

54 

55try: 

56 from requests import Session, HTTPError 

57except ImportError as ex: # pragma: no cover 

58 raise Exception(f"Optional dependency 'requests' not installed. Either install pyTooling with extra dependencies 'pyTooling[pypi]' or install 'requests' directly.") from ex 

59 

60try: 

61 from pyTooling.Decorators import export, readonly 

62 from pyTooling.MetaClasses import ExtendedType, abstractmethod, mustoverride 

63 from pyTooling.Exceptions import ToolingException 

64 from pyTooling.Common import getFullyQualifiedName, firstKey, firstValue 

65 from pyTooling.Dependency import Package, PackageStorage, PackageVersion, PackageDependencyGraph 

66 from pyTooling.GenericPath.URL import URL 

67 from pyTooling.Versioning import SemanticVersion, PythonVersion, Parts 

68except (ImportError, ModuleNotFoundError): # pragma: no cover 

69 print("[pyTooling.Dependency] Could not import from 'pyTooling.*'!") 

70 

71 try: 

72 from Decorators import export, readonly 

73 from MetaClasses import ExtendedType, abstractmethod, mustoverride 

74 from Exceptions import ToolingException 

75 from Common import getFullyQualifiedName, firstKey, firstValue 

76 from Dependency import Package, PackageStorage, PackageVersion, PackageDependencyGraph 

77 from GenericPath.URL import URL 

78 from Versioning import SemanticVersion, PythonVersion, Parts 

79 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover 

80 print("[pyTooling.Dependency] Could not import directly!") 

81 raise ex 

82 

83 

84@export 

85class LazyLoaderState(IntEnum): 

86 Uninitialized = 0 #: No data or minimal data like ID or name. 

87 Initialized = 1 #: Initialized by some __init__ parameters. 

88 PartiallyLoaded = 2 #: Some additional data was loaded. 

89 FullyLoaded = 3 #: All data is loaded. 

90 PostProcessed = 4 #: Loaded data triggered further processing. 

91 

92 

93@export 

94class lazy: 

95 """ 

96 Unified decorator that supports: 

97 1. @lazy(state) def method() 

98 2. @lazy(state) @property def prop() 

99 """ 

100 

101 def __init__(self, _requiredState: LazyLoaderState = LazyLoaderState.PartiallyLoaded): 

102 self._requiredState = _requiredState 

103 self._wrapped = None 

104 

105 def __call__(self, wrapped): 

106 self._wrapped = wrapped 

107 # If it's a function, we update metadata. 

108 # If it's a property, it doesn't support update_wrapper directly. 

109 if hasattr(wrapped, "__name__"): 

110 update_wrapper(self, wrapped) 

111 

112 return self 

113 

114 def __get__(self, obj, objtype=None): 

115 if obj is None: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true

116 return self 

117 

118 # 1. Thread-safe state check 

119 with obj.__lazy_lock__: 

120 if obj.__lazy_state__ < self._requiredState: 

121 obj.__lazy_loader__(self._requiredState) 

122 

123 # 2. Determine if we are wrapping a property or a method 

124 if isinstance(self._wrapped, property): 

125 # If it's a property, call its __get__ to return the value 

126 return self._wrapped.__get__(obj, objtype) 

127 

128 # 3. Otherwise, treat as a method and return a bound wrapper 

129 @wraps(self._wrapped) 

130 def wrapper(*args, **kwargs): 

131 return self._wrapped(obj, *args, **kwargs) 

132 

133 return wrapper 

134 

135 

136@export 

137class LazyLoadableMixin(metaclass=ExtendedType, mixin=True): 

138 __lazy_state__: LazyLoaderState 

139 __lazy_lock__: RLock 

140 

141 def __init__(self, targetLevel: LazyLoaderState = LazyLoaderState.Initialized) -> None: 

142 self.__lazy_state__ = LazyLoaderState.Initialized 

143 self.__lazy_lock__ = RLock() 

144 

145 if targetLevel > self.__lazy_state__: 

146 with self.__lazy_lock__: 

147 self.__lazy_loader__(targetLevel) 

148 

149 @abstractmethod 

150 def __lazy_loader__(self, targetLevel: LazyLoaderState) -> None: 

151 pass 

152 

153 

154@export 

155class Distribution(metaclass=ExtendedType, slots=True): 

156 _filename: str 

157 _url: URL 

158 _uploadTime: datetime 

159 

160 def __init__(self, filename: str, url: Union[str, URL], uploadTime: datetime) -> None: 

161 if not isinstance(filename, str): 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 ex = TypeError("Parameter 'filename' is not of type 'str'.") 

163 ex.add_note(f"Got type '{getFullyQualifiedName(filename)}'.") 

164 raise ex 

165 

166 self._filename = filename 

167 

168 if isinstance(url, str): 168 ↛ 170line 168 didn't jump to line 170 because the condition on line 168 was always true

169 url = URL.Parse(url) 

170 elif not isinstance(url, URL): 

171 ex = TypeError("Parameter 'url' is not of type 'URL'.") 

172 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.") 

173 raise ex 

174 

175 self._url = url 

176 

177 if not isinstance(uploadTime, datetime): 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true

178 ex = TypeError("Parameter 'uploadTime' is not of type 'str'.") 

179 ex.add_note(f"Got type '{getFullyQualifiedName(uploadTime)}'.") 

180 raise ex 

181 

182 self._uploadTime = uploadTime 

183 

184 @readonly 

185 def Filename(self) -> str: 

186 return self._filename 

187 

188 @readonly 

189 def URL(self) -> URL: 

190 return self._url 

191 

192 @readonly 

193 def UploadTime(self) -> datetime: 

194 return self._uploadTime 

195 

196 def __repr__(self) -> str: 

197 return f"Distribution: {self._filename}" 

198 

199 def __str__(self) -> str: 

200 return f"{self._filename}" 

201 

202 

203@export 

204class Release(PackageVersion, LazyLoadableMixin): 

205 _files: List[Distribution] 

206 _requirements: Dict[Union[str, None], List[Requirement]] 

207 

208 _api: Nullable[URL] 

209 _session: Nullable[Session] 

210 

211 def __init__( 

212 self, 

213 version: PythonVersion, 

214 timestamp: datetime, 

215 files: Nullable[Iterable[Distribution]] = None, 

216 requirements: Nullable[Mapping[str, List[Requirement]]] = None, 

217 project: Nullable["Project"] = None, 

218 lazy: LazyLoaderState = LazyLoaderState.Initialized 

219 ) -> None: 

220 if project is not None and (storage := project._storage) is not None: 220 ↛ 224line 220 didn't jump to line 224 because the condition on line 220 was always true

221 self._api = storage._api 

222 self._session = storage._session 

223 else: 

224 self._api = None 

225 self._session = None 

226 

227 super().__init__(version, project, timestamp) 

228 LazyLoadableMixin.__init__(self, lazy) 

229 

230 self._files = [file for file in files] if files is not None else [] 

231 self._requirements = {k: v for k, v in requirements} if requirements is not None else {None: []} 

232 

233 def __lazy_loader__(self, targetLevel: LazyLoaderState) -> None: 

234 if targetLevel >= LazyLoaderState.PartiallyLoaded: 234 ↛ 236line 234 didn't jump to line 236 because the condition on line 234 was always true

235 self.DownloadDetails() 

236 if targetLevel >= LazyLoaderState.PostProcessed: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true

237 self.PostProcess() 

238 

239 @lazy(LazyLoaderState.PostProcessed) 

240 @PackageVersion.DependsOn.getter 

241 def DependsOn(self) -> Dict["Package", Dict[SemanticVersion, "PackageVersion"]]: 

242 return super().DependsOn 

243 

244 @readonly 

245 def Project(self) -> "Project": 

246 return self._package 

247 

248 @lazy(LazyLoaderState.PartiallyLoaded) 

249 @readonly 

250 def Files(self) -> List[Distribution]: 

251 return self._files 

252 

253 @lazy(LazyLoaderState.PartiallyLoaded) 

254 @readonly 

255 def Requirements(self) -> Dict[str, List[Requirement]]: 

256 return self._requirements 

257 

258 def _GetPyPIEndpoint(self) -> str: 

259 return f"{self._package._name.lower()}/{self._version}/json" 

260 

261 def DownloadDetails(self) -> None: 

262 if self._session is None: 262 ↛ 264line 262 didn't jump to line 264 because the condition on line 262 was never true

263 # TODO: NoSessionAvailableException 

264 raise ToolingException(f"No session available.") 

265 

266 response = self._session.get(url=f"{self._api}{self._GetPyPIEndpoint()}") 

267 try: 

268 response.raise_for_status() 

269 except HTTPError as ex: 

270 if ex.response.status_code == 404: 

271 # TODO: ReleaseNotFoundException 

272 raise ToolingException(f"Release '{self._version}' of package '{self._package._name}' not found.") 

273 

274 self.UpdateDetailsFromPyPIJSON(response.json()) 

275 

276 index: PythonPackageIndex = self._package._storage 

277 for requirement in self._requirements[None]: 

278 packageName = requirement.name 

279 index.DownloadProject(packageName, True) 

280 

281 def UpdateDetailsFromPyPIJSON(self, json) -> None: 

282 infoNode = json["info"] 

283 if (extras := infoNode["provides_extra"]) is not None: 

284 self._requirements = {extra: [] for extra in extras} 

285 self._requirements[None] = [] 

286 

287 if (requirements := infoNode["requires_dist"]) is not None: 287 ↛ 308line 287 didn't jump to line 308 because the condition on line 287 was always true

288 brokenRequirements = [] 

289 for requirement in requirements: 

290 req = Requirement(requirement) 

291 

292 # Handle requirements without an extra marker 

293 if req.marker is None: 

294 self._requirements[None].append(req) 

295 continue 

296 

297 for extra in self._requirements.keys(): 

298 if extra is not None and req.marker.evaluate({"extra": extra}): 

299 self._requirements[extra].append(req) 

300 break 

301 else: 

302 brokenRequirements.append(req) 

303 

304 # TODO: raise a warning 

305 if len(brokenRequirements) > 0: 

306 self._requirements[0] = brokenRequirements 

307 

308 self.__lazy_state__ = LazyLoaderState.FullyLoaded 

309 

310 def PostProcess(self) -> None: 

311 index: PythonPackageIndex = self._package._storage 

312 for requirement in self._requirements[None]: 

313 package = index.DownloadProject(requirement.name) 

314 

315 for release in package: 

316 if str(release._version) in requirement.specifier: 

317 self.AddDependencyToPackageVersion(release) 

318 

319 self.SortDependencies() 

320 self.__lazy_state__ = LazyLoaderState.PostProcessed 

321 

322 @lazy(LazyLoaderState.PartiallyLoaded) 

323 def __repr__(self) -> str: 

324 return f"Release: {self._package._name}:{self._version} Files: {len(self._files)}" 

325 

326 def __str__(self) -> str: 

327 return f"{self._version}" 

328 

329 

330@export 

331class Project(Package, LazyLoadableMixin): 

332 _url: Nullable[URL] 

333 

334 _api: Nullable[URL] 

335 _session: Nullable[Session] 

336 

337 def __init__( 

338 self, 

339 name: str, 

340 url: Union[str, URL], 

341 releases: Nullable[Iterable[Release]] = None, 

342 index: Nullable["PythonPackageIndex"] = None, 

343 lazy: LazyLoaderState = LazyLoaderState.Initialized 

344 ) -> None: 

345 if index is not None: 345 ↛ 349line 345 didn't jump to line 349 because the condition on line 345 was always true

346 self._api = index._api 

347 self._session = index._session 

348 else: 

349 self._api = None 

350 self._session = None 

351 

352 super().__init__(name, storage=index) 

353 LazyLoadableMixin.__init__(self, lazy) 

354 

355 # if isinstance(url, str): 

356 # url = URL.Parse(url) 

357 # elif not isinstance(url, URL): 

358 # ex = TypeError("Parameter 'url' is not of type 'URL'.") 

359 # ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.") 

360 # raise ex 

361 # 

362 # self._url = url 

363 # self._releases = {release.Version: release for release in sorted(releases, key=lambda r: r.Version)} if releases is not None else {} 

364 

365 def __lazy_loader__(self, targetLevel: LazyLoaderState) -> None: 

366 if targetLevel >= LazyLoaderState.PartiallyLoaded: 366 ↛ 368line 366 didn't jump to line 368 because the condition on line 366 was always true

367 self.DownloadDetails() 

368 if targetLevel >= LazyLoaderState.PostProcessed: 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true

369 self.DownloadReleaseDetails() 

370 

371 @readonly 

372 def PackageIndex(self) -> "PythonPackageIndex": 

373 return self._storage 

374 

375 @lazy(LazyLoaderState.PartiallyLoaded) 

376 @readonly 

377 def URL(self) -> URL: 

378 return self._url 

379 

380 @lazy(LazyLoaderState.PartiallyLoaded) 

381 @readonly 

382 def Releases(self) -> Dict[PythonVersion, Release]: 

383 return self._versions 

384 

385 @lazy(LazyLoaderState.PartiallyLoaded) 

386 @readonly 

387 def ReleaseCount(self) -> int: 

388 return len(self._versions) 

389 

390 @lazy(LazyLoaderState.PartiallyLoaded) 

391 @readonly 

392 def LatestRelease(self) -> Release: 

393 return firstValue(self._versions) 

394 

395 def _GetPyPIEndpoint(self) -> str: 

396 return f"{self._name.lower()}/json" 

397 

398 def DownloadDetails(self) -> None: 

399 if self._session is None: 399 ↛ 401line 399 didn't jump to line 401 because the condition on line 399 was never true

400 # TODO: NoSessionAvailableException 

401 raise ToolingException(f"No session available.") 

402 

403 response = self._session.get(url=f"{self._api}{self._GetPyPIEndpoint()}") 

404 try: 

405 response.raise_for_status() 

406 except HTTPError as ex: 

407 if ex.response.status_code == 404: 

408 # TODO: ReleaseNotFoundException 

409 raise ToolingException(f"Package '{self._name}' not found.") 

410 

411 self.UpdateDetailsFromPyPIJSON(response.json()) 

412 

413 def UpdateDetailsFromPyPIJSON(self, json) -> None: 

414 infoNode = json["info"] 

415 releasesNode = json["releases"] 

416 

417 # Update project/package URL 

418 self._url = URL.Parse(infoNode["project_url"]) 

419 

420 # Convert key to Version number, skip empty releases 

421 convertedReleasesNode = {} 

422 for k, v in releasesNode.items(): 

423 if len(v) == 0: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true

424 continue 

425 

426 try: 

427 version = PythonVersion.Parse(k) 

428 convertedReleasesNode[version] = v 

429 except ValueError as ex: 

430 print(f"Unsupported version format '{k}' - {ex}") 

431 

432 for version, releaseNode in sorted(convertedReleasesNode.items(), key=lambda t: t[0]): 

433 if Parts.Postfix in version._parts: 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true

434 pass 

435 

436 files = [Distribution(file["filename"], file["url"], datetime.fromisoformat(file["upload_time_iso_8601"]), ) for 

437 file in releaseNode] 

438 lazy = LazyLoaderState.PartiallyLoaded if LazyLoaderState.PartiallyLoaded <= self.__lazy_state__ <= LazyLoaderState.FullyLoaded else LazyLoaderState.Initialized 

439 Release( 

440 version, 

441 files[0]._uploadTime, 

442 files, 

443 project=self, 

444 lazy=lazy 

445 ) 

446 

447 self.SortVersions() 

448 self.__lazy_state__ = LazyLoaderState.FullyLoaded 

449 

450 def DownloadReleaseDetails(self) -> None: 

451 async def ParallelDownloadReleaseDetails(): 

452 async def routine(session, release: Release): 

453 if Parts.Postfix in release._version._parts: 

454 pass 

455 

456 async with session.get(self._GetPyPIEndpoint()) as response: 

457 json = await response.json() 

458 response.raise_for_status() 

459 

460 release.UpdateDetailsFromPyPIJSON(json) 

461 

462 async with ClientSession(base_url=str(self._api), headers={"accept": "application/json"}) as session: 

463 tasks = [] 

464 for release in self._versions.values(): # type: Release 

465 tasks.append(routine(session, release)) 

466 

467 results = await asyncio_gather(*tasks, return_exceptions=True) 

468 delList = [] 

469 for release, result in zip(self.Releases.values(), results): 

470 if isinstance(result, Exception): 

471 delList.append((release, result)) 

472 

473 # TODO: raise a warning 

474 for release, ex in delList: 

475 print(f" Removing {release.Project._name} {release.Version} - {ex}") 

476 del self.Releases[release.Version] 

477 

478 asyncio_run(ParallelDownloadReleaseDetails()) 

479 self.__lazy_state__ = LazyLoaderState.PostProcessed 

480 

481 def __repr__(self) -> str: 

482 return f"Project: {self._name} latest: {self.LatestRelease._version}" 

483 

484 def __str__(self) -> str: 

485 return f"{self._name}" 

486 

487 

488@export 

489class PythonPackageIndex(PackageStorage): 

490 _url: URL 

491 

492 _api: URL 

493 _session: Session 

494 

495 def __init__(self, name: str, url: Union[str, URL], api: Union[str, URL], graph: "PackageDependencyGraph") -> None: 

496 super().__init__(name, graph) 

497 

498 if isinstance(url, str): 498 ↛ 500line 498 didn't jump to line 500 because the condition on line 498 was always true

499 url = URL.Parse(url) 

500 elif not isinstance(url, URL): 

501 ex = TypeError("Parameter 'url' is not of type 'URL'.") 

502 ex.add_note(f"Got type '{getFullyQualifiedName(url)}'.") 

503 raise ex 

504 

505 self._url = url 

506 

507 if isinstance(api, str): 507 ↛ 509line 507 didn't jump to line 509 because the condition on line 507 was always true

508 api = URL.Parse(api) 

509 elif not isinstance(api, URL): 

510 ex = TypeError("Parameter 'api' is not of type 'URL'.") 

511 ex.add_note(f"Got type '{getFullyQualifiedName(api)}'.") 

512 raise ex 

513 

514 self._api = api 

515 

516 self._session = Session() 

517 self._session.headers["accept"] = "application/json" 

518 

519 @readonly 

520 def URL(self) -> URL: 

521 return self._url 

522 

523 @readonly 

524 def API(self) -> URL: 

525 return self._api 

526 

527 @readonly 

528 def Projects(self) -> Dict[str, Project]: 

529 return self._packages 

530 

531 @readonly 

532 def ProjectCount(self) -> int: 

533 return len(self._packages) 

534 

535 def _GetPyPIEndpoint(self, projectName: str) -> str: 

536 return f"{self._api}{projectName.lower()}/json" 

537 

538 def DownloadProject(self, projectName: str, lazy: LazyLoaderState = LazyLoaderState.PartiallyLoaded) -> Project: 

539 project = Project(projectName, "", index=self, lazy=lazy) 

540 

541 return project 

542 

543 def __repr__(self) -> str: 

544 return f"{self._name}" 

545 

546 def __str__(self) -> str: 

547 return f"{self._name}" 

548 

549 

550@export 

551class PythonPackageDependencyGraph(PackageDependencyGraph): 

552 def __init__(self, name: str) -> None: 

553 super().__init__(name)