noxfile.py (9993B)
1 # mypy: disallow-untyped-defs=False, disallow-untyped-calls=False 2 3 import contextlib 4 import datetime 5 import difflib 6 import glob 7 import os 8 import re 9 import shutil 10 import subprocess 11 import sys 12 import tempfile 13 import textwrap 14 import time 15 import webbrowser 16 from pathlib import Path 17 18 import nox 19 20 nox.options.sessions = ["lint"] 21 nox.options.reuse_existing_virtualenvs = True 22 23 24 @nox.session(python=["3.6", "3.7", "3.8", "3.9", "3.10", "pypy3"]) 25 def tests(session): 26 def coverage(*args): 27 session.run("python", "-m", "coverage", *args) 28 29 # Once coverage 5 is used then `.coverage` can move into `pyproject.toml`. 30 session.install("coverage<5.0.0", "pretend", "pytest>=6.2.0", "pip>=9.0.2") 31 session.install(".") 32 33 if "pypy" not in session.python: 34 coverage( 35 "run", 36 "--source", 37 "packaging/", 38 "-m", 39 "pytest", 40 "--strict-markers", 41 *session.posargs, 42 ) 43 coverage("report", "-m", "--fail-under", "100") 44 else: 45 # Don't do coverage tracking for PyPy, since it's SLOW. 46 session.run( 47 "python", 48 "-m", 49 "pytest", 50 "--capture=no", 51 "--strict-markers", 52 *session.posargs, 53 ) 54 55 56 @nox.session(python="3.9") 57 def lint(session): 58 # Run the linters (via pre-commit) 59 session.install("pre-commit") 60 session.run("pre-commit", "run", "--all-files") 61 62 # Check the distribution 63 session.install("build", "twine") 64 session.run("pyproject-build") 65 session.run("twine", "check", *glob.glob("dist/*")) 66 67 68 @nox.session(python="3.9") 69 def docs(session): 70 shutil.rmtree("docs/_build", ignore_errors=True) 71 session.install("furo") 72 session.install("-e", ".") 73 74 variants = [ 75 # (builder, dest) 76 ("html", "html"), 77 ("latex", "latex"), 78 ("doctest", "html"), 79 ] 80 81 for builder, dest in variants: 82 session.run( 83 "sphinx-build", 84 "-W", 85 "-b", 86 builder, 87 "-d", 88 "docs/_build/doctrees/" + dest, 89 "docs", # source directory 90 "docs/_build/" + dest, # output directory 91 ) 92 93 94 @nox.session 95 def release(session): 96 package_name = "packaging" 97 version_file = Path(f"{package_name}/__about__.py") 98 changelog_file = Path("CHANGELOG.rst") 99 100 try: 101 release_version = _get_version_from_arguments(session.posargs) 102 except ValueError as e: 103 session.error(f"Invalid arguments: {e}") 104 return 105 106 # Check state of working directory and git. 107 _check_working_directory_state(session) 108 _check_git_state(session, release_version) 109 110 # Prepare for release. 111 _changelog_update_unreleased_title(release_version, file=changelog_file) 112 session.run("git", "add", str(changelog_file), external=True) 113 _bump(session, version=release_version, file=version_file, kind="release") 114 115 # Tag the release commit. 116 # fmt: off 117 session.run( 118 "git", "tag", 119 "-s", release_version, 120 "-m", f"Release {release_version}", 121 external=True, 122 ) 123 # fmt: on 124 125 # Prepare for development. 126 _changelog_add_unreleased_title(file=changelog_file) 127 session.run("git", "add", str(changelog_file), external=True) 128 129 major, minor = map(int, release_version.split(".")) 130 next_version = f"{major}.{minor + 1}.dev0" 131 _bump(session, version=next_version, file=version_file, kind="development") 132 133 # Checkout the git tag. 134 session.run("git", "checkout", "-q", release_version, external=True) 135 136 session.install("build", "twine") 137 138 # Build the distribution. 139 session.run("python", "-m", "build") 140 141 # Check what files are in dist/ for upload. 142 files = sorted(glob.glob("dist/*")) 143 expected = [ 144 f"dist/{package_name}-{release_version}-py3-none-any.whl", 145 f"dist/{package_name}-{release_version}.tar.gz", 146 ] 147 if files != expected: 148 diff_generator = difflib.context_diff( 149 expected, files, fromfile="expected", tofile="got", lineterm="" 150 ) 151 diff = "\n".join(diff_generator) 152 session.error(f"Got the wrong files:\n{diff}") 153 154 # Get back out into main. 155 session.run("git", "checkout", "-q", "main", external=True) 156 157 # Check and upload distribution files. 158 session.run("twine", "check", *files) 159 160 # Push the commits and tag. 161 # NOTE: The following fails if pushing to the branch is not allowed. This can 162 # happen on GitHub, if the main branch is protected, there are required 163 # CI checks and "Include administrators" is enabled on the protection. 164 session.run("git", "push", "upstream", "main", release_version, external=True) 165 166 # Upload the distribution. 167 session.run("twine", "upload", *files) 168 169 # Open up the GitHub release page. 170 webbrowser.open("https://github.com/pypa/packaging/releases") 171 172 173 # ----------------------------------------------------------------------------- 174 # Helpers 175 # ----------------------------------------------------------------------------- 176 def _get_version_from_arguments(arguments): 177 """Checks the arguments passed to `nox -s release`. 178 179 Only 1 argument that looks like a version? Return the argument. 180 Otherwise, raise a ValueError describing what's wrong. 181 """ 182 if len(arguments) != 1: 183 raise ValueError("Expected exactly 1 argument") 184 185 version = arguments[0] 186 parts = version.split(".") 187 188 if len(parts) != 2: 189 # Not of the form: YY.N 190 raise ValueError("not of the form: YY.N") 191 192 if not all(part.isdigit() for part in parts): 193 # Not all segments are integers. 194 raise ValueError("non-integer segments") 195 196 # All is good. 197 return version 198 199 200 def _check_working_directory_state(session): 201 """Check state of the working directory, prior to making the release.""" 202 should_not_exist = ["build/", "dist/"] 203 204 bad_existing_paths = list(filter(os.path.exists, should_not_exist)) 205 if bad_existing_paths: 206 session.error(f"Remove {', '.join(bad_existing_paths)} and try again") 207 208 209 def _check_git_state(session, version_tag): 210 """Check state of the git repository, prior to making the release.""" 211 # Ensure the upstream remote pushes to the correct URL. 212 allowed_upstreams = [ 213 "git@github.com:pypa/packaging.git", 214 "https://github.com/pypa/packaging.git", 215 ] 216 result = subprocess.run( 217 ["git", "remote", "get-url", "--push", "upstream"], 218 capture_output=True, 219 encoding="utf-8", 220 ) 221 if result.stdout.rstrip() not in allowed_upstreams: 222 session.error(f"git remote `upstream` is not one of {allowed_upstreams}") 223 # Ensure we're on main branch for cutting a release. 224 result = subprocess.run( 225 ["git", "rev-parse", "--abbrev-ref", "HEAD"], 226 capture_output=True, 227 encoding="utf-8", 228 ) 229 if result.stdout != "main\n": 230 session.error(f"Not on main branch: {result.stdout!r}") 231 232 # Ensure there are no uncommitted changes. 233 result = subprocess.run( 234 ["git", "status", "--porcelain"], capture_output=True, encoding="utf-8" 235 ) 236 if result.stdout: 237 print(result.stdout, end="", file=sys.stderr) 238 session.error("The working tree has uncommitted changes") 239 240 # Ensure this tag doesn't exist already. 241 result = subprocess.run( 242 ["git", "rev-parse", version_tag], capture_output=True, encoding="utf-8" 243 ) 244 if not result.returncode: 245 session.error(f"Tag already exists! {version_tag} -- {result.stdout!r}") 246 247 # Back up the current git reference, in a tag that's easy to clean up. 248 _release_backup_tag = "auto/release-start-" + str(int(time.time())) 249 session.run("git", "tag", _release_backup_tag, external=True) 250 251 252 def _bump(session, *, version, file, kind): 253 session.log(f"Bump version to {version!r}") 254 contents = file.read_text() 255 new_contents = re.sub( 256 '__version__ = "(.+)"', f'__version__ = "{version}"', contents 257 ) 258 file.write_text(new_contents) 259 260 session.log("git commit") 261 subprocess.run(["git", "add", str(file)]) 262 subprocess.run(["git", "commit", "-m", f"Bump for {kind}"]) 263 264 265 @contextlib.contextmanager 266 def _replace_file(original_path): 267 # Create a temporary file. 268 fh, replacement_path = tempfile.mkstemp() 269 270 try: 271 with os.fdopen(fh, "w") as replacement: 272 with open(original_path) as original: 273 yield original, replacement 274 except Exception: 275 raise 276 else: 277 shutil.copymode(original_path, replacement_path) 278 os.remove(original_path) 279 shutil.move(replacement_path, original_path) 280 281 282 def _changelog_update_unreleased_title(version, *, file): 283 """Update an "*unreleased*" heading to "{version} - {date}" """ 284 yyyy_mm_dd = datetime.datetime.today().strftime("%Y-%m-%d") 285 title = f"{version} - {yyyy_mm_dd}" 286 287 with _replace_file(file) as (original, replacement): 288 for line in original: 289 if line == "*unreleased*\n": 290 replacement.write(f"{title}\n") 291 replacement.write(len(title) * "~" + "\n") 292 # Skip processing the next line (the heading underline for *unreleased*) 293 # since we already wrote the heading underline. 294 next(original) 295 else: 296 replacement.write(line) 297 298 299 def _changelog_add_unreleased_title(*, file): 300 with _replace_file(file) as (original, replacement): 301 # Duplicate first 3 lines from the original file. 302 for _ in range(3): 303 line = next(original) 304 replacement.write(line) 305 306 # Write the heading. 307 replacement.write( 308 textwrap.dedent( 309 """\ 310 *unreleased* 311 ~~~~~~~~~~~~ 312 313 No unreleased changes. 314 315 """ 316 ) 317 ) 318 319 # Duplicate all the remaining lines. 320 for line in original: 321 replacement.write(line)