Skip to content

Commit 6993b60

Browse files
authored
Merge pull request #7286 from pradyunsg/nicer-nox-setup
Use nox to further automate our release mechanisms
2 parents 3cb4bdd + fca613b commit 6993b60

File tree

5 files changed

+191
-119
lines changed

5 files changed

+191
-119
lines changed

.azure-pipelines/jobs/package.yml

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,19 @@ jobs:
1515
inputs:
1616
versionSpec: '3'
1717

18-
- bash: pip install twine nox setuptools wheel
19-
displayName: Install dependencies
20-
21-
- bash: nox -s generate_authors
22-
displayName: Generate AUTHORS.txt
18+
- bash: |
19+
git config --global user.email "[email protected]"
20+
git config --global user.name "pip"
21+
displayName: Setup Git credentials
2322
24-
- bash: nox -s generate_news -- --yes
25-
displayName: Generate NEWS.rst
23+
- bash: pip install nox
24+
displayName: Install dependencies
2625

27-
- bash: python setup.py sdist bdist_wheel
28-
displayName: Create sdist and wheel
26+
- bash: nox -s prepare-release -- 99.9
27+
displayName: Prepare dummy release
2928

30-
- bash: twine check dist/*
31-
displayName: Check distributions with twine
29+
- bash: nox -s build-release -- 99.9
30+
displayName: Generate distributions for the dummy release
3231

3332
- task: PublishBuildArtifacts@1
3433
displayName: 'Publish Artifact: dist'

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ repos:
3232
args: []
3333
- id: mypy
3434
name: mypy, for Py2
35-
exclude: noxfile.py|docs|tests
35+
exclude: noxfile.py|tools/automation/release|docs|tests
3636
args: ["-2"]
3737

3838
- repo: https://github.com/pre-commit/pygrep-hooks

docs/html/development/release-process.rst

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -80,20 +80,13 @@ Creating a new release
8080
----------------------
8181

8282
#. Checkout the current pip ``master`` branch.
83-
#. Ensure you have the latest ``wheel``, ``setuptools``, ``twine`` and ``nox`` packages installed.
84-
#. Generate a new ``AUTHORS.txt`` (``nox -s generate_authors``) and commit the
85-
results.
86-
#. Bump the version in ``pip/__init__.py`` to the release version and commit
87-
the results. Usually this involves dropping just the ``.devN`` suffix on the
88-
version.
89-
#. Generate a new ``NEWS.rst`` (``nox -s generate_news``) and commit the
90-
results.
91-
#. Create a tag at the current commit, of the form ``YY.N``
92-
(``git tag YY.N``).
93-
#. Checkout the tag (``git checkout YY.N``).
94-
#. Create the distribution files (``python setup.py sdist bdist_wheel``).
95-
#. Upload the distribution files to PyPI using twine
96-
(``twine upload dist/*``).
83+
#. Ensure you have the latest ``nox`` and ``twine`` installed.
84+
#. Prepare for release using ``nox -s prepare-release -- YY.N``.
85+
This will update the relevant files and tag the correct commit.
86+
#. Build the release artifacts using ``nox -s build-release -- YY.N``.
87+
This will checkout the tag, generate the distribution files to be
88+
uploaded and checkout the master branch again.
89+
#. Upload the distribution files to PyPI using ``twine upload dist/*``.
9790
#. Push all of the changes including the tag.
9891
#. Regenerate the ``get-pip.py`` script in the `get-pip repository`_ (as
9992
documented there) and commit the results.
@@ -102,11 +95,6 @@ Creating a new release
10295
adjusting the versions listed in ``Lib/ensurepip/__init__.py``.
10396

10497

105-
.. note::
106-
107-
Steps 3 to 6 are automated in ``nox -s release -- YY.N`` command.
108-
109-
11098
.. note::
11199

112100
If the release dropped the support of an obsolete Python version ``M.m``,
@@ -117,6 +105,7 @@ Creating a new release
117105

118106

119107
.. note::
108+
120109
If the ``get-pip.py`` script needs to be updated due to changes in pip internals
121110
and if the last ``M.m/get-pip.py`` published still uses the default template, make
122111
sure to first duplicate ``templates/default.py`` as ``templates/pre-YY.N.py``
@@ -135,7 +124,7 @@ order to create one of these the changes should already be merged into the
135124
command ``git checkout -b release/YY.N.Z+1 YY.N``.
136125
#. Cherry pick the fixed commits off of the ``master`` branch, fixing any
137126
conflicts.
138-
#. Follow the steps 3 to 6 from above (or call ``nox -s release -- YY.N.Z+1``)
127+
#. Run ``nox -s prepare-release -- YY.N.Z+1``.
139128
#. Merge master into your release branch and drop the news files that have been
140129
included in your release (otherwise they would also appear in the ``YY.N+1``
141130
changelog)

noxfile.py

Lines changed: 55 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44
# The following comment should be removed at some point in the future.
55
# mypy: disallow-untyped-defs=False
66

7-
import io
7+
import glob
88
import os
99
import shutil
10-
import subprocess
10+
import sys
1111

1212
import nox
1313

14+
sys.path.append(".")
15+
from tools.automation import release # isort:skip # noqa
16+
sys.path.pop()
17+
1418
nox.options.reuse_existing_virtualenvs = True
1519
nox.options.sessions = ["lint"]
1620

@@ -27,29 +31,6 @@
2731
VERSION_FILE = "src/pip/__init__.py"
2832

2933

30-
def get_author_list():
31-
"""Get the list of authors from Git commits.
32-
"""
33-
# subprocess because session.run doesn't give us stdout
34-
result = subprocess.run(
35-
["git", "log", "--use-mailmap", "--format=%aN <%aE>"],
36-
capture_output=True,
37-
encoding="utf-8",
38-
)
39-
40-
# Create a unique list.
41-
authors = []
42-
seen_authors = set()
43-
for author in result.stdout.splitlines():
44-
author = author.strip()
45-
if author.lower() not in seen_authors:
46-
seen_authors.add(author.lower())
47-
authors.append(author)
48-
49-
# Sort our list of Authors by their case insensitive name
50-
return sorted(authors, key=lambda x: x.lower())
51-
52-
5334
def run_with_protected_pip(session, *arguments):
5435
"""Do a session.run("pip", *arguments), using a "protected" pip.
5536
@@ -81,11 +62,6 @@ def should_update_common_wheels():
8162
return need_to_repopulate
8263

8364

84-
def update_version_file(new_version):
85-
with open(VERSION_FILE, "w", encoding="utf-8") as f:
86-
f.write('__version__ = "{}"\n'.format(new_version))
87-
88-
8965
# -----------------------------------------------------------------------------
9066
# Development Commands
9167
# These are currently prototypes to evaluate whether we want to switch over
@@ -174,70 +150,62 @@ def lint(session):
174150
# -----------------------------------------------------------------------------
175151
# Release Commands
176152
# -----------------------------------------------------------------------------
177-
@nox.session(python=False)
178-
def generate_authors(session):
179-
# Get our list of authors
180-
session.log("Collecting author names")
181-
authors = get_author_list()
153+
@nox.session(name="prepare-release")
154+
def prepare_release(session):
155+
version = release.get_version_from_arguments(session.posargs)
156+
if not version:
157+
session.error("Usage: nox -s prepare-release -- YY.N[.P]")
158+
159+
session.log("# Ensure nothing is staged")
160+
if release.modified_files_in_git("--staged"):
161+
session.error("There are files staged in git")
162+
163+
session.log(f"# Updating {AUTHORS_FILE}")
164+
release.generate_authors(AUTHORS_FILE)
165+
if release.modified_files_in_git():
166+
release.commit_file(
167+
session, AUTHORS_FILE, message=f"Update {AUTHORS_FILE}",
168+
)
169+
else:
170+
session.log(f"# No changes to {AUTHORS_FILE}")
182171

183-
# Write our authors to the AUTHORS file
184-
session.log("Writing AUTHORS")
185-
with io.open(AUTHORS_FILE, "w", encoding="utf-8") as fp:
186-
fp.write(u"\n".join(authors))
187-
fp.write(u"\n")
172+
session.log("# Generating NEWS")
173+
release.generate_news(session, version)
188174

175+
session.log(f"# Bumping for release {version}")
176+
release.update_version_file(version, VERSION_FILE)
177+
release.commit_file(session, VERSION_FILE, message="Bump for release")
189178

190-
@nox.session
191-
def generate_news(session):
192-
session.log("Generating NEWS")
193-
session.install("towncrier")
179+
session.log("# Tagging release")
180+
release.create_git_tag(session, version, message=f"Release {version}")
194181

195-
# You can pass 2 possible arguments: --draft, --yes
196-
session.run("towncrier", *session.posargs)
182+
session.log("# Bumping for development")
183+
next_dev_version = release.get_next_development_version(version)
184+
release.update_version_file(next_dev_version, VERSION_FILE)
185+
release.commit_file(session, VERSION_FILE, message="Bump for development")
197186

198187

199-
@nox.session
200-
def release(session):
201-
assert len(session.posargs) == 1, "A version number is expected"
202-
new_version = session.posargs[0]
203-
parts = new_version.split('.')
204-
# Expect YY.N or YY.N.P
205-
assert 2 <= len(parts) <= 3, parts
206-
# Only integers
207-
parts = list(map(int, parts))
208-
session.log("Generating commits for version {}".format(new_version))
209-
210-
session.log("Checking that nothing is staged")
211-
# Non-zero exit code means that something is already staged
212-
session.run("git", "diff", "--staged", "--exit-code", external=True)
213-
214-
session.log(f"Updating {AUTHORS_FILE}")
215-
generate_authors(session)
216-
if subprocess.run(["git", "diff", "--exit-code"]).returncode:
217-
session.run("git", "add", AUTHORS_FILE, external=True)
218-
session.run(
219-
"git", "commit", "-m", f"Updating {AUTHORS_FILE}",
220-
external=True,
221-
)
222-
else:
223-
session.log(f"No update needed for {AUTHORS_FILE}")
188+
@nox.session(name="build-release")
189+
def build_release(session):
190+
version = release.get_version_from_arguments(session.posargs)
191+
if not version:
192+
session.error("Usage: nox -s upload-release -- YY.N[.P]")
224193

225-
session.log("Generating NEWS")
226-
session.install("towncrier")
227-
session.run("towncrier", "--yes", "--version", new_version)
194+
session.log("# Ensure no files in dist/")
195+
if release.have_files_in_folder("dist"):
196+
session.error("There are files in dist/. Remove them and try again")
228197

229-
session.log("Updating version")
230-
update_version_file(new_version)
231-
session.run("git", "add", VERSION_FILE, external=True)
232-
session.run("git", "commit", "-m", f"Release {new_version}", external=True)
198+
session.log("# Install dependencies")
199+
session.install("setuptools", "wheel", "twine")
233200

234-
session.log("Tagging release")
235-
session.run(
236-
"git", "tag", "-m", f"Release {new_version}", new_version,
237-
external=True,
238-
)
201+
session.log("# Checkout the tag")
202+
session.run("git", "checkout", version, external=True, silent=True)
203+
204+
session.log("# Build distributions")
205+
session.run("python", "setup.py", "sdist", "bdist_wheel", silent=True)
206+
207+
session.log("# Verify distributions")
208+
session.run("twine", "check", *glob.glob("dist/*"), silent=True)
239209

240-
next_dev_version = f"{parts[0]}.{parts[1] + 1}.dev0"
241-
update_version_file(next_dev_version)
242-
session.run("git", "add", VERSION_FILE, external=True)
243-
session.run("git", "commit", "-m", "Back to development", external=True)
210+
session.log("# Checkout the master branch")
211+
session.run("git", "checkout", "master", external=True, silent=True)

tools/automation/release/__init__.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Helpers for release automation.
2+
3+
These are written according to the order they are called in.
4+
"""
5+
6+
import io
7+
import os
8+
import subprocess
9+
from typing import List, Optional, Set
10+
11+
from nox.sessions import Session
12+
13+
14+
def get_version_from_arguments(arguments: List[str]) -> Optional[str]:
15+
"""Checks the arguments passed to `nox -s release`.
16+
17+
If there is only 1 argument that looks like a pip version, returns that.
18+
Otherwise, returns None.
19+
"""
20+
if len(arguments) != 1:
21+
return None
22+
23+
version = arguments[0]
24+
25+
parts = version.split('.')
26+
if not 2 <= len(parts) <= 3:
27+
# Not of the form: YY.N or YY.N.P
28+
return None
29+
30+
if not all(part.isdigit() for part in parts):
31+
# Not all segments are integers.
32+
return None
33+
34+
# All is good.
35+
return version
36+
37+
38+
def modified_files_in_git(*args: str) -> int:
39+
return subprocess.run(
40+
["git", "diff", "--no-patch", "--exit-code", *args],
41+
capture_output=True,
42+
).returncode
43+
44+
45+
def get_author_list() -> List[str]:
46+
"""Get the list of authors from Git commits.
47+
"""
48+
# subprocess because session.run doesn't give us stdout
49+
result = subprocess.run(
50+
["git", "log", "--use-mailmap", "--format=%aN <%aE>"],
51+
capture_output=True,
52+
encoding="utf-8",
53+
)
54+
55+
# Create a unique list.
56+
authors = []
57+
seen_authors: Set[str] = set()
58+
for author in result.stdout.splitlines():
59+
author = author.strip()
60+
if author.lower() not in seen_authors:
61+
seen_authors.add(author.lower())
62+
authors.append(author)
63+
64+
# Sort our list of Authors by their case insensitive name
65+
return sorted(authors, key=lambda x: x.lower())
66+
67+
68+
def generate_authors(filename: str) -> None:
69+
# Get our list of authors
70+
authors = get_author_list()
71+
72+
# Write our authors to the AUTHORS file
73+
with io.open(filename, "w", encoding="utf-8") as fp:
74+
fp.write(u"\n".join(authors))
75+
fp.write(u"\n")
76+
77+
78+
def commit_file(session: Session, filename: str, *, message: str) -> None:
79+
session.run("git", "add", filename, external=True, silent=True)
80+
session.run("git", "commit", "-m", message, external=True, silent=True)
81+
82+
83+
def generate_news(session: Session, version: str) -> None:
84+
session.install("towncrier")
85+
session.run("towncrier", "--yes", "--version", version, silent=True)
86+
87+
88+
def update_version_file(version: str, filepath: str) -> None:
89+
with open(filepath, "w", encoding="utf-8") as f:
90+
f.write('__version__ = "{}"\n'.format(version))
91+
92+
93+
def create_git_tag(session: Session, tag_name: str, *, message: str) -> None:
94+
session.run(
95+
"git", "tag", "-m", message, tag_name, external=True, silent=True,
96+
)
97+
98+
99+
def get_next_development_version(version: str) -> str:
100+
major, minor, *_ = map(int, version.split("."))
101+
102+
# We have at most 4 releases, starting with 0. Once we reach 3, we'd want
103+
# to roll-over to the next year's release numbers.
104+
if minor == 3:
105+
major += 1
106+
minor = 0
107+
else:
108+
minor += 1
109+
110+
return f"{major}.{minor}.dev0"
111+
112+
113+
def have_files_in_folder(folder_name: str) -> bool:
114+
if not os.path.exists(folder_name):
115+
return False
116+
return bool(os.listdir(folder_name))

0 commit comments

Comments
 (0)