-
Notifications
You must be signed in to change notification settings - Fork 129
Expand file tree
/
Copy pathpypi.py
More file actions
370 lines (305 loc) · 13.7 KB
/
pypi.py
File metadata and controls
370 lines (305 loc) · 13.7 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
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
# -----------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -----------------------------------------------------------------------------
import os
import re
import sys
from distutils.version import LooseVersion # pylint:disable=import-error,no-name-in-module
from docutils import core, io
from knack.log import get_logger
from knack.util import CLIError
from azdev.utilities import (
display, heading, subheading, cmd, py_cmd, get_path_table,
pip_cmd, COMMAND_MODULE_PREFIX, require_azure_cli, require_virtual_env,
find_files)
logger = get_logger(__name__)
HISTORY_NAME = 'HISTORY.rst'
RELEASE_HISTORY_TITLE = 'Release History'
SETUP_PY_NAME = 'setup.py'
# modules which should not be included in setup.py
# because they aren't on PyPI
EXCLUDED_MODULES = ['azure-cli-testsdk']
# region verify History Headings
def check_history():
# TODO: Does not work with extensions
path_table = get_path_table()
selected_modules = list(path_table['core'].items())
heading('Verify History')
module_names = sorted([name for name, _ in selected_modules])
display('Verifying README and HISTORY files for modules: {}'.format(' '.join(module_names)))
failed_mods = []
for name, path in selected_modules:
errors = _check_readme_render(path)
if errors:
failed_mods.append(name)
subheading('{} errors'.format(name))
for error in errors:
logger.error('%s\n', error)
subheading('Results')
if failed_mods:
display('The following modules have invalid README/HISTORYs:')
logger.error('\n'.join(failed_mods))
logger.warning('See above for the full warning/errors')
logger.warning('note: Line numbers in the errors map to the long_description of your setup.py.')
sys.exit(1)
display('OK')
def _check_history_headings(mod_path):
history_path = os.path.join(mod_path, HISTORY_NAME)
source_path = None
destination_path = None
errors = []
with open(history_path, 'r') as f:
input_string = f.read()
_, pub = core.publish_programmatically(
source_class=io.StringInput, source=input_string,
source_path=source_path,
destination_class=io.NullOutput, destination=None,
destination_path=destination_path,
reader=None, reader_name='standalone',
parser=None, parser_name='restructuredtext',
writer=None, writer_name='null',
settings=None, settings_spec=None, settings_overrides={},
config_section=None, enable_exit_status=None)
# Check first heading is Release History
if pub.writer.document.children[0].rawsource != RELEASE_HISTORY_TITLE:
errors.append("Expected '{}' as first heading in HISTORY.rst".format(RELEASE_HISTORY_TITLE))
all_versions = [t['names'][0] for t in pub.writer.document.children if t['names']]
# Check that no headings contain 'unreleased'. We don't require it any more
if any('unreleased' in v.lower() for v in all_versions):
errors.append("We no longer require 'unreleased' in headings. Use the appropriate version number instead.")
# Check that the current package version has a history entry
if not all_versions:
errors.append("Unable to get versions from {}. Check formatting. e.g. there should be a new "
"line after the 'Release History' heading.".format(history_path))
first_version_history = all_versions[0]
actual_version = cmd('python setup.py --version', cwd=mod_path)
# command can output warnings as well, so we just want the last line, which should have the version
actual_version = actual_version.result.splitlines()[-1].strip()
if first_version_history != actual_version:
errors.append("The topmost version in {} does not match version {} defined in setup.py.".format(
history_path, actual_version))
return errors
def _check_readme_render(mod_path):
errors = []
result = cmd('python setup.py check -r -s', cwd=mod_path)
if result.exit_code:
# this outputs some warnings we don't care about
error_lines = []
target_line = 'The following syntax errors were detected'
suppress = True
logger.debug(result.error.output)
# TODO: Checks for syntax errors but potentially not other things
for line in result.error.output.splitlines():
line = str(line).strip()
if not suppress and line:
error_lines.append(line)
if target_line in line:
suppress = False
errors.append(os.linesep.join(error_lines))
errors += _check_history_headings(mod_path)
return errors
# endregion
# region verify PyPI versions
def verify_versions():
import tempfile
import shutil
require_virtual_env()
require_azure_cli()
heading('Verify CLI Versions')
path_table = get_path_table()
modules = list(path_table['core'].items())
modules = [x for x in modules if x[0] not in EXCLUDED_MODULES]
if not modules:
raise CLIError('No modules selected to test.')
display('MODULES: {}'.format(', '.join([x[0] for x in modules])))
results = {}
original_cwd = os.getcwd()
temp_dir = tempfile.mkdtemp()
for mod, mod_path in modules:
if not mod.startswith(COMMAND_MODULE_PREFIX) and mod != 'azure-cli':
mod = '{}{}'.format(COMMAND_MODULE_PREFIX, mod)
results[mod] = {}
results.update(_compare_module_against_pypi(results, temp_dir, mod, mod_path))
shutil.rmtree(temp_dir)
os.chdir(original_cwd)
logger.info('Module'.ljust(40) + 'Local Version'.rjust(20) + 'Public Version'.rjust(20)) # pylint: disable=logging-not-lazy
for mod, data in results.items():
logger.info(mod.ljust(40) + data['local_version'].rjust(20) + data['public_version'].rjust(20))
bump_mods = {k: v for k, v in results.items() if v['status'] == 'BUMP'}
subheading('RESULTS')
if bump_mods:
logger.error('The following modules need their versions bumped. '
'Scroll up for details: %s', ', '.join(bump_mods.keys()))
logger.warning('\nNote that before changing versions, you should consider '
'running `git clean` to remove untracked files from your repo. '
'Files that were once tracked but removed from the source may '
'still be on your machine, resuling in false positives.')
sys.exit(1)
else:
display('OK!')
def _get_module_versions(results, modules):
version_pattern = re.compile(r'.*(?P<ver>\d+.\d+.\d+).*')
for mod, mod_path in modules:
if not mod.startswith(COMMAND_MODULE_PREFIX) and mod != 'azure-cli':
mod = '{}{}'.format(COMMAND_MODULE_PREFIX, mod)
setup_path = find_files(mod_path, 'setup.py')
with open(setup_path[0], 'r') as f:
local_version = 'Unknown'
for line in f.readlines():
if line.strip().startswith('VERSION'):
local_version = version_pattern.match(line).group('ver')
break
results[mod]['local_version'] = local_version
return results
# pylint: disable=too-many-statements
def _compare_module_against_pypi(results, root_dir, mod, mod_path):
import zipfile
version_pattern = re.compile(r'.*azure_cli[^-]*-(\d*.\d*.\d*).*')
downloaded_path = None
downloaded_version = None
build_path = None
build_version = None
build_dir = os.path.join(root_dir, mod, 'local')
pypi_dir = os.path.join(root_dir, mod, 'public')
# download the public PyPI package and extract the version
logger.info('Checking %s...', mod)
result = pip_cmd('download {} --no-deps -d {}'.format(mod, root_dir)).result
try:
result = result.decode('utf-8')
except AttributeError:
pass
for line in result.splitlines():
line = line.strip()
if line.endswith('.whl') and line.startswith('Saved'):
downloaded_path = line.replace('Saved ', '').strip()
downloaded_version = version_pattern.match(downloaded_path).group(1)
break
if 'No matching distribution found' in line:
downloaded_path = None
downloaded_version = 'Unavailable'
break
if not downloaded_version:
raise CLIError('Unexpected error trying to acquire {}: {}'.format(mod, result))
# build from source and extract the version
setup_path = os.path.normpath(mod_path.strip())
os.chdir(setup_path)
py_cmd('setup.py bdist_wheel -d {}'.format(build_dir))
if len(os.listdir(build_dir)) != 1:
raise CLIError('Unexpectedly found multiple build files found in {}.'.format(build_dir))
build_path = os.path.join(build_dir, os.listdir(build_dir)[0])
build_version = version_pattern.match(build_path).group(1)
results[mod].update({
'local_version': build_version,
'public_version': downloaded_version
})
# OK if package is new
if downloaded_version == 'Unavailable':
results[mod]['status'] = 'OK'
return results
# OK if local version is higher than what's on PyPI
if LooseVersion(build_version) > LooseVersion(downloaded_version):
results[mod]['status'] = 'OK'
return results
# slight difference in dist-info dirs, so we must extract the azure folders and compare them
with zipfile.ZipFile(str(downloaded_path), 'r') as z:
z.extractall(pypi_dir)
with zipfile.ZipFile(str(build_path), 'r') as z:
z.extractall(build_dir)
errors = _compare_folders(os.path.join(pypi_dir), os.path.join(build_dir))
# clean up empty strings
errors = [e for e in errors if e]
if errors:
subheading('Differences found in {}'.format(mod))
for error in errors:
logger.warning(error)
results[mod]['status'] = 'OK' if not errors else 'BUMP'
# special case: to make a release, these MUST be bumped, even if it wouldn't otherwise be necessary
if mod in ['azure-cli', 'azure-cli-core']:
if results[mod]['status'] == 'OK':
logger.warning('%s version must be bumped to support release!', mod)
results[mod]['status'] = 'BUMP'
return results
def _diff_files(filename, dir1, dir2):
import difflib
file1 = os.path.join(dir1, filename)
file2 = os.path.join(dir2, filename)
errors = []
with open(file1, 'r') as f1, open(file2, 'r') as f2:
errors.append(os.linesep.join(diff for diff in difflib.context_diff(f1.readlines(), f2.readlines())))
return errors
def _compare_common_files(common_files, dir1, dir2):
errors = []
for filename in common_files:
errors = errors + _diff_files(filename, dir1, dir2)
return errors
def _compare_folders(dir1, dir2):
import filecmp
dirs_cmp = filecmp.dircmp(dir1, dir2)
errors = []
if dirs_cmp.left_only or dirs_cmp.right_only or dirs_cmp.funny_files:
# allow some special cases
if len(dirs_cmp.left_only) == 1 and '__init__.py' in dirs_cmp.left_only:
pass
elif len(dirs_cmp.right_only) == 1 and dirs_cmp.right_only[0].endswith('.whl'):
pass
else:
if dirs_cmp.left_only:
logger.debug('LO: %s', dirs_cmp.left_only)
if dirs_cmp.right_only:
logger.debug('RO: %s', dirs_cmp.right_only)
if dirs_cmp.funny_files:
logger.debug('FF: %s', dirs_cmp.funny_files)
errors.append('Different files in directory structure.')
errors = errors + _compare_common_files(dirs_cmp.common_files, dir1, dir2)
for common_dir in dirs_cmp.common_dirs:
new_dir1 = os.path.join(dir1, common_dir)
new_dir2 = os.path.join(dir2, common_dir)
if common_dir.endswith('.dist-info'):
# special case to check for dependency-only changes
errors = errors + _compare_dependencies(new_dir1, new_dir2)
else:
errors = errors + _compare_folders(new_dir1, new_dir2)
return errors
def _extract_dependencies(path):
dependencies = {}
with open(path, 'r') as f:
for line in f.readlines():
if line.startswith('Requires-Dist:'):
line = line.replace(' ;', '').replace(';', '')
comps = line.split(' ', 2)
if len(comps) == 2:
dependencies[comps[1]] = '_ANY_'
elif len(comps) > 2:
dependencies[comps[1]] = comps[2]
else:
raise CLIError('Unrecognized format in METADATA: {}'.format(line))
return dependencies
def _compare_dependencies(dir1, dir2):
deps1 = _extract_dependencies(os.path.join(dir1, 'METADATA'))
deps2 = _extract_dependencies(os.path.join(dir2, 'METADATA'))
errors = []
mismatch = {}
matched = []
for key, val in deps1.items():
if key in deps2:
if deps2[key] != val:
mismatch[key] = '{} != {}'.format(val, deps2[key])
deps2.pop(key)
matched.append(key)
for key in matched:
deps1.pop(key)
for key, val in deps2.items():
if key in deps1:
if deps1[key] != val:
mismatch[key] = '{} != {}'.format(val, deps1[key])
deps1.pop(key)
if deps1:
errors.append('New dependencies: {}'.format(deps1))
if deps2:
errors.append('Removed dependencies: {}'.format(deps2))
if mismatch:
errors.append('Changed dependencies: {}'.format(mismatch))
return errors
# endregion