-
Notifications
You must be signed in to change notification settings - Fork 7
/
requirements.py
427 lines (340 loc) · 14.6 KB
/
requirements.py
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
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
# Requirements File Parser for Setup Tools
################################################################################
# TODO: Handle version numbers for --editable/-e lines in requirement files.
# TODO: Handle extraction and merging of multiple version constraints.
################################################################################
'''
A requirements file parser that provides a method for setuptools to use
requirements specified in external requirements files.
A base requirements file is supported, and additional suffixed files can be
used to specify requirements for tests and extras. Dependency links are
automatically combined from all requirements files found.
Basic example usage for ``requirements*.txt`` in the same directory:
from requirements import RequirementsParser
requirements = RequirementsParser()
setup(
...
install_requires=requirements.install_requires,
setup_requires=requirements.setup_requires,
tests_require=requirements.tests_require,
extras_require=requirements.extras_require,
dependency_links=requirements.dependency_links,
...
)
The ``RequirementsParser`` class can be instantiated with a custom path,
filename prefix and file extension if required:
requirements = RequirementsParser(path='/', name='depends', extn='conf')
A globbing approach is used to locate additional requirements files which
contain packages for use when testing or to specify optional extra packages.
For example, a file with the name ``requirements-cython.txt`` would be added to
the extra packages dictionary with the name ``cython``.
In addition, ``requirements-tests.txt`` will also be added as the packages
required for testing as well as being added as an extra with the name
``tests``.
If a file named ``dependency_links.txt`` is found in the same path as the
requirements files, dependencies listed in the file will also be added to the
dependency links generated by the requirements parser.
Support has also been added for operating system specific packages such
packages listed in ``requirements+linux.txt`` will only be installed on Linux.
The names that can be used are anything that matches strings generated by
``__import__('platform').system().lower()``.
See more information about requirements files and integration with setup.py:
- http://www.pip-installer.org/en/latest/requirements.html
- http://cburgmer.posterous.com/pip-requirementstxt-and-setuppy
'''
################################################################################
# Imports
import os
import platform
import re
import subprocess
import sys
from collections import defaultdict
from glob import glob
################################################################################
# Exports
__all__ = ['RequirementsParser']
################################################################################
# Constants
# Regular expression for stripping out flags used in requirements files:
__fs = 'Zefir'
__fl = 'always-unzip|editable|find-links|requirement|index-url|extra-index-url'
_re_sf = re.compile(r'^(?:-[%s]|--(?:%s)) *=? *' % (__fs, __fl))
# Regular expression for splitting on versions/extras in requirements files:
_re_ps = re.compile(r'^([^<>= \[]+)(?: *([<>]=?|==) *([^\[]+))?(?: *\[([^\]]+)\])?')
# Regular expression for extracting the egg name from a URL or file path:
_re_en = re.compile(r'^.*#egg=(.*)$')
################################################################################
# Helpers
def _build_filename(path, pattern, name, extn):
'''
Builds filenames from separate components.
:param path: The path in which to search for requirements files.
:type path: string
:param pattern: The pattern for the filename.
:type pattern: string
:param name: The name prefix for the requirements files.
:type name: string
:param extn: The extension for the requirements files.
:type extn: string
:returns: The full filename pattern.
:rtype: string
'''
return os.path.realpath(os.path.join(path, pattern % (name, extn)))
def _strip_flags(line):
'''
Strips flags from the line read from a requirements file.
:param line: The line read from a requirements file.
:type line: string
:returns: The line with any flags stripped.
:rtype: string
'''
return re.sub(_re_sf, '', line)
def _split_package(package):
'''
Splits a package string into four parts: The name, comparision operator,
version string and extras list.
:param package: The package name to split.
:type package: string
:returns: A tuple of name, operator, version and extras.
:rtype: tuple
'''
matches = re.match(_re_ps, package)
if not matches:
return None
components = list(matches.groups())
components = list(map(lambda x: '' if x is None else x.strip(), components))
if components[-1]:
components[-1] = sorted(map(str.strip, components[-1].split(',')))
else:
components[-1] = []
return components
def _extract_egg_names(line):
'''
Extracts the python egg name from the line containing a URL or file path.
:param package: The line to extract the egg name from.
:type package: string
:returns: The egg name for the package specified in the requirements file.
:rtype: string or none
'''
return re.sub(_re_en, r'\1', line)
def _read_requirements_file(filename, data=None):
'''
Reads requirements files and extracts data.
:param filename: The name of the requirements file to read.
:type filename: string
:param data: The data extracted from the requirements file (for recursion).
:type data: defaultdict(list)
:returns: A dictionary of requirements information.
:rtype: dict
'''
# Create a datastructure to store requirements information if required.
if not data:
data = defaultdict(list)
# Keep track of this file so we can prevent infinite recursion:
data['r'].append(filename)
with open(filename, 'r') as f:
for line in f:
line = line.strip()
# Ignore blank or commented lines:
if not line or line.startswith('#'):
continue
# Skip over no-longer used options that may still exist in files:
if line.startswith('-Z') or line.startswith('--always-unzip'):
continue
# Handle editable requirements:
if line.startswith('-e') or line.startswith('--editable'):
line = _strip_flags(line)
data['e'].append(line)
continue
# Handle dependency links:
if line.startswith('-f') or line.startswith('--find-links'):
line = _strip_flags(line)
data['f'].append(line)
continue
# Handle additional requirements files:
if line.startswith('-r') or line.startswith('--requirement'):
line = _strip_flags(line)
filename = os.path.realpath(line)
# Ensure that we do not have circular requirements:
if filename in data['r']:
continue
# Read in and merge the extra requirements:
_read_requirements_file(filename, data)
continue
# Handle package index URL:
if line.startswith('-i') or line.startswith('--index-url'):
line = _strip_flags(line)
data['i'] = [line]
continue
# Handle extra package index URLs:
if line.startswith('--extra-index-url'):
line = _strip_flags(line)
data['i'].append(line)
continue
# Handle packages (removing duplicates or less specific versions):
components = _split_package(line)
if not components:
# FIXME: Something went wrong, ignore for now...
continue
# Compare against all other packages:
updated = False
for package in data['_']:
# Only act if packages have the same name:
cached_name = package[0].lower().replace('_', '-')
current_name = components[0].lower().replace('_', '-')
if cached_name == current_name: # attempt to update if names match
if not package[1]: # cached package has no operator
if components[1]: # have a more specific package
package[1:2] = components[1:2]
else: # cached package has an operator
if not components[1]: # have a less specific package
pass # just fall through and update extras
elif package[1] == components[1]: # matching operator
if package[2] != components[2]: # differing versions
continue
else: # conflicting operators... help!
# FIXME: Find a solution or report an error?
continue
# Update list of extras:
package[3] = sorted(set(package[3] + components[3]))
updated = True
break
if not updated:
# Nothing updated, so append:
data['_'].append(components)
# Combine package versions and names from the cache:
packages = []
for package in data['_']:
if package[-1]:
extras = ', '.join(package[-1])
packages.append('%s%s%s [%s]' % tuple(package[:-1] + [extras]))
else:
packages.append('%s%s%s' % tuple(package[:-1]))
data['p'] = sorted(packages)
return data
################################################################################
# Parser
class RequirementsParser(object):
'''
Parser for requirements files providing helpful properties for populating
the setuptools setup() function with requirements based on requirements
files.
'''
def __init__(self, path='', name='requirements', extn='txt'):
'''
Initialise the parser and parse requirements.
:param path: The path in which to search for requirements files.
:type path: string
:param name: The name prefix for the requirements files.
:type name: string
:param extn: The extension for the requirements files.
:type extn: string
'''
self.data = {}
self.links = []
self.platform = platform.system().lower()
# Handle dependency links file if available:
filename = _build_filename(path, '%s.%s', 'dependency_links', 'txt')
if os.path.isfile(filename):
lines = open(filename, 'r').read().splitlines()
self.links = list(map(str.strip, lines))
paths = []
paths += [_build_filename(path, '%s.%s', name, extn)]
paths += glob(_build_filename(path, '%s[+-]*.%s', name, extn))
for f in paths:
if not os.path.isfile(f):
continue
# Extract extras name and operating system name:
m = re.search(r'(?:-(\w+))?(?:\+(\w+))?\.%s$' % extn, f)
if not m:
continue
source, system = m.groups()
# Ensure that we don't include packages for other operating systems:
if system and not system == self.platform:
continue
if not source:
source = '*'
# If we already have some data, pass it in to be updated:
if source in self.data:
self.data[source] = _read_requirements_file(f, self.data[source])
else:
self.data[source] = _read_requirements_file(f)
@property
def install_requires(self):
'''
Extracts requirements for installation from parsed requirements files.
:returns: The requirements for installation from the requirements files.
:rtype: list
'''
if '*' not in self.data:
return []
data = self.data['*']
install_requires = []
install_requires += data.get('p', [])
install_requires += list(map(_extract_egg_names, data.get('e', [])))
return sorted(set(install_requires))
@property
def setup_requires(self):
'''
Extracts requirements for setup from parsed requirements files.
:returns: The requirements for setup from the requirements files.
:rtype: list
'''
if 'setup' not in self.data:
return []
data = self.data['setup']
setup_requires = []
setup_requires += data.get('p', [])
setup_requires += list(map(_extract_egg_names, data.get('e', [])))
return sorted(set(setup_requires))
@property
def tests_require(self):
'''
Extracts requirements for tests from parsed requirements files.
:returns: The requirements for tests from the requirements files.
:rtype: list
'''
if 'tests' not in self.data:
return []
data = self.data['tests']
tests_require = []
tests_require += data.get('p', [])
tests_require += list(map(_extract_egg_names, data.get('e', [])))
return sorted(set(tests_require))
@property
def extras_require(self):
'''
Extracts requirements for extras from parsed requirements files.
:returns: The requirements for extras from the requirements files.
:rtype: dict
'''
extras_require = {}
for source, data in self.data.items():
if source == '*':
continue
packages = []
packages += data.get('p')
packages += list(map(_extract_egg_names, data.get('e', [])))
packages = sorted(set(packages))
if packages:
extras_require[source] = packages
return extras_require
@property
def dependency_links(self):
'''
Extracts dependency links from parsed requirements files.
:returns: The dependency links from the requirements files.
:rtype: list
'''
dependency_links = []
dependency_links += self.links
for data in self.data.values():
dependency_links += data.get('f', [])
dependency_links += data.get('e', [])
return sorted(set(dependency_links))
################################################################################
# vim:et:ft=python:nowrap:sts=4:sw=4:ts=4