Coverage for pycommons / dev / building / make_dist.py: 98%

96 statements  

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

1"""Make the distribution.""" 

2 

3from argparse import ArgumentParser 

4from configparser import ConfigParser 

5from itertools import chain 

6from typing import Final 

7 

8from pycommons.dev.building.build_info import ( 

9 BuildInfo, 

10 parse_project_arguments, 

11) 

12from pycommons.dev.doc.doc_info import DocInfo, load_doc_info_from_setup_cfg 

13from pycommons.io.arguments import pycommons_argparser 

14from pycommons.io.console import logger 

15from pycommons.io.path import UTF8, Path, delete_path, write_lines 

16from pycommons.io.temp import temp_dir, temp_file 

17from pycommons.processes.python import PYTHON_INTERPRETER 

18from pycommons.processes.shell import STREAM_FORWARD, Command 

19from pycommons.types import type_error 

20 

21#: the prefix commands 

22__PRE_PREFIX: Final[tuple[str, ...]] = ( 

23 "#!/bin/bash", "set -o pipefail", "set -o errtrace", "set -o nounset", 

24 "set -o errexit") 

25 

26#: the prefix commands 

27__PREFIX: Final[tuple[str, ...]] = ( 

28 'echo "Creating virtual environment."', 

29 'python3 -m venv --copies "{VENV}"', 

30 'echo "Activating virtual environment."', 

31 'source "{VENV}/bin/activate"', 

32) 

33 

34#: the suffix commands 

35__SUFFIX: Final[tuple[str, ...]] = ( 

36 'echo "Deactivating virtual environment."', 

37 "deactivate", 

38 'echo "Done."', 

39) 

40 

41 

42#: the virtual environment commands 

43__VENV_CMD: Final[tuple[tuple[str, tuple[str, ...]], ...]] = ( 

44 ("gz distribution with extras", ( 

45 'echo "Installing {GZ_DIST}{EXTRAS} and capturing {REQUIREMENTS}."', 

46 'python3 -m pip --no-input --timeout {TIMEOUT} --retries 100 ' 

47 '--require-virtualenv install "{GZ_DIST}{EXTRAS}"', 

48 'echo "Freezing requirements to {REQUIREMENTS}."', 

49 'pip freeze --all --require-virtualenv ' 

50 '--no-input > "{REQUIREMENTS}"')), 

51 ("wheel distribution with extras", ( 

52 'echo "Installing {WHEEL_DIST}{EXTRAS}."', 

53 'python3 -m pip --no-input --timeout {TIMEOUT} --retries 100 ' 

54 '--require-virtualenv install "{WHEEL_DIST}{EXTRAS}"')), 

55 ("gz distribution without extras", ( 

56 'echo "Installing {GZ_DIST} without extras."', 

57 'python3 -m pip --no-input --timeout {TIMEOUT} --retries 100 ' 

58 '--require-virtualenv install "{GZ_DIST}"')), 

59 ("wheel distribution without extras", ( 

60 'echo "Installing {WHEEL_DIST} without extras."', 

61 'python3 -m pip --no-input --timeout {TIMEOUT} --retries 100 ' 

62 '--require-virtualenv install "{WHEEL_DIST}{EXTRAS}"'))) 

63 

64#: the prefix commands 

65__XZ: Final[tuple[str, ...]] = ( 

66 'tar --dereference --exclude=".nojekyll" -c --transform ' 

67 '"s,^,{BASE}/," * | xz -v -9e -c > "{DEST}"', 

68) 

69 

70 

71def __get_extras(setup_cfg: Path) -> list[str]: 

72 """ 

73 Get all package extras. 

74 

75 :param setup_cfg: the `setup.cfg` file 

76 :returns: the set of extras 

77 

78 >>> root = Path(__file__).up(4) 

79 >>> from contextlib import redirect_stdout 

80 >>> with redirect_stdout(None): 

81 ... ex = __get_extras(root.resolve_inside("setup.cfg")) 

82 >>> print(ex) 

83 ['dev'] 

84 """ 

85 logger(f"Loading extras from {setup_cfg!r}.") 

86 cfg: Final[ConfigParser] = ConfigParser() 

87 cfg.read(setup_cfg, UTF8) 

88 if not cfg.has_section("options.extras_require"): 

89 logger(f"No extras from {setup_cfg!r}.") 

90 return [] 

91 res = sorted(set(map(str.strip, cfg.options("options.extras_require")))) 

92 logger(f"Found extras {res} from {setup_cfg!r}.") 

93 return res 

94 

95 

96def make_dist(info: BuildInfo) -> None: 

97 """ 

98 Create the distribution files. 

99 

100 This code cannot really be unit tested, as it would run the itself 

101 recursively. 

102 

103 :param info: the build information 

104 

105 >>> root = Path(__file__).up(4) 

106 >>> bf = BuildInfo(root, "pycommons", 

107 ... examples_dir=root.resolve_inside("examples"), 

108 ... tests_dir=root.resolve_inside("tests"), 

109 ... dist_dir=root.resolve_inside("dist"), 

110 ... doc_source_dir=root.resolve_inside("docs/source"), 

111 ... doc_dest_dir=root.resolve_inside("docs/build")) 

112 >>> from contextlib import redirect_stdout 

113 >>> with redirect_stdout(None): 

114 ... make_dist(bf) 

115 

116 >>> try: 

117 ... make_dist(None) 

118 ... except TypeError as te: 

119 ... print(str(te)[:50]) 

120 info should be an instance of pycommons.dev.buildi 

121 

122 >>> try: 

123 ... make_dist(1) 

124 ... except TypeError as te: 

125 ... print(str(te)[:50]) 

126 info should be an instance of pycommons.dev.buildi 

127 """ 

128 if not isinstance(info, BuildInfo): 

129 raise type_error(info, "info", BuildInfo) 

130 

131 dest: Final[Path] = info.dist_dir 

132 if dest is None: 

133 raise ValueError(f"Require distribution directory to build {info}.") 

134 logger(f"Now building distribution for {info} to {dest!r}.") 

135 if dest.is_dir(): 

136 delete_path(dest) 

137 dest.ensure_dir_exists() 

138 dest.enforce_dir() 

139 

140 setup_py: Final[Path] = info.base_dir.resolve_inside("setup.py") 

141 if setup_py.is_file(): 

142 logger(f"Checking setup.py file {setup_py!r}.") 

143 info.command((PYTHON_INTERPRETER, setup_py, "check")).execute() 

144 else: 

145 logger("No setup.py file found.") 

146 

147 logger("Building distribution.") 

148 info.command((PYTHON_INTERPRETER, "-m", "build", "-o", dest)).execute() 

149 

150 logger("Now checking distribution.") 

151 info.command((PYTHON_INTERPRETER, "-m", "twine", "check", 

152 f"{dest}/*")).execute() 

153 

154 logger("Loading version information from setup.cfg.") 

155 setup_cfg: Final[Path] = info.base_dir.resolve_inside("setup.cfg") 

156 setup_cfg.enforce_file() 

157 doc_info: Final[DocInfo] = load_doc_info_from_setup_cfg(setup_cfg) 

158 dist_base: Final[str] = f"{info.package_name}-{doc_info.version}" 

159 logger(f"Base file name is {dist_base!r}.") 

160 gz_dist: Final[Path] = dest.resolve_inside(f"{dist_base}.tar.gz") 

161 gz_dist.enforce_file() 

162 logger(f"gz distribution is {gz_dist!r}.") 

163 wheel_dist: Final[Path] = dest.resolve_inside( 

164 f"{dist_base}-py3-none-any.whl") 

165 wheel_dist.enforce_file() 

166 logger(f"wheel distribution is {wheel_dist!r}.") 

167 requirements: Final[Path] = dest.resolve_inside( 

168 f"{dist_base}-requirements_frozen.txt") 

169 logger(f"Will store requirements in {requirements!r}.") 

170 to: Final[str] = str(info.timeout) 

171 

172 extras: Final[list[str]] = __get_extras(setup_cfg) 

173 extras_str: Final[str] = "" if list.__len__(extras) <= 0 \ 

174 else f"[{','.join(extras)}]" 

175 

176 count: int = 0 

177 for what, steps in __VENV_CMD: 

178 count += 1 # noqa: SIM113 

179 if (count > 2) and (str.__len__(extras_str) <= 0): 

180 break 

181 logger(f"Now testing {what}.") 

182 with temp_dir() as venv: 

183 venv_build: Path = temp_file(directory=venv, suffix=".sh") 

184 with venv_build.open_for_write() as wd: 

185 write_lines((s.replace( 

186 "{VENV}", venv).replace("{GZ_DIST}", gz_dist).replace( 

187 "{WHEEL_DIST}", wheel_dist).replace( 

188 "{REQUIREMENTS}", requirements).replace( 

189 "{TIMEOUT}", to).replace("{EXTRAS}", extras_str) 

190 for s in chain(__PRE_PREFIX, __PREFIX, 

191 steps, __SUFFIX)), wd) 

192 Command(("bash", "--noprofile", "-e", "-E", 

193 venv_build), working_dir=venv, 

194 timeout=info.timeout, stdout=STREAM_FORWARD, 

195 stderr=STREAM_FORWARD).execute() 

196 

197 logger("Fixing exact package requirements.") 

198 pack: Final[str] = info.package_name 

199 pack_replace: Final[str] = f"{pack}{extras_str} @" 

200 requirements_txt: Final[str] = requirements.read_all_str() 

201 with requirements.open_for_write() as wd: 

202 write_lines(( 

203 f"{pack}{extras_str}=={doc_info.version}" if s.startswith( 

204 pack_replace) else s for s in str.splitlines( 

205 requirements_txt)), wd) 

206 

207 docs: Final[Path | None] = info.doc_dest_dir 

208 if (docs is not None) and docs.is_dir(): 

209 logger(f"Now compressing documentation {docs!r}.") 

210 doc_name: Final[str] = f"{dist_base}-documentation" 

211 doc_dest: Final[Path] = dest.resolve_inside(f"{doc_name}.tar.xz") 

212 with temp_file(suffix=".sh") as tf: 

213 with tf.open_for_write() as wd: 

214 write_lines((s.replace("{DEST}", doc_dest).replace( 

215 "{BASE}", doc_name) for s in chain( 

216 __PRE_PREFIX, __XZ)), wd) 

217 Command(("bash", "--noprofile", "-e", "-E", tf), 

218 working_dir=docs, timeout=info.timeout, 

219 stdout=STREAM_FORWARD, stderr=STREAM_FORWARD).execute() 

220 doc_dest.enforce_file() 

221 

222 logger("Finished building distribution.") 

223 

224 

225# Run conversion if executed as script 

226if __name__ == "__main__": 

227 parser: Final[ArgumentParser] = pycommons_argparser( 

228 __file__, 

229 "Build the Distribution Files.", 

230 "This utility builds a distribution for python projects " 

231 "in a unified way.") 

232 make_dist(parse_project_arguments(parser))