Coverage for pycommons / dev / building / build_info.py: 99%

136 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-11 03:04 +0000

1"""The project build information.""" 

2from argparse import ArgumentParser, Namespace 

3from dataclasses import dataclass 

4from typing import Final, Iterable 

5 

6from pycommons.io.path import Path, directory_path 

7from pycommons.processes.python import PYTHON_ENV 

8from pycommons.processes.shell import STREAM_FORWARD, Command 

9from pycommons.types import check_int_range, type_error 

10 

11 

12@dataclass(frozen=True, init=False, order=False, eq=False) 

13class BuildInfo: 

14 """ 

15 A class that represents information about building a project. 

16 

17 >>> b = BuildInfo(Path(__file__).up(4), "pycommons", "tests", "examples", 

18 ... "docs/source", "docs/build", "dist") 

19 >>> b.base_dir == Path(__file__).up(4) 

20 True 

21 >>> b.package_name 

22 'pycommons' 

23 >>> b.sources_dir.endswith('pycommons') 

24 True 

25 >>> b.examples_dir.endswith('examples') 

26 True 

27 >>> b.tests_dir.endswith('tests') 

28 True 

29 >>> b.doc_source_dir.endswith('source') 

30 True 

31 >>> b.doc_dest_dir.endswith('build') 

32 True 

33 >>> b.dist_dir.endswith('dist') 

34 True 

35 

36 >>> try: 

37 ... BuildInfo(None, "pycommons") 

38 ... except TypeError as te: 

39 ... print(te) 

40 descriptor '__len__' requires a 'str' object but received a 'NoneType' 

41 

42 >>> try: 

43 ... BuildInfo(1, "pycommons") 

44 ... except TypeError as te: 

45 ... print(te) 

46 descriptor '__len__' requires a 'str' object but received a 'int' 

47 

48 >>> try: 

49 ... BuildInfo(Path(__file__).up(4), None) 

50 ... except TypeError as te: 

51 ... print(te) 

52 descriptor 'strip' for 'str' objects doesn't apply to a 'NoneType' object 

53 

54 >>> try: 

55 ... BuildInfo(Path(__file__).up(4), 1) 

56 ... except TypeError as te: 

57 ... print(te) 

58 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

59 

60 >>> try: 

61 ... BuildInfo(Path(__file__).up(4), "") 

62 ... except ValueError as ve: 

63 ... print(ve) 

64 Relative path must not be empty. 

65 

66 >>> try: 

67 ... BuildInfo(Path(__file__).up(4), ".") 

68 ... except ValueError as ve: 

69 ... print(str(ve)[:32]) 

70 Inconsistent directories ['.', ' 

71 

72 >>> try: 

73 ... BuildInfo(Path(__file__).up(4), "..") 

74 ... except ValueError as ve: 

75 ... print("does not contain" in str(ve)) 

76 True 

77 

78 >>> try: 

79 ... BuildInfo(Path(__file__).up(4), "pycommons", 1) 

80 ... except TypeError as te: 

81 ... print(te) 

82 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

83 

84 >>> try: 

85 ... BuildInfo(Path(__file__).up(4), "pycommons", "..") 

86 ... except ValueError as ve: 

87 ... print("does not contain" in str(ve)) 

88 True 

89 

90 >>> try: 

91 ... BuildInfo(Path(__file__).up(4), "pycommons", ".") 

92 ... except ValueError as ve: 

93 ... print(str(ve)[:27]) 

94 Inconsistent directories [' 

95 

96 >>> try: 

97 ... BuildInfo(Path(__file__).up(4), "pycommons", None, 1) 

98 ... except TypeError as te: 

99 ... print(te) 

100 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

101 

102 >>> try: 

103 ... BuildInfo(Path(__file__).up(4), "pycommons", None, "..") 

104 ... except ValueError as ve: 

105 ... print("does not contain" in str(ve)) 

106 True 

107 

108 >>> try: 

109 ... BuildInfo(Path(__file__).up(4), "pycommons", None, ".") 

110 ... except ValueError as ve: 

111 ... print(str(ve)[:27]) 

112 Inconsistent directories [' 

113 

114 >>> try: 

115 ... BuildInfo(Path(__file__).up(4), "pycommons", doc_source_dir=1) 

116 ... except TypeError as te: 

117 ... print(te) 

118 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

119 

120 >>> try: 

121 ... BuildInfo(Path(__file__).up(4), "pycommons", doc_dest_dir=1) 

122 ... except TypeError as te: 

123 ... print(te) 

124 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

125 

126 >>> try: 

127 ... BuildInfo(Path(__file__).up(4), "pycommons", dist_dir=1) 

128 ... except TypeError as te: 

129 ... print(te) 

130 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

131 

132 >>> try: 

133 ... BuildInfo(Path(__file__).up(4), "pycommons", 

134 ... doc_source_dir="docs", doc_dest_dir="docs/build") 

135 ... except ValueError as ve: 

136 ... print(str(ve)[:20]) 

137 Nested directories ' 

138 """ 

139 

140 #: the path to the project base directory 

141 base_dir: Path 

142 #: the name of the project / package 

143 package_name: str 

144 #: the path to the directory with the package sources 

145 sources_dir: Path 

146 #: the path to the tests directory, if any 

147 tests_dir: Path | None 

148 #: the path to the examples directory, if any 

149 examples_dir: Path | None 

150 #: the source directory for documentation 

151 doc_source_dir: Path | None 

152 #: the destination directory for documentation 

153 doc_dest_dir: Path | None 

154 #: the directory where the distribution files should be located 

155 dist_dir: Path | None 

156 #: the standard timeout 

157 timeout: int 

158 

159 def __init__(self, base_dir: str, 

160 package_name: str, 

161 tests_dir: str | None = None, 

162 examples_dir: str | None = None, 

163 doc_source_dir: str | None = None, 

164 doc_dest_dir: str | None = None, 

165 dist_dir: str | None = None, 

166 timeout: int = 3600) -> None: 

167 """ 

168 Create the build information class. 

169 

170 :param base_dir: the base directory of the project 

171 :param package_name: the package name 

172 :param tests_dir: the tests folder, if any 

173 :param examples_dir: the examples folder, if any 

174 :param doc_source_dir: the documentation source directory, if any 

175 :param doc_dest_dir: the documentation destination directory, if any 

176 :param dist_dir: the distribution directory, if any 

177 :param timeout: the standard timeout in seconds 

178 :raises ValueError: if the directories are inconsistent 

179 """ 

180 object.__setattr__(self, "base_dir", directory_path(base_dir)) 

181 object.__setattr__(self, "package_name", str.strip(package_name)) 

182 object.__setattr__(self, "sources_dir", directory_path( 

183 self.base_dir.resolve_inside(self.package_name))) 

184 object.__setattr__( 

185 self, "tests_dir", 

186 None if tests_dir is None else directory_path( 

187 self.base_dir.resolve_inside(str.strip(tests_dir)))) 

188 object.__setattr__( 

189 self, "examples_dir", 

190 None if examples_dir is None else directory_path( 

191 self.base_dir.resolve_inside(str.strip(examples_dir)))) 

192 object.__setattr__( 

193 self, "doc_source_dir", None if doc_source_dir is None 

194 else directory_path(self.base_dir.resolve_inside( 

195 str.strip(doc_source_dir)))) 

196 object.__setattr__( 

197 self, "doc_dest_dir", None if doc_dest_dir is None 

198 else self.base_dir.resolve_inside(str.strip(doc_dest_dir))) 

199 object.__setattr__( 

200 self, "dist_dir", None if dist_dir is None 

201 else self.base_dir.resolve_inside(str.strip(dist_dir))) 

202 n: int = 3 

203 dirs: set[str] = {self.base_dir, self.sources_dir, self.package_name} 

204 if self.examples_dir is not None: 

205 dirs.add(self.examples_dir) 

206 n += 1 

207 if self.tests_dir is not None: 

208 dirs.add(self.tests_dir) 

209 n += 1 

210 if self.doc_source_dir is not None: 

211 dirs.add(self.doc_source_dir) 

212 n += 1 

213 if self.doc_dest_dir is not None: 

214 dirs.add(self.doc_dest_dir) 

215 n += 1 

216 if self.dist_dir is not None: 

217 dirs.add(self.dist_dir) 

218 n += 1 

219 if set.__len__(dirs) != n: 

220 raise ValueError(f"Inconsistent directories {sorted(dirs)!r}.") 

221 

222 dirs.remove(self.base_dir) 

223 sel: list[Path] = [p for p in dirs if isinstance(p, Path)] 

224 for i, p1 in enumerate(sel): 

225 for j in range(i + 1, list.__len__(sel)): 

226 p2 = sel[j] 

227 if p1.contains(p2) or p2.contains(p1): 

228 raise ValueError( 

229 f"Nested directories {p1!r} and {p2!r}.") 

230 

231 object.__setattr__( 

232 self, "timeout", check_int_range( 

233 timeout, "timeout", 1, 1_000_000_000)) 

234 

235 def __str__(self) -> str: 

236 r""" 

237 Convert this object to a string. 

238 

239 :returns: the string version of this object. 

240 

241 >>> str(BuildInfo(Path(__file__).up(4), "pycommons"))[:15] 

242 "'pycommons' in " 

243 >>> str(BuildInfo(Path(__file__).up(4), "pycommons", "tests"))[-31:] 

244 ', and per-step timeout is 3600s' 

245 >>> str(BuildInfo(Path(__file__).up(4), "pycommons", "tests", 

246 ... "examples"))[-51:] 

247 "amples in 'examples', and per-step timeout is 3600s" 

248 >>> str(BuildInfo(Path(__file__).up(4), "pycommons", None, 

249 ... "examples"))[-35:] 

250 "les', and per-step timeout is 3600s" 

251 >>> for f in str(BuildInfo(Path(__file__).up(4), "pycommons", None, 

252 ... doc_dest_dir="docs/build", doc_source_dir="docs/source", 

253 ... dist_dir="dist")).split("'")[2::2]: 

254 ... print(str.strip(f)) 

255 in 

256 , documentation sources in 

257 , documentation destination in 

258 , distribution destination is 

259 , and per-step timeout is 3600s 

260 """ 

261 text: str = f"{self.package_name!r} in {self.base_dir!r}" 

262 dirs: list[str] = [] 

263 if self.tests_dir is not None: 

264 dirs.append( 

265 f"tests in {self.tests_dir.relative_to(self.base_dir)!r}") 

266 if self.examples_dir is not None: 

267 dirs.append(f"examples in " 

268 f"{self.examples_dir.relative_to(self.base_dir)!r}") 

269 if self.doc_source_dir is not None: 

270 dirs.append(f"documentation sources in " 

271 f"{self.doc_source_dir.relative_to(self.base_dir)!r}") 

272 if self.doc_dest_dir is not None: 

273 dirs.append(f"documentation destination in " 

274 f"{self.doc_dest_dir.relative_to(self.base_dir)!r}") 

275 if self.dist_dir is not None: 

276 dirs.append(f"distribution destination is " 

277 f"{self.dist_dir.relative_to(self.base_dir)!r}") 

278 dirs.append(f"per-step timeout is {self.timeout}s") 

279 n: Final[int] = list.__len__(dirs) 

280 if n == 1: 

281 return f"{text} and {dirs[0]}" 

282 dirs[-1] = f"and {dirs[-1]}" 

283 dirs.insert(0, text) 

284 return ", ".join(dirs) 

285 

286 def command(self, args: Iterable[str]) -> Command: 

287 """ 

288 Create a typical build step command. 

289 

290 This command will receive an environment with all the Python-related 

291 environment variables that were also passed to the current process. 

292 This includes the Path, the Python interpreter's name, as well as 

293 information about the virtual environment, if any. This is necessary 

294 if we use build tools that were installed in this virtual environment. 

295 

296 :param args: the arguments of the command 

297 :param env: the environment to be used with this command 

298 :returns: a command relative to the build directories 

299 

300 >>> b = BuildInfo(Path(__file__).up(4), "pycommons") 

301 >>> cmd = b.command(("cat", "README.txt")) 

302 >>> cmd.working_dir == b.base_dir 

303 True 

304 >>> cmd.command 

305 ('cat', 'README.txt') 

306 >>> cmd.timeout 

307 3600 

308 >>> cmd.stderr == STREAM_FORWARD 

309 True 

310 >>> cmd.stdout == STREAM_FORWARD 

311 True 

312 >>> cmd.timeout 

313 3600 

314 """ 

315 return Command(args, working_dir=self.base_dir, timeout=self.timeout, 

316 stderr=STREAM_FORWARD, stdout=STREAM_FORWARD, 

317 env=PYTHON_ENV) 

318 

319 

320def parse_project_arguments(parser: ArgumentParser, 

321 args: list[str] | None = None) -> BuildInfo: 

322 """ 

323 Load project information arguments from the command line. 

324 

325 :param parser: the argument parser 

326 :param args: the command line arguments 

327 :returns: the build info 

328 :raises: TypeError if the types are wrong 

329 

330 >>> from pycommons.io.arguments import pycommons_argparser 

331 >>> ap = pycommons_argparser(__file__, "a test program", 

332 ... "An argument parser for testing this function.") 

333 >>> ee = parse_project_arguments(ap, ["--root", Path(__file__).up(4), 

334 ... "--package", "pycommons"]) 

335 >>> ee.package_name 

336 'pycommons' 

337 >>> ee.sources_dir.endswith("pycommons") 

338 True 

339 >>> ee.dist_dir.endswith("dist") 

340 True 

341 >>> ee.doc_source_dir.endswith("docs/source") 

342 True 

343 >>> ee.doc_dest_dir.endswith("docs/build") 

344 True 

345 >>> ee.examples_dir.endswith("examples") 

346 True 

347 >>> ee.tests_dir.endswith("tests") 

348 True 

349 

350 >>> try: 

351 ... parse_project_arguments(None) 

352 ... except TypeError as te: 

353 ... print(te) 

354 parser should be an instance of argparse.ArgumentParser but is None. 

355 

356 >>> try: 

357 ... parse_project_arguments(1) 

358 ... except TypeError as te: 

359 ... print(str(te)[:40]) 

360 parser should be an instance of argparse 

361 """ 

362 if not isinstance(parser, ArgumentParser): 

363 raise type_error(parser, "parser", ArgumentParser) 

364 parser.add_argument( 

365 "--root", help="the project root directory", type=Path, nargs="?", 

366 default=".") 

367 parser.add_argument( 

368 "--package", help="the name of the package folder", type=str) 

369 parser.add_argument( 

370 "--tests", 

371 help="the relative path to the tests folder, if any", 

372 nargs="?", default=None) 

373 parser.add_argument( 

374 "--examples", 

375 help="the relative path to the examples folder, if any ", 

376 nargs="?", default=None) 

377 parser.add_argument( 

378 "--doc-src", help="the relative path to the documentation" 

379 " source, if any ", 

380 nargs="?", default=None) 

381 parser.add_argument( 

382 "--doc-dst", help="the relative path to the documentation" 

383 " destination, if any ", 

384 nargs="?", default=None) 

385 parser.add_argument( 

386 "--dist", help="the relative path to the distribution, if any ", 

387 nargs="?", default=None) 

388 parser.add_argument( 

389 "--timeout", help="the per-step timeout", type=int, 

390 nargs="?", default=3600) 

391 res: Final[Namespace] = parser.parse_args(args) 

392 

393 root: Final[Path] = directory_path(res.root) 

394 pack: Final[str] = str.strip(str.strip(res.package)) 

395 done: Final[set[str | None]] = {root, pack} 

396 

397 tests: str | None = res.tests 

398 if (tests is None) and ("tests" not in done) and ( 

399 root.resolve_inside("tests").is_dir()): 

400 tests = "tests" 

401 done.add(tests) 

402 

403 examples: str | None = res.examples 

404 if (examples is None) and ("examples" not in done) and ( 

405 root.resolve_inside("examples").is_dir()): 

406 examples = "examples" 

407 done.add(examples) 

408 

409 doc_src: str | None = res.doc_src 

410 doc_dir: Path | None = None 

411 if (doc_src is None) and ("docs/source" not in done): 

412 doc_dir = root.resolve_inside("docs") 

413 if doc_dir.is_dir() and (doc_dir.resolve_inside("source").is_dir()): 

414 doc_src = "docs/source" 

415 done.add(doc_src) 

416 

417 doc_dst: str | None = res.doc_dst 

418 if (doc_dst is None) and ("docs/build" not in done): 

419 if doc_dir is None: 

420 doc_dir = root.resolve_inside("docs") 

421 if doc_dir.is_dir() and (doc_dir.resolve_inside( 

422 "build").is_dir() or (doc_src == "docs/source")): 

423 doc_dst = "docs/build" 

424 done.add(doc_dst) 

425 

426 dist: str | None = res.dist 

427 if (dist is None) and ("dist" not in done): 

428 ddd = root.resolve_inside("dist") 

429 if (not ddd.exists()) or ddd.is_dir(): 

430 dist = "dist" 

431 

432 return BuildInfo(root, pack, tests, examples, doc_src, doc_dst, dist, 

433 res.timeout) 

434 

435 

436def replace_in_cmd(orig: Iterable[str], replace_with: str, 

437 replace_what: str = ".") -> Iterable[str]: 

438 """ 

439 Replace the occurrences of `replace_what` with `replace_with`. 

440 

441 :param orig: the original sequence 

442 :param replace_with: the string it is to be replace with 

443 :param replace_what: the string to be replaced 

444 :returns: the replaced sequence 

445 

446 >>> replace_in_cmd(('x', '.', 'y'), 'a', '.') 

447 ['x', 'a', 'y'] 

448 >>> replace_in_cmd(('x', '.', 'y'), 'a') 

449 ['x', 'a', 'y'] 

450 

451 >>> try: 

452 ... replace_in_cmd(None, 'a', '.') 

453 ... except TypeError as te: 

454 ... print(te) 

455 orig should be an instance of typing.Iterable but is None. 

456 

457 >>> try: 

458 ... replace_in_cmd(1, 'a', '.') 

459 ... except TypeError as te: 

460 ... print(te) 

461 orig should be an instance of typing.Iterable but is int, namely 1. 

462 

463 >>> try: 

464 ... replace_in_cmd([], 'a', '.') 

465 ... except ValueError as ve: 

466 ... print(ve) 

467 Did not find '.'. 

468 

469 >>> try: 

470 ... replace_in_cmd(['x'], 'a', '.') 

471 ... except ValueError as ve: 

472 ... print(ve) 

473 Did not find '.'. 

474 

475 >>> try: 

476 ... replace_in_cmd(['x'], 'a') 

477 ... except ValueError as ve: 

478 ... print(ve) 

479 Did not find '.'. 

480 

481 >>> try: 

482 ... replace_in_cmd(['x'], None, '.') 

483 ... except TypeError as te: 

484 ... print(te) 

485 descriptor '__len__' requires a 'str' object but received a 'NoneType' 

486 

487 >>> try: 

488 ... replace_in_cmd(['x'], 1, '.') 

489 ... except TypeError as te: 

490 ... print(te) 

491 descriptor '__len__' requires a 'str' object but received a 'int' 

492 

493 >>> try: 

494 ... replace_in_cmd(['x'], '', '.') 

495 ... except ValueError as ve: 

496 ... print(ve) 

497 Invalid replace_with ''. 

498 

499 >>> try: 

500 ... replace_in_cmd(['x'], 'y', None) 

501 ... except TypeError as te: 

502 ... print(te) 

503 descriptor '__len__' requires a 'str' object but received a 'NoneType' 

504 

505 >>> try: 

506 ... replace_in_cmd(['x'], 'y', 1) 

507 ... except TypeError as te: 

508 ... print(te) 

509 descriptor '__len__' requires a 'str' object but received a 'int' 

510 

511 >>> try: 

512 ... replace_in_cmd(['x'], 'x', '') 

513 ... except ValueError as ve: 

514 ... print(ve) 

515 Invalid replace_what ''. 

516 """ 

517 if not isinstance(orig, Iterable): 

518 raise type_error(orig, "orig", Iterable) 

519 if str.__len__(replace_with) <= 0: 

520 raise ValueError(f"Invalid replace_with {replace_with!r}.") 

521 if str.__len__(replace_what) <= 0: 

522 raise ValueError(f"Invalid replace_what {replace_what!r}.") 

523 result: list[str] = [] 

524 found: bool = False 

525 for k in orig: 

526 if k == replace_what: 

527 found = True 

528 result.append(replace_with) 

529 else: 

530 result.append(k) 

531 if not found: 

532 raise ValueError(f"Did not find {replace_what!r}.") 

533 return result