-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy path_python.py
More file actions
312 lines (263 loc) · 13.8 KB
/
_python.py
File metadata and controls
312 lines (263 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# encoding: utf-8
'''PDS Roundup: Python context'''
from .context import Context
from .errors import InvokedProcessError, RoundupError
from .errors import MissingEnvVarError
from .step import ChangeLogStep as BaseChangeLogStep
from .step import Step, StepName, NullStep, RequirementsStep, DocPublicationStep
from .util import invoke, invokeGIT, TAG_RE, commit, delete_tags, git_config, add_version_label_to_open_bugs
from ._detectives import TextFileDetective
import logging, os, re, shutil
_logger = logging.getLogger(__name__)
class PythonContext(Context):
'''A Python context supports Python software proejcts'''
def __init__(self, cwd, environ, args):
self.steps = {
StepName.artifactPublication: _ArtifactPublicationStep,
StepName.build: _BuildStep,
StepName.changeLog: ChangeLogStep,
StepName.cleanup: _CleanupStep,
StepName.docPublication: _DocPublicationStep,
StepName.docs: _DocsStep,
StepName.githubRelease: _GitHubReleaseStep,
StepName.integrationTest: _IntegrationTestStep,
StepName.null: NullStep,
StepName.preparation: _PreparationStep,
StepName.requirements: RequirementsStep,
StepName.unitTest: _UnitTestStep,
StepName.versionBump: _VersionBumpingStep,
StepName.versionCommit: _VersionCommittingStep,
}
super(PythonContext, self).__init__(cwd, environ, args)
class _PythonStep(Step):
'''🐍 Python steps provide some convenience functions to the Python environment'''
def getCheeseshopURL(self):
'''Get the URL to PyPI'''
# 😮 TODO: This should import from twine.utils.DEFAULT_REPOSITORY and TEST_REPOSITORY
# But if we do that we may as well use the Twine API in ``_ArtifactPublicationStep``'s
# ``execute`` method instead of executing the ``twine`` command-line utility.
return 'https://upload.pypi.org/legacy/' if self.assembly.isStable() else 'https://test.pypi.org/legacy/'
def getCheeseshopCredentials(self):
'''Get the username and password (as a tuple) to use to log into the PyPI.
☑️ TODO: Use an API token instead of a username and password.
'''
env = self.assembly.context.environ
username, password = env.get('pypi_username'), env.get('pypi_password')
if not username: raise MissingEnvVarError('pypi_username')
if not password: raise MissingEnvVarError('pypi_password')
return username, password
class _PreparationStep(_PythonStep):
'''Prepare the python repository for action.'''
def execute(self):
git_config()
shutil.rmtree('venv', ignore_errors=True)
# We add access to system site packages so that projects can save time if they need numpy, pandas, etc.
invoke(['python', '-m', 'venv', '--system-site-packages', 'venv'])
# Do the pseudo-equivalent of ``activate``:
venvBin = os.path.abspath(os.path.join(self.assembly.context.cwd, 'venv', 'bin'))
os.environ['PATH'] = f'{venvBin}:{os.environ["PATH"]}'
# Make sure we have the latest of pip+setuptools+wheel
invoke(['/github/workspace/venv/bin/pip', 'install', '--quiet', '--upgrade', 'pip', 'setuptools', 'wheel'])
# Now install the package being rounded up … it should install its own sphinx-build, but if
# not we'll use our own older version (3.2.1 according to github-actions-base)
invoke(['/github/workspace/venv/bin/pip', 'install', '--verbose', '--editable', '.[dev]'])
# ☑️ TODO: what other prep steps are there? What about VERSION.txt overwriting?
class _UnitTestStep(_PythonStep):
'''Unit test step, duh.'''
def execute(self):
_logger.debug('Python unit test step')
tox = os.path.abspath(os.path.join(self.assembly.context.cwd, 'venv', 'bin', 'tox'))
if os.path.isfile(tox):
_logger.debug('Trying the new way: ``tox``')
invoke([tox])
else:
_logger.debug('Trying the old way: ``setup.py test``')
invoke(['python', 'setup.py', 'test'])
class _IntegrationTestStep(_PythonStep):
'''A step to take for integration tests with Python; what actually happens here is yet
to be determined.
'''
def execute(self):
_logger.debug('Python integration test step; TBD')
class _DocsStep(_PythonStep):
'''A step that uses Sphinx to generate documentation'''
def execute(self):
_logger.info('📜 Documentation generation which relies on sphinx-build installed in the venv')
_logger.debug('📜 by the way here is what is in the venv bin')
invoke(['ls', '-l', '/github/workspace/venv/bin'])
_logger.debug('📜 GOT THAT? Now onto /github/workspace/venv/bin/sphinx-build')
invoke(['/github/workspace/venv/bin/sphinx-build', '--version'])
invoke(['/github/workspace/venv/bin/sphinx-build', '-a', '-b', 'html', 'docs/source', '/tmp/docs'])
_logger.debug('📜 Documentation generated successfully; here is /tmp/docs')
invoke(['ls', '-l', '/tmp/docs'])
_logger.debug('📜 GOT THAT? This step is now done.')
class _VersionBumpingStep(_PythonStep):
'''Bump the version but do not commit it (yet).'''
def execute(self):
if not self.assembly.isStable():
_logger.debug('Skipping version bump for unstable build')
return
# Figure out the tag name; we use ``--tags`` to pick up all tags, not just the annotated
# ones. This'll help reduce erros by users who forget to annotate (``-a`` or ``--annoate``)
# their tags. The ``--abbrev 0`` truncates any post-tag commits
tag = invokeGIT(['describe', '--tags', '--abbrev=0', '--match', 'release/*']).strip()
if not tag:
raise RoundupError('🕊 Cannot determine the release tag; version bump failed')
match = TAG_RE.match(tag)
if not match:
raise RoundupError(f'🐎 Stable tag of «{tag}» but not a ``release/`` tag')
major, minor, micro = int(match.group(1)), int(match.group(2)), match.group(4)
full_version = f'{major}.{minor}.{micro}'
_logger.debug('🔖 So we got version %s', full_version)
if micro is None:
raise RoundupError('Invalid release version supplied in tag name. You must supply Major.Minor.Micro')
add_version_label_to_open_bugs(full_version)
_logger.debug("Locating VERSION.txt to update with new release version.")
try:
version_file = TextFileDetective.locate_file(self.assembly.context.cwd)
except ValueError:
msg = 'Unable to locate ./src directory. Is your repository properly structured?'
_logger.debug(msg)
raise RoundupError(msg)
if version_file is None:
raise RoundupError('Unable to locate VERSION.txt in repo. Version bump failed.')
else:
with open(version_file, 'w') as inp:
inp.write(f'{major}.{minor}.{micro}\n')
class _VersionCommittingStep(_PythonStep):
'''Commit the bumped version.'''
def execute(self):
if not self.assembly.isStable():
_logger.debug('Skipping version commit for unstable build')
return
_logger.debug("Locating VERSION.txt to commit")
try:
version_file = TextFileDetective.locate_file(self.assembly.context.cwd)
if version_file is None:
raise RoundupError('Unable to locate VERSION.txt in repo. Version commit failed.')
except ValueError:
msg = 'Unable to locate ./src directory. Is your repository properly structured?'
_logger.debug(msg)
raise RoundupError(msg)
commit(version_file, f'Commiting {version_file} for stable release', self.get_branch_ref())
class _BuildStep(_PythonStep):
'''A step that makes a Python wheel (of cheese)'''
def execute(self):
if self.assembly.isStable():
invoke(['python', 'setup.py', 'bdist_wheel'])
else:
invoke(['python', 'setup.py', 'egg_info', '--tag-build', 'dev', 'bdist_wheel'])
class _GitHubReleaseStep(_PythonStep):
'''A step that releases software to GitHub
'''
def _pruneDev(self):
'''Get rid of any "dev" tags. Apparently we want to do this always; see
https://github.com/NASA-PDS/roundup-action/issues/32#issuecomment-776309904
'''
delete_tags('*dev*')
def _pruneReleaseTags(self):
'''Get rid of ``release/*`` tags.'''
delete_tags('release/*')
def _tagRelease(self):
'''Tag the current release using the v1.2.3-style tag based on the release/1.2.3-style tag.'''
_logger.debug('🏷 Tagging the release')
tag = invokeGIT(['describe', '--tags', '--abbrev=0', '--match', 'release/*']).strip()
if not tag:
_logger.debug('🕊 Cannot determine what tag we are currently on, so skipping re-tagging')
return
match = TAG_RE.match(tag)
if not match:
_logger.debug('🐎 Stable tag at «%s» but not a ``release/`` style tag, so skipping tagging', tag)
return
major, minor, micro = int(match.group(1)), int(match.group(2)), match.group(4)
_logger.debug('🔖 So we got version %d.%d.%s', major, minor, micro)
# roundup-action#90: we no longer bump the version number; just re-tag at the current HEAD
tag = f'v{major}.{minor}.{micro}'
_logger.debug('🆕 New tag will be %s', tag)
invokeGIT(['tag', '--annotate', '--force', '--message', f'Tag release {tag}', tag])
invokeGIT(['push', '--tags'])
def execute(self):
'''Execute the Python GitHub release step'''
_logger.debug('👣 Python GitHub release step')
token = self.getToken()
if not token:
_logger.info('🤷♀️ No GitHub administrative token; cannot release to GitHub')
return
self._pruneDev()
if self.assembly.isStable():
self._tagRelease()
invoke(['/usr/local/bin/python-release', '--debug', '--token', token])
else: # It's unstable release
invoke(['/usr/local/bin/python-release', '--debug', '--snapshot', '--token', token])
self._pruneReleaseTags()
class _ArtifactPublicationStep(_PythonStep):
'''A step that publishes artifacts to the Cheeseshop'''
def execute(self):
# 😮 TODO: It'd be more secure to use PyPI access tokens instead of usernames and passwords!
_logger.info('❓ Just what version of twine is this?')
invoke(['/usr/local/bin/twine', '--version'])
argv = [
'/usr/local/bin/twine',
'upload',
'--username',
self.getCheeseshopCredentials()[0],
'--password',
self.getCheeseshopCredentials()[1],
'--non-interactive',
'--comment',
"🤠 Yee-haw! This here ar-tee-fact got done uploaded by the Roundup!",
'--skip-existing',
'--disable-progress-bar',
'--repository-url',
self.getCheeseshopURL()
]
dists = os.path.join(self.assembly.context.cwd, 'dist')
argv.extend([os.path.join(dists, i) for i in os.listdir(dists) if os.path.isfile(os.path.join(dists, i))])
# 😮 TODO: Use Twine API directly
# But I'm in a rush:
try:
invoke(argv)
except InvokedProcessError:
# Unstable releases, let it slide; this is test.pypi.org anyway, and we are abusing
# it for snapshot releases, when it's probably just for testing release tools—which
# in a way, is what this is.
#
# (We really ought to re-think (ab)using test.pypi.org in this way.)
if self.assembly.isStable(): raise
class _DocPublicationStep(DocPublicationStep):
default_documentation_dir = '/tmp/docs'
class _CleanupStep(_PythonStep):
'''Step that tidies up.'
At this point we're cleaing up so errors are not longer considered awful.
'''
def execute(self):
_logger.debug('Python cleanup step')
if not self.assembly.isStable():
_logger.debug('Skipping cleanup for unstable build')
return
# NASA-PDS/roundup-action#99: delete the release/X.Y.Z tag
tag = invokeGIT(['describe', '--tags', '--abbrev=0', '--match', 'release/*']).strip()
if not tag:
raise RoundupError('🏷 Cannot determine the release tag at cleanup step')
invokeGIT(['push', 'origin', f':{tag}'])
detective = TextFileDetective(self.assembly.context.cwd)
version, version_file = detective.detect(), detective.locate_file(self.assembly.context.cwd)
if not version:
_logger.info('Could not figure out the version left in src/…/VERSION.txt, but we made it this far so punt')
return
match = re.match(r'(\d+)\.(\d+)\.(\d+)', version)
if not match:
_logger.info(f'Expected Major.Minor.Micro version in src/…/VERSION.txt but got «{version}» but whatever')
return
# NASA-PDS/roundup-action#81: Jordan would prefer the ``minor`` version get bumped, not the ``micro`` version:
major, minor, micro = int(match.group(1)), int(match.group(2)) + 1, int(match.group(3))
new_version = f'{major}.{minor}.0'
_logger.debug('🔖 Setting version %s in src/…/VERSION.txt', new_version)
with open(version_file, 'w') as f:
f.write(f'{new_version}\n')
commit(version_file, f'Setting next dev version to {major}.{minor}.{micro}', self.get_branch_ref())
class ChangeLogStep(BaseChangeLogStep):
def execute(self):
_logger.debug('Python changelog step')
delete_tags('*dev*')
super().execute()