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
« 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
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
12@dataclass(frozen=True, init=False, order=False, eq=False)
13class BuildInfo:
14 """
15 A class that represents information about building a project.
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
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'
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'
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
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
60 >>> try:
61 ... BuildInfo(Path(__file__).up(4), "")
62 ... except ValueError as ve:
63 ... print(ve)
64 Relative path must not be empty.
66 >>> try:
67 ... BuildInfo(Path(__file__).up(4), ".")
68 ... except ValueError as ve:
69 ... print(str(ve)[:32])
70 Inconsistent directories ['.', '
72 >>> try:
73 ... BuildInfo(Path(__file__).up(4), "..")
74 ... except ValueError as ve:
75 ... print("does not contain" in str(ve))
76 True
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
84 >>> try:
85 ... BuildInfo(Path(__file__).up(4), "pycommons", "..")
86 ... except ValueError as ve:
87 ... print("does not contain" in str(ve))
88 True
90 >>> try:
91 ... BuildInfo(Path(__file__).up(4), "pycommons", ".")
92 ... except ValueError as ve:
93 ... print(str(ve)[:27])
94 Inconsistent directories ['
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
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
108 >>> try:
109 ... BuildInfo(Path(__file__).up(4), "pycommons", None, ".")
110 ... except ValueError as ve:
111 ... print(str(ve)[:27])
112 Inconsistent directories ['
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
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
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
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 """
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
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.
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}.")
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}.")
231 object.__setattr__(
232 self, "timeout", check_int_range(
233 timeout, "timeout", 1, 1_000_000_000))
235 def __str__(self) -> str:
236 r"""
237 Convert this object to a string.
239 :returns: the string version of this object.
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)
286 def command(self, args: Iterable[str]) -> Command:
287 """
288 Create a typical build step command.
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.
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
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)
320def parse_project_arguments(parser: ArgumentParser,
321 args: list[str] | None = None) -> BuildInfo:
322 """
323 Load project information arguments from the command line.
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
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
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.
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)
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}
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)
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)
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)
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)
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"
432 return BuildInfo(root, pack, tests, examples, doc_src, doc_dst, dist,
433 res.timeout)
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`.
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
446 >>> replace_in_cmd(('x', '.', 'y'), 'a', '.')
447 ['x', 'a', 'y']
448 >>> replace_in_cmd(('x', '.', 'y'), 'a')
449 ['x', 'a', 'y']
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.
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.
463 >>> try:
464 ... replace_in_cmd([], 'a', '.')
465 ... except ValueError as ve:
466 ... print(ve)
467 Did not find '.'.
469 >>> try:
470 ... replace_in_cmd(['x'], 'a', '.')
471 ... except ValueError as ve:
472 ... print(ve)
473 Did not find '.'.
475 >>> try:
476 ... replace_in_cmd(['x'], 'a')
477 ... except ValueError as ve:
478 ... print(ve)
479 Did not find '.'.
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'
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'
493 >>> try:
494 ... replace_in_cmd(['x'], '', '.')
495 ... except ValueError as ve:
496 ... print(ve)
497 Invalid replace_with ''.
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'
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'
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